Files
coreci da15bda6d2 docs(milestone): complete cli-packaging
---ci---
phase: 3
milestone: v0.2
status: complete
requirements:
  covered: [REQ-21, REQ-22, REQ-23]
  partial: []
---/ci---
2026-10-11 00:32:26 +00:00

141 lines
4.9 KiB
Python

#!/usr/bin/env python3
"""Build the coreci-broker CLI zipapp artifact (stdlib only).
Usage: python3 scripts/build_cli.py <version>
Stages an explicit allowlist into a temp dir, packs it with
``python3 -m zipapp ... --python "/usr/bin/env python3" --compress``, then
runs a normalize post-pass (G-009) so same-environment rebuilds are
byte-identical: entries sorted by name, ``date_time=(1980,1,1,0,0,0)``,
``ZIP_DEFLATED``, ``external_attr`` set to constants (files
``0o100644 << 16``, dirs ``0o40755 << 16`` — never preserved/umask-derived),
shebang re-prepended, exec bit preserved. Prints the artifact sha256.
"""
import argparse
import hashlib
import io
import os
import shutil
import stat
import subprocess
import sys
import tempfile
import zipfile
ROOT_SOURCES = ("coreci_broker.py", "coreci_broker_client.py")
UI_SOURCE_DIR = "coreci_broker_ui"
SHIM_SOURCE = os.path.join("cli", "__main__.py")
STAGED_SHIM = "__main__.py"
VERSION_MODULE = "_cli_version.py"
FIXED_DATE_TIME = (1980, 1, 1, 0, 0, 0)
FILE_EXTERNAL_ATTR = 0o100644 << 16
DIR_EXTERNAL_ATTR = 0o40755 << 16
def repo_root():
return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def artifact_name(version):
tag = version[1:] if version.startswith("v") else version
return "coreci-broker-v{}.pyz".format(tag)
def stage_sources(repo, staging):
for name in ROOT_SOURCES:
shutil.copyfile(os.path.join(repo, name), os.path.join(staging, name))
ui_root = os.path.join(repo, UI_SOURCE_DIR)
staged_ui = os.path.join(staging, UI_SOURCE_DIR)
for current, _dirs, files in os.walk(ui_root):
relative = os.path.relpath(current, ui_root)
target_dir = staged_ui if relative == "." else os.path.join(staged_ui, relative)
os.makedirs(target_dir, exist_ok=True)
for name in sorted(files):
shutil.copyfile(os.path.join(current, name), os.path.join(target_dir, name))
shutil.copyfile(os.path.join(repo, SHIM_SOURCE), os.path.join(staging, STAGED_SHIM))
def write_version_module(staging, version):
with open(os.path.join(staging, VERSION_MODULE), "w", encoding="utf-8") as handle:
handle.write('VERSION = "{}"\n'.format(version))
def run_zipapp(staging, artifact):
command = [
sys.executable, "-m", "zipapp", staging,
"-o", artifact,
"--python", "/usr/bin/env python3",
"--compress",
]
completed = subprocess.run(command, capture_output=True, text=True)
if completed.returncode != 0:
detail = (completed.stderr or completed.stdout).strip()
raise RuntimeError(
"zipapp failed with code {}: {}".format(completed.returncode, detail)
)
def normalize_archive(artifact):
with open(artifact, "rb") as handle:
raw = handle.read()
if not raw.startswith(b"#!"):
raise RuntimeError("zipapp artifact is missing its shebang prefix")
shebang = raw.split(b"\n", 1)[0] + b"\n"
entries = []
with zipfile.ZipFile(io.BytesIO(raw)) as archive:
for info in archive.infolist():
payload = b"" if info.is_dir() else archive.read(info)
entries.append((info.filename, payload))
entries.sort(key=lambda entry: entry[0])
body = io.BytesIO()
with zipfile.ZipFile(body, "w", compression=zipfile.ZIP_DEFLATED) as archive:
for name, payload in entries:
info = zipfile.ZipInfo(name, date_time=FIXED_DATE_TIME)
info.compress_type = zipfile.ZIP_DEFLATED
info.external_attr = DIR_EXTERNAL_ATTR if name.endswith("/") else FILE_EXTERNAL_ATTR
archive.writestr(info, payload)
mode = os.stat(artifact).st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH
temp_path = artifact + ".tmp"
with open(temp_path, "wb") as handle:
handle.write(shebang)
handle.write(body.getvalue())
os.replace(temp_path, artifact)
os.chmod(artifact, mode)
def sha256_file(path):
digest = hashlib.sha256()
with open(path, "rb") as handle:
for chunk in iter(lambda: handle.read(65536), b""):
digest.update(chunk)
return digest.hexdigest()
def build(version, repo=None, dist_dir=None):
repo = os.path.abspath(repo or repo_root())
dist_dir = dist_dir or os.path.join(repo, "dist")
os.makedirs(dist_dir, exist_ok=True)
artifact = os.path.join(dist_dir, artifact_name(version))
with tempfile.TemporaryDirectory(prefix="coreci-broker-cli-") as staging:
stage_sources(repo, staging)
write_version_module(staging, version)
run_zipapp(staging, artifact)
normalize_archive(artifact)
return artifact
def main(argv=None):
parser = argparse.ArgumentParser(
description="Build the coreci-broker CLI zipapp artifact."
)
parser.add_argument("version", help="release version of the artifact, e.g. v0.2.1")
args = parser.parse_args(argv)
artifact = build(args.version)
print(artifact)
print("sha256: {}".format(sha256_file(artifact)))
return 0
if __name__ == "__main__":
sys.exit(main())