Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 111 additions & 18 deletions .github/ci/ci-tests/test_release_channel_archive.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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",
Expand All @@ -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."""
Expand Down
80 changes: 64 additions & 16 deletions .github/ci/release_channel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
)
Expand All @@ -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)
Expand All @@ -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(
Expand All @@ -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}")

Expand Down
Loading