diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 439a5ca3..1adbfb24 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -144,7 +144,8 @@ jobs: ../../qa/test_app_smoke_scripted.py \ ../../qa/test_support_vm_preflight.py \ ../../qa/test_version_consistency.py \ - ../../qa/test_generate_release_notes.py + ../../qa/test_generate_release_notes.py \ + ../../qa/test_release_gate_check.py server-contracts: # Phase-1 (DETERMINISTIC-2): deterministic cross-service MCP contract tests diff --git a/.github/workflows/release-on-milestone-close.yml b/.github/workflows/release-on-milestone-close.yml new file mode 100644 index 00000000..e326ad87 --- /dev/null +++ b/.github/workflows/release-on-milestone-close.yml @@ -0,0 +1,240 @@ +name: Release on milestone close (auto-tag — DRY-RUN default, RELEASE-gated) + +# Versioning Phase-2 — the automation behind "complete a milestone → tag/release". +# +# WHAT IT DOES +# When a GitHub milestone is CLOSED (or on a manual workflow_dispatch), this job decides whether +# that milestone is allowed to be tagged + released, and — ONLY in real mode — creates the +# annotated tag + a `gh release`. The decision is made by qa/release_gate_check.py (unit-tested), +# NOT by ad-hoc shell, so the gate logic is covered by tests instead of living only in YAML. +# +# THE GATE (no milestone labels — GitHub milestones don't support them) +# GitHub milestones cannot carry labels, so the "ready-for-release" opt-in is an explicit +# `[release-ready]` MARKER in the milestone TITLE or DESCRIPTION, AND the formal RRI must report +# STATUS: RELEASE (all 11 gates PASSED) via qa/generate_release_notes.py / the per-gate verdict. +# If the marker is absent OR the status is DEVELOPMENT, the job LOGS WHY and EXITS WITHOUT TAGGING +# — a development milestone closing must never auto-tag. See docs/roadmap/release-automation.md. +# +# SAFETY — DRY-RUN BY DEFAULT +# `dry_run` defaults to **true**. On a real milestone-close event there is no input, so dry-run is +# true (the default) UNLESS the repo variable RELEASE_AUTOMATION_LIVE == "true" is set (owner opt-in). +# In dry-run the job runs the FULL resolution + generate_release_notes.py and prints EXACTLY what it +# WOULD tag/release — but creates NOTHING. Only an explicit dispatch `dry_run=false`, or the live +# repo-var, performs the real `git tag` + `gh release create`. This makes the workflow safe to merge +# without it ever acting unexpectedly. +# +# INVARIANTS +# * Reads ONLY the scores ledger + git + gh; runs NO gameplay / heavy job; NEVER touches Eva or any +# gateway. Uses the repo's GITHUB_TOKEN (contents: write for tags/releases). Minimal permissions. +# * The scores ledger (qa/scores.db) is opened READ-ONLY by the gate/notes scripts; this job never +# commits or mutates it. + +on: + milestone: + types: [closed] + workflow_dispatch: + inputs: + milestone: + description: "Milestone title to evaluate (e.g. v1.0.5). Required for a manual run." + required: true + type: string + dry_run: + description: "Dry-run (resolve + print, tag NOTHING). Default true — uncheck to really tag." + required: false + type: boolean + default: true + allow_prerelease_dev: + description: "Allow a -rcN tag to ship as a pre-release even on DEVELOPMENT status (GA always needs RELEASE)." + required: false + type: boolean + default: false + +# One release evaluation at a time per milestone; never cancel an in-flight tag/release. +concurrency: + group: release-on-milestone-close-${{ github.event.milestone.title || github.event.inputs.milestone }} + cancel-in-progress: false + +# Minimal token: contents:write is needed ONLY in real mode (annotated tag + gh release). The dry-run +# default never exercises the write — but the permission is declared at the lowest level that covers +# the real path. Nothing else is granted. +permissions: + contents: write + +jobs: + evaluate-and-maybe-tag: + runs-on: ubuntu-latest + steps: + - name: Checkout (full history + tags for the tag-exists + consistency guards) + uses: actions/checkout@v4 + with: + fetch-depth: 0 + fetch-tags: true + + - name: Install uv + uses: astral-sh/setup-uv@v5 + + - name: Resolve milestone + dry-run mode + id: resolve + # The milestone title/description come from the event payload on a real close, or from the + # dispatch inputs on a manual run. dry_run is TRUE unless an explicit dispatch said false OR + # the owner set the repo var RELEASE_AUTOMATION_LIVE=true. allow_prerelease_dev is opt-in. + env: + EVENT_NAME: ${{ github.event_name }} + MS_TITLE_EVENT: ${{ github.event.milestone.title }} + MS_DESC_EVENT: ${{ github.event.milestone.description }} + MS_TITLE_INPUT: ${{ github.event.inputs.milestone }} + DRY_RUN_INPUT: ${{ github.event.inputs.dry_run }} + ALLOW_PRE_INPUT: ${{ github.event.inputs.allow_prerelease_dev }} + LIVE_VAR: ${{ vars.RELEASE_AUTOMATION_LIVE }} + run: | + set -euo pipefail + if [ "$EVENT_NAME" = "workflow_dispatch" ]; then + TITLE="$MS_TITLE_INPUT" + DESC="" # dispatch has no description; the marker is then expected in the title + else + TITLE="$MS_TITLE_EVENT" + DESC="$MS_DESC_EVENT" + fi + + # dry_run: default true. An explicit dispatch dry_run=false flips it; the live repo-var + # also flips it (owner standing opt-in). If BOTH are absent → dry_run stays true (safe). + DRY_RUN=true + if [ "$EVENT_NAME" = "workflow_dispatch" ] && [ "$DRY_RUN_INPUT" = "false" ]; then + DRY_RUN=false + elif [ "${LIVE_VAR:-}" = "true" ]; then + DRY_RUN=false + fi + + ALLOW_PRE=false + [ "$ALLOW_PRE_INPUT" = "true" ] && ALLOW_PRE=true + + { + echo "title<<__EOF__"; echo "$TITLE"; echo "__EOF__" + echo "desc<<__EOF__"; echo "$DESC"; echo "__EOF__" + echo "dry_run=$DRY_RUN" + echo "allow_pre=$ALLOW_PRE" + } >> "$GITHUB_OUTPUT" + + echo "::notice title=Release automation::milestone='$TITLE' dry_run=$DRY_RUN allow_prerelease_dev=$ALLOW_PRE (live-var='${LIVE_VAR:-unset}')" + if [ -z "$TITLE" ]; then + echo "::error title=Release automation::no milestone title resolved — nothing to evaluate." + exit 1 + fi + + - name: Gate check (qa/release_gate_check.py) — marker + clean tag + version + STATUS:RELEASE + id: gate + # The single decision, extracted so it is unit-tested (qa/test_release_gate_check.py). It is + # READ-ONLY: it reads the committed qa/scores.db + git tags + VERSION files and emits a JSON + # verdict. NO per-gate RRI artifact is committed in the repo, so on a normal run the status + # falls back to ledger-inference — which can NEVER certify RELEASE (the honesty guard). To + # actually GO, point --verdict-json / --rri-json at a real all-PASS RRI artifact (e.g. one + # downloaded from the Release Readiness sweep). This is intentional: a GO requires real + # release evidence, not just a closed milestone. + run: | + set -uo pipefail + ALLOW_FLAG="" + [ "${{ steps.resolve.outputs.allow_pre }}" = "true" ] && ALLOW_FLAG="--allow-prerelease-dev" + + set +e + uv run --directory servers/engine --no-project python "${GITHUB_WORKSPACE}/qa/release_gate_check.py" \ + --milestone-title "${{ steps.resolve.outputs.title }}" \ + --milestone-description "${{ steps.resolve.outputs.desc }}" \ + --repo-root "${GITHUB_WORKSPACE}" \ + --db "${GITHUB_WORKSPACE}/qa/scores.db" \ + $ALLOW_FLAG \ + --out "${GITHUB_WORKSPACE}/gate_verdict.json" + GATE_RC=$? + set -e + + echo "----- gate_verdict.json -----" + cat "${GITHUB_WORKSPACE}/gate_verdict.json" || true + echo "-----------------------------" + + DECISION="$(python3 -c 'import json;print(json.load(open("gate_verdict.json"))["decision"])' 2>/dev/null || echo no_go)" + TAG="$(python3 -c 'import json;print(json.load(open("gate_verdict.json")).get("tag") or "")' 2>/dev/null || echo '')" + PRE="$(python3 -c 'import json;print(str(json.load(open("gate_verdict.json")).get("prerelease")).lower())' 2>/dev/null || echo true)" + { + echo "decision=$DECISION" + echo "tag=$TAG" + echo "prerelease=$PRE" + } >> "$GITHUB_OUTPUT" + + if [ "$DECISION" != "go" ]; then + echo "::notice title=Release automation::NO-GO — exiting WITHOUT tagging. See gate_verdict.json reasons above (rc=$GATE_RC)." + else + echo "::notice title=Release automation::GO — gate passed for tag $TAG (prerelease=$PRE)." + fi + + - name: Generate release notes (qa/generate_release_notes.py) + if: steps.gate.outputs.decision == 'go' + # READ-ONLY over qa/scores.db. The notes carry the DEVELOPMENT-vs-RELEASE banner + the 11-gate + # table + the closed-issue list (via gh, authed by GITHUB_TOKEN). This body is used for the + # release whether we dry-run or really cut it. + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + uv run --directory servers/engine --no-project python "${GITHUB_WORKSPACE}/qa/generate_release_notes.py" \ + --tag "${{ steps.gate.outputs.tag }}" \ + --milestone "${{ steps.resolve.outputs.title }}" \ + --db "${GITHUB_WORKSPACE}/qa/scores.db" \ + --repo "${{ github.repository }}" \ + --out "${GITHUB_WORKSPACE}/RELEASE_NOTES.md" + echo "----- RELEASE_NOTES.md (first 60 lines) -----" + head -60 "${GITHUB_WORKSPACE}/RELEASE_NOTES.md" + + - name: DRY-RUN — print what WOULD be tagged/released (no side effects) + if: steps.gate.outputs.decision == 'go' && steps.resolve.outputs.dry_run == 'true' + run: | + echo "::notice title=Release automation (DRY-RUN)::WOULD create annotated tag '${{ steps.gate.outputs.tag }}' at ${{ github.sha }} and a gh release (prerelease=${{ steps.gate.outputs.prerelease }}). No tag/release was created (dry_run=true)." + echo "===================================================================" + echo "DRY-RUN: would run ->" + echo " git tag -a ${{ steps.gate.outputs.tag }} -m '' ${{ github.sha }}" + echo " git push origin ${{ steps.gate.outputs.tag }}" + echo " gh release create ${{ steps.gate.outputs.tag }} --title ... --notes-file RELEASE_NOTES.md \\" + echo " $([ '${{ steps.gate.outputs.prerelease }}' = 'true' ] && echo --prerelease)" + echo "To go LIVE: re-dispatch with dry_run=false (after one successful dry-run on a real" + echo "closed milestone) or set repo variable RELEASE_AUTOMATION_LIVE=true. Owner sign-off required." + echo "===================================================================" + + - name: REAL — create the annotated tag + gh release (only on dry_run=false) + if: steps.gate.outputs.decision == 'go' && steps.resolve.outputs.dry_run == 'false' + # Reached ONLY by an explicit dispatch dry_run=false OR the live repo-var. Creates the + # annotated tag at the checked-out SHA and a gh release (pre-release when the version has -rc). + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + TAG="${{ steps.gate.outputs.tag }}" + PRE_FLAG="" + [ "${{ steps.gate.outputs.prerelease }}" = "true" ] && PRE_FLAG="--prerelease" + + # Defense-in-depth: re-confirm the tag is still free right before we cut it (gate_check + # already asserted this, but the working tree could have moved). + if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then + echo "::error title=Release automation::tag $TAG appeared since the gate check — refusing to clobber." + exit 1 + fi + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag -a "$TAG" -m "WorldOS $TAG (auto-tagged on milestone close)" "${{ github.sha }}" + git push origin "$TAG" + + gh release create "$TAG" \ + --title "WorldOS $TAG" \ + --notes-file "${GITHUB_WORKSPACE}/RELEASE_NOTES.md" \ + --target "${{ github.sha }}" \ + $PRE_FLAG + echo "::notice title=Release automation::created tag $TAG + gh release (prerelease=${{ steps.gate.outputs.prerelease }})." + + - name: Upload gate verdict + notes (audit trail) + if: always() + uses: actions/upload-artifact@v4 + with: + name: release-gate-${{ github.run_id }} + path: | + gate_verdict.json + RELEASE_NOTES.md + if-no-files-found: ignore + retention-days: 30 diff --git a/docs/roadmap/release-automation.md b/docs/roadmap/release-automation.md new file mode 100644 index 00000000..3280cc7f --- /dev/null +++ b/docs/roadmap/release-automation.md @@ -0,0 +1,88 @@ +# Release automation — auto-tag on milestone close (Versioning Phase-2) + +The automation behind the owner's standing rule **"complete a milestone → tag/release"**. When a +GitHub milestone is closed, a workflow evaluates whether that milestone is allowed to be tagged and +— only when explicitly enabled — creates the annotated git tag + a `gh release`. + +It is **DRAFT / dry-run-by-default / RELEASE-gated**: merging it does not arm it. It cannot tag +anything until an owner flips it live (see [Promoting from dry-run to live](#promoting-from-dry-run-to-live)). + +| Piece | What it is | +|---|---| +| `.github/workflows/release-on-milestone-close.yml` | the workflow: trigger → gate → (dry-run print \| real tag+release) | +| `qa/release_gate_check.py` | the **unit-tested** gate decision (READ-ONLY): marker + clean tag + version consistency + STATUS:RELEASE | +| `qa/test_release_gate_check.py` | the gate's unit tests + a structural lint of the workflow YAML | +| `qa/generate_release_notes.py` | (Phase-1) the release-notes body + the DEVELOPMENT-vs-RELEASE banner | + +## The `[release-ready]` marker convention (solving the no-milestone-labels problem) + +GitHub milestones **do not support labels** — there is no native "this milestone is releasable" flag. +So the opt-in is an explicit text marker: + +> Put the literal token **`[release-ready]`** in the milestone's **title or description** when (and +> only when) you intend its closure to cut a release. + +- The marker is matched **case-insensitively**, with **literal brackets** (so a stray "release ready" + in prose can't trip it). +- A milestone closed **without** the marker is treated as a **development** milestone: the workflow + logs why and **exits without tagging**. Closing a milestone is *not*, by itself, consent to release. +- The marker is necessary but **not sufficient** — the four other gate conditions below must also hold. + +### The full gate (all must hold for a GO) + +1. **Marker** `[release-ready]` present in the milestone title/description. +2. **Clean version tag from the title** — the milestone title must be a clean `vX.Y.Z` + (an optional `-rcN` / `-` pre-release is allowed). `Sprint 12`, `v1.0`, `v1.0.5 (final)` + are all refused. +3. **Tag does not already exist** — never clobber / re-cut an existing release. +4. **Version consistency** — the milestone's base `X.Y.Z` must equal the repo-root `VERSION` file + **and** `servers/engine/__version__.py` (the single source of truth). Bump the source first. +5. **STATUS: RELEASE** — `qa/generate_release_notes.py` / the per-gate verdict must report **all 11 + RRI gates PASSED**. A `DEVELOPMENT` status (any gate SKIPPED/FAILED/MISSING/UNKNOWN) is a NO-GO. + - A **pre-release** (`-rcN`) MAY ship on `DEVELOPMENT` status, but **only** when the run is + dispatched with `allow_prerelease_dev=true`. A clean **GA** (no `-rc`) **always** requires + `RELEASE`. + +> **Where does the RELEASE status come from?** From a real per-gate RRI artifact +> (`release_readiness_verdict.json` or a `release_readiness.py` `RRI.json`) passed to the gate via +> `--verdict-json` / `--rri-json`. No such artifact is committed in the repo, so on a bare run the +> status **falls back to ledger-inference, which can never certify RELEASE** (the honesty guard). To +> actually GO you must point the gate at an all-PASS RRI artifact from a real Release-Readiness sweep. +> This is deliberate: a GO requires real release evidence, not merely a closed milestone. + +## Dry-run safety (why merging this is safe) + +- The `dry_run` input **defaults to `true`**. On a real `milestone: closed` event there is no input, + so dry-run is true unless the owner has set the repo variable `RELEASE_AUTOMATION_LIVE=true`. +- In dry-run the workflow runs the **full** resolution + generates the notes and prints **exactly what + it would tag/release** — but creates **nothing** (no tag, no release). +- Only an explicit `workflow_dispatch` with `dry_run=false`, **or** `RELEASE_AUTOMATION_LIVE=true`, + reaches the real `git tag` + `gh release create` step. +- The real step re-confirms the tag is still free immediately before cutting it (defense-in-depth). +- Minimal permissions: `contents: write` only (for tags/releases). The job runs **no gameplay/heavy + job**, reads the scores ledger **READ-ONLY**, and **never touches Eva or any gateway**. + +## Promoting from dry-run to live + +**Requires owner sign-off.** Recommended order: + +1. **One successful dry-run on a real closed milestone.** Close a `[release-ready]` milestone whose + title matches the bumped `VERSION`, with an all-PASS RRI artifact wired in. Confirm the workflow + reaches the **DRY-RUN** step and prints the intended tag/release with no errors. +2. **Choose how to go live:** + - *Per-cut (safer):* `gh workflow run "release-on-milestone-close.yml" -f milestone=vX.Y.Z -f dry_run=false` + — a one-off real cut, dry-run stays the default for everything else. + - *Standing (owner opt-in):* set the repo variable `RELEASE_AUTOMATION_LIVE=true` + (`gh variable set RELEASE_AUTOMATION_LIVE --body true`) so milestone-close events auto-tag. +3. **Verify** the tag + `gh release` were created (pre-release iff the version has `-rc`), and that the + release body is the generated notes. + +To re-disarm: unset the repo variable (`gh variable delete RELEASE_AUTOMATION_LIVE`); dispatch runs +default back to dry-run. + +## Invariants + +- READ-ONLY on `qa/scores.db` (the engine stays the sole state writer); the workflow never commits or + mutates the ledger. +- No gameplay/heavy job; never touches Eva or any gateway; uses the repo's `GITHUB_TOKEN`. +- The gate decision lives in `qa/release_gate_check.py` (unit-tested), not in ad-hoc workflow shell. diff --git a/qa/release_gate_check.py b/qa/release_gate_check.py new file mode 100644 index 00000000..0ee76001 --- /dev/null +++ b/qa/release_gate_check.py @@ -0,0 +1,268 @@ +#!/usr/bin/env python3 +"""Resolve the auto-tag-on-milestone-close GATE — Versioning Phase-2. + +WHY THIS EXISTS +--------------- +"Complete a milestone → tag/release" is the owner's standing automation. The GitHub Actions +workflow ``.github/workflows/release-on-milestone-close.yml`` fires on ``milestone: closed`` +(or a manual ``workflow_dispatch``) and must decide, deterministically and conservatively, +**whether this milestone is allowed to be tagged/released**. This module is that decision — +extracted from the YAML so it can be UNIT-TESTED (a shell ``if`` in a workflow cannot). + +THE GATE (all must hold for a GO) +--------------------------------- +1. **The opt-in marker.** GitHub milestones do NOT support labels, so the "ready-for-release" + opt-in lives in the milestone's TITLE or DESCRIPTION as an explicit ``[release-ready]`` marker. + Absent ⇒ NO-GO (a development milestone closing must NOT auto-tag). This is the human's + deliberate "yes, cut it" — closing a milestone alone is NOT consent to tag. +2. **A clean version tag from the title.** The milestone title must be a clean ``vX.Y.Z`` (an + optional ``-rcN`` / ``-`` pre-release is allowed). Anything else ⇒ NO-GO. +3. **The tag must not already exist.** If ``vX.Y.Z`` is already a tag ⇒ NO-GO (never clobber / + re-cut a release). +4. **Version consistency.** The milestone's base ``X.Y.Z`` must equal the repo-root ``VERSION`` + file AND ``servers/engine/__version__.py`` (the single source of truth). A mismatch ⇒ NO-GO + (the milestone is for a version the code hasn't been bumped to). +5. **STATUS: RELEASE.** ``generate_release_notes.py`` / the per-gate verdict must report all 11 + RRI gates PASSED (STATUS: RELEASE). If the status is DEVELOPMENT (any gate + SKIPPED/FAILED/MISSING/UNKNOWN) ⇒ NO-GO. A pre-release (``-rc``) is allowed to ship as a + GitHub *pre-release* even on DEVELOPMENT status IF (and only if) ``--allow-prerelease-dev`` is + passed — but a clean GA (no ``-rc``) ALWAYS requires STATUS: RELEASE. + +READ-ONLY: this never mutates qa/scores.db, never tags, never calls ``gh release``. It only reads +the ledger/verdict + git tags + the VERSION files and returns a structured verdict. The workflow +(and only in real, non-dry-run mode) performs the actual tag/release. + +USAGE +----- + python3 qa/release_gate_check.py \ + --milestone-title "v1.0.5" \ + --milestone-description "...[release-ready]..." \ + [--verdict-json release_readiness_verdict.json | --rri-json qa/RRI.json] \ + [--db qa/scores.db] [--allow-prerelease-dev] [--out gate_verdict.json] + +Exit code 0 = GO (safe to tag in real mode); 1 = NO-GO; 2 = a usage / IO error. The structured +JSON verdict (printed to stdout, and to ``--out`` if given) carries ``decision`` (``go`` / +``no_go``), the resolved ``tag`` / ``version`` / ``prerelease`` booleans, and a ``reasons`` list +so the workflow log explains EXACTLY why it would or would not tag. +""" + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys +from pathlib import Path +from typing import Optional + +QA_DIR = Path(__file__).resolve().parent +REPO_ROOT = QA_DIR.parent +sys.path.insert(0, str(QA_DIR)) + +import scores_db # noqa: E402 (READ-ONLY ledger + verdict reader) +import generate_release_notes as grn # noqa: E402 (reuse the DEVELOPMENT/RELEASE logic) + +# The explicit opt-in marker. Case-insensitive; brackets are literal so it can't be tripped by a +# stray "release ready" in prose. This is the documented convention (docs/roadmap/release-automation.md). +RELEASE_READY_MARKER = "[release-ready]" + +# A clean version tag: vMAJOR.MINOR.PATCH with an OPTIONAL pre-release suffix (-rc1, -beta, ...). +# Anchored so "v1.0" (no patch) and "v1.0.5 (final)" (trailing prose) are REFUSED. +_VERSION_TAG_RE = re.compile(r"^v(?P\d+\.\d+\.\d+)(?P
-[0-9A-Za-z.\-]+)?$")
+
+
+def has_release_marker(*texts: Optional[str]) -> bool:
+    """True iff the literal ``[release-ready]`` marker appears in ANY of the given texts."""
+    marker = RELEASE_READY_MARKER.lower()
+    return any(marker in (t or "").lower() for t in texts)
+
+
+def parse_version_tag(title: Optional[str]) -> Optional[dict]:
+    """Parse a milestone title into a tag dict, or None if it isn't a clean vX.Y.Z[-pre].
+
+    Returns ``{"tag": "v1.0.5-rc4", "base": "1.0.5", "prerelease": True}`` — ``prerelease`` is
+    True when a ``-suffix`` is present (an rc / beta / etc. ships as a GitHub pre-release)."""
+    if not title:
+        return None
+    m = _VERSION_TAG_RE.match(title.strip())
+    if not m:
+        return None
+    return {
+        "tag": m.group(0).strip(),
+        "base": m.group("base"),
+        "prerelease": bool(m.group("pre")),
+    }
+
+
+def tag_exists(tag: str, *, repo_root: Path = REPO_ROOT) -> bool:
+    """True iff ``tag`` already exists as a git tag in ``repo_root`` (never clobber)."""
+    try:
+        proc = subprocess.run(
+            ["git", "-C", str(repo_root), "rev-parse", "-q", "--verify", f"refs/tags/{tag}"],
+            capture_output=True, text=True, timeout=30,
+        )
+    except (FileNotFoundError, subprocess.TimeoutExpired):
+        # If git is unavailable we cannot prove the tag is free → be conservative, treat as "exists".
+        return True
+    return proc.returncode == 0
+
+
+def read_repo_version(*, repo_root: Path = REPO_ROOT) -> tuple[Optional[str], Optional[str]]:
+    """Return (VERSION-file, engine __version__) — the two halves of the source of truth.
+
+    Either may be None if unreadable. The consistency guard compares the milestone BASE against
+    both; a None side is reported as a mismatch (we never tag against an unreadable source)."""
+    version_file = None
+    try:
+        version_file = (repo_root / "VERSION").read_text(encoding="utf-8").strip()
+    except OSError:
+        pass
+    engine_version = None
+    vmod = repo_root / "servers" / "engine" / "__version__.py"
+    try:
+        text = vmod.read_text(encoding="utf-8")
+        m = re.search(r'^__version__\s*=\s*["\']([^"\']+)["\']', text, re.MULTILINE)
+        if m:
+            engine_version = m.group(1).strip()
+    except OSError:
+        pass
+    return version_file, engine_version
+
+
+def _resolve_status(
+    *, verdict_json: Optional[str], rri_json: Optional[str], db_path, build_sha: Optional[str]
+) -> tuple[str, str, list[str]]:
+    """Resolve the DEVELOPMENT/RELEASE status by reusing generate_release_notes' gate logic.
+
+    Returns (status, source, not_passed_gates). ``status`` is "RELEASE" / "DEVELOPMENT". When NO
+    per-gate artifact is supplied the status is DEVELOPMENT (the inferred path can never certify
+    RELEASE — the honesty guard), and the source records why."""
+    gate_statuses, source = grn._gate_statuses_from_artifact(
+        rri_json=rri_json, verdict_json=verdict_json, db_path=db_path, build_sha=build_sha,
+    )
+    if gate_statuses is None:
+        rows = scores_db.fetch_rows_readonly(db_path)
+        # Match the latest RRI-bearing row (best-effort, for the few provable gates).
+        row = grn.latest_rri_row(rows)
+        gate_statuses, source = grn._gate_statuses_inferred(row)
+    status, not_passed = grn.development_or_release(gate_statuses)
+    return status, source, not_passed
+
+
+def evaluate_gate(args: argparse.Namespace) -> dict:
+    """The pure decision. Returns a structured verdict dict (no side effects beyond reads)."""
+    reasons: list[str] = []
+    decision = "go"
+
+    def block(reason: str) -> None:
+        nonlocal decision
+        decision = "no_go"
+        reasons.append(reason)
+
+    # 1) The opt-in marker (title OR description).
+    marker_present = has_release_marker(args.milestone_title, args.milestone_description)
+    if marker_present:
+        reasons.append(f"marker `{RELEASE_READY_MARKER}` present (opt-in granted)")
+    else:
+        block(f"marker `{RELEASE_READY_MARKER}` absent in milestone title/description "
+              f"(a development milestone closing must NOT auto-tag)")
+
+    # 2) Clean version tag from the title.
+    parsed = parse_version_tag(args.milestone_title)
+    tag = parsed["tag"] if parsed else None
+    base = parsed["base"] if parsed else None
+    prerelease = bool(parsed["prerelease"]) if parsed else False
+    if parsed:
+        reasons.append(f"milestone title `{args.milestone_title}` → tag `{tag}` "
+                       f"({'pre-release' if prerelease else 'GA'})")
+    else:
+        block(f"milestone title `{args.milestone_title}` is not a clean vX.Y.Z[-pre] "
+              f"(refusing to derive a tag)")
+
+    # 3) The tag must not already exist (only meaningful once we have a tag).
+    if tag is not None:
+        if tag_exists(tag, repo_root=Path(args.repo_root)):
+            block(f"tag `{tag}` already exists (never clobber an existing release)")
+        else:
+            reasons.append(f"tag `{tag}` does not yet exist (free to create)")
+
+    # 4) Version consistency: base must match VERSION and engine __version__.
+    if base is not None:
+        vfile, eng = read_repo_version(repo_root=Path(args.repo_root))
+        if vfile == base and eng == base:
+            reasons.append(f"version consistency OK (VERSION={vfile}, __version__={eng} == {base})")
+        else:
+            block(f"version mismatch: milestone base {base} vs VERSION={vfile!r} / "
+                  f"__version__={eng!r} (bump the source of truth first)")
+
+    # 5) STATUS: RELEASE (all 11 RRI gates PASSED) — the quality gate.
+    status, status_source, not_passed = _resolve_status(
+        verdict_json=args.verdict_json, rri_json=args.rri_json,
+        db_path=args.db, build_sha=args.build_sha,
+    )
+    if status == "RELEASE":
+        reasons.append(f"RRI status RELEASE — all 11 gates PASSED (source: {status_source})")
+    else:
+        np = ", ".join(not_passed) if not_passed else "—"
+        # A pre-release MAY ship on DEVELOPMENT status only with the explicit opt-out; a GA never can.
+        if prerelease and args.allow_prerelease_dev:
+            reasons.append(f"RRI status DEVELOPMENT (not-passed: {np}; source: {status_source}) — "
+                           f"ALLOWED as a pre-release via --allow-prerelease-dev")
+        else:
+            block(f"RRI status DEVELOPMENT (not-passed: {np}; source: {status_source}) — "
+                  f"only an all-11-PASS RELEASE may auto-tag"
+                  + ("" if prerelease else " (a GA always requires STATUS: RELEASE)"))
+
+    return {
+        "schema": "worldos.release-gate-check.v1",
+        "decision": decision,  # "go" | "no_go"
+        "tag": tag,
+        "version": base,
+        "prerelease": prerelease,
+        "marker_present": marker_present,
+        "rri_status": status,
+        "rri_status_source": status_source,
+        "gates_not_passed": not_passed,
+        "reasons": reasons,
+    }
+
+
+def _build_parser() -> argparse.ArgumentParser:
+    p = argparse.ArgumentParser(
+        description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
+    p.add_argument("--milestone-title", required=True,
+                   help="the closed milestone's title (the tag is derived from this, e.g. v1.0.5)")
+    p.add_argument("--milestone-description", default="",
+                   help="the milestone's description (searched for the [release-ready] marker too)")
+    p.add_argument("--verdict-json", default=None,
+                   help="a release_readiness_verdict.json (scores_db) for the 11 per-gate statuses")
+    p.add_argument("--rri-json", default=None,
+                   help="a release_readiness.py RRI.json (converted to the 11 per-gate statuses)")
+    p.add_argument("--build-sha", default=None,
+                   help="build SHA to attribute (defaults to the latest RRI row's build_sha)")
+    p.add_argument("--db", default=str(scores_db.DB_PATH), help="path to scores.db (READ-ONLY)")
+    p.add_argument("--repo-root", default=str(REPO_ROOT),
+                   help="repo root for the tag-exists + VERSION consistency checks")
+    p.add_argument("--allow-prerelease-dev", action="store_true",
+                   help="allow a -rc/-pre tag to proceed on DEVELOPMENT status (GA always needs RELEASE)")
+    p.add_argument("--out", default=None, help="also write the JSON verdict here")
+    return p
+
+
+def main(argv: Optional[list[str]] = None) -> int:
+    args = _build_parser().parse_args(argv)
+    try:
+        verdict = evaluate_gate(args)
+    except Exception as exc:  # pragma: no cover - defensive top-level guard
+        sys.stderr.write(f"release_gate_check: error: {exc}\n")
+        return 2
+    blob = json.dumps(verdict, indent=2)
+    sys.stdout.write(blob + "\n")
+    if args.out:
+        Path(args.out).write_text(blob + "\n", encoding="utf-8")
+    return 0 if verdict["decision"] == "go" else 1
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())
diff --git a/qa/test_release_gate_check.py b/qa/test_release_gate_check.py
new file mode 100644
index 00000000..dc316615
--- /dev/null
+++ b/qa/test_release_gate_check.py
@@ -0,0 +1,312 @@
+#!/usr/bin/env python3
+"""Unit tests for qa/release_gate_check.py — the auto-tag-on-milestone-close gate.
+
+The four load-bearing cases the workflow relies on (and a few more for the edges):
+  * marker present + STATUS: RELEASE + clean unused tag + version match ⇒ GO
+  * STATUS: DEVELOPMENT (any gate not PASSED)                            ⇒ NO-GO
+  * marker absent                                                        ⇒ NO-GO
+  * tag already exists                                                   ⇒ NO-GO
+
+Every test builds an ISOLATED fake repo root (tmp_path) with its own VERSION /
+servers/engine/__version__.py / git tags, and a TEMP scores.db — NEVER the committed
+qa/scores.db (the additive, read-only invariant).
+
+Run:
+    uv run --directory servers/engine python -m pytest ../../qa/test_release_gate_check.py -q -p no:xdist
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import subprocess
+import sys
+from pathlib import Path
+
+import pytest
+
+QA_DIR = Path(__file__).resolve().parent
+sys.path.insert(0, str(QA_DIR))
+
+import scores_db  # noqa: E402
+import release_gate_check as rgc  # noqa: E402
+
+ALL_GATES = scores_db.RRI_CANONICAL_GATES
+
+
+# --------------------------------------------------------------------------- #
+# fixtures / helpers
+# --------------------------------------------------------------------------- #
+def _make_repo(tmp_path: Path, version: str = "1.0.5", *, with_tags: list[str] | None = None) -> Path:
+    """Build a minimal git repo root with VERSION + engine __version__ + optional tags."""
+    root = tmp_path / "repo"
+    (root / "servers" / "engine").mkdir(parents=True)
+    (root / "VERSION").write_text(version + "\n", encoding="utf-8")
+    (root / "servers" / "engine" / "__version__.py").write_text(
+        f'__version__ = "{version}"\n', encoding="utf-8")
+    subprocess.run(["git", "-C", str(root), "init", "-q"], check=True)
+    subprocess.run(["git", "-C", str(root), "config", "user.email", "t@t.t"], check=True)
+    subprocess.run(["git", "-C", str(root), "config", "user.name", "t"], check=True)
+    subprocess.run(["git", "-C", str(root), "add", "-A"], check=True)
+    subprocess.run(["git", "-C", str(root), "commit", "-q", "-m", "init"], check=True)
+    for t in (with_tags or []):
+        subprocess.run(["git", "-C", str(root), "tag", t], check=True)
+    return root
+
+
+def _seed_release_db(db: Path) -> None:
+    """An RRI-bearing ledger row (for ruler provenance in the verdict path)."""
+    scores_db.add_run(
+        "gate-test", db_path=db,
+        surface="GUI-built-app", ts="2026-06-21T00:00:00+00:00", build_sha="abc1234",
+        dm_model="opus", scorer_model="claude", rc_label="v1.0.5",
+        story_overall=4.4, mech_overall=4.6, behavioral="GREEN", rri=10.0,
+        cross_persona_sat=7.5, critical_bugs=0,
+        scoring_config_version="sc_test", lens_config_version="lc_test",
+    )
+
+
+def _rri_json(path: Path, *, failed=None, skipped=None, build_sha="abc1234") -> Path:
+    """A minimal release_readiness.py-shaped RRI.json (all-PASS unless failed/skipped given)."""
+    payload = {
+        "rri": 10.0,
+        "status": "READY" if not (failed or skipped) else "NOT_READY",
+        "release_ready": not (failed or skipped),
+        "build_sha": build_sha,
+        "gates_total": 11,
+        "failed_gates": failed or [],
+        "skipped_gates": skipped or [],
+        "gate_detail": {g: f"{g} detail" for g in ALL_GATES},
+    }
+    path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
+    return path
+
+
+def _args(**kw) -> argparse.Namespace:
+    ns = argparse.Namespace(
+        milestone_title=None, milestone_description="",
+        verdict_json=None, rri_json=None, build_sha=None,
+        db=None, repo_root=None, allow_prerelease_dev=False, out=None,
+    )
+    for k, v in kw.items():
+        setattr(ns, k, str(v) if isinstance(v, Path) else v)
+    return ns
+
+
+# --------------------------------------------------------------------------- #
+# the four load-bearing cases
+# --------------------------------------------------------------------------- #
+def test_marker_and_release_and_clean_tag_is_go(tmp_path):
+    root = _make_repo(tmp_path, "1.0.5")
+    db = tmp_path / "t.db"; _seed_release_db(db)
+    rri = _rri_json(tmp_path / "RRI.json")
+    v = rgc.evaluate_gate(_args(
+        milestone_title="v1.0.5", milestone_description="Ship it. [release-ready]",
+        rri_json=str(rri), db=str(db), repo_root=str(root)))
+    assert v["decision"] == "go", v["reasons"]
+    assert v["tag"] == "v1.0.5"
+    assert v["version"] == "1.0.5"
+    assert v["prerelease"] is False
+    assert v["rri_status"] == "RELEASE"
+
+
+def test_development_status_is_no_go(tmp_path):
+    root = _make_repo(tmp_path, "1.0.5")
+    db = tmp_path / "t.db"; _seed_release_db(db)
+    rri = _rri_json(tmp_path / "RRI.json", skipped=["palette_live"])
+    v = rgc.evaluate_gate(_args(
+        milestone_title="v1.0.5", milestone_description="[release-ready]",
+        rri_json=str(rri), db=str(db), repo_root=str(root)))
+    assert v["decision"] == "no_go"
+    assert v["rri_status"] == "DEVELOPMENT"
+    assert "palette_live" in v["gates_not_passed"]
+    assert any("DEVELOPMENT" in r for r in v["reasons"])
+
+
+def test_missing_marker_is_no_go(tmp_path):
+    root = _make_repo(tmp_path, "1.0.5")
+    db = tmp_path / "t.db"; _seed_release_db(db)
+    rri = _rri_json(tmp_path / "RRI.json")  # all gates PASS
+    v = rgc.evaluate_gate(_args(
+        milestone_title="v1.0.5", milestone_description="just a normal milestone close",
+        rri_json=str(rri), db=str(db), repo_root=str(root)))
+    assert v["decision"] == "no_go"
+    assert v["marker_present"] is False
+    assert any("marker" in r and "absent" in r for r in v["reasons"])
+
+
+def test_tag_already_exists_is_no_go(tmp_path):
+    root = _make_repo(tmp_path, "1.0.5", with_tags=["v1.0.5"])
+    db = tmp_path / "t.db"; _seed_release_db(db)
+    rri = _rri_json(tmp_path / "RRI.json")
+    v = rgc.evaluate_gate(_args(
+        milestone_title="v1.0.5", milestone_description="[release-ready]",
+        rri_json=str(rri), db=str(db), repo_root=str(root)))
+    assert v["decision"] == "no_go"
+    assert any("already exists" in r for r in v["reasons"])
+
+
+# --------------------------------------------------------------------------- #
+# the other refusals + the pre-release nuance
+# --------------------------------------------------------------------------- #
+def test_unclean_title_is_no_go(tmp_path):
+    root = _make_repo(tmp_path, "1.0.5")
+    db = tmp_path / "t.db"; _seed_release_db(db)
+    rri = _rri_json(tmp_path / "RRI.json")
+    for bad in ("Sprint 12", "v1.0", "v1.0.5 (final)", "release 1.0.5", "1.0.5"):
+        v = rgc.evaluate_gate(_args(
+            milestone_title=bad, milestone_description="[release-ready]",
+            rri_json=str(rri), db=str(db), repo_root=str(root)))
+        assert v["decision"] == "no_go", f"{bad!r} should be refused"
+        assert v["tag"] is None
+        assert any("not a clean" in r for r in v["reasons"])
+
+
+def test_version_mismatch_is_no_go(tmp_path):
+    # repo is at 1.0.5 but the milestone claims v1.0.6
+    root = _make_repo(tmp_path, "1.0.5")
+    db = tmp_path / "t.db"; _seed_release_db(db)
+    rri = _rri_json(tmp_path / "RRI.json")
+    v = rgc.evaluate_gate(_args(
+        milestone_title="v1.0.6", milestone_description="[release-ready]",
+        rri_json=str(rri), db=str(db), repo_root=str(root)))
+    assert v["decision"] == "no_go"
+    assert any("version mismatch" in r for r in v["reasons"])
+
+
+def test_prerelease_can_ship_on_development_with_optin(tmp_path):
+    """A -rcN tag MAY proceed on DEVELOPMENT status only with --allow-prerelease-dev."""
+    root = _make_repo(tmp_path, "1.0.5")
+    db = tmp_path / "t.db"; _seed_release_db(db)
+    rri = _rri_json(tmp_path / "RRI.json", skipped=["native_gate"])  # DEVELOPMENT
+    base = dict(milestone_title="v1.0.5-rc5", milestone_description="[release-ready]",
+                rri_json=str(rri), db=str(db), repo_root=str(root))
+    # without the opt-in: blocked
+    assert rgc.evaluate_gate(_args(**base))["decision"] == "no_go"
+    # with the opt-in: allowed (still a pre-release)
+    v = rgc.evaluate_gate(_args(allow_prerelease_dev=True, **base))
+    assert v["decision"] == "go", v["reasons"]
+    assert v["prerelease"] is True
+
+
+def test_ga_never_ships_on_development_even_with_optin(tmp_path):
+    """A clean GA (no -rc) ALWAYS requires STATUS: RELEASE, regardless of the pre-release opt-in."""
+    root = _make_repo(tmp_path, "1.0.5")
+    db = tmp_path / "t.db"; _seed_release_db(db)
+    rri = _rri_json(tmp_path / "RRI.json", failed=["mechanical"])  # DEVELOPMENT
+    v = rgc.evaluate_gate(_args(
+        milestone_title="v1.0.5", milestone_description="[release-ready]",
+        allow_prerelease_dev=True, rri_json=str(rri), db=str(db), repo_root=str(root)))
+    assert v["decision"] == "no_go"
+    assert v["prerelease"] is False
+
+
+def test_no_artifact_is_never_release(tmp_path):
+    """No RRI/verdict artifact ⇒ inferred status can never certify RELEASE (honesty guard)."""
+    root = _make_repo(tmp_path, "1.0.5")
+    db = tmp_path / "t.db"; _seed_release_db(db)
+    v = rgc.evaluate_gate(_args(
+        milestone_title="v1.0.5", milestone_description="[release-ready]",
+        db=str(db), repo_root=str(root)))  # no rri_json / verdict_json
+    assert v["decision"] == "no_go"
+    assert v["rri_status"] == "DEVELOPMENT"
+
+
+def test_verdict_json_path_drives_status(tmp_path):
+    """A pre-emitted release_readiness_verdict.json is an accepted per-gate source."""
+    root = _make_repo(tmp_path, "1.0.5")
+    db = tmp_path / "t.db"; _seed_release_db(db)
+    rri = _rri_json(tmp_path / "RRI.json")  # all PASS
+    verdict_path = tmp_path / "verdict.json"
+    scores_db.release_readiness_verdict(rri, db_path=db, out_path=verdict_path)
+    v = rgc.evaluate_gate(_args(
+        milestone_title="v1.0.5", milestone_description="[release-ready]",
+        verdict_json=str(verdict_path), db=str(db), repo_root=str(root)))
+    assert v["decision"] == "go", v["reasons"]
+    assert v["rri_status"] == "RELEASE"
+
+
+def test_evaluate_gate_does_not_mutate_db(tmp_path):
+    """The gate check is READ-ONLY on scores.db (never writes the committed ledger)."""
+    root = _make_repo(tmp_path, "1.0.5")
+    db = tmp_path / "t.db"; _seed_release_db(db)
+    rri = _rri_json(tmp_path / "RRI.json")
+    before = db.read_bytes()
+    rgc.evaluate_gate(_args(
+        milestone_title="v1.0.5", milestone_description="[release-ready]",
+        rri_json=str(rri), db=str(db), repo_root=str(root)))
+    assert db.read_bytes() == before
+
+
+# --------------------------------------------------------------------------- #
+# the small pure helpers
+# --------------------------------------------------------------------------- #
+def test_parse_version_tag():
+    assert rgc.parse_version_tag("v1.0.5") == {"tag": "v1.0.5", "base": "1.0.5", "prerelease": False}
+    assert rgc.parse_version_tag("v1.0.5-rc4") == {"tag": "v1.0.5-rc4", "base": "1.0.5", "prerelease": True}
+    assert rgc.parse_version_tag("  v2.3.1-beta.2 ") == {"tag": "v2.3.1-beta.2", "base": "2.3.1", "prerelease": True}
+    for bad in (None, "", "v1.0", "1.0.5", "v1.0.5 final", "Sprint", "vX.Y.Z"):
+        assert rgc.parse_version_tag(bad) is None, bad
+
+
+def test_has_release_marker():
+    assert rgc.has_release_marker("blah [release-ready] blah")
+    assert rgc.has_release_marker("title", "desc with [RELEASE-READY]")  # case-insensitive
+    assert not rgc.has_release_marker("release ready", "almost [release ready]")  # no brackets
+    assert not rgc.has_release_marker(None, "")
+
+
+def test_main_exit_codes(tmp_path, capsys):
+    """main() returns 0 on GO, 1 on NO-GO, and writes the JSON verdict."""
+    root = _make_repo(tmp_path, "1.0.5")
+    db = tmp_path / "t.db"; _seed_release_db(db)
+    rri = _rri_json(tmp_path / "RRI.json")
+    out = tmp_path / "gate.json"
+    rc = rgc.main([
+        "--milestone-title", "v1.0.5", "--milestone-description", "[release-ready]",
+        "--rri-json", str(rri), "--db", str(db), "--repo-root", str(root), "--out", str(out)])
+    assert rc == 0
+    assert json.loads(out.read_text())["decision"] == "go"
+    # a no-go path returns 1
+    rc2 = rgc.main([
+        "--milestone-title", "v1.0.5", "--milestone-description", "no marker",
+        "--rri-json", str(rri), "--db", str(db), "--repo-root", str(root)])
+    assert rc2 == 1
+
+
+# --------------------------------------------------------------------------- #
+# the workflow YAML — structural lint (valid YAML + the load-bearing gate shape)
+# --------------------------------------------------------------------------- #
+def test_release_workflow_yaml_is_valid_and_safe():
+    """The auto-tag workflow must keep its safety contract: milestone:closed + workflow_dispatch
+    trigger, dry_run default TRUE, contents:write only, and the real tag/release step guarded by
+    dry_run == 'false'. pyyaml is not an engine dep, so the structural parse is best-effort
+    (when available) and the load-bearing safety checks are pyyaml-free string assertions so this
+    guard ALWAYS runs in CI."""
+    wf = QA_DIR.parent / ".github" / "workflows" / "release-on-milestone-close.yml"
+    assert wf.exists(), f"workflow missing at {wf}"
+    text = wf.read_text(encoding="utf-8")
+
+    # --- pyyaml-free safety contract (always runs) ---
+    assert "types: [closed]" in text, "must trigger on milestone:closed"
+    assert "workflow_dispatch:" in text, "must also have a workflow_dispatch (manual test path)"
+    assert "permissions:\n  contents: write\n" in text, "permissions must be exactly contents:write"
+    # dry_run input defaults to true.
+    assert "dry_run:" in text and "default: true" in text, "dry_run must default to true (safety)"
+    # The REAL tag/release step is guarded by dry_run == 'false' (never runs by default).
+    assert "steps.resolve.outputs.dry_run == 'false'" in text, \
+        "the REAL tag/release step must be guarded by dry_run == 'false'"
+    assert "gh release create" in text, "workflow must create a gh release in real mode"
+
+    # --- deeper structural parse when pyyaml is present (skipped if not installed) ---
+    try:
+        import yaml  # type: ignore
+    except ImportError:
+        return
+    doc = yaml.safe_load(text)
+    on = doc.get("on", doc.get(True))  # YAML 1.1 parses bare `on:` as boolean True
+    assert isinstance(on, dict), "no `on:` trigger block"
+    assert on.get("milestone", {}).get("types") == ["closed"]
+    inputs = on["workflow_dispatch"].get("inputs", {})
+    assert inputs["dry_run"].get("default") is True
+    assert doc.get("permissions") == {"contents": "write"}