Files
rune/scripts/deploy_promotion.py
T
Nick b5c9a3c04e
CI Pipeline / best-practices (push) Successful in 11s
CI Pipeline / canary (push) Successful in 35s
CI Pipeline / lint (push) Successful in 47s
CI Pipeline / documentation (push) Successful in 5s
Auto-version and Release / release (push) Successful in 1m7s
CI Pipeline / gitea-offline (push) Successful in 16s
CI Pipeline / type-check (push) Successful in 32s
CI Pipeline / unit-tests (push) Successful in 1m51s
CI Pipeline / required-checks (push) Successful in 2s
deploy / deploy (push) Failing after 4m35s
feat(release): in-critical-section convergence hook — the WINNER refreshes release history atomically with its promotion (SCAFFOLD-TRANCHE-0012 §12C)
§12C design insight from the 0.66.51 deploy (run 7721): the workflow-step
refresh fired (steps.promotion_truth gate worked) but the authority
readback disagreed — an overlapping deploy had superseded the candidate
between promotion-check and refresh-step. Correct refusal, but the WINNER
never converges because its own refresh can hit the same race.

DEPLOY_PROMOTION.PROMOTE now accepts on_promoted: invoked INSIDE the flock
critical section immediately after the symlink swap — only the promotion
WINNER converges, atomically with the swap. Failure degrades to STALE
(software valid, history lags, retry converges). deploy_stage passes a
convergence hook that builds evidence-derived notes and publishes to Gitea.

Tests: 4 new (hook fires on promotion, NOT fired for stale, failure
degrades not crashes, A→B race winner-converges-inside-lock); suite 1687
green; race tests all green.
2026-09-14 06:26:24 +01:00

225 lines
9.9 KiB
Python

