mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-09 18:15:50 +00:00
Enforce exact release visual assets
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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 = (
|
||||
""
|
||||
)
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user