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

View file

@ -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")

View file

@ -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()

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.

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}."