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.
145 lines
5.3 KiB
Python
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())
|