#!/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 bump_packaging.py 1.2.3 # download the release asset python bump_packaging.py 1.2.3 --asset dist/beefy_player-1.2.3.tar.gz python bump_packaging.py 1.2.3 --commit # also commit both repos python 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. 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())