chore(tools): move bump_packaging.py to the repository root
This commit is contained in:
1 parent
cbce35441d
commit
e000b296b5
3 files changed
+8
-12
No files matched your search
@@ -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 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}<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())
|
||||
Reference in new issue
Block a user