Files
pulse/.github/workflows/qualify-secure-runtime-release.yml
Richard Courtman 79d87a4a2c Qualify release candidates against the branch that owns their version
The secure-runtime qualification required the candidate commit to be an
ancestor of origin/main. Under the release train a patch candidate is cut
from release/vX.Y and is not on main at all, so v6.4.3-rc.1's
qualification failed at "Verify detached release source" before running
any check, and the steward correctly refused to treat the candidate as
stable-capable. Resolve the branch the control plane maps the version to,
fetch that, check ancestry against it, and hand the same ref to the
attestation so the receipt records the line it was verified against.

Refs #1753

Contract-Neutral: release qualification checks ancestry against the mapped release branch; no product or contract change
2026-09-02 17:52:33 +01:00

372 lines
19 KiB
YAML

name: Qualify Secure Runtime Release Candidate
on:
workflow_dispatch:
inputs:
tag:
description: 'Immutable vX.Y.Z-rc.N release tag to qualify'
required: true
type: string
permissions:
attestations: read
contents: read
jobs:
qualify-secure-runtime-release:
name: Qualify Immutable RC on Disposable systemd
runs-on: ubuntu-24.04
timeout-minutes: 45
steps:
- name: Checkout release qualification control
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Resolve immutable RC identity
id: release
env:
GH_TOKEN: ${{ github.token }}
INPUT_TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
tag="${INPUT_TAG:-}"
[[ "${tag}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[1-9][0-9]*$ ]] || {
echo "::error::Secure-runtime release qualification requires an exact vX.Y.Z-rc.N tag."
exit 1
}
release_json="$RUNNER_TEMP/secure-runtime-release.json"
for attempt in $(seq 1 30); do
gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${tag}" > "${release_json}"
if jq -e --arg tag "${tag}" '.tag_name == $tag and .draft == false and .prerelease == true and .immutable == true' "${release_json}" >/dev/null; then
break
fi
if [[ "${attempt}" == 30 ]]; then
jq -c '{tag_name, draft, prerelease, immutable}' "${release_json}" >&2
echo "::error::Release did not reach immutable prerelease state."
exit 1
fi
sleep 10
done
release_id="$(jq -er '.id | select(type == "number" and . > 0)' "${release_json}")"
python3 scripts/write_github_output.py tag "${tag}"
echo "release_id=${release_id}" >> "$GITHUB_OUTPUT"
- name: Checkout exact release tag
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
fetch-depth: 0
ref: ${{ steps.release.outputs.tag }}
- name: Verify detached release source
id: source
env:
TAG: ${{ steps.release.outputs.tag }}
run: |
set -euo pipefail
# The release train cuts patch candidates from release/vX.Y, so the
# candidate is reachable from the branch the control plane maps its
# version to, not necessarily from main (v6.4.3-rc.1, 2026-09-02).
required_branch="$(python3 scripts/release_control/control_plane.py \
--branch-for-version "${TAG#v}")"
[[ "${required_branch}" =~ ^(main|release/v[0-9]+\.[0-9]+)$ ]] || {
echo "::error::Control plane mapped ${TAG} to an unexpected branch: ${required_branch}"
exit 1
}
git fetch --no-tags origin \
"refs/heads/${required_branch}:refs/remotes/origin/${required_branch}"
commit="$(git rev-parse "${TAG}^{commit}")"
test "${GITHUB_REF}" = "refs/tags/${TAG}"
test "${GITHUB_SHA}" = "${commit}"
test "$(git branch --show-current)" = ""
test -z "$(git status --short --untracked-files=all)"
git merge-base --is-ancestor "${commit}" "origin/${required_branch}"
python3 scripts/write_github_output.py commit "${commit}"
python3 scripts/write_github_output.py line_ref "origin/${required_branch}"
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: go.mod
cache: true
- name: Download exact immutable qualification packet
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ steps.release.outputs.tag }}
run: |
set -euo pipefail
mkdir -p \
"$RUNNER_TEMP/secure-runtime-downloads" \
"$RUNNER_TEMP/secure-runtime-evidence" \
"$RUNNER_TEMP/secure-runtime-harness" \
"$RUNNER_TEMP/secure-runtime-preexecution"
for asset in \
checksums.txt \
release-build-provenance.sigstore.json \
secure-runtime-compiler-provenance.sigstore.json \
secure-runtime-build-contract-v1.json \
pulse-secure-runtime-collector-v1-linux-amd64 \
pulse-secure-runtime-collector-v1-linux-amd64.sig \
pulse-secure-runtime-collector-v2-linux-amd64 \
pulse-secure-runtime-collector-v2-linux-amd64.sig \
pulse-secure-runtime-collector-v3-linux-amd64 \
pulse-secure-runtime-collector-v3-linux-amd64.sig \
pulse-agent-linux-amd64 \
pulse-agent-linux-amd64.sig \
pulse-agent-helper-linux-amd64 \
pulse-agent-runner-linux-amd64; do
gh release download "${TAG}" \
--repo "${GITHUB_REPOSITORY}" \
--dir "$RUNNER_TEMP/secure-runtime-downloads" \
--pattern "${asset}"
done
- name: Pre-authenticate exact qualification packet
env:
GH_TOKEN: ${{ github.token }}
PULSE_UPDATE_SIGNING_PUBLIC_KEY: ${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }}
QUALIFIED_COMMIT: ${{ steps.source.outputs.commit }}
LINE_REF: ${{ steps.source.outputs.line_ref }}
RELEASE_ID: ${{ steps.release.outputs.release_id }}
TAG: ${{ steps.release.outputs.tag }}
run: |
set -euo pipefail
download_dir="$RUNNER_TEMP/secure-runtime-downloads"
verification_dir="$RUNNER_TEMP/secure-runtime-preexecution"
test -n "${PULSE_UPDATE_SIGNING_PUBLIC_KEY}"
update_fingerprint="$(
go run ./scripts/release_update_key.go fingerprint \
--public-key "${PULSE_UPDATE_SIGNING_PUBLIC_KEY}"
)"
python3 scripts/release_control/secure_runtime_attestation_v7.py \
--verify-release-packet-only \
--checkout "${GITHUB_WORKSPACE}" \
--commit "${QUALIFIED_COMMIT}" \
--main-ref "${LINE_REF}" \
--collector-v1 "${download_dir}/pulse-secure-runtime-collector-v1-linux-amd64" \
--collector-v2 "${download_dir}/pulse-secure-runtime-collector-v2-linux-amd64" \
--collector-v3 "${download_dir}/pulse-secure-runtime-collector-v3-linux-amd64" \
--collector-v4 "${download_dir}/pulse-agent-linux-amd64" \
--helper "${download_dir}/pulse-agent-helper-linux-amd64" \
--runner "${download_dir}/pulse-agent-runner-linux-amd64" \
--collector-v1-signature "${download_dir}/pulse-secure-runtime-collector-v1-linux-amd64.sig" \
--collector-v2-signature "${download_dir}/pulse-secure-runtime-collector-v2-linux-amd64.sig" \
--collector-v3-signature "${download_dir}/pulse-secure-runtime-collector-v3-linux-amd64.sig" \
--collector-v4-signature "${download_dir}/pulse-agent-linux-amd64.sig" \
--release-candidate-tag "${TAG}" \
--release-repository "${GITHUB_REPOSITORY}" \
--release-id "${RELEASE_ID}" \
--release-checksums "${download_dir}/checksums.txt" \
--release-assembly-provenance "${download_dir}/release-build-provenance.sigstore.json" \
--release-compiler-provenance "${download_dir}/secure-runtime-compiler-provenance.sigstore.json" \
--release-build-contract "${download_dir}/secure-runtime-build-contract-v1.json" \
--expected-release-update-key-fingerprint "${update_fingerprint}" \
--expected-architecture amd64 \
--verified-packet-dir "$RUNNER_TEMP/secure-runtime-verified" \
--output "${verification_dir}/preexecution-verification.json"
- name: Build exact-source qualification harness
run: |
set -euo pipefail
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go test -c \
-o "$RUNNER_TEMP/secure-runtime-harness/installtests-linux-amd64.test" \
./scripts/installtests
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -trimpath \
-o "$RUNNER_TEMP/secure-runtime-harness/docker-fixture-linux-amd64" \
./scripts/installtests/testdata/secure_runtime_docker_fixture.go
- name: Build disposable systemd host image
run: |
set -euo pipefail
context="$RUNNER_TEMP/secure-runtime-systemd-image"
mkdir -p "${context}"
tee "${context}/pulse-qualification-docker.service" >/dev/null <<'UNIT'
[Unit]
Description=Pulse secure-runtime qualification Docker daemon
After=network.target
[Service]
Type=notify
ExecStart=/usr/local/bin/dockerd --host=unix:///var/run/docker.sock --group=root --data-root=/var/lib/pulse-qualification-docker --exec-root=/run/pulse-qualification-docker --pidfile=/run/pulse-qualification-docker.pid --storage-driver=vfs --bridge=none --iptables=false --ip6tables=false --ip-forward=false --ip-masq=false --userland-proxy=false
ExecStartPost=/bin/chown root:root /var/run/docker.sock
ExecStartPost=/bin/chmod 0660 /var/run/docker.sock
Restart=on-failure
RestartSec=1
[Install]
WantedBy=multi-user.target
UNIT
tee "${context}/Dockerfile" >/dev/null <<'DOCKERFILE'
FROM docker:27.5.1-dind@sha256:f649ef046008ca7f926a2571c32b0ac22e5c59eb61b959617f9acc2a4c638cf5 AS dind
FROM ubuntu:24.04@sha256:33ceb71981b602c1a7443a53469e4dba065f7503eab3078a2d7a57a2ab987517
ENV container=docker
COPY --from=dind /usr/local/bin/ /usr/local/bin/
COPY pulse-qualification-docker.service /etc/systemd/system/pulse-qualification-docker.service
RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
ca-certificates curl dbus systemd systemd-sysv util-linux && \
apt-get clean && \
systemctl mask dev-hugepages.mount sys-fs-fuse-connections.mount systemd-remount-fs.service docker.service docker.socket && \
systemctl enable pulse-qualification-docker.service
STOPSIGNAL SIGRTMIN+3
CMD ["/sbin/init"]
DOCKERFILE
docker build \
--tag pulse-secure-runtime-systemd:ubuntu-24.04 \
--file "${context}/Dockerfile" \
"${context}"
- name: Run schema-v7 systemd and rootful Docker qualification
id: lab
env:
TAG: ${{ steps.release.outputs.tag }}
run: |
set -euo pipefail
container="pulse-secure-runtime-qualification-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
echo "container=${container}" >> "$GITHUB_OUTPUT"
docker run \
--detach \
--name "${container}" \
--privileged \
--network none \
--cgroupns=host \
--tmpfs /run \
--tmpfs /run/lock \
--volume /sys/fs/cgroup:/sys/fs/cgroup:rw \
--volume "${GITHUB_WORKSPACE}:/workspace:ro" \
--volume "$RUNNER_TEMP/secure-runtime-verified:/release:ro" \
--volume "$RUNNER_TEMP/secure-runtime-harness:/harness:ro" \
--volume "$RUNNER_TEMP/secure-runtime-evidence:/evidence:rw" \
pulse-secure-runtime-systemd:ubuntu-24.04
for attempt in $(seq 1 30); do
if docker exec "${container}" systemctl show-environment >/dev/null 2>&1; then
break
fi
if [[ "${attempt}" == 30 ]]; then
docker logs "${container}"
exit 1
fi
sleep 2
done
docker exec "${container}" sh -c \
'for command in curl docker dockerd id nsenter runuser systemctl; do command -v "$command" >/dev/null || exit 1; done'
docker exec "${container}" systemctl start pulse-qualification-docker.service
for attempt in $(seq 1 60); do
if docker exec "${container}" docker info >/dev/null 2>&1; then
break
fi
if [[ "${attempt}" == 60 ]]; then
docker exec "${container}" systemctl status pulse-qualification-docker.service --no-pager || true
docker logs "${container}"
exit 1
fi
sleep 1
done
docker exec "${container}" sh -ceu '
test -S /var/run/docker.sock
test "$(stat -c %U:%G:%a /var/run/docker.sock)" = "root:root:660"
daemon_pid="$(systemctl show pulse-qualification-docker.service --property MainPID --value)"
test "${daemon_pid}" -gt 0
case "$(tr "\000" " " < "/proc/${daemon_pid}/cmdline")" in *tcp://*) exit 1;; esac
rootfs="$(mktemp -d)"
cp /harness/docker-fixture-linux-amd64 "${rootfs}/fixture"
chmod 0555 "${rootfs}/fixture"
tar -C "${rootfs}" -cf /tmp/pulse-secure-runtime-rootfs.tar .
docker import --change '\''ENTRYPOINT ["/fixture"]'\'' /tmp/pulse-secure-runtime-rootfs.tar pulse-secure-runtime-fixture:v7 >/dev/null
docker run --detach --name pulse-v7-running --network none pulse-secure-runtime-fixture:v7 >/dev/null
docker run --name pulse-v7-exited --network none pulse-secure-runtime-fixture:v7 --exit >/dev/null
test "$(docker inspect --format "{{.State.Status}}" pulse-v7-running)" = "running"
test "$(docker inspect --format "{{.State.Status}}" pulse-v7-exited)" = "exited"
'
docker exec "${container}" sh -c \
'printf "%s\n" "PULSE_SECURE_RUNTIME_SYSTEMD_LAB=disposable-v1" > /etc/pulse-secure-runtime-lab'
start_epoch="$(date +%s)"
docker exec \
--env PULSE_SECURE_RUNTIME_SYSTEMD_LAB=1 \
--env PULSE_SECURE_RUNTIME_COLLECTOR_V1=/release/pulse-secure-runtime-collector-v1-linux-amd64 \
--env PULSE_SECURE_RUNTIME_COLLECTOR_V1_SIGNATURE=/release/pulse-secure-runtime-collector-v1-linux-amd64.sig \
--env PULSE_SECURE_RUNTIME_COLLECTOR_V2=/release/pulse-secure-runtime-collector-v2-linux-amd64 \
--env PULSE_SECURE_RUNTIME_COLLECTOR_V2_SIGNATURE=/release/pulse-secure-runtime-collector-v2-linux-amd64.sig \
--env PULSE_SECURE_RUNTIME_COLLECTOR_V3=/release/pulse-secure-runtime-collector-v3-linux-amd64 \
--env PULSE_SECURE_RUNTIME_COLLECTOR_V3_SIGNATURE=/release/pulse-secure-runtime-collector-v3-linux-amd64.sig \
--env PULSE_SECURE_RUNTIME_COLLECTOR_V4=/release/pulse-agent-linux-amd64 \
--env PULSE_SECURE_RUNTIME_COLLECTOR_V4_SIGNATURE=/release/pulse-agent-linux-amd64.sig \
--env PULSE_SECURE_RUNTIME_HELPER=/release/pulse-agent-helper-linux-amd64 \
--env PULSE_SECURE_RUNTIME_RUNNER=/release/pulse-agent-runner-linux-amd64 \
--env PULSE_SECURE_RUNTIME_RECEIPT=/evidence/receipt.json \
--env "PULSE_SECURE_RUNTIME_RECEIPT_RECORD_PATH=docs/release-control/v6/internal/records/secure-agent-runtime-systemd-receipt-v7-${TAG}.json" \
--env PULSE_SECURE_RUNTIME_TRANSCRIPT=/evidence/transcript.jsonl \
--env "PULSE_SECURE_RUNTIME_TRANSCRIPT_RECORD_PATH=docs/release-control/v6/internal/records/secure-agent-runtime-systemd-transcript-v7-${TAG}.jsonl" \
"${container}" \
sh -c 'cd /workspace/scripts/installtests && exec /harness/installtests-linux-amd64.test -test.run "^TestSecureRuntimeSystemdDockerV7Lab$" -test.count=1 -test.v'
elapsed="$(( $(date +%s) - start_epoch ))"
if [[ "${elapsed}" -lt 1 ]]; then elapsed=1; fi
echo "elapsed_seconds=${elapsed}" >> "$GITHUB_OUTPUT"
- name: Create release-candidate secure-runtime attestation
env:
GH_TOKEN: ${{ github.token }}
PULSE_UPDATE_SIGNING_PUBLIC_KEY: ${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }}
QUALIFIED_COMMIT: ${{ steps.source.outputs.commit }}
LINE_REF: ${{ steps.source.outputs.line_ref }}
RELEASE_ID: ${{ steps.release.outputs.release_id }}
TAG: ${{ steps.release.outputs.tag }}
WORKFLOW_OUTPUT_1: ${{ steps.lab.outputs.elapsed_seconds }}
run: |
set -euo pipefail
release_dir="$RUNNER_TEMP/secure-runtime-verified"
evidence_dir="$RUNNER_TEMP/secure-runtime-evidence"
test -n "${PULSE_UPDATE_SIGNING_PUBLIC_KEY}"
update_fingerprint="$(
go run ./scripts/release_update_key.go fingerprint \
--public-key "${PULSE_UPDATE_SIGNING_PUBLIC_KEY}"
)"
python3 scripts/release_control/secure_runtime_attestation_v7.py \
--checkout "${GITHUB_WORKSPACE}" \
--commit "${QUALIFIED_COMMIT}" \
--main-ref "${LINE_REF}" \
--receipt "${evidence_dir}/receipt.json" \
--receipt-record-path "docs/release-control/v6/internal/records/secure-agent-runtime-systemd-receipt-v7-${TAG}.json" \
--transcript "${evidence_dir}/transcript.jsonl" \
--collector-v1 "${release_dir}/pulse-secure-runtime-collector-v1-linux-amd64" \
--collector-v2 "${release_dir}/pulse-secure-runtime-collector-v2-linux-amd64" \
--collector-v3 "${release_dir}/pulse-secure-runtime-collector-v3-linux-amd64" \
--collector-v4 "${release_dir}/pulse-agent-linux-amd64" \
--helper "${release_dir}/pulse-agent-helper-linux-amd64" \
--runner "${release_dir}/pulse-agent-runner-linux-amd64" \
--collector-v1-signature "${release_dir}/pulse-secure-runtime-collector-v1-linux-amd64.sig" \
--collector-v2-signature "${release_dir}/pulse-secure-runtime-collector-v2-linux-amd64.sig" \
--collector-v3-signature "${release_dir}/pulse-secure-runtime-collector-v3-linux-amd64.sig" \
--collector-v4-signature "${release_dir}/pulse-agent-linux-amd64.sig" \
--elapsed-seconds "${WORKFLOW_OUTPUT_1}" \
--release-candidate-tag "${TAG}" \
--release-repository "${GITHUB_REPOSITORY}" \
--release-id "${RELEASE_ID}" \
--release-checksums "${release_dir}/checksums.txt" \
--release-assembly-provenance "${release_dir}/release-build-provenance.sigstore.json" \
--release-compiler-provenance "${release_dir}/secure-runtime-compiler-provenance.sigstore.json" \
--release-build-contract "${release_dir}/secure-runtime-build-contract-v1.json" \
--expected-release-update-key-fingerprint "${update_fingerprint}" \
--output "${evidence_dir}/attestation.json"
- name: Stop disposable systemd host
if: ${{ always() && steps.lab.outputs.container != '' }}
env:
WORKFLOW_OUTPUT_1: ${{ steps.lab.outputs.container }}
run: docker rm --force "${WORKFLOW_OUTPUT_1}"
- name: Upload immutable RC qualification evidence
if: ${{ success() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: secure-runtime-qualification-${{ steps.release.outputs.tag }}-${{ steps.source.outputs.commit }}
path: |
${{ runner.temp }}/secure-runtime-evidence/
${{ runner.temp }}/secure-runtime-preexecution/preexecution-verification.json
if-no-files-found: error
retention-days: 90
compression-level: 0