Skip to content

Commit affb6d3

Browse files
committed
fix(release): resume identical profile publications
1 parent dbfaffc commit affb6d3

4 files changed

Lines changed: 158 additions & 2 deletions

File tree

.github/workflows/release-assets.yaml

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -578,8 +578,11 @@ jobs:
578578
echo '#!/usr/bin/env bash'
579579
echo 'set -euo pipefail'
580580
printf 'if gh release view %q >/dev/null 2>&1; then\n' "$TAG"
581-
printf ' echo %q >&2\n' "immutable profile release already exists: $TAG"
582-
printf ' exit 1\n'
581+
printf ' existing=$(mktemp -d)\n'
582+
printf ' trap '"'"'rm -rf "$existing"'"'"' EXIT\n'
583+
printf ' gh release download %q --dir "$existing"\n' "$TAG"
584+
printf ' uv run python scripts/verify-immutable-publication.py --expected %q --actual "$existing"\n' "$RELEASE_DIR"
585+
printf ' echo %q\n' "reusing byte-identical immutable profile release: $TAG"
583586
printf 'else\n'
584587
printf ' gh release create %q' "$TAG"
585588
printf ' %q' "${files[@]}"
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
#!/usr/bin/env python3
2+
"""Require two flat immutable publication directories to contain identical bytes."""
3+
4+
from __future__ import annotations
5+
6+
import argparse
7+
import hashlib
8+
import sys
9+
from pathlib import Path
10+
11+
12+
def publication_files(root: Path, label: str) -> dict[str, Path]:
13+
if root.is_symlink() or not root.is_dir():
14+
raise ValueError(f"{label} publication root is missing or unsafe: {root}")
15+
files: dict[str, Path] = {}
16+
for entry in root.iterdir():
17+
if entry.is_symlink() or not entry.is_file():
18+
raise ValueError(f"{label} publication contains unsafe entry: {entry}")
19+
files[entry.name] = entry
20+
if not files:
21+
raise ValueError(f"{label} publication contains no files")
22+
return files
23+
24+
25+
def file_identity(path: Path) -> tuple[int, str]:
26+
digest = hashlib.sha256()
27+
size = 0
28+
with path.open("rb") as source:
29+
for block in iter(lambda: source.read(1024 * 1024), b""):
30+
size += len(block)
31+
digest.update(block)
32+
return size, digest.hexdigest()
33+
34+
35+
def verify_identical_publication(expected: Path, actual: Path) -> None:
36+
expected_files = publication_files(expected, "expected")
37+
actual_files = publication_files(actual, "existing")
38+
expected_names = set(expected_files)
39+
actual_names = set(actual_files)
40+
if expected_names != actual_names:
41+
raise ValueError(
42+
"immutable publication file set mismatch: "
43+
f"missing={sorted(expected_names - actual_names)} "
44+
f"extra={sorted(actual_names - expected_names)}"
45+
)
46+
mismatches = [
47+
name
48+
for name in sorted(expected_names)
49+
if file_identity(expected_files[name]) != file_identity(actual_files[name])
50+
]
51+
if mismatches:
52+
raise ValueError(f"immutable publication byte mismatch: {mismatches}")
53+
54+
55+
def main() -> int:
56+
parser = argparse.ArgumentParser(description=__doc__)
57+
parser.add_argument("--expected", type=Path, required=True)
58+
parser.add_argument("--actual", type=Path, required=True)
59+
args = parser.parse_args()
60+
try:
61+
verify_identical_publication(args.expected, args.actual)
62+
except (OSError, ValueError) as error:
63+
print(f"immutable publication verification failed: {error}", file=sys.stderr)
64+
return 1
65+
print(f"immutable publication matches exactly: {args.actual}")
66+
return 0
67+
68+
69+
if __name__ == "__main__":
70+
raise SystemExit(main())
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
from __future__ import annotations
2+
3+
import importlib.util
4+
from pathlib import Path
5+
import sys
6+
7+
import pytest
8+
9+
10+
ROOT = Path(__file__).resolve().parents[2]
11+
SCRIPT = ROOT / "scripts" / "verify-immutable-publication.py"
12+
SPEC = importlib.util.spec_from_file_location("verify_immutable_publication", SCRIPT)
13+
assert SPEC is not None and SPEC.loader is not None
14+
VERIFY = importlib.util.module_from_spec(SPEC)
15+
sys.modules[SPEC.name] = VERIFY
16+
SPEC.loader.exec_module(VERIFY)
17+
18+
19+
def _publication(root: Path) -> None:
20+
root.mkdir()
21+
(root / "channel-source-nightly.json").write_bytes(b'{"channel":"nightly"}\n')
22+
(root / "x86_64-rootfs.erofs").write_bytes(b"rootfs")
23+
24+
25+
def test_identical_immutable_publication_is_resumable(tmp_path: Path) -> None:
26+
expected = tmp_path / "expected"
27+
actual = tmp_path / "actual"
28+
_publication(expected)
29+
_publication(actual)
30+
31+
VERIFY.verify_identical_publication(expected, actual)
32+
33+
34+
@pytest.mark.parametrize("mutation", ("missing", "extra", "changed", "nested"))
35+
def test_immutable_publication_rejects_any_file_set_or_byte_drift(
36+
tmp_path: Path,
37+
mutation: str,
38+
) -> None:
39+
expected = tmp_path / "expected"
40+
actual = tmp_path / "actual"
41+
_publication(expected)
42+
_publication(actual)
43+
if mutation == "missing":
44+
(actual / "x86_64-rootfs.erofs").unlink()
45+
elif mutation == "extra":
46+
(actual / "unexpected").write_bytes(b"extra")
47+
elif mutation == "changed":
48+
(actual / "x86_64-rootfs.erofs").write_bytes(b"changed")
49+
else:
50+
(actual / "nested").mkdir()
51+
52+
with pytest.raises(ValueError, match="publication"):
53+
VERIFY.verify_identical_publication(expected, actual)
54+
55+
56+
def test_immutable_publication_rejects_symlinked_files(tmp_path: Path) -> None:
57+
expected = tmp_path / "expected"
58+
actual = tmp_path / "actual"
59+
_publication(expected)
60+
_publication(actual)
61+
target = actual / "x86_64-rootfs.erofs"
62+
target.unlink()
63+
target.symlink_to(expected / "x86_64-rootfs.erofs")
64+
65+
with pytest.raises(ValueError, match="unsafe entry"):
66+
VERIFY.verify_identical_publication(expected, actual)

tests/capsem-release/test_staged_profile_binary_activation.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,3 +179,20 @@ def test_profile_then_binary_reuses_authored_source_without_rebuilding_assets()
179179
assert "Prove binary candidate preserved every profile" in binary
180180
assert 'before.get("profiles") != after.get("profiles")' in binary
181181
assert "name: binary-channel-candidate" in binary
182+
183+
184+
def test_existing_profile_publication_is_reused_only_after_exact_byte_comparison() -> None:
185+
workflow = PROFILE_WORKFLOW.read_text(encoding="utf-8")
186+
publish = _job(workflow, "publish-profile-release", "deploy-channel")
187+
immutable = _step(
188+
publish,
189+
"Publish immutable GitHub profile release",
190+
"Attest VM asset provenance",
191+
)
192+
193+
assert "gh release download" in immutable
194+
assert "scripts/verify-immutable-publication.py" in immutable
195+
assert "--expected" in immutable
196+
assert "--actual" in immutable
197+
assert "gh release upload" not in immutable
198+
assert "immutable profile release already exists" not in immutable

0 commit comments

Comments
 (0)