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: Bind canonical Pulse origin run: | set -euo pipefail test "${GITHUB_REPOSITORY}" = "rcourtman/Pulse" # checkout v7 records the canonical HTTPS URL without .git. The # released attestation expects the equivalent .git spelling and # independently checks the remote tag and branch identities. origin_url="$(git remote get-url origin)" case "${origin_url}" in https://github.com/rcourtman/Pulse|https://github.com/rcourtman/Pulse.git) ;; *) echo "::error::Release checkout origin is not canonical Pulse."; exit 1 ;; esac git remote set-url origin https://github.com/rcourtman/Pulse.git - 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}")" # Run reviewed qualification control from protected main, while the # checkout and the packet remain bound to the immutable release tag. test "${GITHUB_REF}" = "refs/heads/main" test "${GITHUB_WORKFLOW_REF}" = "${GITHUB_REPOSITORY}/.github/workflows/qualify-secure-runtime-release.yml@refs/heads/main" test "${GITHUB_WORKFLOW_SHA}" = "${GITHUB_SHA}" 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@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.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 iproute2 && \ 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 ip nsenter runuser systemctl; do command -v "$command" >/dev/null || exit 1; done' # The immutable RC test needs a reachable non-loopback host-interface # canary to prove that the helper's private network cannot reach it. # Both veth ends stay inside this --network none namespace; neither # endpoint is attached to the runner or an egress bridge. docker exec "${container}" sh -ceu ' ip link add pulse-can0 type veth peer name pulse-can1 ip address add 192.0.2.1/32 dev pulse-can0 ip link set pulse-can1 up ip link set pulse-can0 up test -z "$(ip -4 route show default)" ip -4 address show dev pulse-can0 | grep -F "192.0.2.1/32" ' 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' # The lab writes its receipt and transcript as root inside the # privileged host. Hand the evidence directory to the runner user so # the attester can read it and write the attestation beside it. The # evidence content itself is not touched. docker exec "${container}" chown -R "$(id -u):$(id -g)" /evidence 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