feat(tools): add bump_packaging to sync the AUR package and tap

This commit is contained in:
beefcarver committed 2026-11-14 17:05:22 -05:00
1 parent a597aef8cd
commit 77a7e2b513
1 file changed
+450
+450
View File
@@ -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-<version>`), 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}<version>")
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-<version>, 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())