6Release-time version gates for the OC Chat single-version system.
10Two mechanical, re-runnable gates (Plan 143g-code S5; addendum §"Acceptance
11tests" — orchestrator release-time gates). They run at the ``v0.8.0`` cut (and
12every later release), NOT in the branch worktree suite — no ``v0.8.0`` tag
13exists pre-release, so the *release-mode* run only ever goes green on the exact
14annotated release commit. This module ships the tooling + its unit tests; the
15real invocation is the orchestrator's, per the addendum's bundle transaction.
19- :func:`consistency_gate` / :func:`code_equality_gate` — the two pure gate
20 predicates over already-parsed inputs (fixture-testable).
21- :func:`read_manifest_version` / :func:`read_view_headers` /
22 :func:`read_configured_fallbacks` — isolated I/O: direct source/render reads.
23- :func:`parse_view_header` — the per-view-STRICT header grammar.
24- :func:`is_rejected_fallback` — the reject-on-EITHER fallback predicate.
25- :func:`run_gates` / :func:`main` — mode dispatch + typed process exits.
29- **Consistency gate** (committed axis): the Release-tag helper value ==
30 manifest ``extensions.current_version`` == the four rendered ``**Version:**``
31 headers. Needs the annotated tag, so it runs in ``--mode release`` only.
32- **Code-equality gate** (live axis vs committed): installed package release
33 core == :func:`resolve_version` == manifest ``current_version`` == the
38- ``--mode release`` — ONE invocation runs BOTH gates, EXACT equality (no
39 ``.devN`` / local suffix tolerated), required on the exact annotated
40 ``v0.8.0`` release commit. Any consulted live version equal to EITHER a
41 configured ``fallback_version`` (read from the two pyprojects) OR the pinned
42 sentinel ``0.0.0.dev0`` is rejected — reject-on-EITHER, never a conjunction.
43- ``--mode dev-smoke`` — tolerates ``.devN+ghash`` on the live axis (compares
44 release cores); runs the code-equality + header-consistency checks but NOT
45 the tag-consistency check (no release tag exists in dogfood). Its report
46 STATES it is NOT a release authorization.
48The gate never imports ``registry/render/**``: the manifest read is a DIRECT
49source read of ``oc_chat/roadmap/source/manifest.md`` frontmatter, and the view
50headers are parsed straight from the rendered files.
52Header grammar (per-view STRICT — addendum §"The header fix" V13.3 table):
53``**Version:** {version}`` on all four views; ``· Round {N}`` REQUIRED and
54value-checked on ``ROADMAP-CHANGELOG.md`` only, FORBIDDEN on the three bare
55views (``ROADMAP.md`` / ``ROADMAP-EXTENDED.md`` / product ``CHANGELOG.md``).
59- 0 — all requested gates passed.
60- 1 — a gate violation (drift / inequality / rejected fallback).
61- 2 — usage or configuration error (bad args, missing/malformed inputs).
67 L2 — lifecycle events (gate pass/fail)
68 L3 — semantic details (per-axis values)
73- Gate functions are pure over their parsed inputs (fixture-testable); I/O is
74 isolated in the ``read_*`` / ``load_*`` helpers.
75- Fail-closed with diagnostics NAMING the disagreeing axis.
79- packaging (packaging.version.Version — declared in both pyprojects, S1b)
80- oc_diagnostics (structured debug logging via _dbg)
81- oc_chat.core.release_tag (lazy; resolve_release_tag and release_tag_core - the annotated release tag and its prefix-aware version core)
82- oc_chat.core._version (lazy; resolve_version - the live version for the code-equality gate)
83- yaml (lazy; PyYAML safe_load of the roadmap manifest frontmatter)
85from __future__
import annotations
90from dataclasses
import dataclass, field
91from pathlib
import Path
92from typing
import Optional
94from packaging.version
import InvalidVersion, Version
96from oc_diagnostics
import _dbg
98_dbg(
"OC_CHAT.MAIN",
"module",
"loaded", 4)
101EXIT_GATE_VIOLATION = 1
106PINNED_FALLBACK_SENTINEL =
"0.0.0.dev0"
111_REPO_ROOT = Path(__file__).resolve().parent.parent
112DEFAULT_MANIFEST = _REPO_ROOT /
"oc_chat" /
"roadmap" /
"source" /
"manifest.md"
113DEFAULT_PYPROJECTS = (
114 _REPO_ROOT /
"pyproject.toml",
115 _REPO_ROOT /
"oc_chat" /
"pyproject.toml",
119@dataclass(frozen=True)
121 """One rendered living-set view + whether its header carries ``· Round N``."""
129_ROADMAP_DIR =
"oc_chat/roadmap"
130VIEWS: tuple[ViewSpec, ...] = (
131 ViewSpec(
"roadmap", f
"{_ROADMAP_DIR}/ROADMAP.md",
False),
132 ViewSpec(
"roadmap_extended", f
"{_ROADMAP_DIR}/ROADMAP-EXTENDED.md",
False),
133 ViewSpec(
"roadmap_changelog", f
"{_ROADMAP_DIR}/ROADMAP-CHANGELOG.md",
True),
134 ViewSpec(
"product_changelog",
"CHANGELOG.md",
False),
138_HEADER_RE = re.compile(
139 r"^\*\*Version:\*\*[ \t]+(?P<version>\S+)"
140 r"(?:[ \t]+" + re.escape(_MIDDLE_DOT) +
r"[ \t]+Round[ \t]+(?P<round>\d+))?"
147 """A usage / configuration problem → exit 2 (not a gate violation)."""
153 round_no: Optional[int]
158 """Result of one gate: pass/fail + the human-readable failure reasons."""
162 failures: list[str] = field(default_factory=list)
164 def fail(self, reason: str) ->
None:
175 """Return the YAML frontmatter of a markdown source record as a dict."""
178 except ImportError
as e:
181 text = path.read_text(encoding=
"utf-8")
184 if not text.startswith(
"---"):
186 parts = text.split(
"---", 2)
189 data = yaml.safe_load(parts[1])
or {}
190 if not isinstance(data, dict):
196 """Return manifest ``extensions.current_version`` (direct source read)."""
198 ext = data.get(
"extensions")
199 if not isinstance(ext, dict)
or "current_version" not in ext:
201 f
"{manifest_path}: extensions.current_version missing "
202 "(143g authors it at the cutover)"
204 return str(ext[
"current_version"])
208 """Read every ``[tool.setuptools_scm].fallback_version`` from the pyprojects."""
211 except ImportError
as e:
212 raise GateConfigError(f
"tomllib required (Python >= 3.11): {e}")
from e
213 fallbacks: set[str] = set()
214 for p
in pyproject_paths:
216 data = tomllib.loads(p.read_text(encoding=
"utf-8"))
217 except (OSError, tomllib.TOMLDecodeError)
as e:
219 fb = data.get(
"tool", {}).get(
"setuptools_scm", {}).get(
"fallback_version")
221 fallbacks.add(str(fb))
226 """Parse the first ``**Version:**`` header of a view, per-view-STRICT.
228 Enforces the V13.3 per-view grammar: ``· Round N`` is REQUIRED on the
229 roadmap-changelog view and FORBIDDEN on the three bare views.
231 m = _HEADER_RE.search(text)
233 raise GateConfigError(f
"{view.rel_path}: no '**Version:**' header found")
234 round_no = int(m.group(
"round"))
if m.group(
"round")
is not None else None
235 if view.requires_round
and round_no
is None:
237 f
"{view.rel_path}: header MUST carry '{_MIDDLE_DOT} Round N' (missing)"
239 if not view.requires_round
and round_no
is not None:
241 f
"{view.rel_path}: header MUST NOT carry '{_MIDDLE_DOT} Round N' "
242 f
"(found Round {round_no})"
244 return ParsedHeader(version=m.group(
"version"), round_no=round_no)
248 """Parse the four rendered view headers under ``view_root`` (the repo root)."""
249 headers: dict[str, ParsedHeader] = {}
251 path = view_root / view.rel_path
253 text = path.read_text(encoding=
"utf-8")
266 """Reject-on-EITHER: the pinned sentinel OR any configured fallback value.
268 Never a conjunction — an ``and`` implementation stays green on the pinned
269 untagged-archive state (where configured == sentinel and both fire together)
270 while silently dropping the value-independent belt (R05 both reviewers).
272 return version == PINNED_FALLBACK_SENTINEL
or version
in configured_fallbacks
277 return Version(version).release
278 except InvalidVersion
as e:
279 raise GateConfigError(f
"not a PEP-440 version: {version!r} ({e})")
from e
283 """True iff ``version`` is a final/post release (no dev / pre / local)."""
286 except InvalidVersion:
288 return v.pre
is None and v.dev
is None and v.local
is None
294 manifest_version: str,
295 headers: dict[str, ParsedHeader],
296 configured_fallbacks: set[str],
298 """Release-tag value == manifest current_version == the four headers (exact)."""
306 tag_core = release_tag_core(release_tag)
308 out.fail(f
"manifest current_version is not a clean release: {manifest_version!r}")
310 out.fail(f
"manifest current_version is a rejected fallback: {manifest_version!r}")
311 if tag_core != manifest_version:
313 f
"release tag {release_tag!r} (core {tag_core!r}) != "
314 f
"manifest current_version {manifest_version!r}"
317 hv = headers[view.key].version
318 if hv != manifest_version:
320 f
"{view.rel_path} header version {hv!r} != "
321 f
"manifest current_version {manifest_version!r}"
328 installed_version: str,
329 resolved_version: str,
330 manifest_version: str,
331 headers: dict[str, ParsedHeader],
332 configured_fallbacks: set[str],
335 """Installed pkg == resolve_version() == manifest == headers.
337 ``exact`` (release mode): full-string equality, no suffix tolerated, and the
338 live versions must not be a rejected fallback. Otherwise (dev-smoke): compare
339 release cores only, tolerating ``.devN+local`` on the live axis.
341 out =
GateOutcome(name=
"code-equality", passed=
True)
343 for label, val
in ((
"installed package", installed_version), (
"resolve_version()", resolved_version)):
345 out.fail(f
"{label} version is a rejected fallback: {val!r}")
347 header_versions = {headers[v.key].version
for v
in VIEWS}
348 if len(header_versions) != 1:
349 out.fail(f
"rendered headers disagree: {sorted(header_versions)}")
353 "installed package": installed_version,
354 "resolve_version()": resolved_version,
355 "manifest current_version": manifest_version,
356 **{f
"{v.rel_path} header": headers[v.key].version
for v
in VIEWS},
358 distinct = set(axes.values())
359 if distinct != {manifest_version}:
361 "exact equality failed across axes: "
362 +
", ".join(f
"{k}={val!r}" for k, val
in axes.items())
365 out.fail(f
"manifest current_version is not a clean release: {manifest_version!r}")
369 (
"installed package", installed_version),
370 (
"resolve_version()", resolved_version),
371 (
"manifest current_version", manifest_version),
372 *[(f
"{v.rel_path} header", headers[v.key].version)
for v
in VIEWS],
376 f
"{label} release core {_release_core(val)} != manifest core {core}"
386def _emit(outcome: GateOutcome) ->
None:
388 status =
"PASS" if outcome.passed
else "FAIL"
389 print(f
"[{status}] {outcome.name} gate")
390 for reason
in outcome.failures:
391 print(f
" - {reason}")
392 _dbg(
"OC_CHAT.MAIN", func, f
"{outcome.name}: {status}", 2)
396 """Load inputs, run the mode's gates, return the process exit code."""
398 view_root = Path(args.repo_root)
399 manifest_path = Path(args.manifest)
400 pyprojects = tuple(Path(p)
for p
in args.pyproject)
408 resolved_version = resolve_version()
409 except GateConfigError
as e:
410 print(f
"[ERROR] {e}", file=sys.stderr)
411 _dbg(
"OC_CHAT.MAIN", func, f
"VALIDATION_ERROR: {e}", 3)
414 outcomes: list[GateOutcome] = []
416 if args.mode ==
"release":
419 release_tag = resolve_release_tag(view_root, commit=args.commit)
420 except Exception
as e:
421 print(f
"[FAIL] consistency gate\n - release-tag resolution failed: {e}")
422 _dbg(
"OC_CHAT.MAIN", func, f
"release-tag failure: {e}", 2, override=
True)
423 return EXIT_GATE_VIOLATION
426 release_tag=release_tag,
427 manifest_version=manifest_version,
429 configured_fallbacks=configured_fallbacks,
434 installed_version=installed_version,
435 resolved_version=resolved_version,
436 manifest_version=manifest_version,
438 configured_fallbacks=configured_fallbacks,
445 installed_version=installed_version,
446 resolved_version=resolved_version,
447 manifest_version=manifest_version,
449 configured_fallbacks=configured_fallbacks,
454 for outcome
in outcomes:
457 if args.mode ==
"dev-smoke":
458 print(
"NOTE: --mode dev-smoke is NOT a release authorization "
459 "(tolerates .devN on the live axis; no tag-consistency check).")
461 return EXIT_OK
if all(o.passed
for o
in outcomes)
else EXIT_GATE_VIOLATION
465 """The installed package version resolve_version() consults first."""
467 from importlib.metadata
import PackageNotFoundError
468 from importlib.metadata
import version
as _pkg_version
469 except ImportError
as e:
471 for dist
in (
"oc_chat",
"oc_chat-workspace"):
473 return _pkg_version(dist)
474 except PackageNotFoundError:
477 "neither oc_chat nor oc_chat-workspace is installed; "
478 "run the release procedure's editable install first"
483 p = argparse.ArgumentParser(
484 prog=
"release_gates.py",
485 description=
"OC Chat release-time version gates (Plan 143g-code S5).",
487 p.add_argument(
"--mode", required=
True, choices=(
"release",
"dev-smoke"))
488 p.add_argument(
"--repo-root", default=str(_REPO_ROOT),
489 help=
"repo root holding the rendered views (default: this checkout)")
490 p.add_argument(
"--manifest", default=str(DEFAULT_MANIFEST),
491 help=
"path to roadmap source manifest.md")
492 p.add_argument(
"--pyproject", action=
"append", default=
None,
493 help=
"pyproject.toml to read fallback_version from (repeatable)")
494 p.add_argument(
"--commit", default=
"HEAD",
495 help=
"release commit for the tag-consistency check (release mode)")
499def main(argv: Optional[list[str]] =
None) -> int:
502 args = parser.parse_args(argv)
506 if args.pyproject
is None:
507 args.pyproject = [str(p)
for p
in DEFAULT_PYPROJECTS]
511if __name__ ==
"__main__":
512 raise SystemExit(
main())
None fail(self, str reason)
tuple[int,...] _release_core(str version)
str read_manifest_version(Path manifest_path)
bool _is_clean_release(str version)
bool is_rejected_fallback(str version, set[str] configured_fallbacks)
GateOutcome consistency_gate(*, str release_tag, str manifest_version, dict[str, ParsedHeader] headers, set[str] configured_fallbacks)
set[str] read_configured_fallbacks(tuple[Path,...] pyproject_paths)
int run_gates(argparse.Namespace args)
dict _read_frontmatter(Path path)
GateOutcome code_equality_gate(*, str installed_version, str resolved_version, str manifest_version, dict[str, ParsedHeader] headers, set[str] configured_fallbacks, bool exact)
ParsedHeader parse_view_header(str text, ViewSpec view)
argparse.ArgumentParser build_parser()
None _emit(GateOutcome outcome)
int main(Optional[list[str]] argv=None)
dict[str, ParsedHeader] read_view_headers(Path view_root)