|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""Verify that a tool repo's declared version agrees everywhere. |
| 3 | +
|
| 4 | +Canonical source: ``ufal/atrium-project`` → ``docs/templates/shared/check_version.py``. |
| 5 | +Vendored byte-identically into every tool repo root and held there by |
| 6 | +``para-drift.reusable.yml`` — edit the hub copy, never the vendored one. |
| 7 | +
|
| 8 | +Why this file exists |
| 9 | +-------------------- |
| 10 | +The same ~45-line ``python - <<'PY'`` heredoc was pasted into five ``release.yml`` |
| 11 | +files and again, in a subtly different three-way variant, into |
| 12 | +``security.reusable.yml`` — roughly 360 lines of copy-paste that had already |
| 13 | +drifted apart (issue #18, 2026-07-29 audit). |
| 14 | +
|
| 15 | +It stays a *vendored script* rather than a reusable workflow on purpose: the |
| 16 | +release gate must not depend on a cross-repo ``uses: …@ref`` resolving at |
| 17 | +tag-push time. That ordering gap is what let ``v1.0.0.-beta`` and ``v1.16.2`` |
| 18 | +publish unchecked. A file in the repo is always there. |
| 19 | +
|
| 20 | +Checks |
| 21 | +------ |
| 22 | +* ``CITATION.cff`` top-level ``version:`` parses. |
| 23 | +* ``para_config.txt`` ``[tool] version`` parses. |
| 24 | +* The two agree. |
| 25 | +* If a tag is supplied, it agrees with both. |
| 26 | +
|
| 27 | +A leading ``v`` is tolerated and stripped everywhere, so ``v1.2.3`` in |
| 28 | +``para_config.txt`` matches ``1.2.3`` in ``CITATION.cff``. |
| 29 | +
|
| 30 | +Usage |
| 31 | +----- |
| 32 | + # release.yml — a tag is mandatory |
| 33 | + python check_version.py --citation CITATION.cff \ |
| 34 | + --para-config setup/para_config.txt \ |
| 35 | + --tag "${GITHUB_REF_NAME}" --require-tag |
| 36 | +
|
| 37 | + # security.reusable.yml — consistency check, tag optional |
| 38 | + python check_version.py --citation CITATION.cff --para-config para_config.txt |
| 39 | +
|
| 40 | +Exit status is 0 when everything agrees, 1 otherwise. Failures are emitted as |
| 41 | +``::error::`` workflow commands so they surface on the run's summary. |
| 42 | +""" |
| 43 | + |
| 44 | +from __future__ import annotations |
| 45 | + |
| 46 | +import argparse |
| 47 | +import configparser |
| 48 | +import re |
| 49 | +import sys |
| 50 | + |
| 51 | + |
| 52 | +def _strip_v(value: str | None) -> str | None: |
| 53 | + """Drop a single leading ``v``/``V`` so v1.2.3 and 1.2.3 compare equal.""" |
| 54 | + if value and value[:1] in ("v", "V"): |
| 55 | + return value[1:] |
| 56 | + return value |
| 57 | + |
| 58 | + |
| 59 | +def citation_version(path: str) -> str | None: |
| 60 | + """First column-0 ``version:`` in a CITATION.cff. |
| 61 | +
|
| 62 | + Anchored at column 0 deliberately: CFF nests ``version:`` under |
| 63 | + ``references:``/``preferred-citation:`` for cited works, and those are not |
| 64 | + this tool's version. |
| 65 | + """ |
| 66 | + with open(path, encoding="utf-8") as fh: |
| 67 | + for line in fh: |
| 68 | + match = re.match(r'version:\s*["\']?v?([^"\'\s]+)', line) |
| 69 | + if match: |
| 70 | + return match.group(1) |
| 71 | + return None |
| 72 | + |
| 73 | + |
| 74 | +def paraconfig_version(path: str) -> str | None: |
| 75 | + """``[tool] version`` from a para_config.txt (INI).""" |
| 76 | + parser = configparser.ConfigParser() |
| 77 | + parser.read(path, encoding="utf-8") |
| 78 | + return _strip_v(parser.get("tool", "version", fallback=None)) |
| 79 | + |
| 80 | + |
| 81 | +def collect_errors(citation_path: str, para_path: str, tag: str, require_tag: bool) -> list[str]: |
| 82 | + """Return every disagreement found. Empty list means consistent.""" |
| 83 | + errors: list[str] = [] |
| 84 | + |
| 85 | + try: |
| 86 | + cit = citation_version(citation_path) |
| 87 | + except OSError as exc: |
| 88 | + return [f"could not read {citation_path}: {exc}"] |
| 89 | + try: |
| 90 | + para = paraconfig_version(para_path) |
| 91 | + except (OSError, configparser.Error) as exc: |
| 92 | + return [f"could not read {para_path}: {exc}"] |
| 93 | + |
| 94 | + tag = _strip_v(tag.strip()) or "" |
| 95 | + print(f"CITATION.cff={cit!r} para_config={para!r} tag={tag!r}") |
| 96 | + |
| 97 | + if not cit: |
| 98 | + errors.append(f"could not parse version from {citation_path}") |
| 99 | + if not para: |
| 100 | + errors.append(f"could not parse [tool] version from {para_path}") |
| 101 | + if require_tag and not tag: |
| 102 | + errors.append("no tag ref on this event, but --require-tag was given") |
| 103 | + |
| 104 | + if cit and para and cit != para: |
| 105 | + errors.append(f"version mismatch: {citation_path}={cit} != {para_path}={para}") |
| 106 | + if tag and cit and tag != cit: |
| 107 | + errors.append(f"tag v{tag} != {citation_path} {cit}") |
| 108 | + if tag and para and tag != para: |
| 109 | + errors.append(f"tag v{tag} != {para_path} {para}") |
| 110 | + |
| 111 | + return errors |
| 112 | + |
| 113 | + |
| 114 | +def main(argv: list[str] | None = None) -> int: |
| 115 | + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) |
| 116 | + ap.add_argument("--citation", default="CITATION.cff", help="path to CITATION.cff") |
| 117 | + ap.add_argument( |
| 118 | + "--para-config", |
| 119 | + default="para_config.txt", |
| 120 | + help="path to para_config.txt; repos with a setup/ layout pass setup/para_config.txt", |
| 121 | + ) |
| 122 | + ap.add_argument("--tag", default="", help="tag being released, e.g. v1.2.3; empty to skip tag comparison") |
| 123 | + ap.add_argument("--require-tag", action="store_true", help="fail when --tag is empty (release gate)") |
| 124 | + args = ap.parse_args(argv) |
| 125 | + |
| 126 | + errors = collect_errors(args.citation, args.para_config, args.tag, args.require_tag) |
| 127 | + for err in errors: |
| 128 | + print(f"::error::{err}") |
| 129 | + if errors: |
| 130 | + print( |
| 131 | + "::error::Version check failed — make the tag, CITATION.cff and " |
| 132 | + "para_config.txt [tool] version identical, then re-tag." |
| 133 | + ) |
| 134 | + return 1 |
| 135 | + print("OK — versions agree") |
| 136 | + return 0 |
| 137 | + |
| 138 | + |
| 139 | +if __name__ == "__main__": |
| 140 | + sys.exit(main()) |
0 commit comments