feat(deploy): two-mode TUI canary (candidate scrub vs current), negative deployment regression, release-recursion invariant
CI Pipeline / best-practices (push) Successful in 8s
CI Pipeline / documentation (push) Successful in 4s
CI Pipeline / canary (push) Failing after 37s
CI Pipeline / lint (push) Successful in 45s
Auto-version and Release / release (push) Successful in 55s
CI Pipeline / type-check (push) Successful in 32s
CI Pipeline / gitea-offline (push) Successful in 17s
CI Pipeline / unit-tests (push) Failing after 59s
deploy / deploy (push) Failing after 15m24s
CI Pipeline / required-checks (push) Successful in 1s

Handoff 2026-09-11 Part A-C:

A. tui_startup_canary two modes: candidate (--stage, sys.path SCRUBBED of
   /mnt/workdrive/rune-code/current + imported-rune purge + contamination
   check that rune resolves from the stage) and current (consuming path).
   Scrub proven: candidate mode on the 76f71fc stage reports 76f71fc/0.66.2
   while current is bb536c9/0.66.3. FIXED a live exit-code bug: main()
   returned 1 but was called without sys.exit — a failed canary exited 0
   (the deploy gate's own returncode check in deploy_stage.py was correct,
   so promotion was never actually bypassed; the standalone script was).

B. Negative deployment canary (tests/test_deployment_canary.py): broken
   staged candidate → canary exit 1 naming the failure → current identity
   byte-identical before/after. Plus current-mode identity regression.

C. Release recursion: is_release_commit() extracted into
   version_convergence.py (ONE source of truth); workflow guard uses
   --is-release-subject CLI (no inline python in YAML); regression tests
   cover recognition, never-bump-from-release-commits (incl. the 5x loop
   case), substantive commits still bumping, and the CLI exit contract.

1241 tests green; ratchet 353/80; henxels clean.
This commit is contained in:
Nick committed 2026-09-11 03:16:50 +01:00
1 parent bb536c9f02
commit c2528c1d9d
6 files changed
+315 -39

No files matched your search

