From 7e7fb539119c6e9f257496f16e8a5e358c9617a3 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 29 Aug 2026 22:36:05 +0100 Subject: [PATCH] Gate release publication on immutable setting --- .github/workflows/canonical-governance.yml | 3 + .github/workflows/create-release.yml | 13 ++- .../workflows/recover-release-activation.yml | 8 ++ .../subsystems/deployment-installability.md | 12 +- scripts/check-github-release-immutability.sh | 56 +++++++++ .../check_github_release_immutability_test.py | 109 ++++++++++++++++++ .../release_promotion_policy_test.py | 21 +++- 7 files changed, 217 insertions(+), 5 deletions(-) create mode 100755 scripts/check-github-release-immutability.sh create mode 100755 scripts/release_control/check_github_release_immutability_test.py diff --git a/.github/workflows/canonical-governance.yml b/.github/workflows/canonical-governance.yml index 671da32fe..b674e6cb6 100644 --- a/.github/workflows/canonical-governance.yml +++ b/.github/workflows/canonical-governance.yml @@ -255,6 +255,9 @@ jobs: - name: Run immutable release integrity unit tests run: python3 scripts/release_control/verify_github_release_integrity_test.py + - name: Run immutable release setting unit tests + run: python3 scripts/release_control/check_github_release_immutability_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 160583572..1fd543436 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -1558,6 +1558,7 @@ jobs: - name: Publish the fully staged release env: GH_TOKEN: ${{ github.token }} + IMMUTABILITY_ADMIN_TOKEN: ${{ secrets.WORKFLOW_PAT }} TAG: ${{ needs.prepare.outputs.tag }} RELEASE_ID: ${{ needs.create_release.outputs.release_id }} EXPECTED_COMMIT: ${{ needs.create_release.outputs.target_commitish }} @@ -1775,8 +1776,18 @@ jobs: fi # Publication is now the only irreversible boundary. GitHub must - # report the complete release as immutable before this job commits. + # confirm the repository setting before publication and report the + # complete release as immutable afterward. The immediate setting + # check prevents a mutable public interval if configuration drifts; + # the response check remains defense in depth. require_viable_convergence_owner + if [ -z "${IMMUTABILITY_ADMIN_TOKEN:-}" ]; then + echo "::error::WORKFLOW_PAT with repository Administration (read) is required to prove release immutability." + exit 1 + fi + GH_TOKEN="${IMMUTABILITY_ADMIN_TOKEN}" \ + ./scripts/check-github-release-immutability.sh "${GITHUB_REPOSITORY}" + unset IMMUTABILITY_ADMIN_TOKEN gh api "repos/${{ github.repository }}/releases/${RELEASE_ID}" \ -X PATCH --input "$publish_payload" > "$release_json" activated=true diff --git a/.github/workflows/recover-release-activation.yml b/.github/workflows/recover-release-activation.yml index 4879b27a4..65b069c48 100644 --- a/.github/workflows/recover-release-activation.yml +++ b/.github/workflows/recover-release-activation.yml @@ -230,6 +230,7 @@ jobs: - name: Commit the recovered activation env: GH_TOKEN: ${{ github.token }} + IMMUTABILITY_ADMIN_TOKEN: ${{ secrets.WORKFLOW_PAT }} TAG: ${{ inputs.tag }} SOURCE_RELEASE_RUN_ID: ${{ inputs.source_release_run_id }} EXPECTED_COMMIT: ${{ steps.qualify.outputs.source_sha }} @@ -371,6 +372,13 @@ jobs: fi require_viable_convergence_owner + if [ -z "${IMMUTABILITY_ADMIN_TOKEN:-}" ]; then + echo "::error::WORKFLOW_PAT with repository Administration (read) is required to prove release immutability." + exit 1 + fi + GH_TOKEN="${IMMUTABILITY_ADMIN_TOKEN}" \ + ./scripts/check-github-release-immutability.sh "${GITHUB_REPOSITORY}" + unset IMMUTABILITY_ADMIN_TOKEN gh api "repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}" \ -X PATCH --input "${publish_payload}" > "${release_json}" activated=true diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 1557f3961..d294a916e 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -184,7 +184,8 @@ release-latency optimization. 23c. `.github/scripts/resolve-demo-runtime-profile.sh` 24. `.github/workflows/validate-release-assets.yml` 25. `.github/workflows/install-sh-smoke.yml` -26. `scripts/release_control/customer_promotion_lease.sh` +26. `scripts/check-github-release-immutability.sh` +27. `scripts/release_control/customer_promotion_lease.sh` 27. `pulse-enterprise:.github/workflows/build-pro-release.yml` 28. `pulse-enterprise:scripts/build-pro-binaries.sh` 29. `pulse-enterprise:scripts/build-pro-release.sh` @@ -4412,8 +4413,13 @@ 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. +boundary. Immediately before both normal and recovery publication, an +authenticated Administration-read request to GitHub's repository immutable +releases endpoint must prove that the setting is enabled. An unavailable, +unauthorized, malformed, or disabled response fails closed while the release is +still a draft. GitHub must also return `immutable: true` after publication; +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, diff --git a/scripts/check-github-release-immutability.sh b/scripts/check-github-release-immutability.sh new file mode 100755 index 000000000..540936f09 --- /dev/null +++ b/scripts/check-github-release-immutability.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash + +# Fail closed unless GitHub confirms that future releases in this repository +# will become immutable when a staged draft is published. The endpoint requires +# repository Administration (read), so callers must supply an explicit token +# with that narrow read capability rather than treating an anonymous 404 as a +# disabled setting. + +set -euo pipefail + +if [ "$#" -ne 1 ]; then + echo "Usage: $0 " >&2 + exit 1 +fi + +REPO="$1" +if [[ ! "$REPO" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]; then + echo "Invalid GitHub repository: ${REPO}" >&2 + exit 1 +fi +if [ -z "${GH_TOKEN:-}" ]; then + echo "GH_TOKEN with repository Administration (read) is required to prove release immutability." >&2 + exit 1 +fi + +for command in gh jq; do + if ! command -v "$command" >/dev/null 2>&1; then + echo "${command} is required to check GitHub release immutability." >&2 + exit 1 + fi +done + +setting_json="$(mktemp)" +cleanup() { + rm -f "$setting_json" +} +trap cleanup EXIT + +if ! gh api \ + -H 'Accept: application/vnd.github+json' \ + -H 'X-GitHub-Api-Version: 2026-03-10' \ + "repos/${REPO}/immutable-releases" > "$setting_json"; then + echo "GitHub did not confirm immutable releases for ${REPO}; the setting may be disabled or the token may lack Administration (read)." >&2 + exit 1 +fi + +if ! jq -e \ + '.enabled == true and (.enforced_by_owner | type == "boolean")' \ + "$setting_json" >/dev/null; then + jq -c '{enabled, enforced_by_owner}' "$setting_json" >&2 || true + echo "Immutable releases are not enabled for ${REPO}; refusing to cross the publication boundary." >&2 + exit 1 +fi + +enforced_by_owner="$(jq -r '.enforced_by_owner // false' "$setting_json")" +echo "[OK] GitHub immutable releases are enabled for ${REPO} (enforced_by_owner=${enforced_by_owner})." diff --git a/scripts/release_control/check_github_release_immutability_test.py b/scripts/release_control/check_github_release_immutability_test.py new file mode 100755 index 000000000..01eb32e6a --- /dev/null +++ b/scripts/release_control/check_github_release_immutability_test.py @@ -0,0 +1,109 @@ +#!/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" / "check-github-release-immutability.sh" + + +class CheckGitHubReleaseImmutabilityTest(unittest.TestCase): + def run_check( + self, + response: object = None, + *, + api_succeeds: bool = True, + include_token: bool = True, + repository: str = "rcourtman/Pulse", + ): + if response is None: + response = {"enabled": True, "enforced_by_owner": False} + 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} + cat <<'JSON' + {json.dumps(response)} + JSON + exit {0 if api_succeeds else 1} + """ + ), + encoding="utf-8", + ) + fake_gh.chmod(0o755) + env = os.environ.copy() + env["PATH"] = f"{root}:{env['PATH']}" + if include_token: + env["GH_TOKEN"] = "test-token" + else: + env.pop("GH_TOKEN", None) + result = subprocess.run( + [str(SCRIPT), repository], + 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 + + def test_accepts_enabled_repository_setting(self) -> None: + result, calls = self.run_check() + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("immutable releases are enabled", result.stdout) + self.assertIn("repos/rcourtman/Pulse/immutable-releases", calls) + self.assertIn("X-GitHub-Api-Version: 2026-03-10", calls) + + def test_rejects_disabled_repository_setting(self) -> None: + result, _ = self.run_check( + {"enabled": False, "enforced_by_owner": False} + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn("refusing to cross the publication boundary", result.stderr) + + def test_rejects_unavailable_or_unauthorized_setting(self) -> None: + result, _ = self.run_check(api_succeeds=False) + self.assertNotEqual(result.returncode, 0) + self.assertIn("may be disabled or the token may lack", result.stderr) + + def test_rejects_malformed_success_response(self) -> None: + result, _ = self.run_check({"enforced_by_owner": False}) + self.assertNotEqual(result.returncode, 0) + self.assertIn("not enabled", result.stderr) + + result, _ = self.run_check( + {"enabled": True, "enforced_by_owner": "false"} + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn("not enabled", result.stderr) + + def test_requires_explicit_administration_read_token(self) -> None: + result, calls = self.run_check(include_token=False) + self.assertNotEqual(result.returncode, 0) + self.assertIn("Administration (read) is required", result.stderr) + self.assertEqual(calls, "") + + def test_rejects_invalid_repository_before_api_call(self) -> None: + result, calls = self.run_check(repository="not-a-repository") + self.assertNotEqual(result.returncode, 0) + self.assertIn("Invalid GitHub repository", result.stderr) + self.assertEqual(calls, "") + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/release_control/release_promotion_policy_test.py b/scripts/release_control/release_promotion_policy_test.py index 816fcefc9..21fea0673 100644 --- a/scripts/release_control/release_promotion_policy_test.py +++ b/scripts/release_control/release_promotion_policy_test.py @@ -351,6 +351,8 @@ class ReleasePromotionPolicyTest(unittest.TestCase): dispatch = workflow_job_block(workflow, "dispatch_release_convergence") activation = workflow_job_block(workflow, "activate_release") commit_verdict = workflow_job_block(workflow, "release_commit_verdict") + recovery = read(".github/workflows/recover-release-activation.yml") + recovery_activation = workflow_job_block(recovery, "recover_activation") for dependency in ( "create_release", @@ -402,6 +404,20 @@ class ReleasePromotionPolicyTest(unittest.TestCase): 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) + for publication_job in (activation, recovery_activation): + with self.subTest(publication_job=publication_job[:40]): + self.assertIn( + "IMMUTABILITY_ADMIN_TOKEN: ${{ secrets.WORKFLOW_PAT }}", + publication_job, + ) + self.assertIn("check-github-release-immutability.sh", publication_job) + setting_check = publication_job.index( + "check-github-release-immutability.sh" + ) + self.assertLess( + setting_check, + publication_job.index('-X PATCH --input', setting_check), + ) marker_upload = activation.index('gh release upload "${TAG}"') publish_patch = activation.index( '-X PATCH --input "$publish_payload"', marker_upload @@ -779,7 +795,10 @@ class ReleasePromotionPolicyTest(unittest.TestCase): '.status == "completed" and .conclusion == "success"', verdict ) self.assertLess( - activation.index("require_viable_convergence_owner\n gh api"), + activation.index( + "require_viable_convergence_owner\n" + " if [ -z \"${IMMUTABILITY_ADMIN_TOKEN:-}\" ]" + ), activation.index("-X PATCH --input \"$publish_payload\""), ) marker_upload = activation.index('gh release upload "${TAG}"')