yams/scripts/ci/generate_release_manifest.py
trvon d534f090bd
build(packaging): store symlinks in zips, zstd rpm, correct Arch metadata
- macOS zips were built with `zip -r`, which follows symlinks: bin/yams
  (-> yams-cli) shipped as a second 34 MB copy and each versioned dylib
  link became another full file. `zip -ry` stores the links.
- RPMs used rpm's gzip-class default payload (37.5 MB vs 28.6 MB for the
  zstd .deb of the same tree); the spec now sets w19.zstdio (rpm >= 4.14).
  The spec's License also said Apache-2.0.
- The Arch PKGBUILD and the CI .PKGINFO generator declared Apache-2.0
  (the project is GPL-3.0-or-later) and depended on liburing, libarchive,
  taglib, sqlite, openssl, curl and boost-libs, none of which the
  installed binaries load: readelf shows they need only libstdc++,
  libgcc_s, libc and libm, so depends is gcc-libs and glibc.
- packaging/appimage was never built by any workflow; it and the
  *.AppImage globs in release.yml and the release manifest go away.
- packaging/homebrew/yams.rb was a stale CMake-era formula; release.yml
  renders the tap formula from yams.rb.template, which stays.

Signed-off-by: trvon <me@trevon.dev>
2026-10-01 17:15:46 -06:00

348 lines
13 KiB
Python
Executable file

