121 lines
4.0 KiB
Python
121 lines
4.0 KiB
Python
#!/usr/bin/env python3
|
|
"""Build a reproducible, verifiable Stackchain Dashboard release bundle."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import gzip
|
|
import hashlib
|
|
import io
|
|
import json
|
|
import tarfile
|
|
from pathlib import Path
|
|
|
|
|
|
RUNTIME_DIRECTORIES = ("docs", "frontend", "src")
|
|
RUNTIME_FILES = ("README.md", "requirements.txt")
|
|
|
|
|
|
def sha256(data: bytes) -> str:
|
|
return hashlib.sha256(data).hexdigest()
|
|
|
|
|
|
def runtime_files(root: Path) -> list[Path]:
|
|
files = [root / name for name in RUNTIME_FILES if (root / name).is_file()]
|
|
for name in RUNTIME_DIRECTORIES:
|
|
directory = root / name
|
|
if directory.is_dir():
|
|
files.extend(path for path in directory.rglob("*") if path.is_file())
|
|
return sorted(files, key=lambda path: path.relative_to(root).as_posix())
|
|
|
|
|
|
def manifest_for(root: Path, files: list[Path], commit: str) -> dict:
|
|
return {
|
|
"schema_version": 1,
|
|
"commit": commit,
|
|
"files": {
|
|
path.relative_to(root).as_posix(): {
|
|
"sha256": sha256(path.read_bytes()),
|
|
"size": path.stat().st_size,
|
|
}
|
|
for path 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]:
|
|
root = root.resolve()
|
|
files = runtime_files(root)
|
|
missing = [name for name in RUNTIME_FILES if not (root / name).is_file()]
|
|
if missing or not (root / "src").is_dir() or not (root / "frontend").is_dir():
|
|
raise ValueError("release root is missing required runtime files")
|
|
|
|
embedded_manifest = manifest_for(root, files, commit)
|
|
archive_members: dict[str, tuple[bytes, bool]] = {
|
|
path.relative_to(root).as_posix(): (
|
|
path.read_bytes(),
|
|
bool(path.stat().st_mode & 0o111),
|
|
)
|
|
for path 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()
|
|
for path in build(args.root, args.output_dir, args.commit, args.source_date_epoch):
|
|
print(path)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|