The termux-main pool deletes a package's previous archive when it rebuilds, so the runtime-lib pin table and the bionic lock rows rot without warning. The last rotation broke a build on eight rows at once, and the stager's concurrent downloads only surfaced whichever 404 won the race. pm now owns the pin table it repairs: scripts/termux/runtime_libs.json moves to pm/termux_runtime_libs.json, so pins live in pm/ and scripts consume them — the direction scripts/ci/archive_inputs.py already reads pm/lock.json in. `hermes pm update --termux` repins exactly the rows whose archive the pool has replaced, hashing each replacement against the index SHA256 before writing url/version/hash together. `--check` reports without writing and exits 1, so a retired pin can fail a cheap preflight instead of a payload build. It is a repair, not an update: an alive pin is never moved, because a repin can land a rebuilt library under a moved soname and the table is the payload's recursive DT_NEEDED closure. A pin whose package the pool has dropped outright is reported and left alone. `--termux` runs alone — names/--target/--uv/--npm are ignored, since repairing foreign-target pins is not a version resolution. Verified: `pm update --termux --check` against the live pool reports 89 rows served; a table deliberately pinned to the retired libiconv 1.18-1 repins to 1.19 with the pool's hash through the real network path; 19 new tests; the tests/pm, tests/ci and tests/scripts suites have the same failure set as the base commit (91 pre-existing Windows environment failures, none new).
106 lines
4.2 KiB
Python
106 lines
4.2 KiB
Python
"""The wheelhouse manifest is the proof used to admit restored build output."""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import re
|
|
from pathlib import Path
|
|
|
|
|
|
def build_identity(repo: Path, builder: str, platform_tag: str, python_abi: str) -> dict[str, str]:
|
|
files = (
|
|
"uv.lock", "pyproject.toml", "pm/lock.json", "pm/termux_runtime_libs.json",
|
|
"scripts/termux/build_config.sh",
|
|
"scripts/termux/termux_build.sh", "scripts/termux/build_wheels.py",
|
|
"scripts/termux/retag_wheel.py", "scripts/termux/python_linkage.py",
|
|
"scripts/termux/wheelhouse_cache.py", "scripts/termux/build_environment.py",
|
|
"pm/environment.py", "pm/build_operations.py", "pm/operations.py",
|
|
"pm/pyproject.toml", "pm/uv.lock",
|
|
"scripts/termux/termux-builder.Dockerfile",
|
|
)
|
|
return {
|
|
"builder": builder,
|
|
"platformTag": platform_tag,
|
|
"pythonAbi": python_abi,
|
|
**{name: _sha256(repo / name) for name in files},
|
|
}
|
|
|
|
|
|
def _sha256(path: Path) -> str:
|
|
with path.open("rb") as stream:
|
|
return hashlib.file_digest(stream, "sha256").hexdigest()
|
|
|
|
|
|
def write_manifest(payload: Path, identity: dict[str, str], **metadata: str) -> None:
|
|
wheels = sorted((payload / "wheelhouse").glob("*.whl"))
|
|
if not wheels:
|
|
raise ValueError("cannot cache an empty wheelhouse")
|
|
index = {
|
|
**metadata,
|
|
"schemaVersion": 2,
|
|
"inputs": identity,
|
|
"resolvedSha256": _sha256(payload / ".work/resolved.txt"),
|
|
"buildSetSha256": _sha256(payload / ".work/build_set.txt"),
|
|
"wheels": [{"name": w.name, "sha256": _sha256(w)} for w in wheels],
|
|
}
|
|
(payload / "index.json").write_text(json.dumps(index, indent=2) + "\n", encoding="utf-8")
|
|
(payload / "SHA256SUMS").write_text(
|
|
"".join(f"{w['sha256']} {w['name']}\n" for w in index["wheels"]),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
|
|
def is_usable(payload: Path, identity: dict[str, str]) -> bool:
|
|
try:
|
|
index = json.loads((payload / "index.json").read_text(encoding="utf-8-sig"))
|
|
if index["schemaVersion"] != 2 or index["inputs"] != identity:
|
|
return False
|
|
if index["resolvedSha256"] != _sha256(payload / ".work/resolved.txt"):
|
|
return False
|
|
if index["buildSetSha256"] != _sha256(payload / ".work/build_set.txt"):
|
|
return False
|
|
wheels = index["wheels"]
|
|
names = [w["name"] for w in wheels]
|
|
actual = {w.name for w in (payload / "wheelhouse").glob("*.whl")}
|
|
if not names or len(names) != len(set(names)) or set(names) != actual:
|
|
return False
|
|
for wheel in wheels:
|
|
name = wheel["name"]
|
|
if "/" in name or "\\" in name or name in (".", ".."):
|
|
return False
|
|
path = payload / "wheelhouse" / name
|
|
if path.is_symlink() or _sha256(path) != wheel["sha256"]:
|
|
return False
|
|
return True
|
|
except (OSError, ValueError, KeyError, TypeError):
|
|
return False
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("action", choices=("check", "write"))
|
|
parser.add_argument("--payload", type=Path, required=True)
|
|
parser.add_argument("--repo", type=Path, required=True)
|
|
parser.add_argument("--builder", required=True)
|
|
parser.add_argument("--platform-tag", required=True)
|
|
parser.add_argument("--python-abi", required=True)
|
|
provenance = parser.add_mutually_exclusive_group()
|
|
provenance.add_argument("--tag", default="")
|
|
provenance.add_argument("--commit")
|
|
args = parser.parse_args()
|
|
if args.commit is not None and not re.fullmatch(r"[a-f0-9]{40}", args.commit):
|
|
parser.error("--commit requires an exact full SHA")
|
|
identity = build_identity(args.repo, args.builder, args.platform_tag, args.python_abi)
|
|
if args.action == "check":
|
|
return 0 if is_usable(args.payload, identity) else 1
|
|
write_manifest(
|
|
args.payload, identity, **({"commit": args.commit} if args.commit else {"tag": args.tag}),
|
|
platformTag=args.platform_tag, pythonAbi=args.python_abi,
|
|
)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|