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_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")
VERSIONISH_RE = re.compile(r"\b[0-9]+\.[0-9]+\.[0-9]+\b")
LOCAL_SOURCE0 = "%{name}-%{version}.tar.gz"
def check_template(spec_text: str) -> list[str]:
problems: list[str] = []
version = spec_version(spec_text)
if version != "@VERSION@":
problems.append(
f"Version: is {version!r}, not '@VERSION@' — this spec is a template; "
".copr/Makefile renders it from Cargo.toml when it builds the SRPM, so a "
"recorded version is both redundant and a thing to forget at release time"
)
for line in _preamble(spec_text).splitlines():
if line.lstrip().startswith("#"):
continue
m = VERSIONISH_RE.search(line)
if m:
problems.append(f"preamble line records a version ({m.group(0)}): {line.strip()!r}")
source0 = spec_source0(spec_text)
if source0 != LOCAL_SOURCE0:
problems.append(
f"Source0 is {source0!r}, not {LOCAL_SOURCE0!r} — .copr/Makefile builds the "
"source archive from the checkout, and a URL would make the spec unbuildable "
"until after a release again"
)
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"
)
if not re.search(r"^%license\s+.*\bLICENSE\b.*\bNOTICE\b", spec_text, re.MULTILINE):
problems.append(
"%files does not carry `%license LICENSE NOTICE` — NOTICE holds the MIT "
"attribution for the adapted Fastfetch logos, which must ship with every copy"
)
newest = newest_changelog_entry(spec_text)
if "@" in newest:
problems.append(
f"the newest %changelog entry is a sentinel ({newest}) — render_packaging.py "
"prepends the entry for the rendered version, so this one must be history"
)
return problems
_GOOD_SPEC = """\
# COPR spec for retch. A comment may cite 0.9.7 as history without that being a pin.
Name: retch
Version: @VERSION@
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: %{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, and neither must a 1.2.3 written here.
%build
export RUSTFLAGS="%{build_rustflags}"
cargo build --release --locked
%files
%license LICENSE NOTICE
%doc README.md
%{_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
"""
def _self_test() -> int:
failures = []
def check(name: str, cond: bool, detail: str = "") -> None:
if not cond:
failures.append(f"{name}: {detail}")
clean = check_template(_GOOD_SPEC)
check("template fixture clean", clean == [], f"got {clean}")
live = Path(__file__).resolve().parent.parent / "packaging" / "copr" / "retch.spec"
if live.is_file():
live_problems = check_template(live.read_text(encoding="utf-8"))
check("live spec clean", live_problems == [], f"got {live_problems}")
pinned = _GOOD_SPEC.replace("Version: @VERSION@", "Version: 0.9.7")
probs = check_template(pinned)
check("pinned Version detected", any("Version:" in p for p in probs), f"got {probs}")
smuggled = _GOOD_SPEC.replace(
"License: GPL-3.0-or-later",
"Provides: retch = 0.9.7\nLicense: GPL-3.0-or-later",
)
probs = check_template(smuggled)
check("preamble version detected", any("records a version" in p for p in probs), f"got {probs}")
check("prose version ignored",
not any("records a version" in p for p in check_template(_GOOD_SPEC)),
"the 1.2.3 in %description was read as a pin")
url_source = _GOOD_SPEC.replace(
"Source0: %{name}-%{version}.tar.gz",
"Source0: %{url}/archive/refs/tags/v%{version}.tar.gz#/%{name}-%{version}.tar.gz")
probs = check_template(url_source)
check("URL 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 = check_template(unlocked)
check("--locked removal detected", any("--locked" in p for p in probs), f"got {probs}")
for label, replacement in (("NOTICE dropped", "%license LICENSE"),
("%license downgraded to %doc", "%doc LICENSE NOTICE")):
bad = _GOOD_SPEC.replace("%license LICENSE NOTICE", replacement)
probs = check_template(bad)
check(f"{label} detected", any("%license" in p for p in probs), f"got {probs}")
sentinel_log = _GOOD_SPEC.replace("- 0.9.7-1", "- @VERSION@-1")
probs = check_template(sentinel_log)
check("sentinel changelog detected", any("%changelog" in p for p in probs), f"got {probs}")
check("preamble scoped", spec_version(_GOOD_SPEC) == "@VERSION@",
f"got {spec_version(_GOOD_SPEC)}")
check("newest changelog entry", newest_changelog_entry(_GOOD_SPEC) == "0.9.7-1",
f"got {newest_changelog_entry(_GOOD_SPEC)!r}")
try:
spec_version("Name: retch\n")
check("missing Version raises", False, "spec_version accepted a spec with no Version:")
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"
if not spec.is_file():
print(f"error: {spec} not found", file=sys.stderr)
return 1
try:
problems = check_template(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} is no longer a valid template:", file=sys.stderr)
for p in problems:
print(f" {p}", file=sys.stderr)
print("\nThe version comes from Cargo.toml at SRPM time; see .copr/Makefile.",
file=sys.stderr)
return 1
print("packaging/copr/retch.spec is a template (records no version, builds from the checkout)")
return 0
if __name__ == "__main__":
sys.exit(main())