Files
beefy-player/bump_packaging.py
T
beefcarver 4fd688e673 fix(packaging): verify the release download against a CA bundle that exists
python-build-standalone's OpenSSL has default trust paths that need not
exist on the host, so urlopen failed with "unable to get local issuer
certificate" while browsers and curl accepted the same chain.  Fall back
to certifi when OpenSSL's default cafile is missing, and name the bundle
in use when verification still fails so the next occurrence is one line
to diagnose.
2026-11-15 09:51:01 -05:00

493 lines
19 KiB
Python

#!/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 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 os
import re
import ssl
import subprocess
import sys
import tarfile
import tempfile
import urllib.error
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().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}<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 ca_file() -> str | None:
"""The CA bundle to verify with, or None to let OpenSSL use its own default.
`uv python install` fetches a python-build-standalone interpreter whose
OpenSSL is compiled with default trust paths that need not exist on the
host (on macOS, and on distros that keep certs only in
/etc/ssl/certs/ca-certificates.crt, they often do not). Verification then
fails with `unable to get local issuer certificate` for every site, even
though curl and browsers are happy. Fall back to certifi, which `httpx`
already puts in the environment.
"""
if os.environ.get("SSL_CERT_FILE"):
return None # ssl honours the variable itself; do not second-guess it
paths = ssl.get_default_verify_paths()
for candidate in (paths.cafile, paths.openssl_cafile):
if candidate and Path(candidate).is_file():
return None
try:
import certifi
except ModuleNotFoundError:
return None
return certifi.where()
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}")
cafile = ca_file()
context = ssl.create_default_context(cafile=cafile)
target = Path(tempfile.mkdtemp()) / f"{ASSET_NAME}-{version}.tar.gz"
try:
with urllib.request.urlopen(url, context=context) as response, target.open(
"wb"
) as handle:
handle.write(response.read())
except urllib.error.URLError as error:
# urlopen wraps socket/SSL failures; the cause is in `.reason`.
if isinstance(error.reason, ssl.SSLCertVerificationError):
bundle = cafile or ssl.get_default_verify_paths().cafile or "no bundle found"
raise fail(
f"cannot fetch the release asset ({error.reason.verify_message or error.reason}); "
f"verified against {bundle}. If that bundle is stale, point SSL_CERT_FILE "
"at a current one (on Arch and Debian that is "
"/etc/ssl/certs/ca-certificates.crt)"
) from error
raise fail(f"cannot fetch the release asset ({error.reason}); is the tag published?") from error
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())