From 77a7e2b513a070bce4b3e3ce93d1e9a7099bdcd0 Mon Sep 17 00:00:00 2001 From: beefcarver Date: Sat, 14 Nov 2026 17:05:22 -0500 Subject: [PATCH] feat(tools): add bump_packaging to sync the AUR package and tap --- tools/bump_packaging.py | 450 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 450 insertions(+) create mode 100644 tools/bump_packaging.py diff --git a/tools/bump_packaging.py b/tools/bump_packaging.py new file mode 100644 index 0000000..0e9c04c --- /dev/null +++ b/tools/bump_packaging.py @@ -0,0 +1,450 @@ +#!/usr/bin/env python3 +"""Sync the AUR package and the Homebrew tap formula with a tagged release. + +A release carries its version in six places: `pyproject.toml`, +`beefy_player.__version__`, `flake.nix`, `contrib/aur/PKGBUILD`, +`contrib/aur/.SRCINFO` and the tap's formula. Only the first three are edited +by hand; this tool writes the rest from the published release. + +Both packagers build the sdist attached to the release, not a forge-generated +archive of the tag: an uploaded asset is immutable, so the checksum recorded in +`PKGBUILD` and the formula keeps matching, which is not guaranteed for archives +generated on the fly. The sdist's root directory is hatchling's PEP 503 name +(`beefy_player-`), so the `PKGBUILD` cds into `$_srcname` rather than +`$pkgname`. + +Usage: + + python tools/bump_packaging.py 1.2.3 # download the release asset + python tools/bump_packaging.py 1.2.3 --asset dist/beefy_player-1.2.3.tar.gz + python tools/bump_packaging.py 1.2.3 --commit # also commit both repos + python tools/bump_packaging.py 1.2.3 --check # verify, write nothing + +`.SRCINFO` is never edited directly; it is regenerated by evaluating +`PKGBUILD` in bash and serialising it in makepkg's field order. That keeps it +honest without needing makepkg (or an Arch host) at hand. + +After `--commit`, the tap has to be pushed before this repository: its commit +is new, and a gitlink pointing at a commit the tap's remote does not have yet +breaks every fresh clone with `--recurse-submodules`. +""" + +from __future__ import annotations + +import argparse +import hashlib +import re +import subprocess +import sys +import tarfile +import tempfile +import urllib.request +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +PYPROJECT = ROOT / "pyproject.toml" +INIT = ROOT / "beefy_player/__init__.py" +FLAKE = ROOT / "flake.nix" +AUR = ROOT / "contrib/aur" +PKGBUILD = AUR / "PKGBUILD" +SRCINFO = AUR / ".SRCINFO" +TAP = ROOT / "contrib/homebrew" +FORMULA = TAP / "Formula/b/beefy-player.rb" + +HOMEPAGE = "https://tildeforge.dev/beefcarver/beefy-player" +#: The sdist's own name, as hatchling builds it (PEP 503: underscores). +ASSET_NAME = "beefy_player" +RELEASE_ASSET = HOMEPAGE + "/releases/download/v{version}/{name}-{version}.tar.gz" + +#: The fields `PKGBUILD` may define, in the order `makepkg --printsrcinfo` +#: emits them. A field outside this list is an error, not something to +#: silently drop. +SRCINFO_ORDER = ( + "pkgdesc", + "pkgver", + "pkgrel", + "epoch", + "url", + "arch", + "license", + "groups", + "depends", + "makedepends", + "checkdepends", + "optdepends", + "provides", + "conflicts", + "replaces", + "backup", + "options", + "install", + "changelog", + "source", + "noextract", + "validpgpkeys", + "sha256sums", +) + +#: Evaluates PKGBUILD (it is valid shell) and dumps the metadata as tab +#: separated `key\tvalue` pairs, so quoting and `$var` expansion are the +#: real ones rather than whatever a hand-rolled parser guesses. +PKGBUILD_DUMP = r""" +set -euo pipefail +source "$1" +for v in pkgname pkgdesc pkgver pkgrel url; do + printf '%s\t%s\n' "$v" "${!v}" +done +for field in arch license groups depends makedepends checkdepends optdepends \ + provides conflicts replaces backup options source noextract \ + validpgpkeys sha256sums; do + eval "values=(\"\${${field}[@]}\")" + for v in "${values[@]}"; do + printf '%s\t%s\n' "$field" "$v" + done +done +""" + +#: Replaces the `_commit`-pinned git-archive source with the release asset. +ASSET_SOURCE = ( + "# The sdist attached to the v$pkgver release. tools/bump_packaging.py keeps\n" + "# this and sha256sums in step with the tag; build/check/package use\n" + "# $_srcname because the sdist unpacks to hatchling's PEP 503 name.\n" + '_srcname="beefy_player-$pkgver"\n' + 'source=("$pkgname-$pkgver.tar.gz::$url/releases/download/v$pkgver/$_srcname.tar.gz")\n' +) + + +class BumpError(SystemExit): + """A precondition failed; nothing was written.""" + + +def say(message: str) -> None: + print(message, flush=True) + + +def fail(message: str) -> BumpError: + return BumpError(f"bump_packaging: {message}") + + +def git(*args: str, cwd: Path = ROOT) -> str: + result = subprocess.run( + ["git", *args], cwd=cwd, check=True, text=True, capture_output=True + ) + return result.stdout.strip() + + +def git_ok(*args: str, cwd: Path = ROOT) -> bool: + """Run a git query whose non-zero exit is an answer, not a failure.""" + return subprocess.run(["git", *args], cwd=cwd, capture_output=True).returncode == 0 + + +def normalise(version: str) -> str: + """Accept `1.2.3` or the tag spelling `v1.2.3`.""" + version = version.removeprefix("v") + if not re.fullmatch(r"\d+\.\d+\.\d+(?:[abrc][\w.]*|\.post\d+)?", version): + raise fail(f"{version!r} is not a version") + return version + + +def declared_versions() -> dict[str, str]: + """The three places the version is written by hand.""" + patterns = { + str(PYPROJECT.relative_to(ROOT)): (PYPROJECT, r'(?m)^version = "([^"]+)"'), + str(INIT.relative_to(ROOT)): (INIT, r'(?m)^__version__ = "([^"]+)"'), + str(FLAKE.relative_to(ROOT)): (FLAKE, r'(?m)^\s*version = "([^"]+)"'), + } + found = {} + for label, (path, pattern) in patterns.items(): + match = re.search(pattern, path.read_text()) + if match is None: + raise fail(f"no version found in {label}") + found[label] = match.group(1) + return found + + +def check_versions(version: str) -> None: + """The tag, pyproject and __version__ must agree before packaging moves.""" + declared = declared_versions() + stale = {label: value for label, value in declared.items() if value != version} + if stale: + detail = ", ".join(f"{label} has {value}" for label, value in stale.items()) + raise fail(f"version {version} is not released yet: {detail}") + say(f"version {version} agrees in {len(declared)} files") + + +def sdist_version(asset: Path) -> str: + """The version the sdist says it is, from its root directory name.""" + with tarfile.open(asset) as archive: + first = next(iter(archive), None) + if first is None: + raise fail(f"{asset} is empty") + root = first.name.split("/", 1)[0] + prefix = f"{ASSET_NAME}-" + if not root.startswith(prefix): + raise fail(f"{asset} unpacks to {root!r}, expected {prefix}") + return root[len(prefix) :] + + +def sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1 << 20), b""): + digest.update(chunk) + return digest.hexdigest() + + +def fetch_asset(version: str, override: Path | None) -> Path: + if override is not None: + if not override.is_file(): + raise fail(f"no such asset: {override}") + return override + url = RELEASE_ASSET.format(version=version, name=ASSET_NAME) + say(f"downloading {url}") + target = Path(tempfile.mkdtemp()) / f"{ASSET_NAME}-{version}.tar.gz" + try: + with urllib.request.urlopen(url) as response, target.open("wb") as handle: + handle.write(response.read()) + except OSError as error: + raise fail(f"cannot fetch the release asset ({error}); is the tag published?") from error + return target + + +def replace_line(text: str, pattern: str, replacement: str, what: str) -> str: + """Substitute a single line, insisting the pattern occurs exactly once.""" + new, count = re.subn(pattern, replacement, text, flags=re.MULTILINE) + if count != 1: + raise fail(f"expected exactly one {what} in PKGBUILD, found {count}") + return new + + +def migrate_source(text: str) -> str: + """Turn the `_commit`-pinned git archive into a release-asset source.""" + if "releases/download/v$pkgver" in text: + return text + lines = text.splitlines(keepends=True) + pinned = [index for index, line in enumerate(lines) if line.startswith("_commit=")] + if not pinned: + raise fail("PKGBUILD has neither a release asset nor a _commit pin") + # Take the comment block that explains the pin with it. + start = pinned[0] + while start > 0 and lines[start - 1].startswith("#"): + start -= 1 + sums = [index for index, line in enumerate(lines) if line.startswith("sha256sums=")] + if not sums: + raise fail("PKGBUILD has no sha256sums line") + say("migrating PKGBUILD from the _commit archive to the release asset") + replaced = [*lines[:start], ASSET_SOURCE, *lines[sums[0] :]] + return "".join(replaced) + + +def bump_pkgbuild(version: str, checksum: str) -> None: + text = migrate_source(PKGBUILD.read_text()) + text = replace_line( + text, r"^pkgver=.*$", f"pkgver={version}", "pkgver assignment" + ) + text = replace_line(text, r"^pkgrel=.*$", "pkgrel=1", "pkgrel assignment") + text = replace_line( + text, + r"^sha256sums=\(.*\)$", + f"sha256sums=('{checksum}')", + "sha256sums assignment", + ) + if 'cd "$pkgname"' in text: + # The sdist unpacks to beefy_player-, not beefy-player. + text = text.replace('cd "$pkgname"', 'cd "$_srcname"') + say("pointed build/check/package at $_srcname (the sdist's root directory)") + if "_srcname=" not in text: + raise fail("PKGBUILD has no _srcname; rerun after adding the release asset source") + if 'releases/download/v$pkgver' not in text: + raise fail("PKGBUILD does not source the release asset") + PKGBUILD.write_text(text) + say("wrote contrib/aur/PKGBUILD") + + +def render_srcinfo() -> str: + """Regenerate .SRCINFO from PKGBUILD the way makepkg --printsrcinfo does.""" + result = subprocess.run( + ["bash", "-c", PKGBUILD_DUMP, "bash", str(PKGBUILD)], + cwd=AUR, + check=True, + text=True, + capture_output=True, + ) + fields: dict[str, list[str]] = {} + for line in result.stdout.splitlines(): + key, _, value = line.partition("\t") + fields.setdefault(key, []).append(value) + name = fields.pop("pkgname", [None])[0] + if name is None: + raise fail("PKGBUILD defines no pkgname") + unknown = sorted(set(fields) - set(SRCINFO_ORDER)) + if unknown: + raise fail(f"PKGBUILD defines {unknown}; add them to SRCINFO_ORDER first") + body = [f"\t{key} = {value}" for key in SRCINFO_ORDER for value in fields.get(key, [])] + return "\n".join([f"pkgbase = {name}", *body]) + f"\n\npkgname = {name}\n" + + +def bump_srcinfo() -> None: + rendered = render_srcinfo() + if SRCINFO.read_text() == rendered: + say("contrib/aur/.SRCINFO already matches PKGBUILD") + return + SRCINFO.write_text(rendered) + say("regenerated contrib/aur/.SRCINFO") + + +def bump_formula(version: str, checksum: str) -> None: + text = FORMULA.read_text() + url = RELEASE_ASSET.format(version=version, name=ASSET_NAME) + text = replace_line(text, r'^ url ".*"$', f' url "{url}"', "url line") + text = replace_line( + text, r'^ sha256 "[0-9a-f]{64}".*$', f' sha256 "{checksum}"', "sha256 line" + ) + # The default branch is master, and --HEAD is only useful if it resolves. + text = text.replace('branch: "main"', 'branch: "master"') + FORMULA.write_text(text) + say("wrote contrib/homebrew/Formula/b/beefy-player.rb") + + +def require_clean(path: Path, label: str) -> None: + """Refuse to edit on top of uncommitted work; ignore untracked files. + + Untracked files cannot end up in the bump commit because `commit` stages + explicit paths, so a stray `dist/` or scratch note is not a reason to stop. + """ + dirty = git("status", "--porcelain", "--untracked-files=no", cwd=path) + if dirty: + raise fail(f"{label} has uncommitted changes:\n{dirty}") + + +def preflight(commit_too: bool) -> None: + """Checks that must pass before anything is written.""" + require_clean(ROOT, "this repository") + require_clean(TAP, "the tap submodule") + if commit_too and not git_ok("symbolic-ref", "--short", "-q", "HEAD", cwd=TAP): + # A fresh clone checks submodules out at the recorded commit, where a + # commit would be unreachable and silently dropped by the next update. + say("submodule is in detached HEAD; checking out master") + git("checkout", "master", cwd=TAP) + + +def commit(version: str) -> None: + if not git_ok("merge-base", "--is-ancestor", "origin/master", "HEAD", cwd=TAP): + raise fail("the tap's master is not an ancestor of HEAD; fetch and rebase first") + if not git("status", "--porcelain", cwd=TAP): + say("tap formula unchanged; nothing to commit there") + else: + git("add", str(FORMULA.relative_to(TAP)), cwd=TAP) + staged = set(git("diff", "--cached", "--name-only", cwd=TAP).splitlines()) + if staged != {str(FORMULA.relative_to(TAP))}: + raise fail(f"unexpected staged files in the tap: {sorted(staged)}") + git("commit", "-m", f"feat(beefy-player): {version}", cwd=TAP) + say(f'committed tap: "feat(beefy-player): {version}"') + expected = {str(PKGBUILD.relative_to(ROOT)), str(SRCINFO.relative_to(ROOT))} + git("add", *expected, cwd=ROOT) + git("add", str(TAP.relative_to(ROOT)), cwd=ROOT) # record the new gitlink + expected.add(str(TAP.relative_to(ROOT))) + staged = set(git("diff", "--cached", "--name-only", cwd=ROOT).splitlines()) + if staged != expected: + raise fail(f"unexpected staged files here: {sorted(staged)}") + git("commit", "-m", f"build(release): packaging for v{version}", cwd=ROOT) + say(f'committed here: "build(release): packaging for v{version}"') + say( + "\nPush the tap first, then this repository:\n" + f" git -C {TAP.relative_to(ROOT)} push\n" + " git push\n" + "A gitlink whose commit is missing from the tap's remote breaks every\n" + "clone that uses --recurse-submodules." + ) + + +def check(version: str) -> int: + """Report drift without writing anything.""" + problems: list[str] = [] + text = PKGBUILD.read_text() + + def field(name: str) -> str: + match = re.search(rf"^{name}=(.*)$", text, re.MULTILINE) + if match is None: + problems.append(f"PKGBUILD has no {name}") + return "" + return match.group(1).strip("'\"") + + if field("pkgver") != version: + problems.append(f"PKGBUILD pkgver is {field('pkgver') or 'unset'}, expected {version}") + if field("pkgrel") != "1": + problems.append(f"PKGBUILD pkgrel is {field('pkgrel')}, expected 1") + if "releases/download/v$pkgver" not in text: + problems.append("PKGBUILD still sources a _commit archive, not the release asset") + if 'cd "$_srcname"' not in text: + problems.append("PKGBUILD cds into $pkgname, which the sdist does not unpack to") + + info = re.search(r"(?m)^\tpkgver = (.*)$", SRCINFO.read_text()) + if info is None or info.group(1) != version: + problems.append(f".SRCINFO pkgver is {info.group(1) if info else 'missing'}, expected {version}") + if SRCINFO.read_text() != render_srcinfo(): + problems.append(".SRCINFO does not match PKGBUILD (regenerate it)") + + formula = FORMULA.read_text() + asset = RELEASE_ASSET.format(version=version, name=ASSET_NAME) + if f'url "{asset}"' not in formula: + problems.append(f"formula url is not {asset}") + if 'branch: "main"' in formula: + problems.append('formula head branch is "main"; this repository uses master') + if re.search(r'sha256 "0{64}"', formula): + problems.append("formula still has the placeholder sha256") + + for problem in problems: + say(f"drift: {problem}") + if problems: + say(f"\n{len(problems)} problem(s) for {version}") + return 1 + say(f"packaging is in sync with {version}") + return 0 + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("version", help="release version, with or without the v prefix") + parser.add_argument( + "--asset", + type=Path, + help="hash a local sdist instead of downloading the release asset", + ) + parser.add_argument( + "--commit", + action="store_true", + help="commit the tap and the packaging bump when done", + ) + parser.add_argument( + "--check", + action="store_true", + help="report drift and exit; writes nothing", + ) + args = parser.parse_args(argv) + version = normalise(args.version) + + if args.check: + return check(version) + + check_versions(version) + preflight(commit_too=args.commit) + asset = fetch_asset(version, args.asset) + measured = sdist_version(asset) + if measured != version: + raise fail(f"{asset} is the sdist of {measured}, not {version}") + checksum = sha256(asset) + say(f"{asset.name} sha256 {checksum}") + + bump_pkgbuild(version, checksum) + bump_srcinfo() + bump_formula(version, checksum) + if args.commit: + commit(version) + else: + say("\nfiles written; rerun with --commit to record them") + return 0 + + +if __name__ == "__main__": + sys.exit(main())