diff --git a/tools/nightly_release.py b/tools/nightly_release.py index 630e6b53..59b3216a 100644 --- a/tools/nightly_release.py +++ b/tools/nightly_release.py @@ -426,14 +426,6 @@ def render_notes( f"- {baseline_label}: `{previous_ref}`", *([f"- Baseline commit: `{previous_sha}`"] if previous_sha is not None else []), "", - *([ - "## Android developer archives", - "", - "Android `arm64-v8a` and `x86_64` archives are experimental CLI developer builds, not APKs.", - "Compilation and ELF checks do not qualify Android device runtime, transfers or VPN behavior.", - "Completed-file delivery and route discovery have known Android limitations. Read the bundled `emulebb-rust/README.txt` before use.", - "", - ] if channel == "nightly" else []), "## Profile compatibility", "", ] diff --git a/tools/release_publish.py b/tools/release_publish.py index 1c01c7ad..14f90d2e 100644 --- a/tools/release_publish.py +++ b/tools/release_publish.py @@ -4,18 +4,23 @@ from __future__ import annotations import argparse +import errno import importlib import os import hashlib import json import re import runpy +import shutil import subprocess import sys import tarfile +import tempfile import tomllib +from contextlib import contextmanager from pathlib import Path from typing import Sequence +from urllib.parse import quote sys.dont_write_bytecode = True @@ -308,6 +313,182 @@ def read(archive: Path): return read +def release_download_guidance(data: dict, layout: dict, candidate_name: str) -> str: + """Render downloads from the verified inventory and exact OCI identities.""" + version = data["releaseVersion"] + stem = f"emulebb-rust-v{version}-" + names = {artifact["name"] for artifact in data["artifacts"]} + repository = data["workflow"]["repository"] + base = f"https://github.com/{repository}/releases/download/rust-v{quote(version, safe='')}" + + def link(name: str, label: str = "") -> str: + return f"[{label or name.removeprefix(stem)}]({base}/{quote(name, safe='')})" + + groups = {"Native packages": [], "Portable Linux musl": []} + platforms = {"windows-x64": "Windows x64", "windows-arm64": "Windows ARM64", + "linux-x64": "Linux x64 GNU", "linux-arm64": "Linux ARM64 GNU", + "macos-x64": "macOS Intel", "macos-arm64": "macOS Apple Silicon", + "linux-x64-musl": "Linux x64 musl", "linux-arm64-musl": "Linux ARM64 musl"} + payloads = set() + for platform, assets in layout.items(): + present = [name for name in assets if name in names] + if present: + heading = "Portable Linux musl" if platform.endswith("-musl") else "Native packages" + groups[heading].append((platforms.get(platform, platform), " ยท ".join(map(link, present)))) + payloads.update(present) + android = [item for item in data.get("experimentalAndroid", []) if item["asset"] in names] + if android: + groups["Experimental Android native packages"] = [ + (item["abi"], link(item["asset"])) for item in android + ] + payloads.update(item["asset"] for item in android) + + lines = ["", "## Downloads", ""] + for heading, rows in groups.items(): + if rows: + lines.extend((f"### {heading}", "")) + if heading == "Experimental Android native packages": + lines.extend(( + "Experimental native CLI developer archives. APK packaging and signing remain deferred.", + "Evidence covers compilation and ELF checks. Device runtime, transfers and VPN behavior remain unqualified.", + "Completed-file delivery and route discovery have known Android limitations. Read the bundled `emulebb-rust/README.txt` before use.", + "", + )) + lines.extend(("| Platform | Download |", "|---|---|")) + lines.extend(f"| {platform} | {downloads} |" for platform, downloads in rows) + lines.append("") + if data["images"]: + lines.extend(("### Container images", "", "| Variant | Platforms | Immutable image |", "|---|---|---|")) + for variant, image in sorted(data["images"].items()): + reference = f"ghcr.io/{repository.lower()}@{image['ociManifestDigest']}" + lines.append(f"| {variant.capitalize()} | {', '.join(sorted(image['platforms']))} | `{reference}` |") + lines.append("") + + lines.extend(("### Verification and supporting files", "")) + if "SHA256SUMS" in names: + lines.append(f"- {link('SHA256SUMS')}") + lines.append(f"- {link(candidate_name, 'Candidate manifest')}") + workflow = data["workflow"] + run = f"https://github.com/{repository}/actions/runs/{workflow['runId']}/attempts/{workflow['runAttempt']}" + lines.extend((f"- [GitHub workflow and build provenance]({run})", "")) + supporting = sorted(names - payloads - {"SHA256SUMS", f"RELEASE-{version}-NOTES.md"}) + if supporting: + lines.extend(("
", "Package manifests, SBOMs and validation reports", "")) + lines.extend(f"- {link(name)}" for name in supporting) + lines.extend(("", "
", "")) + lines.append("") + return "\n".join(lines).rstrip() + + +@contextmanager +def release_finalization_lock(release_dir: Path): + """Hold a crash-released OS guard for this release directory's public bytes.""" + lock = release_dir.parent / f".{release_dir.name}-finalization.lock" + # WHY: retain the inode after unlocking so contenders cannot lock different + # replacement files while validating and committing the same public bytes. + with lock.open("a+b") as stream: + if os.fstat(stream.fileno()).st_size == 0: + stream.write(b"\0") + stream.flush() + stream.seek(0) + if os.name == "nt": + import msvcrt + + def acquire(): + msvcrt.locking(stream.fileno(), msvcrt.LK_NBLCK, 1) + + def release(): + msvcrt.locking(stream.fileno(), msvcrt.LK_UNLCK, 1) + else: + import fcntl + + def acquire(): + fcntl.flock(stream.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + + def release(): + fcntl.flock(stream.fileno(), fcntl.LOCK_UN) + try: + acquire() + except OSError as error: + if error.errno not in {errno.EACCES, errno.EAGAIN, errno.EDEADLK}: + raise + raise RuntimeError("Release candidate finalization is already in progress") from error + try: + yield + finally: + stream.seek(0) + release() + + +def finalize_release_candidate(owner, *, release_dir: Path, context, archives_dir: Path, archive_identity) -> None: + """Serialize validation, final public-byte changes, candidate creation and recovery.""" + with release_finalization_lock(release_dir): + _finalize_release_candidate( + owner, release_dir=release_dir, context=context, archives_dir=archives_dir, archive_identity=archive_identity, + ) + + +def _finalize_release_candidate(owner, *, release_dir: Path, context, archives_dir: Path, archive_identity) -> None: + """Commit final public bytes while the caller holds their per-release OS lock.""" + candidate = owner.candidate_path(release_dir, context) + if candidate.exists() or candidate.is_symlink(): + raise RuntimeError("Candidate manifest already exists and cannot be rewritten") + data = owner.candidate_manifest( + release_dir=release_dir, context=context, archives_dir=archives_dir, archive_identity=archive_identity, + ) + notes = release_dir / f"RELEASE-{context.version}-NOTES.md" + original = re.sub( + r"(?ms)^\n.*?^\n?", + "", notes.read_text(encoding="utf-8"), + ) + introduction, separator, remainder = original.partition("\n## ") + if not separator: + raise RuntimeError("Release notes require the preflighted section structure") + guidance = release_download_guidance(data, owner.package_layout(context), candidate.name) + # WHY: notes become public candidate bytes. Verify original assembly first, + # then refresh their checksums before the final candidate revalidates them. + members = tuple(release_dir / name for name in sorted( + item["name"] for item in data["artifacts"] if item["name"] != "SHA256SUMS" + )) + sums = release_dir / "SHA256SUMS" + staging = Path(tempfile.mkdtemp(prefix=f".{release_dir.name}-notes-", dir=release_dir.parent)) + replaced = [] + candidate_write_started = False + try: + for path in (notes, sums): + shutil.copyfile(path, staging / path.name) + staged_notes = staging / "updated-notes" + staged_notes.write_text(f"{introduction.rstrip()}\n\n{guidance}\n\n## {remainder}", encoding="utf-8", newline="\n") + # WHY: cancellation can arrive just after a successful rename, so its + # backed-up destination must already belong to rollback before mutation. + replaced.append(notes) + staged_notes.replace(notes) + staged_sums = staging / "updated-checksums" + staged_sums.write_text(owner._sha256sums_text(release_dir, members), encoding="ascii", newline="\n") + replaced.append(sums) + staged_sums.replace(sums) + candidate_write_started = True + owner.write_candidate( + release_dir=release_dir, context=context, archives_dir=archives_dir, archive_identity=archive_identity, + ) + except BaseException as failure: + try: + for path in reversed(replaced): + (staging / path.name).replace(path) + if candidate_write_started and not isinstance(failure, FileExistsError): + candidate.unlink(missing_ok=True) + except OSError as error: + raise RuntimeError(f"Release recovery requires retained original files in {staging}") from error + shutil.rmtree(staging) + raise + # WHY: the candidate now binds the committed notes/checksum bytes. Optional + # backup cleanup must not report a failed operation that cannot be retried. + try: + shutil.rmtree(staging) + except OSError as error: + print(f"Warning: Release candidate committed; backup cleanup incomplete at {staging}: {error}", file=sys.stderr) + + def candidate_outputs(path: Path, data: dict, output: Path | None) -> None: """Expose only verified scalar identities to dependent publishing jobs.""" values = {"candidate_sha256": _sha256(path), @@ -572,7 +753,7 @@ def main(argv: Sequence[str] | None = None) -> int: if args.command == "candidate-manifest": if args.output.resolve() != path.resolve(): raise RuntimeError("Candidate output must use its canonical release filename") - owner.write_candidate(**common) + finalize_release_candidate(owner, **common) data = json.loads(path.read_text(encoding="utf-8")) else: data = owner.verify_candidate(**common) diff --git a/tools/tests/test_nightly_release.py b/tools/tests/test_nightly_release.py index 026ac95c..719ea0b0 100644 --- a/tools/tests/test_nightly_release.py +++ b/tools/tests/test_nightly_release.py @@ -265,7 +265,7 @@ def test_unchanged_schema_is_explicit(self) -> None: self.assertNotIn("incompatible with the previous build", rendered) self.assertNotIn("experimental profile", rendered) - def test_android_compile_only_developer_scope_is_explicit_only_for_nightly(self) -> None: + def test_preflight_notes_defer_platform_claims_until_inventory_is_verified(self) -> None: for channel in ("nightly", "candidate", "beta"): version = "0.1.0-beta.2" + (".nightly.20261003.g9e43ea5e" if channel == "nightly" else "") with self.subTest(channel=channel): @@ -274,16 +274,9 @@ def test_android_compile_only_developer_scope_is_explicit_only_for_nightly(self) source_sha="9" * 40, schema_version=25, previous_schema_version=25, changes=(), ) - if channel == "nightly": - for fragment in ("## Android developer archives", "`arm64-v8a` and `x86_64`", - "experimental CLI developer builds, not APKs", - "Compilation and ELF checks do not qualify Android device runtime, transfers or VPN behavior", - "Completed-file delivery and route discovery have known Android limitations", - "`emulebb-rust/README.txt`"): - self.assertIn(fragment, rendered) - else: - self.assertNotIn("Android developer archives", rendered) - self.assertNotIn("experimental CLI", rendered) + self.assertNotIn("Android developer archives", rendered) + self.assertNotIn("experimental CLI", rendered) + self.assertNotIn("## Downloads", rendered) class TestReleaseDocuments(unittest.TestCase): diff --git a/tools/tests/test_release_publish.py b/tools/tests/test_release_publish.py index 825400c0..939f0bed 100644 --- a/tools/tests/test_release_publish.py +++ b/tools/tests/test_release_publish.py @@ -446,6 +446,334 @@ def test_invalid_or_missing_digest_is_rejected_before_registry_access(self) -> N self.assertFalse(self.calls) +class TestReleaseDownloadGuidance(unittest.TestCase): + version = "0.1.0-beta.2.nightly.20261111.gaaaaaaaa" + + def inventory(self, suffixes: tuple[str, ...]) -> dict: + return { + "releaseVersion": self.version, + "workflow": {"repository": "example/project", "runId": "101", "runAttempt": "2"}, + "artifacts": [{"name": f"emulebb-rust-v{self.version}-{suffix}"} for suffix in suffixes], + "images": {}, + } + + def test_downloads_and_supporting_links_use_present_inventory_and_actual_digests(self) -> None: + data = self.inventory(("windows-x64.zip", "linux-amd64-musl.tar.zst", + "android-arm64-v8a.tar.zst", "windows-x64.sbom.spdx.json")) + data["artifacts"].append({"name": "SHA256SUMS"}) + stem = f"emulebb-rust-v{self.version}-" + data["experimentalAndroid"] = [{"abi": "arm64-v8a", "asset": stem + "android-arm64-v8a.tar.zst"}] + digest = "sha256:" + "a" * 64 + data["images"] = {"ubuntu": {"ociManifestDigest": digest, "archiveSha256": "b" * 64, + "platforms": {"linux/amd64": {}, "linux/arm64": {}}}} + layout = {"windows-x64": (stem + "windows-x64.zip",), + "linux-x64-musl": (stem + "linux-amd64-musl.tar.zst",)} + candidate = stem + "candidate-manifest.json" + rendered = PUBLISH.release_download_guidance(data, layout, candidate) + for heading in ("Native packages", "Portable Linux musl", "Experimental Android native packages", + "Container images", "Verification and supporting files"): + self.assertIn("### " + heading, rendered) + for artifact in data["artifacts"]: + self.assertIn(f"/releases/download/rust-v{self.version}/{artifact['name']}", rendered) + self.assertIn(f"ghcr.io/example/project@{digest}", rendered) + self.assertNotIn("b" * 64, rendered) + self.assertIn(candidate, rendered) + self.assertIn("/actions/runs/101/attempts/2", rendered) + self.assertIn("Device runtime, transfers and VPN behavior remain unqualified", rendered) + self.assertIn("APK packaging and signing remain deferred", rendered) + self.assertNotIn("android-x86_64", rendered) + self.assertNotIn("Alpine", rendered) + + def test_absent_platforms_and_empty_groups_are_omitted(self) -> None: + data = self.inventory(("windows-x64.zip",)) + stem = f"emulebb-rust-v{self.version}-" + layout = {"windows-x64": (stem + "windows-x64.zip",), + "windows-arm64": (stem + "windows-arm64.zip",), + "linux-x64-musl": (stem + "linux-amd64-musl.tar.zst",)} + data["experimentalAndroid"] = [{"abi": "x86_64", "asset": stem + "android-x86_64.tar.zst"}] + rendered = PUBLISH.release_download_guidance(data, layout, stem + "candidate-manifest.json") + self.assertIn("Windows x64", rendered) + for fragment in ("Windows ARM64", "Portable Linux musl", "Android", "Container images", + "SHA256SUMS", "SBOMs", "ghcr.io"): + self.assertNotIn(fragment, rendered) + + def test_format4_full_and_reduced_profiles_only_claim_their_verified_inventory(self) -> None: + suffixes = {"windows-x64": "windows-x64.zip", "windows-arm64": "windows-arm64.zip", + "linux-x64": "linux-amd64.deb", "linux-arm64": "linux-arm64.deb", + "macos-x64": "macos-x64.dmg", "macos-arm64": "macos-arm64.dmg"} + for profile in ("full", "windows-linux"): + with self.subTest(profile=profile): + version = self.version if profile == "full" else "0.1.0-beta.3" + stem = f"emulebb-rust-v{version}-" + # Reuse the actual Rust profile owner instead of duplicating + # which native platforms belong to the selected inventory. + layout = {row["slug"]: (stem + suffixes[row["slug"]],) + for row in PUBLISH.native_matrix(profile)} + layout["linux-x64-musl"] = (stem + "linux-amd64-musl.tar.zst",) + layout["linux-arm64-musl"] = (stem + "linux-arm64-musl.tar.zst",) + data = self.inventory(()) + data.update(formatVersion=4, platformProfile=profile, releaseVersion=version, + channel="nightly" if profile == "full" else "candidate") + data["artifacts"] = [{"name": name} for names in layout.values() for name in names] + if profile == "full": + data["experimentalAndroid"] = [ + {"abi": abi, "asset": stem + f"android-{abi}.tar.zst"} + for abi in ("arm64-v8a", "x86_64") + ] + data["artifacts"].extend({"name": item["asset"]} for item in data["experimentalAndroid"]) + rendered = PUBLISH.release_download_guidance(data, layout, stem + "candidate-manifest.json") + for label in ("Windows x64", "Windows ARM64", "Linux x64 GNU", "Linux ARM64 GNU", "musl"): + self.assertIn(label, rendered) + if profile == "full": + for label in ("macOS Intel", "macOS Apple Silicon", "arm64-v8a", "android-x86_64"): + self.assertIn(label, rendered) + else: + for absent in ("macOS", "macos-", "Android", "android-", "APK"): + self.assertNotIn(absent, rendered) + + def test_finalization_verifies_original_inputs_and_rehashes_final_notes(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + notes = root / f"RELEASE-{self.version}-NOTES.md" + original = f"# eMuleBB Rust {self.version}\n\nCurrent nightly.\n\n## Profile compatibility\n\nUse a fresh test profile.\n" + notes.write_text(original, encoding="utf-8") + (root / "SHA256SUMS").write_text("original checksum\n", encoding="ascii") + asset = root / f"emulebb-rust-v{self.version}-windows-x64.zip" + asset.write_bytes(b"synthetic package") + data = self.inventory(("windows-x64.zip",)) + data["artifacts"].extend(({"name": notes.name}, {"name": "SHA256SUMS"})) + owner = mock.Mock() + context = mock.Mock(version=self.version) + candidate = root / f"emulebb-rust-v{self.version}-candidate-manifest.json" + owner.candidate_path.return_value = candidate + owner.package_layout.return_value = {"windows-x64": (asset.name,)} + + def verify_original(**_kwargs): + self.assertEqual(notes.read_text(encoding="utf-8"), original) + self.assertEqual((root / "SHA256SUMS").read_text(), "original checksum\n") + return data + + def format_sums(directory, members): + self.assertEqual(directory, root) + self.assertIn("## Downloads", notes.read_text(encoding="utf-8")) + return "".join(f"{hashlib.sha256(path.read_bytes()).hexdigest()} {path.name}\n" for path in members) + + owner.candidate_manifest.side_effect = verify_original + owner._sha256sums_text.side_effect = format_sums + owner.write_candidate.side_effect = lambda **_kwargs: candidate.write_text("{}\n", encoding="utf-8") + PUBLISH.finalize_release_candidate( + owner, release_dir=root, context=context, archives_dir=root / "archives", archive_identity=object(), + ) + result = notes.read_text(encoding="utf-8") + self.assertLess(result.index("## Downloads"), result.index("## Profile compatibility")) + self.assertIn(f"{hashlib.sha256(notes.read_bytes()).hexdigest()} {notes.name}\n", + (root / "SHA256SUMS").read_text()) + owner.candidate_manifest.assert_called_once() + owner._sha256sums_text.assert_called_once() + owner.write_candidate.assert_called_once() + finalized = notes.read_bytes(), (root / "SHA256SUMS").read_bytes() + candidate.unlink() + owner.candidate_manifest.side_effect = None + owner.candidate_manifest.return_value = data + PUBLISH.finalize_release_candidate( + owner, release_dir=root, context=context, archives_dir=root / "archives", archive_identity=object(), + ) + self.assertEqual((notes.read_bytes(), (root / "SHA256SUMS").read_bytes()), finalized) + self.assertEqual(notes.read_text(encoding="utf-8").count("## Downloads\n"), 1) + + def test_write_and_pre_candidate_failures_restore_original_pair_for_retry(self) -> None: + for fault in ("notes-replace", "checksum-write", "checksum-replace", "pre-candidate", "partial-candidate", + "notes-post-replace-cancel", "checksum-post-replace-cancel"): + with self.subTest(fault=fault), tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + notes = root / f"RELEASE-{self.version}-NOTES.md" + sums = root / "SHA256SUMS" + notes.write_text("# Current nightly\n\n## Profile compatibility\n\nUse a test profile.\n", encoding="utf-8") + sums.write_bytes(b"original sums\n") + original = notes.read_bytes(), sums.read_bytes() + owner = mock.Mock() + candidate = root / f"emulebb-rust-v{self.version}-candidate-manifest.json" + owner.candidate_path.return_value = candidate + owner.package_layout.return_value = {} + data = self.inventory(()) + data["artifacts"] = [{"name": notes.name}, {"name": sums.name}] + + def verify_original(**_kwargs): + self.assertEqual((notes.read_bytes(), sums.read_bytes()), original) + return data + + def format_sums(_directory, members): + return "".join(f"{hashlib.sha256(path.read_bytes()).hexdigest()} {path.name}\n" for path in members) + + replace, write_text = Path.replace, Path.write_text + + def replace_with_failure(path, target): + if ((fault == "notes-replace" and path.name == "updated-notes") + or (fault == "checksum-replace" and path.name == "updated-checksums")): + raise OSError("synthetic replacement failure") + result = replace(path, target) + if ((fault == "notes-post-replace-cancel" and path.name == "updated-notes") + or (fault == "checksum-post-replace-cancel" and path.name == "updated-checksums")): + raise KeyboardInterrupt("synthetic cancellation after successful replacement") + return result + + def write_with_failure(path, *args, **kwargs): + if fault == "checksum-write" and path.name == "updated-checksums": + raise OSError("synthetic checksum failure") + return write_text(path, *args, **kwargs) + + def fail_candidate(**_kwargs): + if fault == "partial-candidate": + candidate.write_bytes(b"partial candidate") + raise OSError("synthetic candidate failure") + + owner.candidate_manifest.side_effect = verify_original + owner._sha256sums_text.side_effect = format_sums + owner.write_candidate.side_effect = fail_candidate + common = {"release_dir": root, "context": mock.Mock(version=self.version), + "archives_dir": root / "archives", "archive_identity": object()} + with (mock.patch.object(Path, "replace", new=replace_with_failure), + mock.patch.object(Path, "write_text", new=write_with_failure)): + failure_type = KeyboardInterrupt if fault.endswith("post-replace-cancel") else OSError + with self.assertRaisesRegex(failure_type, "synthetic"): + PUBLISH.finalize_release_candidate(owner, **common) + self.assertEqual((notes.read_bytes(), sums.read_bytes()), original) + self.assertFalse(candidate.exists()) + owner.write_candidate.side_effect = lambda **_kwargs: candidate.write_bytes(b"complete candidate") + PUBLISH.finalize_release_candidate(owner, **common) + self.assertEqual(notes.read_text(encoding="utf-8").count("## Downloads\n"), 1) + self.assertIn(hashlib.sha256(notes.read_bytes()).hexdigest(), sums.read_text()) + self.assertEqual(candidate.read_bytes(), b"complete candidate") + + def test_input_integrity_failure_preserves_notes_and_checksums(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + notes = root / f"RELEASE-{self.version}-NOTES.md" + notes.write_bytes(b"original notes") + sums = root / "SHA256SUMS" + sums.write_bytes(b"original checksums") + owner = mock.Mock() + owner.candidate_path.return_value = root / f"emulebb-rust-v{self.version}-candidate-manifest.json" + owner.candidate_manifest.side_effect = RuntimeError("input integrity failed") + with self.assertRaisesRegex(RuntimeError, "input integrity failed"): + PUBLISH.finalize_release_candidate( + owner, release_dir=root, context=mock.Mock(version=self.version), + archives_dir=root / "archives", archive_identity=object(), + ) + self.assertEqual(notes.read_bytes(), b"original notes") + self.assertEqual(sums.read_bytes(), b"original checksums") + owner._sha256sums_text.assert_not_called() + + def test_postcommit_backup_cleanup_failure_preserves_bound_bytes_and_interrupts(self) -> None: + for fault in (OSError("synthetic cleanup failure"), KeyboardInterrupt()): + with self.subTest(fault=type(fault).__name__), tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + notes = root / f"RELEASE-{self.version}-NOTES.md" + notes.write_text("# Current nightly\n\n## Profile compatibility\n\nUse a test profile.\n", encoding="utf-8") + sums = root / "SHA256SUMS" + sums.write_bytes(b"original sums\n") + originals = notes.read_bytes(), sums.read_bytes() + candidate = root / f"emulebb-rust-v{self.version}-candidate-manifest.json" + data = self.inventory(()) + data.update(formatVersion=4, platformProfile="full", channel="nightly") + data["artifacts"] = [{"name": notes.name}, {"name": sums.name}] + owner = mock.Mock() + owner.candidate_path.return_value = candidate + owner.candidate_manifest.return_value = data + owner.package_layout.return_value = {} + owner._sha256sums_text.side_effect = lambda _directory, members: "".join( + f"{hashlib.sha256(path.read_bytes()).hexdigest()} {path.name}\n" for path in members) + + def commit(**_kwargs): + candidate.write_text(json.dumps({ + "notesSha256": hashlib.sha256(notes.read_bytes()).hexdigest(), + "checksumsSha256": hashlib.sha256(sums.read_bytes()).hexdigest(), + }), encoding="utf-8") + + owner.write_candidate.side_effect = commit + stderr = io.StringIO() + with (mock.patch.object(PUBLISH.shutil, "rmtree", side_effect=fault) as cleanup, + mock.patch.object(PUBLISH.sys, "stderr", stderr)): + arguments = {"release_dir": root, "context": mock.Mock(version=self.version), + "archives_dir": root / "archives", "archive_identity": object()} + if isinstance(fault, KeyboardInterrupt): + with self.assertRaises(KeyboardInterrupt): + PUBLISH.finalize_release_candidate(owner, **arguments) + else: + PUBLISH.finalize_release_candidate(owner, **arguments) + backup = cleanup.call_args.args[0] + self.assertEqual(((backup / notes.name).read_bytes(), (backup / sums.name).read_bytes()), originals) + committed = json.loads(candidate.read_text(encoding="utf-8")) + self.assertEqual(committed["notesSha256"], hashlib.sha256(notes.read_bytes()).hexdigest()) + self.assertEqual(committed["checksumsSha256"], hashlib.sha256(sums.read_bytes()).hexdigest()) + self.assertIn("## Downloads\n", notes.read_text(encoding="utf-8")) + self.assertIn(hashlib.sha256(notes.read_bytes()).hexdigest(), sums.read_text(encoding="ascii")) + if isinstance(fault, OSError): + self.assertIn("Warning: Release candidate committed", stderr.getvalue()) + self.assertIn(str(backup), stderr.getvalue()) + else: + self.assertEqual(stderr.getvalue(), "") + + def test_competing_process_cannot_mutate_or_rollback_the_winners_bound_bytes(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + notes = root / f"RELEASE-{self.version}-NOTES.md" + sums = root / "SHA256SUMS" + notes.write_text("# Current nightly\n\n## Profile compatibility\n\nUse a test profile.\n", encoding="utf-8") + sums.write_bytes(b"original sums\n") + original = notes.read_bytes(), sums.read_bytes() + owner = mock.Mock() + candidate = root / f"emulebb-rust-v{self.version}-candidate-manifest.json" + owner.candidate_path.return_value = candidate + owner.package_layout.return_value = {} + data = self.inventory(()) + data["artifacts"] = [{"name": notes.name}, {"name": sums.name}] + contender = ( + "import runpy,sys; from pathlib import Path; " + "runpy.run_path(sys.argv[1])['finalize_release_candidate'](" + "None,release_dir=Path(sys.argv[2]),context=None," + "archives_dir=Path(sys.argv[2]),archive_identity=None)" + ) + + def verify_original(**_kwargs): + result = subprocess.run( + [sys.executable, "-B", "-c", contender, str(SCRIPT_PATH), str(root)], + capture_output=True, text=True, timeout=10, + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn("Release candidate finalization is already in progress", result.stderr) + self.assertEqual((notes.read_bytes(), sums.read_bytes()), original) + self.assertFalse(candidate.exists()) + return data + + def format_sums(_directory, members): + return "".join(f"{hashlib.sha256(path.read_bytes()).hexdigest()} {path.name}\n" for path in members) + + def commit_candidate(**_kwargs): + candidate.write_text(json.dumps({ + "notesSha256": hashlib.sha256(notes.read_bytes()).hexdigest(), + "checksumsSha256": hashlib.sha256(sums.read_bytes()).hexdigest(), + }), encoding="utf-8") + + owner.candidate_manifest.side_effect = verify_original + owner._sha256sums_text.side_effect = format_sums + owner.write_candidate.side_effect = commit_candidate + common = {"release_dir": root, "context": mock.Mock(version=self.version), + "archives_dir": root / "archives", "archive_identity": object()} + PUBLISH.finalize_release_candidate(owner, **common) + committed = notes.read_bytes(), sums.read_bytes(), candidate.read_bytes() + second = mock.Mock() + second.candidate_path.return_value = candidate + with self.assertRaisesRegex(RuntimeError, "already exists"): + PUBLISH.finalize_release_candidate(second, **common) + self.assertEqual((notes.read_bytes(), sums.read_bytes(), candidate.read_bytes()), committed) + identity = json.loads(candidate.read_text(encoding="utf-8")) + self.assertEqual(identity["notesSha256"], hashlib.sha256(notes.read_bytes()).hexdigest()) + self.assertEqual(identity["checksumsSha256"], hashlib.sha256(sums.read_bytes()).hexdigest()) + second.candidate_manifest.assert_not_called() + second.write_candidate.assert_not_called() + + class TestCandidateControl(unittest.TestCase): def test_registry_writes_require_actual_archive_verification_and_current_source_checks(self) -> None: for fault in ("archive", "assembly-hash", "source"): @@ -527,6 +855,7 @@ def test_assembly_and_verification_delegate_to_the_checked_packaging_owner(self) extra = ["--github-output", str(root / "github-output")] if command == "candidate-manifest": extra += ["--output", str(path)] + path.unlink() if command in {"candidate-manifest", "verify-candidate"}: extra += ["--archives-dir", str(root / "archives"), "--harness-repo", str(root / "harness")] else: @@ -535,17 +864,23 @@ def test_assembly_and_verification_delegate_to_the_checked_packaging_owner(self) mock.patch.object(PUBLISH, "candidate_owner", return_value=owner), mock.patch.object(PUBLISH, "candidate_context", return_value=context), mock.patch.object(PUBLISH, "archive_identity_reader", return_value=reader) as load_reader, + mock.patch.object(PUBLISH, "finalize_release_candidate") as finalize, ): + if command == "candidate-manifest": + finalize.side_effect = lambda *_args, **_kwargs: path.write_text(json.dumps(data), encoding="utf-8") self.assertEqual(PUBLISH.main([command, *self.common(root), *extra]), 0) if command == "candidate-manifest": - owner.write_candidate.assert_called_once_with( - release_dir=root, context=context, archives_dir=root / "archives", archive_identity=reader, + finalize.assert_called_once_with( + owner, release_dir=root, context=context, archives_dir=root / "archives", archive_identity=reader, ) + owner.write_candidate.assert_not_called() elif command == "verify-candidate": + finalize.assert_not_called() owner.verify_candidate.assert_called_once_with( release_dir=root, context=context, archives_dir=root / "archives", archive_identity=reader, ) else: + finalize.assert_not_called() load_reader.assert_not_called() owner.verify_public_candidate.assert_called_once_with( release_dir=root, context=context, verified_candidate_sha256="c" * 64, @@ -555,6 +890,27 @@ def test_assembly_and_verification_delegate_to_the_checked_packaging_owner(self) self.assertIn("ubuntu_digest=sha256:" + "a" * 64, outputs) self.assertIn("alpine_digest=sha256:" + "b" * 64, outputs) + def test_existing_candidate_cannot_trigger_notes_or_checksum_rewrites(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary).resolve() + path = root / "emulebb-rust-v0.1.0-beta.2-candidate-manifest.json" + path.write_bytes(b"immutable candidate") + owner = mock.Mock() + owner.candidate_path.return_value = path + with ( + mock.patch.object(PUBLISH, "candidate_owner", return_value=owner), + mock.patch.object(PUBLISH, "candidate_context", return_value=object()), + mock.patch.object(PUBLISH, "archive_identity_reader", return_value=object()), + ): + with self.assertRaisesRegex(RuntimeError, "already exists"): + PUBLISH.main([ + "candidate-manifest", *self.common(root), "--output", str(path), + "--archives-dir", str(root / "archives"), "--harness-repo", str(root / "harness"), + ]) + self.assertEqual(path.read_bytes(), b"immutable candidate") + owner.candidate_manifest.assert_not_called() + owner.write_candidate.assert_not_called() + def test_local_calls_fail_before_checkout_or_import(self) -> None: args = PUBLISH.build_parser().parse_args([ "verify-public-candidate", *self.common(Path("synthetic")),