172 lines
6.0 KiB
Python
172 lines
6.0 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",
|
|
"scripts/rotate_private_state.py",
|
|
"scripts/rotate_unfiled_drafts.py",
|
|
)
|
|
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()
|