Enforce exact release visual assets

This commit is contained in:
Richard Courtman
2026-08-29 02:58:36 +01:00
parent 1f6007715a
commit cb0e55671f
7 changed files with 163 additions and 54 deletions
@@ -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}"
@@ -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<<EOF" >> $GITHUB_OUTPUT
cat "$OUTPUT_FILE" >> $GITHUB_OUTPUT
@@ -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,
@@ -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) {
+67 -28
View File
@@ -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"(?P<tag>v[0-9]+\.[0-9]+\.[0-9]+(?:-(?:rc|alpha|beta)\.[0-9]+)?)/"
r"(?P<name>release-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:
@@ -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:
@@ -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)