#!/usr/bin/env python3
"""deploy_promotion.py — monotonic promotion authority for Rune deploys.
SCAFFOLD contract DEPLOY-MONOTONIC-001. Root cause of the 2026-09-13
deploy-order race (deploys 7355/7359): deploy_stage.py promoted
unconditionally in job-finish order — no comparison against the currently
deployed commit, no lock, no truthful outcome for a stale candidate.
This module owns the PROMOTION decision. The invariant:
current never regresses along the canonical release branch.
Ordering authority: GIT ANCESTRY on the canonical release branch
(refs/heads/master). Rune's release flow is linear history — every deploy
candidate is a master push (tranche commit or its auto-bump descendant).
candidate == deployed commit -> NOOP_ALREADY_CURRENT (idempotent)
candidate is DESCENDANT of it -> allowed (normal forward deploy)
candidate is ANCESTOR of it -> STALE_SUPERSEDED (refuse; truthful)
no ancestry relationship -> REFUSED_DIVERGENT (fail closed)
deployed commit unknown/unreadable-> REFUSED_UNKNOWN_CURRENT (fail closed)
Mechanism (smallest that provides the invariant, per brief §4-5):
1. promotion-time stale check — ancestry comparison against the commit
current ACTUALLY points to at promotion time (not at job start);
2. an flock'd critical section around the read-compare-write of
current/last-good so two concurrent promotions cannot interleave
between the ancestry check and the symlink swap.
Workflow-level concurrency is NOT relied on (brief §5): the fence lives at
the promotion operation itself.
Truthful outcomes (brief §8) — build/stage success != promotion success:
PROMOTED candidate became current (verified below)
NOOP_ALREADY_CURRENT same commit already deployed; nothing mutated
STALE_SUPERSEDED a newer commit is already current; nothing mutated
REFUSED_DIVERGENT ordering unprovable; fail closed
REFUSED_UNKNOWN_CURRENT deployed commit unreadable; fail closed
FAILED_* verification/staging failures (in deploy_stage)
A stale or refused outcome is a NON-ERROR terminal state for the release
pipeline (the newer build simply won) but MUST NOT print "PROMOTED" and
MUST NOT mutate current/last-good. deploy_stage's exit code and the
workflow's post-deploy runtime verification enforce this: the workflow
asserts current == candidate after a PROMOTED outcome, so a stale outcome
makes the job report honestly rather than masquerade.
"""
from __future__ import annotations
import fcntl
import json
import os
import subprocess
from pathlib import Path
DEPLOY_ROOT = Path("/mnt/workdrive/rune-code")
RELEASE_BRANCH = "refs/heads/master"
OUTCOME_PROMOTED = "PROMOTED"
OUTCOME_NOOP = "NOOP_ALREADY_CURRENT"
OUTCOME_STALE = "STALE_SUPERSEDED"
OUTCOME_DIVERGENT = "REFUSED_DIVERGENT"
OUTCOME_UNKNOWN_CURRENT = "REFUSED_UNKNOWN_CURRENT"
# ── pure decision logic (unit-tested; no I/O) ──────────────────────────────
def decide_promotion(candidate_sha: str, deployed_sha: str, ancestry: str) -> str:
"""Pure promotion decision from a precomputed ancestry relationship.
ancestry is one of: 'same' | 'descendant' | 'ancestor' | 'divergent' |
'unknown'. Fail-closed: anything not provably allowed is refused."""
if candidate_sha == deployed_sha or ancestry == "same":
return OUTCOME_NOOP
if ancestry == "descendant":
return OUTCOME_PROMOTED
if ancestry == "ancestor":
return OUTCOME_STALE
if ancestry == "divergent":
return OUTCOME_DIVERGENT
return OUTCOME_UNKNOWN_CURRENT
def ancestry_of(candidate_sha: str, deployed_sha: str, repo: Path, fetch_on_divergent: bool = True) -> str:
"""Classify candidate vs deployed using git merge-base --is-ancestor on
the canonical release branch's repository. Returns 'unknown' on any git
failure (fail-closed callers treat that as refusal).
Observed live 2026-09-13 (deploy 7449, SHA 535c2909): a DELAYED older
candidate's checkout cannot contain commits newer than itself — its
local object store lacks the deployed commit entirely, so ancestry
classifies 'divergent' and fail-closed refuses. Correct refusal, wrong
reason: the candidate IS an ancestor. When the local classification is
divergent, fetch origin's canonical branch first (best-effort; the
deploy workflow's checkout has credentials configured) and re-classify.
If still divergent afterwards, the refusal stands with better
evidence."""
def _is_ancestor(a: str, b: str) -> bool:
r = subprocess.run(
["git", "-C", str(repo), "-c", f"safe.directory={repo}", "merge-base", "--is-ancestor", a, b],
capture_output=True,
text=True,
)
return r.returncode == 0
if candidate_sha == deployed_sha:
return "same"
if _is_ancestor(candidate_sha, deployed_sha):
return "ancestor" # candidate is OLDER than what's deployed
if _is_ancestor(deployed_sha, candidate_sha):
return "descendant" # candidate is NEWER (normal forward deploy)
if fetch_on_divergent:
try:
subprocess.run(
["git", "-C", str(repo), "-c", f"safe.directory={repo}", "fetch", "origin", "master", "--quiet"],
capture_output=True,
text=True,
timeout=60,
)
except (OSError, subprocess.TimeoutExpired):
pass # best-effort: classification below still fails closed
if _is_ancestor(candidate_sha, deployed_sha):
return "ancestor"
if _is_ancestor(deployed_sha, candidate_sha):
return "descendant"
return "divergent"
def read_deployed_commit(deploy_root: Path = DEPLOY_ROOT) -> str:
"""Read the commit the `current` symlink ACTUALLY points to right now —
from the deployed build identity, the deployment authority (never from
a stale job-start artifact). Empty string if unreadable."""
current = deploy_root / "current"
try:
target = Path(os.readlink(current))
except OSError:
return ""
identity = target / "src" / "rune" / "_build_identity.json"
try:
data = json.loads(identity.read_text(encoding="utf-8"))
return str(data.get("commit", ""))
except (OSError, ValueError):
return ""
# ── the fenced promotion operation ──────────────────────────────────────────
def promote(
candidate_sha: str,
stage: Path,
tmp_stage: Path,
repo: Path,
deploy_root: Path = DEPLOY_ROOT,
on_promoted=None,
) -> tuple[str, str]:
"""Atomically decide + promote under an exclusive lock.
Returns (outcome, detail). ONLY the PROMOTED outcome mutates
current/last-good, and it does so inside the same critical section as
the decision — no interleaving window between check and swap.
Crash-safety ordering (brief §16): the stage rename happens BEFORE the
lock (a staged-but-unpromoted tree is inert), the symlink swap is a
single rename(2) via a temporary link, so current is never absent and
never half-updated. last-good is refreshed BEFORE the swap only from a
value that was genuinely current a moment ago.
"""
deploy_root.mkdir(parents=True, exist_ok=True)
lock_path = deploy_root / ".promotion.lock"
with open(lock_path, "w") as lock_fd:
fcntl.flock(lock_fd, fcntl.LOCK_EX)
try:
deployed = read_deployed_commit(deploy_root)
if not deployed:
return (OUTCOME_UNKNOWN_CURRENT, "deployed commit unreadable — fail closed")
rel = ancestry_of(candidate_sha, deployed, repo)
outcome = decide_promotion(candidate_sha, deployed, rel)
if outcome != OUTCOME_PROMOTED:
return (outcome, f"deployed={deployed[:12]} ancestry={rel}")
# --- mutate (still inside the lock) ---
current = deploy_root / "current"
last_good = deploy_root / "last-good"
prev_target = os.readlink(current) if current.is_symlink() else ""
if prev_target and (deploy_root / "last-good").is_symlink():
(deploy_root / "last-good").unlink()
if prev_target:
_atomic_symlink(deploy_root, "last-good", prev_target)
if stage.exists():
# re-deploying an already-staged SHA: restage over it
import shutil
shutil.rmtree(stage)
os.rename(tmp_stage, stage)
_atomic_symlink_target(deploy_root, "current", stage)
detail = f"current -> {stage.name}"
if on_promoted is not None:
# §12C: race-safe convergence — the WINNER refreshes release
# history atomically with its promotion, inside the lock.
# Failure degrades to STALE (software valid, history lags;
# retry by any later deploy converges idempotently).
try:
on_promoted(candidate_sha)
detail += " (+ release-history converged)"
except Exception as exc: # noqa: BLE001
detail += f" (release-history refresh FAILED: {exc!s:.120} — STALE, retry converges)"
return (OUTCOME_PROMOTED, detail)
finally:
fcntl.flock(lock_fd, fcntl.LOCK_UN)
def _atomic_symlink(deploy_root: Path, name: str, target: str) -> None:
"""Symlink swap that never leaves `name` absent: write temp link, rename
over the final name (rename(2) is atomic on the same filesystem)."""
_atomic_symlink_target(deploy_root, name, Path(target))
def _atomic_symlink_target(deploy_root: Path, name: str, target: Path) -> None:
tmp = deploy_root / f".{name}.swap-{os.getpid()}"
if tmp.is_symlink() or tmp.exists():
tmp.unlink()
tmp.symlink_to(target)
os.replace(tmp, deploy_root / name)