mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-19 10:33:29 +00:00
Enforce exact release visual assets
This commit is contained in:
parent
1f6007715a
commit
cb0e55671f
7 changed files with 163 additions and 54 deletions
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue