stackchain-dashboard/scripts/build_release.py
timmy 2082fbd5eb
All checks were successful
CI / lint (pull_request) Successful in 2m38s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m24s
CI / release-candidate (pull_request) Has been skipped
ci: build releases from declared Git commits (Closes #1044)
2026-08-17 22:25:38 +00:00

167 lines
5.9 KiB
Python

#!/usr/bin/env python3
"""Build a reproducible release bundle from an exact Git commit."""
from __future__ import annotations
import argparse
import gzip
import hashlib
import io
import json
import re
import subprocess
import tarfile
from dataclasses import dataclass
from pathlib import Path, PurePosixPath
RUNTIME_DIRECTORIES = ("docs", "frontend", "src")
RUNTIME_FILES = ("README.md", "requirements.txt")
EXCLUDED_PARTS = {"__pycache__", ".pytest_cache"}
EXCLUDED_SUFFIXES = (".pyc", ".pyo")
COMMIT_PATTERN = re.compile(r"[0-9a-f]{40}")
@dataclass(frozen=True)
class RuntimeFile:
name: str
data: bytes
executable: bool
def sha256(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def _git(root: Path, *args: str, text: bool = False) -> subprocess.CompletedProcess:
return subprocess.run(
["git", "-C", str(root), *args],
capture_output=True,
check=False,
text=text,
)
def runtime_files_at_commit(root: Path, commit: str) -> list[RuntimeFile]:
"""Read the runtime allowlist directly from the declared Git tree."""
root = root.resolve()
if not COMMIT_PATTERN.fullmatch(commit):
raise ValueError("declared commit must be a full 40-character Git object ID")
resolved = _git(root, "rev-parse", "--verify", f"{commit}^{{commit}}", text=True)
if resolved.returncode != 0 or resolved.stdout.strip() != commit:
raise ValueError("declared commit is unavailable")
listing = _git(root, "ls-tree", "-rz", "--full-tree", commit)
if listing.returncode != 0:
raise ValueError("could not inspect the declared commit")
files: list[RuntimeFile] = []
for record in listing.stdout.split(b"\0"):
if not record:
continue
metadata, raw_name = record.split(b"\t", 1)
mode, object_type, object_id = metadata.decode("ascii").split()
name = raw_name.decode("utf-8")
path = PurePosixPath(name)
selected = name in RUNTIME_FILES or (path.parts and path.parts[0] in RUNTIME_DIRECTORIES)
if not selected or object_type != "blob":
continue
if mode not in {"100644", "100755"}:
raise ValueError(f"release member is not a regular file: {name}")
if EXCLUDED_PARTS.intersection(path.parts) or name.endswith(EXCLUDED_SUFFIXES):
raise ValueError(f"declared commit contains generated release member: {name}")
blob = _git(root, "cat-file", "blob", object_id)
if blob.returncode != 0:
raise ValueError(f"could not read release member: {name}")
files.append(RuntimeFile(name, blob.stdout, mode == "100755"))
names = {item.name for item in files}
missing = [name for name in RUNTIME_FILES if name not in names]
if missing or not any(name.startswith("src/") for name in names) or not any(
name.startswith("frontend/") for name in names
):
raise ValueError("declared commit is missing required runtime files")
return sorted(files, key=lambda item: item.name)
def manifest_for(files: list[RuntimeFile], commit: str) -> dict:
return {
"schema_version": 1,
"commit": commit,
"files": {
item.name: {"sha256": sha256(item.data), "size": len(item.data)} for item in files
},
}
def json_bytes(payload: dict) -> bytes:
return (json.dumps(payload, indent=2, sort_keys=True) + "\n").encode()
def tar_info(name: str, size: int, epoch: int, executable: bool = False) -> tarfile.TarInfo:
info = tarfile.TarInfo(name)
info.size = size
info.mtime = epoch
info.mode = 0o755 if executable else 0o644
info.uid = info.gid = 0
info.uname = info.gname = "root"
return info
def build(root: Path, output_dir: Path, commit: str, epoch: int) -> tuple[Path, Path, Path]:
files = runtime_files_at_commit(root, commit)
embedded_manifest = manifest_for(files, commit)
archive_members: dict[str, tuple[bytes, bool]] = {
item.name: (item.data, item.executable) for item in files
}
archive_members["release-manifest.json"] = (json_bytes(embedded_manifest), False)
output_dir.mkdir(parents=True, exist_ok=True)
stem = f"stackchain-dashboard-{commit[:12]}"
archive_path = output_dir / f"{stem}.tar.gz"
with archive_path.open("wb") as raw:
with gzip.GzipFile(fileobj=raw, mode="wb", filename="", mtime=epoch) as compressed:
with tarfile.open(fileobj=compressed, mode="w", format=tarfile.PAX_FORMAT) as archive:
for name in sorted(archive_members):
data, executable = archive_members[name]
archive.addfile(tar_info(name, len(data), epoch, executable), io.BytesIO(data))
archive_data = archive_path.read_bytes()
digest = sha256(archive_data)
public_manifest = {
**embedded_manifest,
"artifact": {
"name": archive_path.name,
"sha256": digest,
"size": len(archive_data),
},
}
manifest_path = output_dir / f"{stem}.manifest.json"
checksum_path = output_dir / f"{stem}.sha256"
manifest_path.write_bytes(json_bytes(public_manifest))
checksum_path.write_text(f"{digest} {archive_path.name}\n")
return archive_path, manifest_path, checksum_path
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--root", type=Path, default=Path.cwd())
parser.add_argument("--output-dir", type=Path, default=Path("dist"))
parser.add_argument("--commit", required=True)
parser.add_argument("--source-date-epoch", type=int, required=True)
return parser.parse_args()
def main() -> None:
args = parse_args()
try:
paths = build(args.root, args.output_dir, args.commit, args.source_date_epoch)
except (OSError, ValueError) as error:
raise SystemExit(f"release build failed: {error}") from error
for path in paths:
print(path)
if __name__ == "__main__":
main()