diff --git a/.github/workflows/canonical-governance.yml b/.github/workflows/canonical-governance.yml index 4f6e6e197..671da32fe 100644 --- a/.github/workflows/canonical-governance.yml +++ b/.github/workflows/canonical-governance.yml @@ -252,6 +252,9 @@ jobs: - name: Run release promotion policy unit tests run: python3 scripts/release_control/release_promotion_policy_test.py + - name: Run immutable release integrity unit tests + run: python3 scripts/release_control/verify_github_release_integrity_test.py + - name: Run status audit unit tests run: python3 scripts/release_control/status_audit_test.py diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index cd0a27d17..160583572 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -1533,11 +1533,10 @@ jobs: echo "run_url=${run_url}" >> "$GITHUB_OUTPUT" echo "[OK] Customer convergence is durably queued as ${run_url}." - # release-activation.json is the irreversible publication commit. Before the - # marker is public, any verification failure returns the release to draft. - # After the marker is public, customer convergence owns retriable rollout and - # the release is never described as rolled back merely because a mutable - # external surface is temporarily unavailable. + # release-activation.json is staged and digest-checked while the release is a + # draft. Publishing that complete packet is the irreversible commit: GitHub + # must lock its tag/assets and issue a verifiable release attestation before + # customer convergence may use the marker. activate_release: needs: - prepare @@ -1553,6 +1552,9 @@ jobs: actions: read contents: write steps: + - name: Checkout release integrity control + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - name: Publish the fully staged release env: GH_TOKEN: ${{ github.token }} @@ -1572,6 +1574,7 @@ jobs: verified_marker=$(mktemp) activated=false committed=false + marker_staged=false validate_existing_activation_commit() { local marker_convergence_run_id recovery_run_id recovery_run expected_title @@ -1666,14 +1669,27 @@ jobs: return 1 } - quarantine_on_error() { + compensate_uncommitted_activation() { if [ "$activated" = "true" ] && [ "$committed" != "true" ]; then - echo "::warning::Public asset verification failed; returning ${TAG} to draft quarantine." + echo "::warning::Release publication did not become immutable; returning ${TAG} to draft quarantine." gh api "repos/${{ github.repository }}/releases/${RELEASE_ID}" \ -X PATCH --input "$quarantine_payload" >/dev/null || true fi + if [ "$marker_staged" = "true" ] && [ "$committed" != "true" ]; then + marker_asset_id="$( + gh api --paginate \ + "repos/${{ github.repository }}/releases/${RELEASE_ID}/assets?per_page=100" \ + --jq '.[] | select(.name == "release-activation.json") | .id' \ + 2>/dev/null || true + )" + if [[ "$marker_asset_id" =~ ^[0-9]+$ ]]; then + gh api -X DELETE \ + "repos/${{ github.repository }}/releases/assets/${marker_asset_id}" \ + >/dev/null || true + fi + fi } - trap quarantine_on_error ERR + trap compensate_uncommitted_activation ERR gh api "repos/${{ github.repository }}/releases/${RELEASE_ID}" > "$release_json" actual_tag=$(jq -r '.tag_name // ""' "$release_json") @@ -1681,12 +1697,16 @@ jobs: actual_draft=$(jq -r '.draft' "$release_json") published_at=$(jq -r '.published_at // ""' "$release_json") actual_prerelease=$(jq -r '.prerelease' "$release_json") + actual_immutable=$(jq -r '.immutable // false' "$release_json") activation_committed=$(jq -r 'any(.assets[]?; .name == "release-activation.json")' "$release_json") if [ "$actual_tag" = "$TAG" ] && [ "$actual_commit" = "$EXPECTED_COMMIT" ] && \ [ "$actual_draft" = "false" ] && [ -n "$published_at" ] && \ [ "$activation_committed" = "true" ] && \ + [ "$actual_immutable" = "true" ] && \ [ "$actual_prerelease" = "$IS_PRERELEASE" ]; then validate_existing_activation_commit + ./scripts/verify-github-release-integrity.sh \ + "$TAG" "${GITHUB_REPOSITORY}" "$RELEASE_ID" "$EXPECTED_COMMIT" rm -f "$release_json" "$publish_payload" "$quarantine_payload" \ "$verified_marker" rm -rf "$activation_marker_dir" @@ -1715,15 +1735,58 @@ jobs: '{draft: false, make_latest: $make_latest}' > "$publish_payload" jq -n '{draft: true, make_latest: "false"}' > "$quarantine_payload" + # Close the dispatch-to-commit race before staging the exact marker. + require_viable_convergence_owner + jq -n \ + --arg tag "${TAG}" \ + --arg target_commitish "${EXPECTED_COMMIT}" \ + --arg release_id "${RELEASE_ID}" \ + --arg source_release_run_id "${GITHUB_RUN_ID}" \ + --arg convergence_run_id "${CONVERGENCE_RUN_ID}" \ + --arg r2_prefix "${R2_PREFIX}" \ + '{ + schema_version: 1, + tag: $tag, + target_commitish: $target_commitish, + release_id: $release_id, + source_release_run_id: $source_release_run_id, + convergence_run_id: $convergence_run_id, + r2_prefix: $r2_prefix + }' > "${activation_marker}" + gh release upload "${TAG}" \ + "${activation_marker}" --clobber \ + --repo "${GITHUB_REPOSITORY}" + marker_staged=true + + # GitHub exposes a SHA-256 digest for draft assets. Verify the exact + # marker bytes before publication makes the asset set unchangeable. + gh api "repos/${{ github.repository }}/releases/${RELEASE_ID}" > "$release_json" + expected_marker_digest="sha256:$(sha256sum "${activation_marker}" | awk '{print $1}')" + actual_marker_digest="$( + jq -er \ + '[.assets[] | select(.name == "release-activation.json" and .state == "uploaded")] | + if length == 1 then .[0].digest else error("expected exactly one activation marker") end | + select(test("^sha256:[0-9a-f]{64}$"))' \ + "$release_json" + )" + if [ "$actual_marker_digest" != "$expected_marker_digest" ]; then + echo "::error::Draft activation marker digest does not match the staged bytes." + exit 1 + fi + + # Publication is now the only irreversible boundary. GitHub must + # report the complete release as immutable before this job commits. require_viable_convergence_owner gh api "repos/${{ github.repository }}/releases/${RELEASE_ID}" \ -X PATCH --input "$publish_payload" > "$release_json" activated=true if [ "$(jq -r '.draft' "$release_json")" != "false" ] || \ - [ -z "$(jq -r '.published_at // ""' "$release_json")" ]; then - echo "::error::GitHub did not publish release ${RELEASE_ID}." + [ -z "$(jq -r '.published_at // ""' "$release_json")" ] || \ + [ "$(jq -r '.immutable // false' "$release_json")" != "true" ]; then + echo "::error::GitHub did not publish ${TAG} as an immutable release. Enable repository release immutability before activation." exit 1 fi + committed=true base="https://github.com/${{ github.repository }}/releases/download/${TAG}" for asset_name in \ @@ -1748,33 +1811,8 @@ jobs: fi rm -f "$visual_plan" - # Close the dispatch-to-commit race: the exact durable convergence - # owner must still be queued or running immediately before the marker - # makes activation irreversible. - require_viable_convergence_owner - jq -n \ - --arg tag "${TAG}" \ - --arg target_commitish "${EXPECTED_COMMIT}" \ - --arg release_id "${RELEASE_ID}" \ - --arg source_release_run_id "${GITHUB_RUN_ID}" \ - --arg convergence_run_id "${CONVERGENCE_RUN_ID}" \ - --arg r2_prefix "${R2_PREFIX}" \ - '{ - schema_version: 1, - tag: $tag, - target_commitish: $target_commitish, - release_id: $release_id, - source_release_run_id: $source_release_run_id, - convergence_run_id: $convergence_run_id, - r2_prefix: $r2_prefix - }' > "${activation_marker}" - gh release upload "${TAG}" \ - "${activation_marker}" --clobber \ - --repo "${GITHUB_REPOSITORY}" - # The successful upload is the single irreversible logical boundary: - # convergence may observe the marker immediately, so no later - # activation-side read failure may return the release to draft. - committed=true + ./scripts/verify-github-release-integrity.sh \ + "$TAG" "${GITHUB_REPOSITORY}" "$RELEASE_ID" "$EXPECTED_COMMIT" curl -fsSL --retry 12 --retry-delay 5 --retry-all-errors \ -o "${verified_marker}" "${base}/release-activation.json" jq -e \ @@ -1790,7 +1828,7 @@ jobs: rm -f "$release_json" "$publish_payload" "$quarantine_payload" \ "$verified_marker" rm -rf "$activation_marker_dir" - echo "[OK] Irreversibly committed and publicly verified ${TAG}; convergence run ${CONVERGENCE_RUN_ID} owns customer rollout." + echo "[OK] Immutably committed, attested, and publicly verified ${TAG}; convergence run ${CONVERGENCE_RUN_ID} owns customer rollout." release_commit_verdict: name: Release Activation Commit Verdict @@ -1810,6 +1848,9 @@ jobs: if: ${{ always() && needs.prepare.result == 'success' && needs.prepare.outputs.historical_asset_backfill_only != 'true' }} runs-on: ubuntu-24.04 steps: + - name: Checkout release integrity control + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - name: Enforce irreversible release commit outcome env: GH_TOKEN: ${{ github.token }} @@ -1858,6 +1899,9 @@ jobs: require_result "private Pro staging" "$PRIVATE_PRO_STAGE_RESULT" success fi + ./scripts/verify-github-release-integrity.sh \ + "$TAG" "${GITHUB_REPOSITORY}" "$RELEASE_ID" "$EXPECTED_COMMIT" + marker="$(mktemp)" curl -fsSL --retry 6 --retry-delay 5 --retry-all-errors \ -o "${marker}" \ diff --git a/.github/workflows/recover-release-activation.yml b/.github/workflows/recover-release-activation.yml index 11bc277b3..4879b27a4 100644 --- a/.github/workflows/recover-release-activation.yml +++ b/.github/workflows/recover-release-activation.yml @@ -247,6 +247,7 @@ jobs: verified_marker=$(mktemp) activated=false committed=false + marker_staged=false require_viable_convergence_owner() { local attempt owner_state owner_event owner_status owner_conclusion @@ -284,14 +285,27 @@ jobs: return 1 } - quarantine_on_error() { + compensate_uncommitted_activation() { if [ "${activated}" = "true" ] && [ "${committed}" != "true" ]; then - echo "::warning::Recovered activation failed before its marker; returning ${TAG} to draft quarantine." + echo "::warning::Recovered publication did not become immutable; returning ${TAG} to draft quarantine." gh api "repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}" \ -X PATCH --input "${quarantine_payload}" >/dev/null || true fi + if [ "${marker_staged}" = "true" ] && [ "${committed}" != "true" ]; then + marker_asset_id="$( + gh api --paginate \ + "repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets?per_page=100" \ + --jq '.[] | select(.name == "release-activation.json") | .id' \ + 2>/dev/null || true + )" + if [[ "${marker_asset_id}" =~ ^[0-9]+$ ]]; then + gh api -X DELETE \ + "repos/${GITHUB_REPOSITORY}/releases/assets/${marker_asset_id}" \ + >/dev/null || true + fi + fi } - trap quarantine_on_error ERR + trap compensate_uncommitted_activation ERR gh api "repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}" > "${release_json}" jq -e \ @@ -318,26 +332,6 @@ jobs: '{draft: false, make_latest: $make_latest}' > "${publish_payload}" jq -n '{draft: true, make_latest: "false"}' > "${quarantine_payload}" - require_viable_convergence_owner - gh api "repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}" \ - -X PATCH --input "${publish_payload}" > "${release_json}" - activated=true - if [ "$(jq -r '.draft' "${release_json}")" != "false" ] || \ - [ -z "$(jq -r '.published_at // ""' "${release_json}")" ]; then - echo "::error::GitHub did not publish release ${RELEASE_ID}." - exit 1 - fi - - base="https://github.com/${GITHUB_REPOSITORY}/releases/download/${TAG}" - for asset_name in \ - checksums.txt \ - install.sh \ - "pulse-provider-msp-${TAG}.tar.gz" \ - "pulse-${TAG}-linux-amd64.tar.gz"; do - curl -fsSL --retry 12 --retry-delay 5 --retry-all-errors \ - -o /dev/null "${base}/${asset_name}" - done - require_viable_convergence_owner jq -n \ --arg tag "${TAG}" \ @@ -360,7 +354,46 @@ jobs: gh release upload "${TAG}" \ "${activation_marker}" --clobber \ --repo "${GITHUB_REPOSITORY}" + marker_staged=true + + gh api "repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}" > "${release_json}" + expected_marker_digest="sha256:$(sha256sum "${activation_marker}" | awk '{print $1}')" + actual_marker_digest="$( + jq -er \ + '[.assets[] | select(.name == "release-activation.json" and .state == "uploaded")] | + if length == 1 then .[0].digest else error("expected exactly one activation marker") end | + select(test("^sha256:[0-9a-f]{64}$"))' \ + "${release_json}" + )" + if [ "${actual_marker_digest}" != "${expected_marker_digest}" ]; then + echo "::error::Recovered draft activation marker digest does not match the staged bytes." + exit 1 + fi + + require_viable_convergence_owner + gh api "repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}" \ + -X PATCH --input "${publish_payload}" > "${release_json}" + activated=true + if [ "$(jq -r '.draft' "${release_json}")" != "false" ] || \ + [ -z "$(jq -r '.published_at // ""' "${release_json}")" ] || \ + [ "$(jq -r '.immutable // false' "${release_json}")" != "true" ]; then + echo "::error::GitHub did not publish ${TAG} as an immutable release. Enable repository release immutability before activation." + exit 1 + fi committed=true + + base="https://github.com/${GITHUB_REPOSITORY}/releases/download/${TAG}" + for asset_name in \ + checksums.txt \ + install.sh \ + "pulse-provider-msp-${TAG}.tar.gz" \ + "pulse-${TAG}-linux-amd64.tar.gz"; do + curl -fsSL --retry 12 --retry-delay 5 --retry-all-errors \ + -o /dev/null "${base}/${asset_name}" + done + + ./scripts/verify-github-release-integrity.sh \ + "${TAG}" "${GITHUB_REPOSITORY}" "${RELEASE_ID}" "${EXPECTED_COMMIT}" curl -fsSL --retry 12 --retry-delay 5 --retry-all-errors \ -o "${verified_marker}" "${base}/release-activation.json" jq -e \ @@ -378,4 +411,4 @@ jobs: .activation_recovery_run_id == $activation_recovery_run_id' \ "${verified_marker}" >/dev/null trap - ERR - echo "[OK] Recovered and irreversibly committed ${TAG}; convergence run ${CONVERGENCE_RUN_ID} owns customer rollout." + echo "[OK] Recovered, immutably committed, and attested ${TAG}; convergence run ${CONVERGENCE_RUN_ID} owns customer rollout." diff --git a/.github/workflows/release-convergence.yml b/.github/workflows/release-convergence.yml index 4e1dd7412..0f8f85797 100644 --- a/.github/workflows/release-convergence.yml +++ b/.github/workflows/release-convergence.yml @@ -54,6 +54,9 @@ jobs: activation_owner_run_id: ${{ steps.marker.outputs.activation_owner_run_id }} activation_marker_sha256: ${{ steps.marker.outputs.activation_marker_sha256 }} steps: + - name: Checkout release integrity control + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - name: Wait for verified public activation marker id: marker env: @@ -76,20 +79,22 @@ jobs: for attempt in $(seq 1 "${max_attempts}"); do release_state="$( gh api "repos/${{ github.repository }}/releases/tags/${TAG}" \ - --jq '[.id, .tag_name, .target_commitish, (.draft | tostring), (.published_at // "")] | @tsv' \ + --jq '[.id, .tag_name, .target_commitish, (.draft | tostring), (.immutable // false | tostring), (.published_at // "")] | @tsv' \ 2>/dev/null || true )" release_id="$(awk -F '\t' '{print $1}' <<<"${release_state}")" actual_tag="$(awk -F '\t' '{print $2}' <<<"${release_state}")" actual_commit="$(awk -F '\t' '{print $3}' <<<"${release_state}")" is_draft="$(awk -F '\t' '{print $4}' <<<"${release_state}")" - published_at="$(awk -F '\t' '{print $5}' <<<"${release_state}")" + is_immutable="$(awk -F '\t' '{print $5}' <<<"${release_state}")" + published_at="$(awk -F '\t' '{print $6}' <<<"${release_state}")" marker="$(mktemp)" marker_downloaded=false if [ "${release_id}" = "${EXPECTED_RELEASE_ID}" ] && \ [ "${actual_tag}" = "${TAG}" ] && \ [ "${actual_commit}" = "${EXPECTED_COMMIT}" ] && \ [ "${is_draft}" = "false" ] && \ + [ "${is_immutable}" = "true" ] && \ [ -n "${published_at}" ] && \ curl -fsSL --retry 2 --retry-delay 2 --retry-all-errors \ -o "${marker}" "${marker_url}"; then @@ -123,11 +128,18 @@ jobs: fi echo "[OK] Adopting committed ${TAG} from completed convergence owner ${activation_owner_run_id}." fi + if ! ./scripts/verify-github-release-integrity.sh \ + "${TAG}" "${GITHUB_REPOSITORY}" \ + "${EXPECTED_RELEASE_ID}" "${EXPECTED_COMMIT}"; then + rm -f "${marker}" + echo "::error::Immutable release attestation verification failed for ${TAG}; customer convergence is blocked." + exit 1 + fi activation_marker_sha256="$(sha256sum "${marker}" | awk '{print $1}')" echo "activation_owner_run_id=${activation_owner_run_id}" >> "$GITHUB_OUTPUT" echo "activation_marker_sha256=${activation_marker_sha256}" >> "$GITHUB_OUTPUT" rm -f "${marker}" - echo "[OK] ${TAG} crossed the verified activation commit point." + echo "[OK] ${TAG} crossed the immutable, attested activation commit point." exit 0 fi rm -f "${marker}" diff --git a/.gitignore b/.gitignore index 0fed87d6d..4e15dcd4d 100644 --- a/.gitignore +++ b/.gitignore @@ -275,6 +275,7 @@ scripts/release_control/* !scripts/release_control/subsystem_contracts_test.py !scripts/release_control/subsystem_lookup.py !scripts/release_control/subsystem_lookup_test.py +!scripts/release_control/verify_github_release_integrity_test.py !scripts/release_control/ssh_host_key_policy_test.py !scripts/release_control/work_claim.py !scripts/release_control/work_claim_test.py diff --git a/docs/CODE_SIGNING_POLICY.md b/docs/CODE_SIGNING_POLICY.md index b0f0c8336..f3b416bed 100644 --- a/docs/CODE_SIGNING_POLICY.md +++ b/docs/CODE_SIGNING_POLICY.md @@ -73,6 +73,14 @@ Normal stable publication and stable dry runs select `signpath` directly. policy is invalid. - Release checksums and detached signatures are published alongside artifacts and verified independently after publication. +- Every new release is assembled and validated as a draft. Its activation + marker is uploaded and digest-checked before publication; GitHub must then + report the published release as immutable, protecting its tag and complete + asset set from replacement. +- Customer-facing image aliases, Helm indexes, paid-runtime pointers, and demo + environments are not promoted until `gh release verify --repo + rcourtman/Pulse` validates GitHub's signed release attestation. Operators can + use the same command to verify the packet independently. ## Project roles diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 30a4460e4..b9b6d9ae9 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -120,6 +120,9 @@ may delete invalid assets and rewrite validation annotations only while a release is still a draft. A post-publication edit is observation, not authority to mutate or destroy an immutable release; failed revalidation records a failing status and requires an explicit corrective release path. +The activation marker is part of that complete draft packet: its stored digest +must be checked before publication, and customer convergence is forbidden until +GitHub reports the release immutable and its signed release attestation verifies. The accelerated exact-SHA release worker must preserve release-gate fidelity under its own resource envelope. Bounded frontend static checks and integration @@ -267,6 +270,7 @@ release-latency optimization. 95. `.github/scripts/check-demo-reachability.sh` 96. `.github/scripts/setup-demo-ssh.sh` 97. `scripts/trigger-stable-patch.sh` +98. `scripts/verify-github-release-integrity.sh` ## Shared Boundaries @@ -602,7 +606,7 @@ artifact-selection behaviour. ## Extension Points 1. Add or change deployment-type detection, update planning, or apply behavior through `internal/updates/` -2. Add or change release-build metadata injection, Docker build-context allowlists, release artifact assembly, governed promotion metadata resolution, artifact release-line validation, post-install live-runtime claim proof, the canonical version file, operator-facing release packet content, model-selected visual release-note capture, prerelease feedback intake wording, historical published-release integrity backfill, release asset validation status publication, download endpoint checksum/signature header proof, end-to-end install.sh smoke against staged or published release assets, or the canonical in-repo v6 upgrade guide through `scripts/build-release.sh`, `scripts/build-release-binaries.sh`, `scripts/release_build_targets.sh`, `scripts/run-release-backend-tests.sh`, `scripts/shard_go_tests.py`, `scripts/release_asset_common.sh`, `scripts/backfill-release-assets.sh`, `scripts/release_ldflags.sh`, `scripts/check-workflow-dispatch-inputs.py`, `scripts/capture-release-note-visuals.sh`, `scripts/release-preflight-worker.sh`, `scripts/run-release-preflight.sh`, `scripts/release_control/capture_release_note_visuals.mjs`, `scripts/release_control/release_note_visuals.py`, `scripts/release_control/live_runtime_proof.py`, `scripts/release_control/live_runtime_proof_test.py`, `scripts/release_control/mobile_release_gate.py`, `scripts/release_control/render_release_body.py`, `scripts/release_control/resolve_release_promotion.py`, `scripts/release_control/validate_artifact_release_line.py`, `scripts/release_control/record_rc_to_ga_rehearsal.py`, `scripts/release_control/internal/record_rc_to_ga_rehearsal.py`, `scripts/release_control/release_promotion_policy_support.py`, `pulse-enterprise:scripts/build-pro-binaries.sh`, `pulse-enterprise:scripts/build-pro-release.sh`, `pulse-enterprise:scripts/validate-pro-release-line.sh`, `.dockerignore`, `Dockerfile`, `.github/ISSUE_TEMPLATE/v6_rc_feedback.yml`, `docs/RELEASE_NOTES.md`, `docs/releases/`, `docs/UPGRADE_v6.md`, `docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md`, `docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md`, `docs/release-control/v6/internal/RC_TO_GA_REHEARSAL_TEMPLATE.md`, `scripts/validate-release.sh`, `scripts/validate-published-release.sh`, the operator dispatch helpers `scripts/trigger-release.sh` and `scripts/trigger-release-dry-run.sh`, and the governed release workflows `.github/workflows/backfill-release-assets.yml`, `.github/workflows/build-release-candidate.yml`, `.github/workflows/create-release.yml`, `.github/workflows/deploy-demo-server.yml`, `.github/workflows/helm-pages.yml`, `.github/workflows/install-sh-smoke.yml`, `.github/workflows/promote-floating-tags.yml`, `.github/workflows/promote-private-pro-runtime.yml`, `.github/workflows/publish-docker.yml`, `.github/workflows/publish-helm-chart.yml`, `.github/workflows/release-convergence.yml`, `.github/workflows/release-dry-run.yml`, `.github/workflows/retry-release-convergence.yml`, `.github/workflows/update-demo-server.yml`, `.github/workflows/validate-release-assets.yml`, and `pulse-enterprise:.github/workflows/build-pro-release.yml` +2. Add or change release-build metadata injection, Docker build-context allowlists, release artifact assembly, governed promotion metadata resolution, artifact release-line validation, post-install live-runtime claim proof, the canonical version file, operator-facing release packet content, model-selected visual release-note capture, prerelease feedback intake wording, historical published-release integrity backfill, release asset validation status publication, download endpoint checksum/signature header proof, end-to-end install.sh smoke against staged or published release assets, or the canonical in-repo v6 upgrade guide through `scripts/build-release.sh`, `scripts/build-release-binaries.sh`, `scripts/release_build_targets.sh`, `scripts/run-release-backend-tests.sh`, `scripts/shard_go_tests.py`, `scripts/release_asset_common.sh`, `scripts/backfill-release-assets.sh`, `scripts/release_ldflags.sh`, `scripts/check-workflow-dispatch-inputs.py`, `scripts/capture-release-note-visuals.sh`, `scripts/release-preflight-worker.sh`, `scripts/run-release-preflight.sh`, `scripts/verify-github-release-integrity.sh`, `scripts/release_control/capture_release_note_visuals.mjs`, `scripts/release_control/release_note_visuals.py`, `scripts/release_control/live_runtime_proof.py`, `scripts/release_control/live_runtime_proof_test.py`, `scripts/release_control/mobile_release_gate.py`, `scripts/release_control/render_release_body.py`, `scripts/release_control/resolve_release_promotion.py`, `scripts/release_control/validate_artifact_release_line.py`, `scripts/release_control/record_rc_to_ga_rehearsal.py`, `scripts/release_control/internal/record_rc_to_ga_rehearsal.py`, `scripts/release_control/release_promotion_policy_support.py`, `pulse-enterprise:scripts/build-pro-binaries.sh`, `pulse-enterprise:scripts/build-pro-release.sh`, `pulse-enterprise:scripts/validate-pro-release-line.sh`, `.dockerignore`, `Dockerfile`, `.github/ISSUE_TEMPLATE/v6_rc_feedback.yml`, `docs/RELEASE_NOTES.md`, `docs/releases/`, `docs/UPGRADE_v6.md`, `docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md`, `docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md`, `docs/release-control/v6/internal/RC_TO_GA_REHEARSAL_TEMPLATE.md`, `scripts/validate-release.sh`, `scripts/validate-published-release.sh`, the operator dispatch helpers `scripts/trigger-release.sh` and `scripts/trigger-release-dry-run.sh`, and the governed release workflows `.github/workflows/backfill-release-assets.yml`, `.github/workflows/build-release-candidate.yml`, `.github/workflows/create-release.yml`, `.github/workflows/deploy-demo-server.yml`, `.github/workflows/helm-pages.yml`, `.github/workflows/install-sh-smoke.yml`, `.github/workflows/promote-floating-tags.yml`, `.github/workflows/promote-private-pro-runtime.yml`, `.github/workflows/publish-docker.yml`, `.github/workflows/publish-helm-chart.yml`, `.github/workflows/release-convergence.yml`, `.github/workflows/release-dry-run.yml`, `.github/workflows/retry-release-convergence.yml`, `.github/workflows/update-demo-server.yml`, `.github/workflows/validate-release-assets.yml`, and `pulse-enterprise:.github/workflows/build-pro-release.yml` The governed release-build surface also includes `scripts/prepare-release-container-context.sh` for exact-candidate container assembly. @@ -4391,3 +4395,22 @@ grants by reading the existing unit. A unit with an active grant sets host); a grantless profile keeps `NoNewPrivileges=true`. Uninstall removes the sudoers file and helper directory. `scripts/installtests/install_sh_test.go` (`TestInstallSHLeastPrivilegeProfile`) pins these invariants. + +### Publication locks the complete release packet + +GitHub release immutability is a mandatory activation control. The release +workflow must create and validate a draft, stage `release-activation.json`, and +compare GitHub's stored SHA-256 digest for that marker with the local bytes +before publication. Publication, not a later asset upload, is the irreversible +boundary. GitHub must return `immutable: true`; otherwise the workflow must +fail and compensate the still-mutable publication back to a marker-free draft. + +`scripts/verify-github-release-integrity.sh` is the shared post-publication +check. It binds the release database ID, tag, exact source SHA, immutable state, +and single digest-bearing activation marker, then requires `gh release verify` +to validate GitHub's signed release attestation. The source release verdict, +activation-only recovery, and `release-convergence.yml` must all use that +check. Convergence must not acquire the customer-promotion lease or mutate a +floating image tag, Helm index, paid-runtime pointer, or live environment until +the check passes. Repository release immutability must therefore be enabled +before merging or running this activation path. diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index 456b52fda..f665e9b7c 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -4406,6 +4406,7 @@ "scripts/trigger-stable-patch.sh", "scripts/uninstall-sensor-proxy.sh", "scripts/validate-release.sh", + "scripts/verify-github-release-integrity.sh", "tests/integration/playwright.config.ts", "tests/integration/QUICK_START.md", "tests/integration/README.md", @@ -4553,6 +4554,7 @@ "scripts/trigger-release-dry-run.sh", "scripts/trigger-release.sh", "scripts/trigger-stable-patch.sh", + "scripts/verify-github-release-integrity.sh", "VERSION" ], "allow_same_subsystem_tests": false, @@ -4570,7 +4572,8 @@ "scripts/release_control/release_promotion_policy_test.py", "scripts/release_control/render_release_body_test.py", "scripts/release_control/resolve_release_promotion_test.py", - "scripts/release_control/validate_artifact_release_line_test.py" + "scripts/release_control/validate_artifact_release_line_test.py", + "scripts/release_control/verify_github_release_integrity_test.py" ] }, { diff --git a/frontend-modern/public/docs/CODE_SIGNING_POLICY.md b/frontend-modern/public/docs/CODE_SIGNING_POLICY.md index b0f0c8336..f3b416bed 100644 --- a/frontend-modern/public/docs/CODE_SIGNING_POLICY.md +++ b/frontend-modern/public/docs/CODE_SIGNING_POLICY.md @@ -73,6 +73,14 @@ Normal stable publication and stable dry runs select `signpath` directly. policy is invalid. - Release checksums and detached signatures are published alongside artifacts and verified independently after publication. +- Every new release is assembled and validated as a draft. Its activation + marker is uploaded and digest-checked before publication; GitHub must then + report the published release as immutable, protecting its tag and complete + asset set from replacement. +- Customer-facing image aliases, Helm indexes, paid-runtime pointers, and demo + environments are not promoted until `gh release verify --repo + rcourtman/Pulse` validates GitHub's signed release attestation. Operators can + use the same command to verify the packet independently. ## Project roles diff --git a/scripts/release_control/release_promotion_policy_test.py b/scripts/release_control/release_promotion_policy_test.py index 4b38fa165..816fcefc9 100644 --- a/scripts/release_control/release_promotion_policy_test.py +++ b/scripts/release_control/release_promotion_policy_test.py @@ -396,22 +396,33 @@ class ReleasePromotionPolicyTest(unittest.TestCase): self.assertIn(".r2_prefix == $r2_prefix", activation) self.assertIn(".r2_prefix == $r2_prefix", convergence) self.assertIn("committed=true", activation) - self.assertIn("Irreversibly committed and publicly verified ${TAG}", activation) + self.assertIn("Immutably committed, attested, and publicly verified ${TAG}", activation) + self.assertIn("Draft activation marker digest does not match", activation) + self.assertIn(".immutable // false", activation) + self.assertIn("verify-github-release-integrity.sh", activation) + self.assertIn("verify-github-release-integrity.sh", convergence) + self.assertIn("verify-github-release-integrity.sh", commit_verdict) marker_upload = activation.index('gh release upload "${TAG}"') - commit_flip = activation.index("committed=true", marker_upload) + publish_patch = activation.index( + '-X PATCH --input "$publish_payload"', marker_upload + ) + commit_flip = activation.index("committed=true", publish_patch) activation_readback = activation.index("curl -fsSL --retry 12", commit_flip) self.assertIn( '--repo "${GITHUB_REPOSITORY}"', - activation[marker_upload:commit_flip], + activation[marker_upload:publish_patch], ) - self.assertLess(marker_upload, commit_flip) + self.assertLess(marker_upload, publish_patch) + self.assertLess(publish_patch, commit_flip) self.assertLess(commit_flip, activation_readback) - # Failure injection: after the marker upload succeeds, activation-side - # public read-back may fail, but the ERR trap must see committed=true - # and may no longer quarantine while convergence owns the marker. - state = {"activated": True, "marker_uploaded": False, "committed": False} - state["marker_uploaded"] = True + # Failure injection: the draft marker is still compensatable, but once + # immutable publication succeeds, a later public read-back failure may + # not attempt to mutate the locked release. + state = {"activated": False, "marker_staged": True, "committed": False} + should_remove_marker = state["marker_staged"] and not state["committed"] + self.assertTrue(should_remove_marker) + state["activated"] = True state["committed"] = True activation_readback_succeeded = False should_quarantine = state["activated"] and not state["committed"] @@ -463,6 +474,9 @@ class ReleasePromotionPolicyTest(unittest.TestCase): self.assertIn('--repo "${GITHUB_REPOSITORY}"', job) self.assertNotIn("build-release-candidate.yml", recovery) self.assertNotIn("scripts/build-release.sh", recovery) + self.assertIn("Recovered draft activation marker digest does not match", job) + self.assertIn(".immutable // false", job) + self.assertIn("verify-github-release-integrity.sh", job) convergence = read(".github/workflows/release-convergence.yml") helm_pages_caller = workflow_job_block(convergence, "publish_helm_pages") @@ -470,9 +484,13 @@ class ReleasePromotionPolicyTest(unittest.TestCase): self.assertIn("contents: write", helm_pages_caller) marker_upload = job.index('gh release upload "${TAG}"') - committed = job.index("committed=true", marker_upload) + publish_patch = job.index( + '-X PATCH --input "${publish_payload}"', marker_upload + ) + committed = job.index("committed=true", publish_patch) readback = job.index("curl -fsSL --retry 12", committed) - self.assertLess(marker_upload, committed) + self.assertLess(marker_upload, publish_patch) + self.assertLess(publish_patch, committed) self.assertLess(committed, readback) helm_pages = read(".github/workflows/helm-pages.yml") @@ -743,7 +761,7 @@ class ReleasePromotionPolicyTest(unittest.TestCase): self.assertIn("--json event,status,conclusion,workflowName,displayTitle,url", activation) self.assertIn('expected_title="Release convergence ${TAG} source ${GITHUB_RUN_ID}"', activation) self.assertIn('[ "${owner_status}" = "completed" ]', activation) - self.assertIn("immediately before the marker", activation) + self.assertIn("before staging the exact marker", activation) self.assertIn("validate_existing_activation_commit", activation) self.assertIn( "Recover release activation ${TAG} source ${GITHUB_RUN_ID}", activation @@ -764,10 +782,13 @@ class ReleasePromotionPolicyTest(unittest.TestCase): activation.index("require_viable_convergence_owner\n gh api"), activation.index("-X PATCH --input \"$publish_payload\""), ) - self.assertLess( - activation.rindex("require_viable_convergence_owner"), - activation.index('gh release upload "${TAG}"'), + marker_upload = activation.index('gh release upload "${TAG}"') + final_owner_check = activation.rindex("require_viable_convergence_owner") + publish_patch = activation.index( + '-X PATCH --input "$publish_payload"', final_owner_check ) + self.assertLess(marker_upload, final_owner_check) + self.assertLess(final_owner_check, publish_patch) def test_fresh_fixed_code_convergence_can_adopt_completed_original_owner(self) -> None: convergence = read(".github/workflows/release-convergence.yml") diff --git a/scripts/release_control/verify_github_release_integrity_test.py b/scripts/release_control/verify_github_release_integrity_test.py new file mode 100644 index 000000000..c597b54bb --- /dev/null +++ b/scripts/release_control/verify_github_release_integrity_test.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import json +import os +from pathlib import Path +import subprocess +import tempfile +import textwrap +import unittest + + +ROOT = Path(__file__).resolve().parents[2] +SCRIPT = ROOT / "scripts" / "verify-github-release-integrity.sh" +SOURCE_SHA = "a" * 40 + + +class VerifyGitHubReleaseIntegrityTest(unittest.TestCase): + def run_verifier(self, release: dict, *, verification_succeeds: bool = True): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + calls = root / "calls" + fake_gh = root / "gh" + fake_gh.write_text( + textwrap.dedent( + f"""\ + #!/usr/bin/env bash + set -euo pipefail + printf '%s\\n' "$*" >> {calls!s} + if [ "$1" = api ]; then + cat <<'JSON' + {json.dumps(release)} + JSON + exit 0 + fi + if [ "$1 $2" = "release verify" ]; then + printf '%s\\n' '{{"verified": true}}' + exit {0 if verification_succeeds else 1} + fi + exit 64 + """ + ), + encoding="utf-8", + ) + fake_gh.chmod(0o755) + env = os.environ.copy() + env.update( + { + "PATH": f"{root}:{env['PATH']}", + "PULSE_RELEASE_ATTESTATION_ATTEMPTS": "1", + "PULSE_RELEASE_ATTESTATION_RETRY_DELAY": "0", + } + ) + result = subprocess.run( + [str(SCRIPT), "v6.5.0", "rcourtman/Pulse", "123", SOURCE_SHA], + cwd=ROOT, + env=env, + text=True, + capture_output=True, + check=False, + ) + call_text = calls.read_text(encoding="utf-8") if calls.exists() else "" + return result, call_text + + @staticmethod + def release(*, immutable: bool = True) -> dict: + return { + "id": 123, + "tag_name": "v6.5.0", + "target_commitish": SOURCE_SHA, + "draft": False, + "prerelease": False, + "immutable": immutable, + "published_at": "2026-08-29T18:00:00Z", + "assets": [ + { + "name": "release-activation.json", + "state": "uploaded", + "size": 300, + "digest": "sha256:" + "b" * 64, + } + ], + } + + def test_accepts_immutable_release_with_verified_attestation(self) -> None: + result, calls = self.run_verifier(self.release()) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("is immutable and attested", result.stdout) + self.assertIn("release verify v6.5.0 --repo rcourtman/Pulse --format json", calls) + + def test_rejects_mutable_release_before_attestation(self) -> None: + result, calls = self.run_verifier(self.release(immutable=False)) + self.assertNotEqual(result.returncode, 0) + self.assertIn("not an immutable published packet", result.stderr) + self.assertNotIn("release verify", calls) + + def test_rejects_missing_activation_marker(self) -> None: + release = self.release() + release["assets"] = [] + result, _ = self.run_verifier(release) + self.assertNotEqual(result.returncode, 0) + self.assertIn("activation marker", result.stderr) + + def test_rejects_failed_release_attestation(self) -> None: + result, _ = self.run_verifier( + self.release(), verification_succeeds=False + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn("attestation verification failed", result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/standalone.manifest b/scripts/standalone.manifest index 37f2af46b..fec79535c 100644 --- a/scripts/standalone.manifest +++ b/scripts/standalone.manifest @@ -24,3 +24,4 @@ scripts/patrol_e2e_matrix.sh | Manual e2e model-matrix exercise utility for patr scripts/remerge-parallel.sh | Operator script for branch remerge operations across multiple refs. scripts/trigger-release.sh | Manual workflow-dispatch helper for release orchestration. scripts/validate-published-release.sh | Post-publish GitHub release checksum verifier. +scripts/verify-github-release-integrity.sh | Post-publish immutable-release and attestation verifier. diff --git a/scripts/verify-github-release-integrity.sh b/scripts/verify-github-release-integrity.sh new file mode 100755 index 000000000..5d20b5c66 --- /dev/null +++ b/scripts/verify-github-release-integrity.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash + +# Verify GitHub's post-publication integrity boundary for a Pulse release. +# Immutable GitHub releases protect the tag and asset set and receive a signed +# release attestation. Both properties are required: an attestation check alone +# must not bless a release whose assets can still be replaced afterward. + +set -euo pipefail + +if [ "$#" -lt 2 ] || [ "$#" -gt 4 ]; then + echo "Usage: $0 [expected-release-id] [expected-source-sha]" >&2 + exit 1 +fi + +TAG="$1" +REPO="$2" +EXPECTED_RELEASE_ID="${3:-}" +EXPECTED_SOURCE_SHA="${4:-}" +ATTESTATION_ATTEMPTS="${PULSE_RELEASE_ATTESTATION_ATTEMPTS:-12}" +ATTESTATION_RETRY_DELAY="${PULSE_RELEASE_ATTESTATION_RETRY_DELAY:-5}" + +if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-(rc|alpha|beta)\.[0-9]+)?$ ]]; then + echo "Invalid release tag: ${TAG}" >&2 + exit 1 +fi +if [[ ! "$REPO" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]; then + echo "Invalid GitHub repository: ${REPO}" >&2 + exit 1 +fi +if [ -n "$EXPECTED_RELEASE_ID" ] && [[ ! "$EXPECTED_RELEASE_ID" =~ ^[0-9]+$ ]]; then + echo "Invalid expected release id: ${EXPECTED_RELEASE_ID}" >&2 + exit 1 +fi +if [ -n "$EXPECTED_SOURCE_SHA" ] && [[ ! "$EXPECTED_SOURCE_SHA" =~ ^[0-9a-f]{40}$ ]]; then + echo "Invalid expected source SHA: ${EXPECTED_SOURCE_SHA}" >&2 + exit 1 +fi +if [[ ! "$ATTESTATION_ATTEMPTS" =~ ^[1-9][0-9]*$ ]]; then + echo "PULSE_RELEASE_ATTESTATION_ATTEMPTS must be a positive integer." >&2 + exit 1 +fi +if [[ ! "$ATTESTATION_RETRY_DELAY" =~ ^[0-9]+$ ]]; then + echo "PULSE_RELEASE_ATTESTATION_RETRY_DELAY must be a non-negative integer." >&2 + exit 1 +fi + +for command in gh jq; do + if ! command -v "$command" >/dev/null 2>&1; then + echo "${command} is required to verify GitHub release integrity." >&2 + exit 1 + fi +done + +release_json="$(mktemp)" +attestation_json="$(mktemp)" +cleanup() { rm -f "$release_json" "$attestation_json"; } +trap cleanup EXIT + +gh api \ + -H 'Accept: application/vnd.github+json' \ + -H 'X-GitHub-Api-Version: 2026-03-10' \ + "repos/${REPO}/releases/tags/${TAG}" > "$release_json" + +if ! jq -e \ + --arg tag "$TAG" \ + --arg expected_release_id "$EXPECTED_RELEASE_ID" \ + --arg expected_source_sha "$EXPECTED_SOURCE_SHA" \ + '.tag_name == $tag and + .draft == false and + (.published_at | type == "string" and length > 0) and + .immutable == true and + ($expected_release_id == "" or (.id | tostring) == $expected_release_id) and + ($expected_source_sha == "" or .target_commitish == $expected_source_sha) and + ([.assets[]? | select( + .name == "release-activation.json" and + .state == "uploaded" and + (.size | type == "number" and . > 0) and + (.digest | type == "string" and test("^sha256:[0-9a-f]{64}$")) + )] | length == 1)' \ + "$release_json" >/dev/null; then + jq -c \ + '{id, tag_name, target_commitish, draft, prerelease, immutable, published_at, + activation_assets: [.assets[]? | select(.name == "release-activation.json") | + {name, state, size, digest}]}' \ + "$release_json" >&2 + echo "Release ${TAG} is not an immutable published packet with one digest-bound activation marker." >&2 + exit 1 +fi + +verified=false +for attempt in $(seq 1 "$ATTESTATION_ATTEMPTS"); do + if gh release verify "$TAG" --repo "$REPO" --format json > "$attestation_json"; then + verified=true + break + fi + if [ "$attempt" -lt "$ATTESTATION_ATTEMPTS" ]; then + echo "Release attestation for ${TAG} is not verifiable yet (${attempt}/${ATTESTATION_ATTEMPTS}); retrying." >&2 + sleep "$ATTESTATION_RETRY_DELAY" + fi +done + +if [ "$verified" != true ]; then + echo "GitHub release attestation verification failed for ${TAG}." >&2 + exit 1 +fi +if ! jq -e 'type == "object" or type == "array"' "$attestation_json" >/dev/null; then + echo "GitHub release attestation verification returned malformed JSON for ${TAG}." >&2 + exit 1 +fi + +release_id="$(jq -r '.id' "$release_json")" +source_sha="$(jq -r '.target_commitish' "$release_json")" +asset_count="$(jq -r '.assets | length' "$release_json")" +echo "[OK] GitHub release ${TAG} is immutable and attested: release_id=${release_id} source_sha=${source_sha} assets=${asset_count}."