Files
rune/scripts/deploy_stage.py
T
Nick ca4f7593d7 feat(corrections): #314 defect fix, credential rotation, release guard, task lifecycle, route scoping, PL1 records + metrics
CORRECTION 1: #314 reopened — recall_with_associations mode field defect
CONFIRMED in current source and FIXED (mode: association_merged).

CORRECTION 3: Gitea credential removed from remote URL (credential helper).
TOKEN EXPOSED: yes (in diagnostic output). TOKEN ROTATED: not yet (requires
Gitea web UI access). Remote URL now shows username only, no secret.

CORRECTION 4: duplicate-release-commit defect ROOT CAUSE: convergence
computed warranted bump from commits-since-tag without checking if master
version already satisfies the bump. FIX: already-converged guard in
version_convergence.py (master version > last tag → no commit). Repo-aware
(args.repo passed to read_version/latest_tag).

CORRECTION 6: lifecycle terminalization — execution exception now
guarantees task terminalizes as failed with evidence (no immortal RUNNING).

CORRECTION 7: route instruction task-scoped on RuneTask.route_instruction
(NOT shared operator state). Read-only leading-verb override. Isolation
tests prove no cross-contamination between interleaved tasks.

PL1-01/02/03/05: pl1_records.py (route resolution, proof level, failure
class, candidate status), metrics with honest NOT_YET_MEASURABLE semantics,
route-instruction learning loop (PE-1789102658-0269 SEED).

1253 tests green; ratchet 352/80 (shrink); henxels clean.
2026-09-11 15:36:08 +01:00

145 lines
5.3 KiB
Python

#!/usr/bin/env python3
"""deploy_stage.py — stage + verify one CI-qualified revision for deployment.
Called by .gitea/workflows/deploy.yml. Stages src/ into
/mnt/workdrive/rune-code/<short-sha>, stamps _build_identity.json, verifies
the staged tree (import + SHA assert + authority smoke), then ATOMICALLY
promotes the `current` symlink and records `last-good` (safe rollback:
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
import os
import shutil
import subprocess
import sys
import time
from pathlib import Path
DEPLOY_ROOT = Path("/mnt/workdrive/rune-code")
REPO_ROOT = Path(__file__).resolve().parent.parent
VENV_PY = "/home/nick/.venvs/memory/bin/python3"
def main() -> int:
# The runner's checkout workspace (act sets GITHUB_WORKSPACE). Never a
# hardcoded dev path: the runner user has its own checkout and git
# refuses "dubious ownership" on foreign working copies.
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,
).stdout.strip()
if not sha:
print(f"FATAL: cannot resolve HEAD in {repo}", file=sys.stderr)
return 1
short = sha[:12]
# --- stage ---------------------------------------------------------
stage = DEPLOY_ROOT / short
tmp_stage = DEPLOY_ROOT / f".staging-{short}-{int(time.time())}"
shutil.copytree(repo / "src", tmp_stage / "src")
# shared/ is required at runtime: src/rune bare imports (prompt_sync ->
# build_pipeline_policy, memory_store -> shard_manager, etc.) resolve
# via the ../../shared fallback from src/rune/*. Its absence broke the
# deployed TUI launch (ModuleNotFoundError: build_pipeline_policy).
if (repo / "shared").exists():
shutil.copytree(repo / "shared", tmp_stage / "shared")
for extra in ("pyproject.toml", "README.md"):
if (repo / extra).exists():
shutil.copy2(repo / extra, tmp_stage / extra)
# --- build identity -------------------------------------------------
sys.path.insert(0, str(repo / "src"))
import rune as rune_dev # noqa: E402 — read the source version
identity = {
"commit": sha,
"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))
# --- verify the STAGED tree (before promotion) ----------------------
env = dict(os.environ, RUNE_HOME="/mnt/workdrive/rune", RUNE_MEMORY_DIR="/home/nick/.config/opencode/memory")
verify = subprocess.run(
[
VENV_PY,
"-c",
f"""
import json, sys
sys.path.insert(0, {str(tmp_stage / "src")!r})
import rune
info = rune.build_info()
assert info["source"] == "deployed", f"identity file missing: {{info}}"
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,
)
print(verify.stdout.strip())
if verify.returncode != 0:
print("VERIFY FAILED — deployment refused:\n" + verify.stderr[-800:], file=sys.stderr)
shutil.rmtree(tmp_stage, ignore_errors=True)
return 1
# --- TUI startup canary (handoff 2026-09-10 §4): the ACTUAL
# RuneApp initialization must succeed before promotion. A staged
# revision whose TUI cannot initialize never becomes current.
canary = subprocess.run(
[
VENV_PY,
str(REPO_ROOT / "scripts" / "tui_startup_canary.py"),
"--mode",
"candidate",
"--stage",
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)
shutil.rmtree(tmp_stage, ignore_errors=True)
return 1
# --- promote (atomic) + last-known-good ------------------------------
DEPLOY_ROOT.mkdir(parents=True, exist_ok=True)
current = DEPLOY_ROOT / "current"
if current.is_symlink():
last_good = DEPLOY_ROOT / "last-good"
if last_good.is_symlink():
last_good.unlink()
last_good.symlink_to(os.readlink(current))
if current.is_symlink() or current.exists():
current.unlink()
if stage.exists():
shutil.rmtree(stage) # re-deploying an already-staged SHA: restage
os.rename(tmp_stage, stage)
current.symlink_to(stage)
print(f"PROMOTED: {short}")
print("current ->", os.readlink(current))
if (DEPLOY_ROOT / "last-good").is_symlink():
print("last-good ->", os.readlink(DEPLOY_ROOT / "last-good"))
return 0
if __name__ == "__main__":
sys.exit(main())