From 0de6d88078ff3635f06ea3eef08d82eaa6b59fc0 Mon Sep 17 00:00:00 2001 From: Bryan Date: Wed, 23 Sep 2026 18:37:01 +0000 Subject: [PATCH] ci: upload immutable release assets before publish --- .../ci-tests/test_release_channel_archive.py | 129 +++++++++++++++--- .github/ci/release_channel.py | 80 ++++++++--- 2 files changed, 175 insertions(+), 34 deletions(-) diff --git a/.github/ci/ci-tests/test_release_channel_archive.py b/.github/ci/ci-tests/test_release_channel_archive.py index 1521aac0f4..5b4f80be03 100644 --- a/.github/ci/ci-tests/test_release_channel_archive.py +++ b/.github/ci/ci-tests/test_release_channel_archive.py @@ -765,7 +765,7 @@ def test_existing_identical_immutable_release_is_a_noop(self) -> None: json.dumps(release_channel.directory_checksums(remote)), encoding="utf-8" ) snapshot = release_channel.ReleaseSnapshot( - "os-bind-rp-26.7-1.36_2", True, remote, manifest + "os-bind-rp-26.7-1.36_2", True, remote, manifest, immutable=True ) with ( patch.object(release_channel, "snapshot_release", return_value=snapshot), @@ -823,16 +823,20 @@ def test_absent_immutable_release_is_created_and_verified(self) -> None: json.dumps(release_channel.directory_checksums(staged)), encoding="utf-8" ) published = release_channel.ReleaseSnapshot( - absent.tag, True, staged, manifest + absent.tag, True, staged, manifest, immutable=True ) - calls: list[list[str]] = [] + calls: list[tuple[list[str], int]] = [] + + def fake_run_gh(arguments: list[str], *, attempts: int = 3) -> None: + calls.append((arguments, attempts)) + with ( patch.object( release_channel, "snapshot_release", side_effect=(absent, published), ), - patch.object(release_channel, "run_gh", side_effect=calls.append), + patch.object(release_channel, "run_gh", side_effect=fake_run_gh), ): release_channel.publish_immutable_release( "resolver-plugins/plugins", @@ -841,24 +845,113 @@ def test_absent_immutable_release_is_created_and_verified(self) -> None: "os-bind-rp 26.7 1.36_2", ) - self.assertEqual("release", calls[0][0]) - self.assertEqual("create", calls[0][1]) - self.assertIn("--latest=false", calls[0]) self.assertEqual( [ - [ - "release", "upload", absent.tag, - str(staged / "os-bind-rp-1.36_2.pkg"), - "--clobber", "--repo", "resolver-plugins/plugins", - ], - [ - "release", "upload", absent.tag, - str(staged / "build-metadata.txt"), - "--clobber", "--repo", "resolver-plugins/plugins", - ], + "release", "create", absent.tag, + str(staged / "os-bind-rp-1.36_2.pkg"), + str(staged / "build-metadata.txt"), + "--repo", "resolver-plugins/plugins", + "--title", "os-bind-rp 26.7 1.36_2", + "--latest=false", + ], + calls[0][0], + ) + self.assertEqual(1, calls[0][1]) + self.assertFalse( + any(call[:2] == ["release", "upload"] for call, _ in calls) + ) + + def test_complete_immutable_release_draft_is_published_on_retry(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + staged = root / "staged" + staged.mkdir() + (staged / "os-bind-rp-1.36_2.pkg").write_bytes(b"plugin") + manifest = root / "manifest.json" + manifest.write_text( + json.dumps(release_channel.directory_checksums(staged)), encoding="utf-8" + ) + draft = release_channel.ReleaseSnapshot( + "os-bind-rp-26.7-1.36_2", True, staged, manifest, draft=True + ) + published = release_channel.ReleaseSnapshot( + draft.tag, True, staged, manifest, immutable=True + ) + calls: list[tuple[list[str], int]] = [] + + def fake_run_gh(arguments: list[str], *, attempts: int = 3) -> None: + calls.append((arguments, attempts)) + + with ( + patch.object( + release_channel, + "snapshot_release", + side_effect=(draft, published), + ), + patch.object(release_channel, "run_gh", side_effect=fake_run_gh), + ): + release_channel.publish_immutable_release( + "resolver-plugins/plugins", + draft.tag, + staged, + "os-bind-rp 26.7 1.36_2", + ) + + self.assertEqual( + [ + "release", "edit", draft.tag, "--draft=false", "--latest=false", + "--repo", "resolver-plugins/plugins", ], - [call for call in calls if call[:2] == ["release", "upload"]], + calls[0][0], ) + self.assertEqual(1, calls[0][1]) + + def test_partial_immutable_release_draft_is_replaced_on_retry(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + staged = root / "staged" + partial = root / "partial" + staged.mkdir() + partial.mkdir() + (staged / "os-bind-rp-1.36_2.pkg").write_bytes(b"plugin") + (partial / "os-bind-rp-1.36_2.pkg").write_bytes(b"partial") + partial_manifest = root / "partial.json" + partial_manifest.write_text( + json.dumps(release_channel.directory_checksums(partial)), encoding="utf-8" + ) + draft = release_channel.ReleaseSnapshot( + "os-bind-rp-26.7-1.36_2", True, partial, partial_manifest, draft=True + ) + published_manifest = root / "published.json" + published_manifest.write_text( + json.dumps(release_channel.directory_checksums(staged)), encoding="utf-8" + ) + published = release_channel.ReleaseSnapshot( + draft.tag, True, staged, published_manifest, immutable=True + ) + calls: list[tuple[list[str], int]] = [] + + def fake_run_gh(arguments: list[str], *, attempts: int = 3) -> None: + calls.append((arguments, attempts)) + + with ( + patch.object( + release_channel, + "snapshot_release", + side_effect=(draft, published), + ), + patch.object(release_channel, "run_gh", side_effect=fake_run_gh), + ): + release_channel.publish_immutable_release( + "resolver-plugins/plugins", + draft.tag, + staged, + "os-bind-rp 26.7 1.36_2", + ) + + self.assertEqual(["release", "delete"], calls[0][0][:2]) + self.assertEqual(["release", "create"], calls[1][0][:2]) + self.assertEqual([1, 1], [attempts for _, attempts in calls]) def test_existing_snapshot_is_materialized_for_an_exact_release_retry(self) -> None: """A published version is reused instead of rebuilt under a new control commit.""" diff --git a/.github/ci/release_channel.py b/.github/ci/release_channel.py index 2363e7da2d..c97dd35455 100644 --- a/.github/ci/release_channel.py +++ b/.github/ci/release_channel.py @@ -455,14 +455,14 @@ def asset_order(directory: Path) -> list[Path]: GH_ATTEMPTS = 3 -def run_gh(arguments: list[str]) -> None: +def run_gh(arguments: list[str], *, attempts: int = GH_ATTEMPTS) -> None: command = ["gh", *arguments] - for attempt in range(1, GH_ATTEMPTS + 1): + for attempt in range(1, attempts + 1): try: subprocess.run(command, check=True, timeout=GH_TIMEOUT_SECONDS) return except subprocess.TimeoutExpired: - if attempt == GH_ATTEMPTS: + if attempt == attempts: raise @@ -797,16 +797,31 @@ def materialize_existing_snapshot( class ReleaseSnapshot: """Verified local bytes required to restore one mutable GitHub Release.""" - def __init__(self, tag: str, existed: bool, directory: Path, manifest: Path) -> None: + def __init__( + self, + tag: str, + existed: bool, + directory: Path, + manifest: Path, + *, + draft: bool = False, + immutable: bool = False, + ) -> None: self.tag = tag self.existed = existed self.directory = directory self.manifest = manifest + self.draft = draft + self.immutable = immutable def release_snapshots_match(left: ReleaseSnapshot, right: ReleaseSnapshot) -> bool: """Return whether two observations contain the same remote Release bytes.""" - if left.existed != right.existed: + if ( + left.existed != right.existed + or left.draft != right.draft + or left.immutable != right.immutable + ): return False if not left.existed: return True @@ -888,7 +903,10 @@ def staged_source_descends_from_current(current: Path, staged: Path) -> bool: def snapshot_release(repository: str, tag: str, recovery: Path) -> ReleaseSnapshot: """Download and checksum every pre-promotion asset before changing a Release.""" result = subprocess.run( - ["gh", "release", "view", tag, "--repo", repository, "--json", "assets"], + [ + "gh", "release", "view", tag, "--repo", repository, + "--json", "assets,isDraft,isImmutable", + ], capture_output=True, text=True, ) @@ -900,9 +918,18 @@ def snapshot_release(repository: str, tag: str, recovery: Path) -> ReleaseSnapsh raise RuntimeError(result.stderr.strip() or f"cannot inspect GitHub Release {tag}") payload = json.loads(result.stdout) assets = payload.get("assets") - if not isinstance(assets, list) or not all( - isinstance(asset, dict) and isinstance(asset.get("name"), str) and asset["name"] - for asset in assets + draft = payload.get("isDraft") + immutable = payload.get("isImmutable") + if ( + not isinstance(assets, list) + or not isinstance(draft, bool) + or not isinstance(immutable, bool) + or not all( + isinstance(asset, dict) + and isinstance(asset.get("name"), str) + and asset["name"] + for asset in assets + ) ): raise RuntimeError(f"cannot read GitHub Release assets for {tag}") directory.mkdir(parents=True, exist_ok=False) @@ -915,7 +942,9 @@ def snapshot_release(repository: str, tag: str, recovery: Path) -> ReleaseSnapsh raise RuntimeError(f"cannot preserve GitHub Release asset {tag}/{name}") checksums[name] = sha256(downloaded) manifest.write_text(json.dumps(checksums, sort_keys=True) + "\n", encoding="utf-8") - return ReleaseSnapshot(tag, True, directory, manifest) + return ReleaseSnapshot( + tag, True, directory, manifest, draft=draft, immutable=immutable + ) def publish_immutable_release( @@ -924,18 +953,37 @@ def publish_immutable_release( """Create an immutable Release, or accept an exact byte-for-byte retry.""" with tempfile.TemporaryDirectory() as temporary_directory: existing = snapshot_release(repository, tag, Path(temporary_directory)) - if existing.existed: + if existing.existed and not existing.draft: if not snapshot_matches_directory(existing, directory): raise RuntimeError(f"immutable GitHub Release has different bytes: {tag}") + if not existing.immutable: + raise RuntimeError(f"GitHub Release is not immutable: {tag}") return + create = not existing.existed + if existing.existed: + if snapshot_matches_directory(existing, directory): + run_gh([ + "release", "edit", tag, "--draft=false", "--latest=false", + "--repo", repository, + ], attempts=1) + else: + run_gh([ + "release", "delete", tag, "--yes", "--repo", repository, + ], attempts=1) + create = True + if create: + run_gh([ + "release", "create", tag, + *(str(asset) for asset in asset_order(directory)), + "--repo", repository, "--title", title, "--latest=false", + ], attempts=1) - run_gh([ - "release", "create", tag, - "--repo", repository, "--title", title, "--latest=false", - ]) - upload_release_assets(repository, tag, asset_order(directory)) with tempfile.TemporaryDirectory() as temporary_directory: published = snapshot_release(repository, tag, Path(temporary_directory)) + if published.draft: + raise RuntimeError(f"GitHub Release remains a draft: {tag}") + if not published.immutable: + raise RuntimeError(f"GitHub Release is not immutable: {tag}") if not snapshot_matches_directory(published, directory): raise RuntimeError(f"published immutable GitHub Release has different bytes: {tag}")