From cb0e55671f0d3abe63ce2c3f136e54276ed9921e Mon Sep 17 00:00:00 2001 From: Richard Courtman Date: Sat, 29 Aug 2026 02:58:36 +0100 Subject: [PATCH] Enforce exact release visual assets --- .../workflows/recover-release-activation.yml | 7 +- .github/workflows/validate-release-assets.yml | 8 +- .../subsystems/deployment-installability.md | 18 ++++ .../installtests/build_release_assets_test.go | 3 + scripts/release_candidate_manifest.py | 95 +++++++++++++------ .../release_candidate_manifest_test.py | 79 +++++++++++---- .../release_promotion_policy_test.py | 7 ++ 7 files changed, 163 insertions(+), 54 deletions(-) diff --git a/.github/workflows/recover-release-activation.yml b/.github/workflows/recover-release-activation.yml index c194ba7b9..11bc277b3 100644 --- a/.github/workflows/recover-release-activation.yml +++ b/.github/workflows/recover-release-activation.yml @@ -162,13 +162,14 @@ jobs: gh api --paginate --slurp \ "repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets?per_page=100" \ | jq '[.[][]]' > "${release_assets}" + gh api "repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}" \ + --jq '.body // ""' > "${release_body}" python3 scripts/release_candidate_manifest.py verify-release \ --manifest "${manifest_dir}/release-candidate.json" \ --assets-json "${release_assets}" \ --version "${VERSION}" \ - --source-sha "${SOURCE_SHA}" - gh api "repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}" \ - --jq '.body // ""' > "${release_body}" + --source-sha "${SOURCE_SHA}" \ + --release-body-file "${release_body}" python3 scripts/release_control/render_release_body.py \ --version "${VERSION}" \ --validate-body-file "${release_body}" diff --git a/.github/workflows/validate-release-assets.yml b/.github/workflows/validate-release-assets.yml index bb668d07c..cf4b8e31f 100644 --- a/.github/workflows/validate-release-assets.yml +++ b/.github/workflows/validate-release-assets.yml @@ -198,6 +198,8 @@ jobs: gh api --paginate \ "repos/${{ github.repository }}/releases/${{ steps.context.outputs.release_id }}/assets?per_page=100" \ --slurp > "$RUNNER_TEMP/release-assets.json" + gh api "repos/${{ github.repository }}/releases/${{ steps.context.outputs.release_id }}" \ + --jq '.body // ""' > "$RUNNER_TEMP/release-body.md" count="$(jq '[.[][]] | length' "$RUNNER_TEMP/release-assets.json")" if [ "$count" -eq 0 ]; then echo "::error::No assets found in release" @@ -304,6 +306,7 @@ jobs: CANDIDATE_MANIFEST_ARTIFACT: ${{ inputs.candidate_manifest_artifact }} run: | set +e + set -o pipefail echo "Running validation script..." OUTPUT_FILE=$(mktemp) @@ -313,7 +316,8 @@ jobs: --manifest release-candidate-manifest/release-candidate.json \ --assets-json "$RUNNER_TEMP/release-assets.json" \ --version "${{ steps.context.outputs.version }}" \ - --source-sha "${{ steps.context.outputs.target_commitish }}" 2>&1 | tee "$OUTPUT_FILE" + --source-sha "${{ steps.context.outputs.target_commitish }}" \ + --release-body-file "$RUNNER_TEMP/release-body.md" 2>&1 | tee "$OUTPUT_FILE" elif [ "${{ steps.docker.outputs.image_available }}" = "true" ]; then echo "Running full validation (Docker + assets)..." scripts/validate-release.sh \ @@ -328,7 +332,7 @@ jobs: "release" 2>&1 | tee "$OUTPUT_FILE" fi - VALIDATION_EXIT_CODE=$? + VALIDATION_EXIT_CODE=${PIPESTATUS[0]} echo "VALIDATION_OUTPUT<> $GITHUB_OUTPUT cat "$OUTPUT_FILE" >> $GITHUB_OUTPUT diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 28845f218..75af16cd6 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -2005,6 +2005,24 @@ and the other independent release gates, but it must depend on successful draft creation or recovery so release-line validation always observes the current immutable tag target. +Stable release run `33223712880` at exact source SHA +`58b07ef36cc815c2309ba43874ca5365f928ff77` subsequently passed every +immutable release gate on failed-job attempt 2, including the complete +race-enabled backend suite and release readiness. Activation remained +fail-closed because the first attempt's convergence owner had already ended +after the original backend failure. Activation-only recovery run `33227557895` +then exposed two independent draft-integrity conditions before publication: +the draft retained two unreferenced screenshot assets from a superseded visual +plan, and the manifest verifier treated all release-note screenshots as +unexpected even when the validated release body referenced them. The reusable +validation workflow had detected that mismatch but lost the Python exit status +through `tee`, so its job incorrectly reported success. Release validation and +activation recovery must preserve the verifier's pipeline status, require the +immutable candidate asset set plus exactly the non-empty, server-digested +release-note screenshots referenced by the validated body, and reject every +unreferenced asset. Recovery remains activation-only and may not rebuild or +replace the qualified candidate. + The preceding `v6.4.0-rc.8` qualification attempt used exact source SHA `bac7e5d9526d76a6b4e34738511b07609dda80ed`. Release run `33128595650` passed preparation, frontend bundle, Windows installer smoke, release smoke, diff --git a/scripts/installtests/build_release_assets_test.go b/scripts/installtests/build_release_assets_test.go index 40d53c0da..c567586ee 100644 --- a/scripts/installtests/build_release_assets_test.go +++ b/scripts/installtests/build_release_assets_test.go @@ -2749,6 +2749,7 @@ func TestReleasePipelinePromotesOneImmutableCandidate(t *testing.T) { `.path == ".github/workflows/create-release.yml"`, `release-candidate-manifest-${source_sha}-${version}`, `scripts/release_candidate_manifest.py verify-release`, + `--release-body-file "${release_body}"`, `failure outside the recoverable activation boundary`, `release-convergence.yml/dispatches`, `activation_recovery_run_id`, @@ -2794,6 +2795,8 @@ func TestReleasePipelinePromotesOneImmutableCandidate(t *testing.T) { for _, needle := range []string{ `inputs.candidate_manifest_artifact != ''`, `scripts/release_candidate_manifest.py verify-release`, + `--release-body-file "$RUNNER_TEMP/release-body.md"`, + `VALIDATION_EXIT_CODE=${PIPESTATUS[0]}`, `inputs.candidate_manifest_artifact == ''`, } { if !strings.Contains(validationWorkflow, needle) { diff --git a/scripts/release_candidate_manifest.py b/scripts/release_candidate_manifest.py index 2d7069143..ee7120f19 100644 --- a/scripts/release_candidate_manifest.py +++ b/scripts/release_candidate_manifest.py @@ -20,6 +20,11 @@ RELEASE_NOTE_VISUAL_PATTERN = re.compile( r"^release-note-[a-z0-9]+(?:-[a-z0-9]+)*-(?:before|now)\.png$" ) MAX_RELEASE_NOTE_VISUAL_ASSETS = 20 +RELEASE_NOTE_VISUAL_URL_PATTERN = re.compile( + r"https://github\.com/[^/\s)]+/[^/\s)]+/releases/download/" + r"(?Pv[0-9]+\.[0-9]+\.[0-9]+(?:-(?:rc|alpha|beta)\.[0-9]+)?)/" + r"(?Prelease-note-[a-z0-9]+(?:-[a-z0-9]+)*-(?:before|now)\.png)" +) def sha256_file(path: Path) -> str: @@ -168,8 +173,43 @@ def load_release_assets(path: Path) -> list[dict[str, Any]]: return payload -def verify_release(manifest: dict[str, Any], release_assets: list[dict[str, Any]]) -> None: +def release_note_visual_assets(body: str, expected_tag: str) -> set[str]: + assets: set[str] = set() + for match in RELEASE_NOTE_VISUAL_URL_PATTERN.finditer(body): + if match.group("tag") == expected_tag: + assets.add(match.group("name")) + if len(assets) > MAX_RELEASE_NOTE_VISUAL_ASSETS: + raise ValueError( + "release body references too many release-note visual sidecars: " + f"{len(assets)} > {MAX_RELEASE_NOTE_VISUAL_ASSETS}" + ) + return assets + + +def verify_release( + manifest: dict[str, Any], + release_assets: list[dict[str, Any]], + auxiliary_assets: set[str] | None = None, +) -> None: expected = manifest_assets_by_name(manifest) + expected_auxiliary = set(auxiliary_assets or ()) + overlap = sorted(set(expected) & expected_auxiliary) + if overlap: + raise ValueError(f"auxiliary assets overlap candidate manifest: {overlap}") + invalid_auxiliary = sorted( + name + for name in expected_auxiliary + if not RELEASE_NOTE_VISUAL_PATTERN.fullmatch(name) + ) + if invalid_auxiliary: + raise ValueError( + f"invalid release-note visual sidecar name(s): {invalid_auxiliary}" + ) + if len(expected_auxiliary) > MAX_RELEASE_NOTE_VISUAL_ASSETS: + raise ValueError( + "published release contains too many release-note visual sidecars: " + f"{len(expected_auxiliary)} > {MAX_RELEASE_NOTE_VISUAL_ASSETS}" + ) actual: dict[str, dict[str, Any]] = {} for index, asset in enumerate(release_assets): name = asset.get("name") @@ -179,33 +219,11 @@ def verify_release(manifest: dict[str, Any], release_assets: list[dict[str, Any] raise ValueError(f"release contains duplicate asset: {name}") actual[name] = asset - missing = sorted(set(expected) - set(actual)) - extra = sorted(set(actual) - set(expected)) - invalid_extra = [ - name for name in extra if not RELEASE_NOTE_VISUAL_PATTERN.fullmatch(name) - ] - if missing or invalid_extra: + expected_names = set(expected) | expected_auxiliary + if set(actual) != expected_names: + missing = sorted(expected_names - set(actual)) + extra = sorted(set(actual) - expected_names) raise ValueError(f"published asset set mismatch: missing={missing}, extra={extra}") - if len(extra) > MAX_RELEASE_NOTE_VISUAL_ASSETS: - raise ValueError( - "published release contains too many release-note visual sidecars: " - f"{len(extra)} > {MAX_RELEASE_NOTE_VISUAL_ASSETS}" - ) - - # Release-note comparisons are generated and uploaded after the immutable - # binary candidate manifest is sealed. They are presentation-only, but - # recovery still constrains them to non-empty PNG sidecars with GitHub's - # server-side SHA-256 metadata instead of accepting arbitrary extra assets. - for name in extra: - visual = actual[name] - size = visual.get("size") - digest = visual.get("digest") - if not isinstance(size, int) or isinstance(size, bool) or size <= 0: - raise ValueError(f"release-note visual has invalid size: {name}") - if not isinstance(digest, str) or not re.fullmatch( - r"sha256:[0-9a-f]{64}", digest - ): - raise ValueError(f"release-note visual has invalid digest: {name}") for name, expected_asset in expected.items(): actual_asset = actual[name] @@ -218,6 +236,19 @@ def verify_release(manifest: dict[str, Any], release_assets: list[dict[str, Any] f"expected {expected_digest}, got {actual_asset.get('digest')!r}" ) + for name in sorted(expected_auxiliary): + asset = actual[name] + if ( + not isinstance(asset.get("size"), int) + or isinstance(asset["size"], bool) + or asset["size"] <= 0 + ): + raise ValueError(f"published auxiliary asset is empty: {name}") + if not isinstance(asset.get("digest"), str) or not re.fullmatch( + r"sha256:[0-9a-f]{64}", asset["digest"] + ): + raise ValueError(f"published auxiliary asset has no SHA-256 digest: {name}") + def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) @@ -240,6 +271,7 @@ def parse_args() -> argparse.Namespace: release.add_argument("--assets-json", type=Path, required=True) release.add_argument("--version", required=True) release.add_argument("--source-sha", required=True) + release.add_argument("--release-body-file", type=Path) return parser.parse_args() @@ -268,9 +300,16 @@ def main() -> int: manifest = load_manifest(args.manifest) verify_manifest_identity(manifest, args.version, args.source_sha) release_assets = load_release_assets(args.assets_json) - verify_release(manifest, release_assets) + auxiliary_assets: set[str] = set() + if args.release_body_file is not None: + body = args.release_body_file.read_text(encoding="utf-8") + auxiliary_assets = release_note_visual_assets( + body, f"v{args.version}" + ) + verify_release(manifest, release_assets, auxiliary_assets) print( f"Published release matches candidate: assets={len(manifest['assets'])} " + f"auxiliary_assets={len(auxiliary_assets)} " f"version={manifest['version']} source_sha={manifest['source_sha']}" ) except (OSError, ValueError) as exc: diff --git a/scripts/release_control/release_candidate_manifest_test.py b/scripts/release_control/release_candidate_manifest_test.py index 7d53b0a73..d2aef7b1c 100644 --- a/scripts/release_control/release_candidate_manifest_test.py +++ b/scripts/release_control/release_candidate_manifest_test.py @@ -10,6 +10,7 @@ from release_candidate_manifest import ( create_manifest, load_release_assets, manifest_assets_by_name, + release_note_visual_assets, verify_local, verify_release, ) @@ -98,7 +99,7 @@ class ReleaseCandidateManifestTest(unittest.TestCase): with self.assertRaisesRegex(ValueError, "published asset digest mismatch"): verify_release(manifest, release_assets) - def test_verify_release_allows_only_governed_visual_sidecars(self) -> None: + def test_verify_release_accepts_only_body_referenced_visual_assets(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: release_dir = self.create_release_dir(Path(temp_dir)) manifest = create_manifest(release_dir, "6.1.0", SOURCE_SHA) @@ -110,28 +111,54 @@ class ReleaseCandidateManifestTest(unittest.TestCase): } for asset in manifest["assets"] ] - release_assets.extend( - [ - { - "name": "release-note-inline-view-before.png", - "size": 123, - "digest": "sha256:" + "2" * 64, - }, - { - "name": "release-note-inline-view-now.png", - "size": 456, - "digest": "sha256:" + "3" * 64, - }, - ] + visual_name = "release-note-settings-before.png" + release_assets.append( + { + "name": visual_name, + "size": 123, + "digest": "sha256:" + "2" * 64, + } ) + body = ( + "![Settings](https://github.com/rcourtman/Pulse/releases/download/" + f"v6.1.0/{visual_name})" + ) + visuals = release_note_visual_assets(body, "v6.1.0") - verify_release(manifest, release_assets) + self.assertEqual(visuals, {visual_name}) + verify_release(manifest, release_assets, visuals) - release_assets[-1]["name"] = "release-note-inline-view-later.png" - with self.assertRaisesRegex(ValueError, "published asset set mismatch"): - verify_release(manifest, release_assets) + release_assets.append( + { + "name": "release-note-stale.png", + "size": 456, + "digest": "sha256:" + "3" * 64, + } + ) + with self.assertRaisesRegex(ValueError, "release-note-stale.png"): + verify_release(manifest, release_assets, visuals) - def test_verify_release_rejects_unverifiable_visual_sidecar(self) -> None: + def test_verify_release_requires_every_body_referenced_visual(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + release_dir = self.create_release_dir(Path(temp_dir)) + manifest = create_manifest(release_dir, "6.1.0", SOURCE_SHA) + release_assets = [ + { + "name": asset["name"], + "size": asset["size"], + "digest": f"sha256:{asset['sha256']}", + } + for asset in manifest["assets"] + ] + + with self.assertRaisesRegex(ValueError, "release-note-missing.png"): + verify_release( + manifest, + release_assets, + {"release-note-missing.png"}, + ) + + def test_verify_release_rejects_invalid_or_empty_visual_sidecar(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: release_dir = self.create_release_dir(Path(temp_dir)) manifest = create_manifest(release_dir, "6.1.0", SOURCE_SHA) @@ -151,8 +178,18 @@ class ReleaseCandidateManifestTest(unittest.TestCase): } ) - with self.assertRaisesRegex(ValueError, "invalid size"): - verify_release(manifest, release_assets) + with self.assertRaisesRegex(ValueError, "empty"): + verify_release( + manifest, + release_assets, + {"release-note-settings-now.png"}, + ) + with self.assertRaisesRegex(ValueError, "invalid release-note"): + verify_release( + manifest, + release_assets, + {"release-note-settings-later.png"}, + ) def test_release_metadata_loader_flattens_paginated_arrays(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: diff --git a/scripts/release_control/release_promotion_policy_test.py b/scripts/release_control/release_promotion_policy_test.py index b81338c98..a874db4d5 100644 --- a/scripts/release_control/release_promotion_policy_test.py +++ b/scripts/release_control/release_promotion_policy_test.py @@ -449,6 +449,11 @@ class ReleasePromotionPolicyTest(unittest.TestCase): self.assertIn("failure outside the recoverable activation boundary", job) self.assertIn("release-candidate-manifest-${source_sha}-${version}", job) self.assertIn("scripts/release_candidate_manifest.py verify-release", job) + self.assertIn('--release-body-file "${release_body}"', job) + self.assertLess( + job.index("--jq '.body // \"\"' > \"${release_body}\""), + job.index("scripts/release_candidate_manifest.py verify-release"), + ) self.assertIn('(any(.assets[]?; .name == "release-activation.json") | not)', job) self.assertIn("release-convergence.yml/dispatches", job) self.assertIn("return_run_details: true", job) @@ -1459,6 +1464,8 @@ class ReleasePromotionPolicyTest(unittest.TestCase): ) self.assertIn("candidate_manifest_artifact:", validation_workflow) self.assertIn("release_candidate_manifest.py verify-release", validation_workflow) + self.assertIn('--release-body-file "$RUNNER_TEMP/release-body.md"', validation_workflow) + self.assertIn("VALIDATION_EXIT_CODE=${PIPESTATUS[0]}", validation_workflow) self.assertIn("if: ${{ needs.prepare.outputs.historical_asset_backfill_only == 'true' }}", content) self.assertIn("issues: write", content) self.assertIn("statuses: write", content)