Skip to content
Open
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
8 changes: 0 additions & 8 deletions tools/nightly_release.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
"",
]
Expand Down
183 changes: 182 additions & 1 deletion tools/release_publish.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 = ["<!-- emulebb-release-downloads:start -->", "## 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(("<details>", "<summary>Package manifests, SBOMs and validation reports</summary>", ""))
lines.extend(f"- {link(name)}" for name in supporting)
lines.extend(("", "</details>", ""))
lines.append("<!-- emulebb-release-downloads:end -->")
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)^<!-- emulebb-release-downloads:start -->\n.*?^<!-- emulebb-release-downloads:end -->\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),
Expand Down Expand Up @@ -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)
Expand Down
15 changes: 4 additions & 11 deletions tools/tests/test_nightly_release.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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):
Expand Down
Loading
Loading