from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
TEMPLATE_VERSION = 1
class ParseError(Exception):
def _preamble(text: str) -> str:
out = []
for line in text.splitlines():
if re.match(r"^%(prep|build|install|check|files|changelog|description|package)\b", line):
break
out.append(line)
return "\n".join(out)
def spec_tag(text: str, tag: str) -> str:
m = re.search(rf"^{tag}:\s*(\S.*?)\s*$", _preamble(text), re.MULTILINE)
if not m:
raise ParseError(f"spec has no `{tag}:` tag in its preamble")
return m.group(1)
def spec_version(text: str) -> str:
return spec_tag(text, "Version")
def spec_release(text: str) -> str:
return re.sub(r"%\{\?dist\}\s*$", "", spec_tag(text, "Release")).strip()
def spec_source0(text: str) -> str:
return spec_tag(text, "Source0")
def newest_changelog_entry(text: str) -> str:
m = re.search(r"^%changelog\s*$(.*)", text, re.MULTILINE | re.DOTALL)
if not m:
raise ParseError("spec has no %changelog section")
for line in m.group(1).splitlines():
if line.startswith("*"):
trailer = re.search(r"-\s*(\S+)\s*$", line)
if not trailer:
raise ParseError(f"newest %changelog entry has no `- <version>-<release>`: {line!r}")
return trailer.group(1)
raise ParseError("%changelog section contains no entries")
def pkgbuild_pkgver(text: str) -> str:
m = re.search(r"^pkgver=(\S+)\s*$", text, re.MULTILINE)
if not m:
raise ParseError("PKGBUILD has no pkgver")
return m.group(1).strip("'\"")
def cargo_version(text: str) -> str:
m = re.search(r"^\[package\]\s*$(.*?)(?=^\[|\Z)", text, re.MULTILINE | re.DOTALL)
if not m:
raise ParseError("Cargo.toml has no [package] section")
v = re.search(r'^version\s*=\s*"([^"]+)"', m.group(1), re.MULTILINE)
if not v:
raise ParseError("Cargo.toml [package] has no version")
return v.group(1)
def _parts(version: str) -> tuple[int, ...]:
if not re.fullmatch(r"\d+(\.\d+)*", version):
raise ParseError(f"cannot compare non-numeric version {version!r}")
return tuple(int(p) for p in version.split("."))
def compare(spec_text: str, pkgbuild_text: str, cargo_text: str) -> list[str]:
problems: list[str] = []
version = spec_version(spec_text)
release = spec_release(spec_text)
pkgver = pkgbuild_pkgver(pkgbuild_text)
cargo = cargo_version(cargo_text)
if version != pkgver:
problems.append(
f"spec Version: is {version} but packaging/aur/PKGBUILD pkgver is {pkgver} — "
f"both track the last RELEASED tag, so one of them was not bumped"
)
if _parts(version) > _parts(cargo):
problems.append(
f"spec Version: {version} is AHEAD of Cargo.toml {cargo} — Source0 pins tag "
f"v{version}, which cannot exist yet"
)
newest = newest_changelog_entry(spec_text)
expected = f"{version}-{release}"
if newest != expected:
problems.append(
f"newest %changelog entry is {newest} but Version-Release is {expected} — "
f"the changelog was not updated with the bump"
)
source0 = spec_source0(spec_text)
if "%{version}" not in source0:
problems.append(
f"Source0 does not reference %{{version}}: {source0!r} — a hardcoded version "
f"silently decouples the tarball from Version:"
)
if not re.search(r"^\s*cargo build\b.*--locked", spec_text, re.MULTILINE):
problems.append(
"the %build `cargo build` does not pass --locked — with internet-enabled COPR "
"builds and no vendor tarball it is the only thing pinning dependency resolution"
)
return problems
_GOOD_SPEC = """\
# COPR spec for retch.
Name: retch
Version: 0.9.7
Release: 1%{?dist}
Summary: A fast, feature-rich system information fetcher written in Rust
License: GPL-3.0-or-later
URL: https://github.com/l1a/retch
Source0: %{url}/archive/refs/tags/v%{version}.tar.gz#/%{name}-%{version}.tar.gz
%description
retch is a system information fetcher. Version: numbers in prose must not be
read as the package's own version.
%build
export RUSTFLAGS="%{build_rustflags}"
cargo build --release --locked
%files
%{_bindir}/retch
%changelog
* Mon Aug 31 2026 Ken Tobias <nobody@example.com> - 0.9.7-1
- Update to 0.9.7
* Mon Aug 31 2026 Ken Tobias <nobody@example.com> - 0.9.4-1
- Initial COPR packaging
"""
_GOOD_PKGBUILD = "pkgname=retch\npkgver=0.9.7\npkgrel=1\n"
_GOOD_CARGO = '[package]\nname = "retch-cli"\nversion = "0.9.9"\n\n[dependencies]\nclap = "4.6"\n'
def _self_test() -> int:
failures = []
def check(name: str, cond: bool, detail: str = "") -> None:
if not cond:
failures.append(f"{name}: {detail}")
clean = compare(_GOOD_SPEC, _GOOD_PKGBUILD, _GOOD_CARGO)
check("consistent spec", clean == [], f"got {clean}")
stale = _GOOD_SPEC.replace("Version: 0.9.7", "Version: 0.9.4")
stale = stale.replace("- 0.9.7-1", "- 0.9.4-1")
probs = compare(stale, _GOOD_PKGBUILD, _GOOD_CARGO)
check("stale Version detected",
any("PKGBUILD pkgver" in p for p in probs), f"got {probs}")
ahead = _GOOD_SPEC.replace("Version: 0.9.7", "Version: 1.0.0")
ahead = ahead.replace("- 0.9.7-1", "- 1.0.0-1")
probs = compare(ahead, "pkgname=retch\npkgver=1.0.0\npkgrel=1\n", _GOOD_CARGO)
check("ahead-of-Cargo detected", any("AHEAD of Cargo.toml" in p for p in probs), f"got {probs}")
probs = compare(_GOOD_SPEC, _GOOD_PKGBUILD,
'[package]\nname = "retch-cli"\nversion = "0.9.99"\n')
check("trailing Cargo is fine", probs == [], f"got {probs}")
nochangelog = _GOOD_SPEC.replace("Version: 0.9.7", "Version: 0.9.8")
probs = compare(nochangelog, "pkgname=retch\npkgver=0.9.8\npkgrel=1\n", _GOOD_CARGO)
check("stale changelog detected", any("%changelog" in p for p in probs), f"got {probs}")
hardcoded = _GOOD_SPEC.replace(
"%{url}/archive/refs/tags/v%{version}.tar.gz#/%{name}-%{version}.tar.gz",
"%{url}/archive/refs/tags/v0.9.7.tar.gz#/retch-0.9.7.tar.gz")
probs = compare(hardcoded, _GOOD_PKGBUILD, _GOOD_CARGO)
check("hardcoded Source0 detected", any("Source0" in p for p in probs), f"got {probs}")
unlocked = _GOOD_SPEC.replace("cargo build --release --locked", "cargo build --release")
probs = compare(unlocked, _GOOD_PKGBUILD, _GOOD_CARGO)
check("--locked removal detected", any("--locked" in p for p in probs), f"got {probs}")
check("preamble scoped", spec_version(_GOOD_SPEC) == "0.9.7",
f"got {spec_version(_GOOD_SPEC)}")
check("release strips dist", spec_release(_GOOD_SPEC) == "1",
f"got {spec_release(_GOOD_SPEC)!r}")
check("newest changelog entry", newest_changelog_entry(_GOOD_SPEC) == "0.9.7-1",
f"got {newest_changelog_entry(_GOOD_SPEC)!r}")
check("cargo version scoped", cargo_version(_GOOD_CARGO) == "0.9.9",
f"got {cargo_version(_GOOD_CARGO)}")
try:
spec_version("Name: retch\n")
check("missing Version raises", False, "spec_version accepted a spec with no Version:")
except ParseError:
pass
try:
_parts("0.9.7-rc.1")
check("non-numeric version raises", False, "_parts accepted a pre-release string")
except ParseError:
pass
if failures:
for f in failures:
print(f" FAIL {f}", file=sys.stderr)
print(f"copr_check.py self-test FAILED ({len(failures)})", file=sys.stderr)
return 1
print(f"copr_check.py self-test passed (template v{TEMPLATE_VERSION})")
return 0
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("--self-test", action="store_true", help="run built-in tests and exit")
ap.add_argument("--root", default=None, help="repository root (default: this script's parent)")
args = ap.parse_args()
if args.self_test:
return _self_test()
root = Path(args.root) if args.root else Path(__file__).resolve().parent.parent
spec = root / "packaging" / "copr" / "retch.spec"
pkgbuild = root / "packaging" / "aur" / "PKGBUILD"
cargo = root / "Cargo.toml"
for f in (spec, pkgbuild, cargo):
if not f.is_file():
print(f"error: {f} not found", file=sys.stderr)
return 1
try:
problems = compare(spec.read_text(encoding="utf-8"),
pkgbuild.read_text(encoding="utf-8"),
cargo.read_text(encoding="utf-8"))
version = spec_version(spec.read_text(encoding="utf-8"))
except ParseError as e:
print(f"error: {e}", file=sys.stderr)
return 1
if problems:
print(f"error: {spec} has drifted:", file=sys.stderr)
for p in problems:
print(f" {p}", file=sys.stderr)
print("\nAt release time, bump it with: just copr-bump <version>", file=sys.stderr)
return 1
print(f"packaging/copr: retch.spec is consistent (Version {version})")
return 0
if __name__ == "__main__":
sys.exit(main())