Require immutable attested releases

This commit is contained in:
pulse-triage[bot]
2026-08-29 19:54:27 +01:00
parent 08581a2bc9
commit 1d170de364
13 changed files with 467 additions and 82 deletions
@@ -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
+82 -38
View File
@@ -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}" \
@@ -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."
+15 -3
View File
@@ -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}"
+1
View File
@@ -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
+8
View File
@@ -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 <tag> --repo
rcourtman/Pulse` validates GitHub's signed release attestation. Operators can
use the same command to verify the packet independently.
## Project roles
@@ -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.
@@ -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"
]
},
{
@@ -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 <tag> --repo
rcourtman/Pulse` validates GitHub's signed release attestation. Operators can
use the same command to verify the packet independently.
## Project roles
@@ -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")
@@ -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()
+1
View File
@@ -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.
+114
View File
@@ -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 <tag> <owner/repo> [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}."