diff --git a/Makefile b/Makefile index 022a5ed8..de74fd18 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: verify-phase6 verify-enet-integration verify-steam-templates verify-supply-chain verify-kind-agones verify-allocated-compose verify-multiplayer-local verify-multiplayer-load +.PHONY: verify-phase6 verify-enet-integration verify-steam-templates verify-supply-chain verify-kind-agones verify-allocated-compose verify-multiplayer-local verify-multiplayer-load verify-release-gate verify-multiplayer-local: bash scripts/verify_multiplayer_local.sh @@ -6,6 +6,10 @@ verify-multiplayer-local: verify-multiplayer-load: (cd server && go test -tags load ./api -run TestQueueCreateHTTPLoad -count=1) +verify-release-gate: + @test -n "$(RELEASE_REPORT)" || (echo "RELEASE_REPORT=/path/to/report.json is required" >&2; exit 2) + python3 scripts/verify_release_gate.py "$(RELEASE_REPORT)" + verify-phase6: bash scripts/verify_phase6.sh diff --git a/multiplayer-next.md b/multiplayer-next.md index f20379e3..ce303146 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -1252,7 +1252,7 @@ the local/CI/community transport, not a silent production fallback. | 8.50 `[D:8.25,8.37,8.43,8.49]` | Network/chaos suite: 100 ms RTT, jitter/loss, client/API/matcher restart, game-pod death, node drain, Redis failover and control-plane loss | System recovers to a defined state; infrastructure-caused cases cannot penalise affected players | | 8.51 `[D:8.17,8.18,8.30,8.31,8.45]` | **IN PROGRESS.** The opt-in `make verify-multiplayer-load` gate drives 10,000 real HTTP queue-create requests through the service with 256 in flight and records p95/p99; the handler and in-process ownership boundary are exercised without weakening normal tests | Local API load passes at p95 <250 ms in normal and race runs; PostgreSQL saturation, >=100 proposals/s, durable matcher fencing under load, forecast launch concurrency x2, and replica scaling remain live infrastructure gates | | 8.52 `[D:8.32,8.34,8.45,8.51]` | **IN PROGRESS.** Allocator now supports an opt-in, per-replica fixed-window allocation quota per EU/NA region (`--allocation-quota` / `--allocation-quota-window`), checked before any provider call and safe under concurrent attempts | Normal/race/vet tests cover quota exhaustion, window reset, region isolation, invalid input, and atomic concurrent consumption; measured regional cost model, shared/global quota, budget alerts, and denial-of-wallet production rehearsal remain | -| 8.53 `[D:7.8,8.13,8.38,8.45,8.46,8.48,8.49,8.50,8.51,8.52]` | Progressive release: development → internal → casual canary → casual → provisional ranked → ranked | Each promotion requires SLO/security/cost gates, rollback rehearsal, EU+NA playtests and unchanged legacy gates; rollback criteria and owner are explicit | +| 8.53 `[D:7.8,8.13,8.38,8.45,8.46,8.48,8.49,8.50,8.51,8.52]` | **IN PROGRESS.** `scripts/verify_release_gate.py` provides a fail-closed promotion check for the ordered development → internal → casual canary → casual → provisional ranked → ranked stages, requiring an evidence report for SLO, security, cost, rollback, EU+NA playtests, and both legacy gates | Validator and adversarial tests cover skipped stages, unknown stages, missing gates, non-boolean gate values, and blank release IDs; the actual reports, production rollback rehearsal, regional playtests, and live promotion remain open | Implementation invariants for every task above: diff --git a/scripts/test_verify_release_gate.py b/scripts/test_verify_release_gate.py new file mode 100644 index 00000000..47f408c2 --- /dev/null +++ b/scripts/test_verify_release_gate.py @@ -0,0 +1,56 @@ +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) + +from verify_release_gate import validate_release_report + + +def valid_report(): + return { + "release_id": "release-2026-09-01-001", + "from_stage": "development", + "to_stage": "internal", + "slo_passed": True, + "security_passed": True, + "cost_passed": True, + "rollback_rehearsed": True, + "playtests": {"eu_passed": True, "na_passed": True}, + "legacy": {"phase6_passed": True, "enet_passed": True}, + } + + +class ReleaseGateTest(unittest.TestCase): + def test_accepts_one_complete_promotion(self): + self.assertEqual(validate_release_report(valid_report()), ("development", "internal")) + + def test_rejects_skipped_stage(self): + report = valid_report() + report["to_stage"] = "casual" + with self.assertRaises(ValueError): + validate_release_report(report) + + def test_rejects_missing_or_false_gate(self): + report = valid_report() + del report["playtests"]["na_passed"] + with self.assertRaises(ValueError): + validate_release_report(report) + report = valid_report() + report["cost_passed"] = 1 + with self.assertRaises(ValueError): + validate_release_report(report) + + def test_rejects_unknown_stage_and_blank_release(self): + report = valid_report() + report["release_id"] = " " + with self.assertRaises(ValueError): + validate_release_report(report) + report = valid_report() + report["from_stage"] = "experimental" + with self.assertRaises(ValueError): + validate_release_report(report) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/verify_release_gate.py b/scripts/verify_release_gate.py new file mode 100644 index 00000000..1faf251d --- /dev/null +++ b/scripts/verify_release_gate.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +"""Fail-closed validation for a multiplayer promotion evidence report.""" + +import json +import sys +from typing import Any + + +STAGES = ("development", "internal", "casual-canary", "casual", "provisional-ranked", "ranked") + + +def _required_true(report: dict[str, Any], key: str) -> None: + value: Any = report + for part in key.split("."): + if not isinstance(value, dict) or part not in value: + raise ValueError(f"missing gate: {key}") + value = value[part] + if value is not True: + raise ValueError(f"gate did not pass: {key}") + + +def validate_release_report(report: dict[str, Any]) -> tuple[str, str]: + if not isinstance(report, dict): + raise ValueError("release report must be an object") + source = report.get("from_stage") + target = report.get("to_stage") + if source not in STAGES or target not in STAGES: + raise ValueError("from_stage and to_stage must be known release stages") + if STAGES.index(target) != STAGES.index(source) + 1: + raise ValueError(f"promotion must advance exactly one stage: {source!r} -> {target!r}") + if not isinstance(report.get("release_id"), str) or not report["release_id"].strip(): + raise ValueError("release_id is required") + for gate in ( + "slo_passed", "security_passed", "cost_passed", "rollback_rehearsed", + "playtests.eu_passed", "playtests.na_passed", "legacy.phase6_passed", + "legacy.enet_passed", + ): + _required_true(report, gate) + return source, target + + +def main() -> int: + if len(sys.argv) != 2: + print(f"usage: {sys.argv[0]} report.json", file=sys.stderr) + return 2 + try: + with open(sys.argv[1], encoding="utf-8") as handle: + source, target = validate_release_report(json.load(handle)) + except (OSError, ValueError, json.JSONDecodeError) as error: + print(f"release gate failed: {error}", file=sys.stderr) + return 1 + print(f"release gate passed: {source} -> {target}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())