feat: enforce stable-control vs dev runtime modes for Gitea MCP (Closes #615)
The stable-control runtime ADR (docs/architecture/mcp-stable-control-runtime-policy-adr.md)
established the policy but had no runtime enforcement: a daemon relaunched from a
feature worktree still holds production credentials and will happily mutate real
issues. This adds the enforcement layer (acceptance criteria 6-11).
stable_control_runtime.py:
- classify_runtime_mode(): stable-control | dev-test | unknown, inferred from the
process root and checkout branch, with an explicit GITEA_MCP_RUNTIME_MODE
declaration for packaged layouts that have no git checkout.
- build_runtime_report(): runtime mode, git SHA, branch, checkout path, process
root, active workspace, repo binding, profile, identity, dirty files,
alignment, and real_mutations_allowed.
- assess_runtime_mutation_gate(): fails closed on dev-test targeting production,
unknown runtime, dirty stable checkout, dev-worktree launch, and unsafe
process-root/workspace alignment.
- Post-transport-flap re-proving tracked per namespace, so proving the author
namespace never implies reviewer, merger, or reconciler (#584).
- assess_promotion_record(): promotion must record previous and promoted SHAs
plus health, identity, profile, workspace, capability, and rollback proof.
Server wiring:
- _profile_operation_gate() consults the runtime gate alongside the #420 parity
gate. gitea.read is never blocked, so an operator can still diagnose a sick
runtime.
- The gate reads a startup snapshot rather than shelling out per mutation, for
the same reason parity uses a startup baseline: the runtime a process serves
from is fixed when it loads its code. Enforcement is decided from how the
process was loaded, so per-test production simulation cannot switch it on.
- gitea_get_runtime_context() reports the live runtime under
stable_control_runtime and points at the promotion runbook when blocked.
Docs and tooling:
- docs/stable-runtime-promotion-runbook.md: operator promotion procedure,
required record fields, per-namespace re-proving, rollback.
- scripts/promote-stable-runtime: read-only helper that emits and validates a
promotion record; it never restarts anything.
- Five canonical [THREAD STATE LEDGER] examples: runtime healthy, transport flap
recovered, namespace not yet re-proven, promotion completed, rollback required.
- ADR section 5 follow-ups marked landed; runbooks cross-link the new runbook.
Validation: 47 new tests in tests/test_stable_control_runtime.py. Full suite
3827 passed / 6 skipped / 2 failed; both failures are pre-existing on master
059ee77 (verified in a clean baseline worktree: identical 2 failures,
3780 passed).
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Executable
+171
@@ -0,0 +1,171 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
usage: scripts/promote-stable-runtime [--root <path>] [--promoted <sha>] \
|
||||
[--source-branch <branch>] [--source-pr <n>] \
|
||||
[--restart-method <text>] [--rollback <text>] \
|
||||
[--health-check-proof <text>] \
|
||||
[--identity-proof <text>] [--profile-proof <text>] \
|
||||
[--workspace-proof <text>] \
|
||||
[--mutation-capability-proof <text>]
|
||||
|
||||
Emit and validate a stable-control-runtime promotion record (#615).
|
||||
|
||||
This helper is READ-ONLY. It never fetches, merges, restarts, reloads, or kills
|
||||
anything: promotion itself is an operator action documented in
|
||||
docs/stable-runtime-promotion-runbook.md. The helper reads the current runtime
|
||||
state, assembles the required record, validates it with
|
||||
stable_control_runtime.assess_promotion_record(), and prints it for the operator
|
||||
to act on and archive.
|
||||
|
||||
Defaults:
|
||||
--root the repository root containing this script
|
||||
--promoted HEAD of that root
|
||||
|
||||
Exit status is non-zero when the assembled record is incomplete, so a promotion
|
||||
cannot be recorded without its proof fields.
|
||||
|
||||
Example:
|
||||
scripts/promote-stable-runtime \
|
||||
--source-branch feat/issue-615-runtime-mode-enforcement \
|
||||
--source-pr 770 \
|
||||
--restart-method "IDE client reconnect (/mcp)" \
|
||||
--rollback "git -C <root> merge --ff-only <previous-sha>; reconnect client" \
|
||||
--health-check-proof "gitea_assess_mcp_namespace_health: all four healthy" \
|
||||
--identity-proof "gitea_whoami per namespace" \
|
||||
--profile-proof "gitea_get_runtime_context per namespace" \
|
||||
--workspace-proof "process root == canonical root; clean" \
|
||||
--mutation-capability-proof "gitea_resolve_task_capability: allowed"
|
||||
EOF
|
||||
}
|
||||
|
||||
ROOT=""
|
||||
PROMOTED=""
|
||||
SOURCE_BRANCH=""
|
||||
SOURCE_PR=""
|
||||
RESTART_METHOD=""
|
||||
ROLLBACK=""
|
||||
HEALTH_PROOF=""
|
||||
IDENTITY_PROOF=""
|
||||
PROFILE_PROOF=""
|
||||
WORKSPACE_PROOF=""
|
||||
CAPABILITY_PROOF=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--root) ROOT="${2:-}"; shift 2 ;;
|
||||
--promoted) PROMOTED="${2:-}"; shift 2 ;;
|
||||
--source-branch) SOURCE_BRANCH="${2:-}"; shift 2 ;;
|
||||
--source-pr) SOURCE_PR="${2:-}"; shift 2 ;;
|
||||
--restart-method) RESTART_METHOD="${2:-}"; shift 2 ;;
|
||||
--rollback) ROLLBACK="${2:-}"; shift 2 ;;
|
||||
--health-check-proof) HEALTH_PROOF="${2:-}"; shift 2 ;;
|
||||
--identity-proof) IDENTITY_PROOF="${2:-}"; shift 2 ;;
|
||||
--profile-proof) PROFILE_PROOF="${2:-}"; shift 2 ;;
|
||||
--workspace-proof) WORKSPACE_PROOF="${2:-}"; shift 2 ;;
|
||||
--mutation-capability-proof) CAPABILITY_PROOF="${2:-}"; shift 2 ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) echo "unknown argument: $1" >&2; usage; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT="${ROOT:-$(cd "$SCRIPT_DIR/.." && pwd)}"
|
||||
|
||||
if ! git -C "$ROOT" rev-parse --show-toplevel >/dev/null 2>&1; then
|
||||
echo "error: '$ROOT' is not a git checkout; cannot read runtime SHAs" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PREVIOUS="${GITEA_MCP_PREVIOUS_RUNTIME_SHA:-}"
|
||||
if [[ -z "$PREVIOUS" ]]; then
|
||||
# The runtime the operator is replacing. Best-effort: the commit master
|
||||
# pointed at before the fast-forward, recorded in the reflog.
|
||||
PREVIOUS="$(git -C "$ROOT" rev-parse 'master@{1}' 2>/dev/null || true)"
|
||||
fi
|
||||
PROMOTED="${PROMOTED:-$(git -C "$ROOT" rev-parse HEAD)}"
|
||||
BRANCH="$(git -C "$ROOT" rev-parse --abbrev-ref HEAD)"
|
||||
DIRTY="$(git -C "$ROOT" status --porcelain | wc -l | tr -d ' ')"
|
||||
STAMP="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
|
||||
if [[ -z "$WORKSPACE_PROOF" ]]; then
|
||||
WORKSPACE_PROOF="root=$ROOT branch=$BRANCH dirty_files=$DIRTY"
|
||||
fi
|
||||
if [[ -z "$ROLLBACK" && -n "$PREVIOUS" ]]; then
|
||||
ROLLBACK="git -C $ROOT merge --ff-only $PREVIOUS (or checkout $PREVIOUS), then reload the runtime by the same sanctioned method and re-prove every namespace"
|
||||
fi
|
||||
|
||||
cat <<EOF
|
||||
# Stable control runtime promotion record (#615)
|
||||
# Generated $STAMP by scripts/promote-stable-runtime (read-only)
|
||||
|
||||
previous_runtime_sha: ${PREVIOUS:-<MISSING: record the SHA the runtime served before promotion>}
|
||||
promoted_runtime_sha: ${PROMOTED}
|
||||
source_branch: ${SOURCE_BRANCH:-<MISSING: pass --source-branch>}
|
||||
source_pr: ${SOURCE_PR:-<MISSING: pass --source-pr>}
|
||||
restart_method: ${RESTART_METHOD:-<MISSING: pass --restart-method>}
|
||||
health_check_proof: ${HEALTH_PROOF:-<MISSING: pass --health-check-proof>}
|
||||
identity_proof: ${IDENTITY_PROOF:-<MISSING: pass --identity-proof>}
|
||||
profile_proof: ${PROFILE_PROOF:-<MISSING: pass --profile-proof>}
|
||||
workspace_proof: ${WORKSPACE_PROOF}
|
||||
mutation_capability_proof: ${CAPABILITY_PROOF:-<MISSING: pass --mutation-capability-proof>}
|
||||
rollback_instructions: ${ROLLBACK:-<MISSING: pass --rollback>}
|
||||
EOF
|
||||
|
||||
if [[ "$DIRTY" != "0" ]]; then
|
||||
{
|
||||
echo
|
||||
echo "WARNING: the stable checkout has $DIRTY dirty file(s); a dirty stable"
|
||||
echo " runtime is itself a mutation blocker (dirty_stable_runtime_checkout)."
|
||||
} >&2
|
||||
fi
|
||||
|
||||
PYTHON_BIN="${PYTHON_BIN:-python3}"
|
||||
RECORD_JSON="$(
|
||||
ROOT="$ROOT" \
|
||||
PREVIOUS="$PREVIOUS" PROMOTED="$PROMOTED" \
|
||||
SOURCE_BRANCH="$SOURCE_BRANCH" SOURCE_PR="$SOURCE_PR" \
|
||||
RESTART_METHOD="$RESTART_METHOD" HEALTH_PROOF="$HEALTH_PROOF" \
|
||||
IDENTITY_PROOF="$IDENTITY_PROOF" PROFILE_PROOF="$PROFILE_PROOF" \
|
||||
WORKSPACE_PROOF="$WORKSPACE_PROOF" CAPABILITY_PROOF="$CAPABILITY_PROOF" \
|
||||
ROLLBACK="$ROLLBACK" \
|
||||
"$PYTHON_BIN" -c '
|
||||
import json
|
||||
import os
|
||||
|
||||
print(json.dumps({
|
||||
"previous_runtime_sha": os.environ.get("PREVIOUS", ""),
|
||||
"promoted_runtime_sha": os.environ.get("PROMOTED", ""),
|
||||
"source_branch": os.environ.get("SOURCE_BRANCH", ""),
|
||||
"source_pr": os.environ.get("SOURCE_PR", ""),
|
||||
"restart_method": os.environ.get("RESTART_METHOD", ""),
|
||||
"health_check_proof": os.environ.get("HEALTH_PROOF", ""),
|
||||
"identity_proof": os.environ.get("IDENTITY_PROOF", ""),
|
||||
"profile_proof": os.environ.get("PROFILE_PROOF", ""),
|
||||
"workspace_proof": os.environ.get("WORKSPACE_PROOF", ""),
|
||||
"mutation_capability_proof": os.environ.get("CAPABILITY_PROOF", ""),
|
||||
"rollback_instructions": os.environ.get("ROLLBACK", ""),
|
||||
}))
|
||||
'
|
||||
)"
|
||||
|
||||
RECORD_JSON="$RECORD_JSON" ROOT="$ROOT" "$PYTHON_BIN" -c '
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.environ["ROOT"])
|
||||
import stable_control_runtime as scr
|
||||
|
||||
record = json.loads(os.environ["RECORD_JSON"])
|
||||
assessment = scr.assess_promotion_record(record)
|
||||
print()
|
||||
print("validation:", json.dumps(assessment, indent=2))
|
||||
print()
|
||||
if not assessment["valid"]:
|
||||
print("Promotion record is INCOMPLETE - do not archive it as a promotion.")
|
||||
sys.exit(1)
|
||||
print("Promotion record is complete. Archive it on the tracking issue.")
|
||||
'
|
||||
Reference in New Issue
Block a user