oc_chat
Loading...
Searching...
No Matches
release_gates.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# tools/release_gates.py
4
5"""
6Release-time version gates for the OC Chat single-version system.
7
8Purpose
9-------
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.
16
17Responsibilities
18----------------
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.
26
27The two gates
28-------------
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
34 headers.
35
36Modes
37-----
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.
47
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.
51
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``).
56
57Exit codes
58----------
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).
62
63Diagnostics
64-----------
65Domain: OC_CHAT.MAIN
66Levels:
67 L2 — lifecycle events (gate pass/fail)
68 L3 — semantic details (per-axis values)
69 L4 — deep tracing
70
71Contracts
72---------
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.
76
77Dependencies
78------------
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)
84"""
85from __future__ import annotations
86
87import argparse
88import re
89import sys
90from dataclasses import dataclass, field
91from pathlib import Path
92from typing import Optional
93
94from packaging.version import InvalidVersion, Version
95
96from oc_diagnostics import _dbg
97
98_dbg("OC_CHAT.MAIN", "module", "loaded", 4)
99
100EXIT_OK = 0
101EXIT_GATE_VIOLATION = 1
102EXIT_USAGE = 2
103
104# The pinned degraded sentinel shared by both pyprojects' fallback_version
105# (Plan 143g-code S1). Reject-on-EITHER this OR any configured fallback value.
106PINNED_FALLBACK_SENTINEL = "0.0.0.dev0"
107
108# Middle dot separating version and round in the roadmap-changelog header.
109_MIDDLE_DOT = "·"
110
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",
116)
117
118
119@dataclass(frozen=True)
121 """One rendered living-set view + whether its header carries ``· Round N``."""
122
123 key: str
124 rel_path: str
125 requires_round: bool
126
127
128# The four rendered views (addendum §S5 render-side inputs; per-view Round policy).
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),
135)
136
137# `**Version:** X` optionally followed by ` · Round N`. First match wins.
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+))?"
141 r"[ \t]*$",
142 re.MULTILINE,
143)
144
145
146class GateConfigError(Exception):
147 """A usage / configuration problem → exit 2 (not a gate violation)."""
148
149
150@dataclass
152 version: str
153 round_no: Optional[int]
154
155
156@dataclass
158 """Result of one gate: pass/fail + the human-readable failure reasons."""
159
160 name: str
161 passed: bool
162 failures: list[str] = field(default_factory=list)
163
164 def fail(self, reason: str) -> None:
165 self.passed = False
166 self.failures.append(reason)
167
168
169# ---------------------------------------------------------------------------
170# I/O — direct source / render reads (never imports registry/render/**)
171# ---------------------------------------------------------------------------
172
173
174def _read_frontmatter(path: Path) -> dict:
175 """Return the YAML frontmatter of a markdown source record as a dict."""
176 try:
177 import yaml # PyYAML — the house source loader (canonical.py) uses it too
178 except ImportError as e: # pragma: no cover — PyYAML present in this env
179 raise GateConfigError(f"PyYAML required to read {path}: {e}") from e
180 try:
181 text = path.read_text(encoding="utf-8")
182 except OSError as e:
183 raise GateConfigError(f"cannot read {path}: {e}") from e
184 if not text.startswith("---"):
185 raise GateConfigError(f"{path}: no YAML frontmatter fence")
186 parts = text.split("---", 2)
187 if len(parts) < 3:
188 raise GateConfigError(f"{path}: unterminated frontmatter fence")
189 data = yaml.safe_load(parts[1]) or {}
190 if not isinstance(data, dict):
191 raise GateConfigError(f"{path}: frontmatter is not a mapping")
192 return data
193
194
195def read_manifest_version(manifest_path: Path) -> str:
196 """Return manifest ``extensions.current_version`` (direct source read)."""
197 data = _read_frontmatter(manifest_path)
198 ext = data.get("extensions")
199 if not isinstance(ext, dict) or "current_version" not in ext:
200 raise GateConfigError(
201 f"{manifest_path}: extensions.current_version missing "
202 "(143g authors it at the cutover)"
203 )
204 return str(ext["current_version"])
205
206
207def read_configured_fallbacks(pyproject_paths: tuple[Path, ...]) -> set[str]:
208 """Read every ``[tool.setuptools_scm].fallback_version`` from the pyprojects."""
209 try:
210 import tomllib # Python >= 3.11
211 except ImportError as e: # pragma: no cover — repo requires >= 3.10, CI covers 3.11+
212 raise GateConfigError(f"tomllib required (Python >= 3.11): {e}") from e
213 fallbacks: set[str] = set()
214 for p in pyproject_paths:
215 try:
216 data = tomllib.loads(p.read_text(encoding="utf-8"))
217 except (OSError, tomllib.TOMLDecodeError) as e:
218 raise GateConfigError(f"cannot parse {p}: {e}") from e
219 fb = data.get("tool", {}).get("setuptools_scm", {}).get("fallback_version")
220 if fb:
221 fallbacks.add(str(fb))
222 return fallbacks
223
224
225def parse_view_header(text: str, view: ViewSpec) -> ParsedHeader:
226 """Parse the first ``**Version:**`` header of a view, per-view-STRICT.
227
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.
230 """
231 m = _HEADER_RE.search(text)
232 if m is None:
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:
236 raise GateConfigError(
237 f"{view.rel_path}: header MUST carry '{_MIDDLE_DOT} Round N' (missing)"
238 )
239 if not view.requires_round and round_no is not None:
240 raise GateConfigError(
241 f"{view.rel_path}: header MUST NOT carry '{_MIDDLE_DOT} Round N' "
242 f"(found Round {round_no})"
243 )
244 return ParsedHeader(version=m.group("version"), round_no=round_no)
245
246
247def read_view_headers(view_root: Path) -> dict[str, ParsedHeader]:
248 """Parse the four rendered view headers under ``view_root`` (the repo root)."""
249 headers: dict[str, ParsedHeader] = {}
250 for view in VIEWS:
251 path = view_root / view.rel_path
252 try:
253 text = path.read_text(encoding="utf-8")
254 except OSError as e:
255 raise GateConfigError(f"cannot read view {view.rel_path}: {e}") from e
256 headers[view.key] = parse_view_header(text, view)
257 return headers
258
259
260# ---------------------------------------------------------------------------
261# Pure predicates + gates (fixture-testable — no I/O)
262# ---------------------------------------------------------------------------
263
264
265def is_rejected_fallback(version: str, configured_fallbacks: set[str]) -> bool:
266 """Reject-on-EITHER: the pinned sentinel OR any configured fallback value.
267
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).
271 """
272 return version == PINNED_FALLBACK_SENTINEL or version in configured_fallbacks
273
274
275def _release_core(version: str) -> tuple[int, ...]:
276 try:
277 return Version(version).release
278 except InvalidVersion as e:
279 raise GateConfigError(f"not a PEP-440 version: {version!r} ({e})") from e
280
281
282def _is_clean_release(version: str) -> bool:
283 """True iff ``version`` is a final/post release (no dev / pre / local)."""
284 try:
285 v = Version(version)
286 except InvalidVersion:
287 return False
288 return v.pre is None and v.dev is None and v.local is None
289
290
292 *,
293 release_tag: str,
294 manifest_version: str,
295 headers: dict[str, ParsedHeader],
296 configured_fallbacks: set[str],
297) -> GateOutcome:
298 """Release-tag value == manifest current_version == the four headers (exact)."""
299 out = GateOutcome(name="consistency", passed=True)
300 # Plan 151 T2 / AC 18a: PREFIX-AWARE strip — the release-tag namespace is
301 # `oc_chat-v*` post-migration; the strip is single-sourced from
302 # oc_chat.core.release_tag (which reads the prefix from the scm config).
303 # The old bare `[1:]` v-strip would compare 'oc_chat-v0.8.0' to '0.8.0'.
304 from oc_chat.core.release_tag import release_tag_core
305
306 tag_core = release_tag_core(release_tag)
307 if not _is_clean_release(manifest_version):
308 out.fail(f"manifest current_version is not a clean release: {manifest_version!r}")
309 if is_rejected_fallback(manifest_version, configured_fallbacks):
310 out.fail(f"manifest current_version is a rejected fallback: {manifest_version!r}")
311 if tag_core != manifest_version:
312 out.fail(
313 f"release tag {release_tag!r} (core {tag_core!r}) != "
314 f"manifest current_version {manifest_version!r}"
315 )
316 for view in VIEWS:
317 hv = headers[view.key].version
318 if hv != manifest_version:
319 out.fail(
320 f"{view.rel_path} header version {hv!r} != "
321 f"manifest current_version {manifest_version!r}"
322 )
323 return out
324
325
327 *,
328 installed_version: str,
329 resolved_version: str,
330 manifest_version: str,
331 headers: dict[str, ParsedHeader],
332 configured_fallbacks: set[str],
333 exact: bool,
334) -> GateOutcome:
335 """Installed pkg == resolve_version() == manifest == headers.
336
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.
340 """
341 out = GateOutcome(name="code-equality", passed=True)
342
343 for label, val in (("installed package", installed_version), ("resolve_version()", resolved_version)):
344 if is_rejected_fallback(val, configured_fallbacks):
345 out.fail(f"{label} version is a rejected fallback: {val!r}")
346
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)}")
350
351 if exact:
352 axes = {
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},
357 }
358 distinct = set(axes.values())
359 if distinct != {manifest_version}:
360 out.fail(
361 "exact equality failed across axes: "
362 + ", ".join(f"{k}={val!r}" for k, val in axes.items())
363 )
364 if not _is_clean_release(manifest_version):
365 out.fail(f"manifest current_version is not a clean release: {manifest_version!r}")
366 else:
367 core = _release_core(manifest_version)
368 for label, val in (
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],
373 ):
374 if _release_core(val) != core:
375 out.fail(
376 f"{label} release core {_release_core(val)} != manifest core {core}"
377 )
378 return out
379
380
381# ---------------------------------------------------------------------------
382# CLI
383# ---------------------------------------------------------------------------
384
385
386def _emit(outcome: GateOutcome) -> None:
387 func = "_emit"
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)
393
394
395def run_gates(args: argparse.Namespace) -> int:
396 """Load inputs, run the mode's gates, return the process exit code."""
397 func = "run_gates"
398 view_root = Path(args.repo_root)
399 manifest_path = Path(args.manifest)
400 pyprojects = tuple(Path(p) for p in args.pyproject)
401
402 try:
403 manifest_version = read_manifest_version(manifest_path)
404 headers = read_view_headers(view_root)
405 configured_fallbacks = read_configured_fallbacks(pyprojects)
406 installed_version = _installed_version()
407 from oc_chat.core._version import resolve_version
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)
412 return EXIT_USAGE
413
414 outcomes: list[GateOutcome] = []
415
416 if args.mode == "release":
417 try:
418 from oc_chat.core.release_tag import ReleaseTagError, resolve_release_tag
419 release_tag = resolve_release_tag(view_root, commit=args.commit)
420 except Exception as e: # ReleaseTagError or unexpected — fail-closed as a violation
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
424 outcomes.append(
426 release_tag=release_tag,
427 manifest_version=manifest_version,
428 headers=headers,
429 configured_fallbacks=configured_fallbacks,
430 )
431 )
432 outcomes.append(
434 installed_version=installed_version,
435 resolved_version=resolved_version,
436 manifest_version=manifest_version,
437 headers=headers,
438 configured_fallbacks=configured_fallbacks,
439 exact=True,
440 )
441 )
442 else: # dev-smoke
443 outcomes.append(
445 installed_version=installed_version,
446 resolved_version=resolved_version,
447 manifest_version=manifest_version,
448 headers=headers,
449 configured_fallbacks=configured_fallbacks,
450 exact=False,
451 )
452 )
453
454 for outcome in outcomes:
455 _emit(outcome)
456
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).")
460
461 return EXIT_OK if all(o.passed for o in outcomes) else EXIT_GATE_VIOLATION
462
463
465 """The installed package version resolve_version() consults first."""
466 try:
467 from importlib.metadata import PackageNotFoundError
468 from importlib.metadata import version as _pkg_version
469 except ImportError as e: # pragma: no cover
470 raise GateConfigError(f"importlib.metadata unavailable: {e}") from e
471 for dist in ("oc_chat", "oc_chat-workspace"):
472 try:
473 return _pkg_version(dist)
474 except PackageNotFoundError:
475 continue
476 raise GateConfigError(
477 "neither oc_chat nor oc_chat-workspace is installed; "
478 "run the release procedure's editable install first"
479 )
480
481
482def build_parser() -> argparse.ArgumentParser:
483 p = argparse.ArgumentParser(
484 prog="release_gates.py",
485 description="OC Chat release-time version gates (Plan 143g-code S5).",
486 )
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)")
496 return p
497
498
499def main(argv: Optional[list[str]] = None) -> int:
500 parser = build_parser()
501 try:
502 args = parser.parse_args(argv)
503 except SystemExit:
504 # argparse already printed usage; normalize to our usage exit code.
505 return EXIT_USAGE
506 if args.pyproject is None:
507 args.pyproject = [str(p) for p in DEFAULT_PYPROJECTS]
508 return run_gates(args)
509
510
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)
str _installed_version()
argparse.ArgumentParser build_parser()
None _emit(GateOutcome outcome)
int main(Optional[list[str]] argv=None)
dict[str, ParsedHeader] read_view_headers(Path view_root)