#!/usr/bin/env python3
"""Generate and validate the immutable GitHub release manifest."""
from __future__ import annotations
import argparse
import hashlib
import json
import re
import sys
from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import quote
SCHEMA_VERSION = 1
FULL_SHA_RE = re.compile(r"^[0-9a-f]{40}$")
NIGHTLY_TAG_RE = re.compile(r"^experimental-nightly-[0-9]{8}-[0-9a-f]{40}$")
CHECKSUM_RE = re.compile(r"^([0-9a-fA-F]{64})[ \t]+[*]?([^\r\n]+)$")
DISTRIBUTABLE_SUFFIXES = (
".pkg.tar.zst",
".tar.gz",
".zip",
".deb",
".rpm",
".pkg",
".msi",
)
ARCHITECTURE_PATTERNS = {
"x86_64": (
re.compile(r"(?<![A-Za-z0-9])x86_64(?![A-Za-z0-9])", re.IGNORECASE),
re.compile(r"(?<![A-Za-z0-9])amd64(?![A-Za-z0-9])", re.IGNORECASE),
re.compile(r"(?<![A-Za-z0-9])x64(?![A-Za-z0-9])", re.IGNORECASE),
),
"aarch64": (
re.compile(r"(?<![A-Za-z0-9])aarch64(?![A-Za-z0-9])", re.IGNORECASE),
re.compile(r"(?<![A-Za-z0-9])arm64(?![A-Za-z0-9])", re.IGNORECASE),
re.compile(r"(?<![A-Za-z0-9])armv8(?![A-Za-z0-9])", re.IGNORECASE),
),
}
def positive_int(value: str | int, label: str) -> int:
try:
parsed = int(value)
except (TypeError, ValueError) as error:
raise ValueError(f"{label} must be a positive integer") from error
if parsed <= 0:
raise ValueError(f"{label} must be a positive integer")
return parsed
def normalize_architecture(filename: str) -> str:
matches = {
architecture
for architecture, patterns in ARCHITECTURE_PATTERNS.items()
if any(pattern.search(filename) for pattern in patterns)
}
if not matches:
raise ValueError(f"unknown architecture in asset filename: {filename}")
if len(matches) != 1:
raise ValueError(f"ambiguous architecture in asset filename: {filename}")
return matches.pop()
def read_checksums(path: Path) -> dict[str, str]:
if not path.is_file():
raise ValueError(f"missing checksum manifest: {path}")
checksums: dict[str, str] = {}
for line_number, raw_line in enumerate(
path.read_text(encoding="utf-8").splitlines(), start=1
):
if not raw_line.strip():
continue
match = CHECKSUM_RE.fullmatch(raw_line)
if match is None:
raise ValueError(f"invalid SHA256SUMS line {line_number}")
digest, filename = match.groups()
if Path(filename).name != filename or filename in {".", ".."}:
raise ValueError(f"invalid asset name in SHA256SUMS: {filename}")
if filename in checksums:
raise ValueError(f"duplicate asset name in SHA256SUMS: {filename}")
checksums[filename] = digest.lower()
if not checksums:
raise ValueError("SHA256SUMS contains no distributable assets")
return checksums
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def validate_run_url(url: str, repository: str, run_id: int, label: str) -> None:
expected = f"/repos/{repository}/actions/runs/{run_id}"
browser_expected = f"/{repository}/actions/runs/{run_id}"
if expected not in url and browser_expected not in url:
raise ValueError(f"{label} URL is not tied to exact run ID {run_id}: {url}")
if "/latest" in url or "/branches/" in url:
raise ValueError(f"{label} URL is mutable: {url}")
def validate_published_at(value: str) -> str:
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError as error:
raise ValueError("published_at must be an RFC 3339 timestamp") from error
if parsed.tzinfo is None:
raise ValueError("published_at must include a timezone")
return parsed.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def release_asset_url(
github_server_url: str, repository: str, tag: str, filename: str
) -> str:
if not github_server_url.startswith("https://"):
raise ValueError("GitHub server URL must use HTTPS")
if repository.count("/") != 1 or any(part == "" for part in repository.split("/")):
raise ValueError(f"invalid GitHub repository: {repository}")
if tag.lower() in {"latest", "nightly", "stable"} or "/" in tag:
raise ValueError(f"mutable release tag is not allowed: {tag}")
url = (
f"{github_server_url.rstrip('/')}/{repository}/releases/download/"
f"{quote(tag, safe='._-')}/{quote(filename, safe='._-')}"
)
if "/releases/latest" in url or "/releases/download/latest/" in url:
raise ValueError(f"mutable release URL is not allowed: {url}")
return url
def build_manifest(
*,
assets_dir: Path,
checksums_path: Path,
channel: str,
version: str,
tag: str,
source_sha: str,
source_ref: str,
repository: str,
github_server_url: str,
release_run_id: str | int,
release_run_attempt: str | int,
release_run_url: str,
tests_run_id: str | int | None,
tests_run_attempt: str | int | None,
tests_run_url: str | None,
ipc_protocol_version: str | int,
p2p_protocol_version: str | int,
published_at: str,
) -> dict[str, object]:
if channel not in {"stable", "nightly", "weekly"}:
raise ValueError("channel must be stable, nightly, or weekly")
if FULL_SHA_RE.fullmatch(source_sha) is None:
raise ValueError("source_sha must be a full 40-character source SHA")
if not source_ref.startswith("refs/"):
raise ValueError("source_ref must be a full refs/... reference")
if channel == "stable" and source_ref != f"refs/tags/{tag}":
raise ValueError("stable manifest source_ref must match its immutable tag")
if channel != "stable" and source_ref != "refs/heads/experimental":
raise ValueError(
"non-stable manifest source_ref must be refs/heads/experimental"
)
if channel == "nightly" and NIGHTLY_TAG_RE.fullmatch(tag) is None:
raise ValueError(
"nightly tag must match experimental-nightly-YYYYMMDD-<full source SHA>"
)
if channel == "nightly" and not tag.endswith(source_sha):
raise ValueError("nightly tag source SHA does not match source_sha")
run_id = positive_int(release_run_id, "release workflow run ID")
run_attempt = positive_int(release_run_attempt, "release workflow run attempt")
validate_run_url(release_run_url, repository, run_id, "release workflow")
tests_workflow: dict[str, object] | None = None
if channel != "stable":
if (
tests_run_id is None
or tests_run_id == ""
or tests_run_attempt is None
or tests_run_attempt == ""
or not tests_run_url
):
raise ValueError("non-stable manifest requires an exact Tests workflow run")
tests_id = positive_int(tests_run_id, "Tests workflow run ID")
tests_attempt = positive_int(tests_run_attempt, "Tests workflow run attempt")
validate_run_url(tests_run_url, repository, tests_id, "Tests workflow")
if not tests_run_url.endswith(f"/attempts/{tests_attempt}"):
raise ValueError("Tests workflow URL must identify the exact run attempt")
tests_workflow = {
"run_id": tests_id,
"run_attempt": tests_attempt,
"url": tests_run_url,
}
checksums = read_checksums(checksums_path)
distributables = {
path.name
for path in assets_dir.iterdir()
if path.is_file() and path.name.endswith(DISTRIBUTABLE_SUFFIXES)
}
unchecksummed = sorted(distributables - checksums.keys())
if unchecksummed:
raise ValueError(
"distributable assets missing from SHA256SUMS: " + ", ".join(unchecksummed)
)
unexpected = sorted(checksums.keys() - distributables)
if unexpected:
missing = [name for name in unexpected if not (assets_dir / name).is_file()]
if missing:
raise ValueError(
"missing asset referenced by SHA256SUMS: " + ", ".join(missing)
)
raise ValueError(
"SHA256SUMS includes non-distributable assets: " + ", ".join(unexpected)
)
assets: list[dict[str, object]] = []
for filename, expected_digest in sorted(checksums.items()):
path = assets_dir / filename
if not path.is_file():
raise ValueError(f"missing asset referenced by SHA256SUMS: {filename}")
actual_digest = sha256_file(path)
if actual_digest != expected_digest:
raise ValueError(
f"checksum mismatch for {filename}: expected {expected_digest}, "
f"got {actual_digest}"
)
assets.append(
{
"name": filename,
"filename": filename,
"size_bytes": path.stat().st_size,
"architecture": normalize_architecture(filename),
"sha256": actual_digest,
"url": release_asset_url(github_server_url, repository, tag, filename),
}
)
ipc_version = positive_int(ipc_protocol_version, "IPC protocol version")
p2p_version = positive_int(p2p_protocol_version, "P2P protocol version")
return {
"schema_version": SCHEMA_VERSION,
"channel": channel,
"version": version,
"tag": tag,
"published_at": validate_published_at(published_at),
"source_sha": source_sha,
"source_ref": source_ref,
"release_workflow_run_id": run_id,
"release_workflow_run_attempt": run_attempt,
"release_workflow_run_url": release_run_url,
"tests_workflow_run_id": (
tests_workflow["run_id"] if tests_workflow is not None else None
),
"tests_workflow_run_attempt": (
tests_workflow["run_attempt"] if tests_workflow is not None else None
),
"tests_workflow_run_url": (
tests_workflow["url"] if tests_workflow is not None else None
),
"ipc_protocol_version": ipc_version,
"p2p_protocol_version": p2p_version,
"assets": assets,
}
def read_protocol_version(path: Path, symbol: str) -> int:
if not path.is_file():
raise ValueError(f"protocol header does not exist: {path}")
pattern = re.compile(rf"\b{re.escape(symbol)}\b\s*=\s*([0-9]+)\s*;")
matches = pattern.findall(path.read_text(encoding="utf-8"))
if len(matches) != 1:
raise ValueError(f"expected exactly one {symbol} definition in {path}")
return positive_int(matches[0], symbol)
def write_manifest(output: Path, manifest: dict[str, object]) -> None:
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(
json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8"
)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--assets-dir", type=Path, required=True)
parser.add_argument("--checksums", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--channel", required=True)
parser.add_argument("--version", required=True)
parser.add_argument("--tag", required=True)
parser.add_argument("--source-sha", required=True)
parser.add_argument("--source-ref", required=True)
parser.add_argument("--repository", required=True)
parser.add_argument("--github-server-url", default="https://github.com")
parser.add_argument("--release-run-id", required=True)
parser.add_argument("--release-run-attempt", required=True)
parser.add_argument("--release-run-url", required=True)
parser.add_argument("--tests-run-id", default="")
parser.add_argument("--tests-run-attempt", default="")
parser.add_argument("--tests-run-url", default="")
parser.add_argument("--ipc-protocol-header", type=Path, required=True)
parser.add_argument("--p2p-protocol-header", type=Path, required=True)
parser.add_argument("--published-at", required=True)
return parser.parse_args()
def main() -> int:
args = parse_args()
try:
manifest = build_manifest(
assets_dir=args.assets_dir,
checksums_path=args.checksums,
channel=args.channel,
version=args.version,
tag=args.tag,
source_sha=args.source_sha,
source_ref=args.source_ref,
repository=args.repository,
github_server_url=args.github_server_url,
release_run_id=args.release_run_id,
release_run_attempt=args.release_run_attempt,
release_run_url=args.release_run_url,
tests_run_id=args.tests_run_id,
tests_run_attempt=args.tests_run_attempt,
tests_run_url=args.tests_run_url,
ipc_protocol_version=read_protocol_version(
args.ipc_protocol_header, "PROTOCOL_VERSION"
),
p2p_protocol_version=read_protocol_version(
args.p2p_protocol_header, "kP2pProtocolVersion"
),
published_at=args.published_at,
)
write_manifest(args.output, manifest)
except (OSError, ValueError) as error:
print(f"release manifest error: {error}", file=sys.stderr)
return 2
return 0
if __name__ == "__main__":
raise SystemExit(main())