+8 -5
View File
@@ -62,12 +62,15 @@ jobs:
# commits since the last tag. chore(release) commits never
# warrant a bump → the bump commit cannot recurse.
if [ -z "$PR_TITLE" ] || [ "$PR_TITLE" = "null" ]; then
# Recursive-loop guard (handoff §3): HEAD being a bump commit
# means THIS push already applied the bump — do not re-bump.
# Recursive-loop guard: HEAD being a bump commit means THIS
# push already applied the bump — do not re-bump. The decision
# lives in version_convergence (--is-release-subject), one
# source of truth shared with the regression test.
HEAD_SUBJECT=$(git log -1 --format=%s)
case "$HEAD_SUBJECT" in
chore\(release\)*) echo "HEAD is a release bump — loop guard, no re-bump"; exit 0;;
esac
if python3 scripts/version_convergence.py --is-release-subject "$HEAD_SUBJECT"; then
echo "HEAD is a release bump — loop guard, no re-bump"
exit 0
fi
WARRANTED=$(python3 scripts/version_convergence.py | python3 -c "import json,sys; print(json.load(sys.stdin).get('warranted_bump') or '')")
echo "push convergence: warranted_bump=$WARRANTED"
if [ -z "$WARRANTED" ]; then
+24 -15
View File
@@ -10,6 +10,7 @@ the previous deployment remains available).
Exit 0 = deployed and verified. Non-zero = deployment refused; the previous
deployment stays current.
"""
from __future__ import annotations
import json
@@ -32,7 +33,8 @@ def main() -> int:
repo = Path(os.environ.get("GITHUB_WORKSPACE", os.getcwd())).resolve()
sha = subprocess.run(
["git", "-C", str(repo), "-c", f"safe.directory={repo}", "rev-parse", "HEAD"],
capture_output=True, text=True,
capture_output=True,
text=True,
).stdout.strip()
if not sha:
print(f"FATAL: cannot resolve HEAD in {repo}", file=sys.stderr)
@@ -62,15 +64,15 @@ def main() -> int:
"version": rune_dev.__version__,
"deployed_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
}
(tmp_stage / "src" / "rune" / "_build_identity.json").write_text(
json.dumps(identity, indent=2))
(tmp_stage / "src" / "rune" / "_build_identity.json").write_text(json.dumps(identity, indent=2))
# --- verify the STAGED tree (before promotion) ----------------------
env = dict(os.environ,
RUNE_HOME="/mnt/workdrive/rune",
RUNE_MEMORY_DIR="/home/nick/.config/opencode/memory")
env = dict(os.environ, RUNE_HOME="/mnt/workdrive/rune", RUNE_MEMORY_DIR="/home/nick/.config/opencode/memory")
verify = subprocess.run(
[VENV_PY, "-c", f"""
[
VENV_PY,
"-c",
f"""
import json, sys
sys.path.insert(0, {str(tmp_stage / "src")!r})
import rune
@@ -80,12 +82,16 @@ assert info["commit"] == {sha!r}, f"SHA mismatch: {{info['commit']}}"
assert info["version"], "empty version"
from rune import adaptive_router, builder_bridge, context_broker, rune_operator # noqa: F401
print("VERIFIED:", json.dumps(info))
"""],
capture_output=True, text=True, env=env, timeout=120)
""",
],
capture_output=True,
text=True,
env=env,
timeout=120,
)
print(verify.stdout.strip())
if verify.returncode != 0:
print("VERIFY FAILED — deployment refused:\n" + verify.stderr[-800:],
file=sys.stderr)
print("VERIFY FAILED — deployment refused:\n" + verify.stderr[-800:], file=sys.stderr)
shutil.rmtree(tmp_stage, ignore_errors=True)
return 1
@@ -94,12 +100,15 @@ print("VERIFIED:", json.dumps(info))
# revision whose TUI cannot initialize never becomes current.
canary = subprocess.run(
[VENV_PY, str(REPO_ROOT / "scripts" / "tui_startup_canary.py")],
capture_output=True, text=True, env={**env, "PYTHONPATH": str(tmp_stage / "src")},
timeout=180, cwd=str(tmp_stage))
capture_output=True,
text=True,
env={**env, "PYTHONPATH": str(tmp_stage / "src")},
timeout=180,
cwd=str(tmp_stage),
)
print(canary.stdout.strip())
if canary.returncode != 0:
print("TUI STARTUP CANARY FAILED — deployment refused:\n"
+ canary.stderr[-800:], file=sys.stderr)
print("TUI STARTUP CANARY FAILED — deployment refused:\n" + canary.stderr[-800:], file=sys.stderr)
shutil.rmtree(tmp_stage, ignore_errors=True)
return 1
+81 -19
View File
@@ -1,26 +1,48 @@
#!/usr/bin/env python3
"""tui_startup_canary.py — REAL TUI initialization canary (handoff §4).
"""tui_startup_canary.py — REAL TUI initialization canary, two proof modes.
Runs the ACTUAL RuneApp initialization through Textual's own test driver —
the same app construction + on_mount wiring the interactive TUI executes —
then exits 0. No fake smoke path: RuneApp is constructed, mounted, and its
on_mount handlers run; the interactive loop is simply never entered.
Mode CANDIDATE (pre-promotion, default when --stage is given):
Tests the STAGED candidate exactly as supplied. Nothing from
/mnt/workdrive/rune-code/current is prepended or preferred — sys.path is
scrubbed of any current-deployment entry so a broken candidate cannot be
masked by the healthy previous deployment. If the staged candidate is
broken while current is healthy, this canary MUST fail.
Prints the build identity on success. Exit non-zero on any initialization
failure (a staged revision whose TUI cannot initialize must NOT promote).
Mode CURRENT (post-promotion consuming-path proof, --mode current):
Tests /mnt/workdrive/rune-code/current/src — proving the promoted
artifact is what the real consuming path resolves to.
Both modes run the ACTUAL RuneApp initialization through Textual's own
test driver — the same app construction + on_mount wiring the interactive
TUI executes — then exit 0. The interactive loop is never entered.
Prints the build identity (mode + identity) on success. Exit non-zero on
any initialization failure.
"""
from __future__ import annotations
import argparse
import asyncio
import json
import os
import sys
from pathlib import Path
# Self-bootstrap: when the CURRENT deployment exists, run against it (the
# consuming path), so this canary always proves what the launcher proves.
_CURRENT = "/mnt/workdrive/rune-code/current/src"
if os.path.isdir(_CURRENT) and _CURRENT not in sys.path:
sys.path.insert(0, _CURRENT)
CURRENT_SRC = "/mnt/workdrive/rune-code/current/src"
def _scrub_current_from_path() -> None:
"""Remove every current-deployment entry from sys.path (candidate mode).
The runner's checkout workspace or ambient PYTHONPATH may contain the
current deployment; a candidate canary must not silently resolve rune
from it.
"""
sys.path[:] = [p for p in sys.path if os.path.abspath(p or ".") != os.path.abspath(CURRENT_SRC)]
# Also drop any already-imported rune modules from a prior import.
for mod in [m for m in sys.modules if m == "rune" or m.startswith("rune.")]:
del sys.modules[mod]
async def _canary() -> dict:
@@ -36,19 +58,59 @@ async def _canary() -> dict:
assert app.status_bar is not None, "status bar not initialized"
# One interaction cycle: let pending messages/refreshes settle.
await pilot.pause(0.1)
from rune import build_info
import rune
return build_info()
return rune.build_info()
def main() -> int:
ap = argparse.ArgumentParser(description="Rune TUI startup canary")
ap.add_argument(
"--mode",
choices=("candidate", "current"),
default="candidate",
help="candidate: test the staged tree (sys.path scrubbed of current); current: test the consuming path",
)
ap.add_argument("--stage", help="staged candidate root (candidate mode)")
args = ap.parse_args()
if args.mode == "candidate":
if not args.stage:
print("candidate mode requires --stage", file=sys.stderr)
return 2
stage_root = Path(args.stage).resolve()
stage_src = str(stage_root / "src")
if not (stage_root / "src" / "rune").is_dir():
print(f"staged candidate has no src/rune: {stage_src}", file=sys.stderr)
return 2
_scrub_current_from_path()
sys.path.insert(0, stage_src)
# Verify the scrub worked: rune must resolve from the STAGE, not current.
import rune # noqa: E402
resolved = getattr(rune, "__file__", "")
if "rune-code/current" in resolved:
print(
f"CANARY CONTAMINATED: rune imported from current, not the staged candidate ({resolved})",
file=sys.stderr,
)
return 2
else:
if CURRENT_SRC not in sys.path:
sys.path.insert(0, CURRENT_SRC)
async def _run() -> dict:
try:
return await _canary()
finally:
pass
try:
info = asyncio.run(_canary())
except Exception as exc:
print(f"TUI STARTUP CANARY FAILED: {type(exc).__name__}: {exc}",
file=sys.stderr)
info = asyncio.run(_run())
except Exception as exc: # noqa: BLE001 — the canary's JOB is to catch any startup failure and report it as a failed gate; narrowing here would let an unexpected exception class crash the runner instead of failing the gate cleanly.
print(f"TUI STARTUP CANARY FAILED ({args.mode}): {type(exc).__name__}: {exc}", file=sys.stderr)
return 1
print("TUI STARTUP VERIFIED:", json.dumps(info))
print(json.dumps({"mode": args.mode, **info}))
return 0
+22
View File
@@ -114,6 +114,18 @@ def highest_warranted_bump(subjects: list[str]) -> str | None:
return None
def is_release_commit(subject: str) -> bool:
"""True when the commit is a release/version bump commit.
ONE SOURCE OF TRUTH consumed by auto-version.yml (push convergence
guard) and by the recursion regression test: a release commit must
NEVER trigger another version bump (recursive-loop defect observed
2026-09-11: five 0.66.2 bumps before the guard landed).
"""
s = subject.strip().lower()
return s.startswith("chore(release)") or s.startswith("chore: release")
def next_version(version: str, kind: str) -> str:
"""Apply a bump kind to a version string (0.x convention: breaking -> minor)."""
major, minor, patch = version.split(".")
@@ -189,8 +201,18 @@ def decision_in_repo(repo: Path | None = None) -> dict[str, Any]:
def main(argv: list[str] | None = None) -> dict[str, Any]:
parser = argparse.ArgumentParser(description="Release convergence decision (JSON on stdout)")
parser.add_argument(
"--is-release-subject",
dest="is_release_subject",
metavar="SUBJECT",
help="exit 0 when SUBJECT is a release bump commit, exit 1 otherwise (loop guard); no JSON",
)
parser.add_argument("--repo", type=Path, default=None, help="repo root (default: cwd)")
args = parser.parse_args(argv)
if args.is_release_subject is not None:
import sys as _sys
_sys.exit(0 if is_release_commit(args.is_release_subject) else 1)
decision = decision_in_repo(args.repo)
print(json.dumps(decision))
return decision
+93
View File
@@ -0,0 +1,93 @@
"""Negative deployment proof (handoff 2026-09-11 Part B): a broken staged
candidate MUST fail the pre-promotion canary and MUST NOT touch current.
Skipped when the local deployment layout (/mnt/workdrive/rune-code) is not
present (e.g. CI runners) — the property it guards is deployment-local."""
import json
import os
import shutil
import subprocess
import sys
import unittest
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
DEPLOY_ROOT = Path("/mnt/workdrive/rune-code")
CANARY = REPO / "scripts" / "tui_startup_canary.py"
VENV_PY = "/home/nick/.venvs/memory/bin/python3"
@unittest.skipUnless(
DEPLOY_ROOT.exists() and (DEPLOY_ROOT / "current").is_symlink() and Path(VENV_PY).exists(),
"local deployment layout not present",
)
class NegativeDeploymentCanary(unittest.TestCase):
def test_broken_candidate_fails_and_current_untouched(self):
# Pick the newest known-good stage as the base for the broken fixture.
stages = sorted(
(
p
for p in DEPLOY_ROOT.iterdir()
if p.is_dir() and (p / "src" / "rune").is_dir() and p.name not in ("current", "last-good")
),
key=lambda p: p.name,
)
self.assertTrue(stages, "no known-good stage to copy")
base = stages[-1]
stage = DEPLOY_ROOT / "negative-test-stage"
if stage.exists():
shutil.rmtree(stage)
shutil.copytree(base, stage)
tui = stage / "src" / "rune" / "rune_tui.py"
tui.write_text(tui.read_text() + "\nraise RuntimeError('deliberately broken candidate')\n")
identity_file = DEPLOY_ROOT / "current" / "src" / "rune" / "_build_identity.json"
before = json.loads(identity_file.read_text())
env = dict(os.environ, RUNE_HOME="/mnt/workdrive/rune", RUNE_MEMORY_DIR="/home/nick/.config/opencode/memory")
r = subprocess.run(
[
sys.executable if Path(sys.executable).exists() else VENV_PY,
str(CANARY),
"--mode",
"candidate",
"--stage",
str(stage),
],
capture_output=True,
text=True,
timeout=180,
cwd=str(REPO),
env=env,
)
self.assertNotEqual(r.returncode, 0, "broken candidate must fail")
self.assertIn("deliberately broken", r.stderr)
after = json.loads(identity_file.read_text())
self.assertEqual(before, after, "current untouched by failed candidate")
shutil.rmtree(stage, ignore_errors=True)
def test_current_mode_reports_deployed_identity(self):
identity_file = DEPLOY_ROOT / "current" / "src" / "rune" / "_build_identity.json"
expected = json.loads(identity_file.read_text())
env = dict(os.environ, RUNE_HOME="/mnt/workdrive/rune", RUNE_MEMORY_DIR="/home/nick/.config/opencode/memory")
r = subprocess.run(
[sys.executable if Path(sys.executable).exists() else VENV_PY, str(CANARY), "--mode", "current"],
capture_output=True,
text=True,
timeout=180,
cwd=str(REPO),
env=env,
)
self.assertEqual(r.returncode, 0, r.stderr[-300:])
report = json.loads(r.stdout.strip().splitlines()[-1])
self.assertEqual(report["mode"], "current")
self.assertEqual(report["commit"], expected["commit"])
self.assertEqual(report["version"], expected["version"])
if __name__ == "__main__":
unittest.main()
+87
View File
@@ -0,0 +1,87 @@
"""Release recursion regression (handoff 2026-09-11 Part C).
The rule: a release bump commit MUST NOT trigger another version bump.
The decision lives in version_convergence (ONE source of truth) — the
workflow guard calls --is-release-subject; these tests exercise the same
function the workflow uses, plus the CLI exit-code contract itself.
"""
import os
import subprocess
import sys
import unittest
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO / "scripts"))
from version_convergence import is_release_commit # noqa: E402
class ReleaseRecursionRule(unittest.TestCase):
def test_release_commit_is_recognized(self):
# The EXACT subject shape the auto-version workflow generates.
self.assertTrue(
is_release_commit(
"chore(release): auto-bump version to 0.66.2 — earned by # "
"(patch: converged release (push-to-master bump))"
)
)
self.assertTrue(is_release_commit("chore(release): auto-bump version to 0.61.0"))
self.assertTrue(is_release_commit("chore: release v0.60.0"))
def test_release_commit_never_warrants_a_bump(self):
# The deeper invariant: classify_subject('other') does not feed
# highest_warranted_bump, so a release commit alone cannot bump.
from version_convergence import classify_subject, highest_warranted_bump
subject = (
"chore(release): auto-bump version to 0.66.2 — earned by # (patch: converged release (push-to-master bump))"
)
self.assertEqual(classify_subject(subject), "other")
self.assertIsNone(highest_warranted_bump([subject]))
self.assertIsNone(highest_warranted_bump([subject] * 5)) # the loop case
def test_substantive_commits_still_bump(self):
from version_convergence import classify_subject, highest_warranted_bump
self.assertEqual(classify_subject("feat(ADAPT-LIVE): route discriminator"), "feat")
self.assertEqual(classify_subject("fix(deploy): prefix-match"), "fix")
self.assertEqual(
highest_warranted_bump(["feat(x): a", "fix(y): b"]),
"minor",
)
def test_cli_exit_contract(self):
# The workflow consumes the CLI, not the Python API — prove the
# exit-code contract the guard depends on.
r = subprocess.run(
[
sys.executable,
str(REPO / "scripts" / "version_convergence.py"),
"--is-release-subject",
"chore(release): auto-bump version to 0.66.2",
],
capture_output=True,
text=True,
timeout=60,
cwd=str(REPO),
)
self.assertEqual(r.returncode, 0, "release subject → guard exits 0 (skip)")
r2 = subprocess.run(
[
sys.executable,
str(REPO / "scripts" / "version_convergence.py"),
"--is-release-subject",
"feat(x): real feature",
],
capture_output=True,
text=True,
timeout=60,
cwd=str(REPO),
)
self.assertEqual(r2.returncode, 1, "feat subject → guard exits 1 (bump)")
if __name__ == "__main__":
unittest.main()