vignette/scripts/check-outcome-os-release-manifest.py
Yun Chan 16e791e044 G0~G8 성과·동맹 측정 OS 작업 일괄 고정
8월 7일까지 워킹트리에만 남아 있던 미커밋 작업을 커밋한다. 여러 사본
폴더(worktree·clone)에 흩어져 있던 중간 스냅샷을 정리하기 전에 원본을
git 이력으로 고정하는 것이 목적이다.

- contracts/routes/services: measurement, outcome_trajectory, rupture_repair,
  deliberate_practice, calibration_transfer, supervision_research,
  multimodal_alliance, continuous_improvement 계열 신규 모듈과 테스트
- infra/db/init: 07~16 마이그레이션(측정 기반~calibration transfer 실행)
- apps/web: 세션 리뷰 카드·관리 화면·E2E 스펙 추가
- docs/ops: G0~G8 라이브 통합·배포·롤백 증거 문서와 evidence JSON/PNG
- scripts: smoke·ledger·릴리스 에이전트·NAS 프리뷰 운영 스크립트

engine.public 로그 .bak과 apps/web/test-results 산출물은 커밋에서 제외했다.
2026-08-08 01:30:53 +09:00

502 lines
20 KiB
Python

#!/usr/bin/env python3
"""Fail-closed validator for the Outcome/Alliance OS clean-release manifest."""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import subprocess
import sys
import tempfile
from collections import Counter
from pathlib import Path, PurePosixPath
from typing import Any
REPO_ROOT = Path(__file__).resolve().parents[1]
DEFAULT_MANIFEST = (
REPO_ROOT / "docs/ops/outcome-os-clean-release-manifest-2026-08-07.json"
)
EXPECTED_GOALS = {f"G{number}" for number in range(9)}
ALLOWED_RELATED_GOALS = EXPECTED_GOALS | {"release-governance"}
EXPECTED_DISPOSITIONS = {
"related": "candidate-whole-file",
"mixed": "hunk-review-required",
"excluded": "exclude-from-outcome-release",
}
G7_GATE_ID = "g7-external-proof"
G7_ALLOWED_STATUSES = {"blocked", "satisfied-by-validated-g7-proof"}
REQUIRED_SATISFIED_GATES = {
"mixed-files-cleared": "satisfied-by-verified-release-patch",
"g8-agentic-runtime-promotion": "satisfied-by-isolated-nas-release",
"g8-public-proof": "satisfied-by-public-release",
"aos-011-public-deployment-proof": "satisfied-by-public-release",
"aos-012-final-ssot-sync": "satisfied-by-final-checkers",
}
def count_patch_files(patch_bytes: bytes) -> int:
"""Count canonical git file sections without trusting manifest metadata."""
return sum(
1 for line in patch_bytes.splitlines() if line.startswith(b"diff --git ")
)
def validate_satisfied_gate_evidence(
gate_map: dict[str, dict[str, Any]],
assembly: dict[str, Any],
errors: list[str],
) -> None:
"""Bind a satisfied release gate to the exact verified patch artifact."""
gate = gate_map.get("mixed-files-cleared")
if not isinstance(gate, dict):
return
evidence = gate.get("evidence")
if not isinstance(evidence, str) or not evidence.strip():
errors.append("release gate mixed-files-cleared must record patch evidence")
return
patch_sha256 = assembly.get("patch_sha256")
if not isinstance(patch_sha256, str) or len(patch_sha256) != 64:
errors.append("release_assembly.patch_sha256 must be a SHA-256 hex digest")
return
if patch_sha256.lower() not in evidence.lower():
errors.append(
"release gate mixed-files-cleared evidence does not reference "
"the current release_assembly.patch_sha256"
)
def verify_patch_against_base(
patch_path: Path,
base_commit: str,
errors: list[str],
) -> None:
head = subprocess.run(
["git", "rev-parse", "HEAD"],
cwd=REPO_ROOT,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=False,
)
current_head = head.stdout.decode("ascii", errors="replace").strip()
if head.returncode != 0:
errors.append("cannot resolve current HEAD for release base guard")
return
if current_head != base_commit:
errors.append(
f"release base HEAD drift: expected {base_commit}, current {current_head}"
)
with tempfile.TemporaryDirectory(prefix="vignette-release-index-check-") as temp:
index_env = os.environ.copy()
index_env["GIT_INDEX_FILE"] = str(Path(temp) / "canonical.index")
read_tree = subprocess.run(
["git", "read-tree", base_commit],
cwd=REPO_ROOT,
env=index_env,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=False,
)
if read_tree.returncode != 0:
detail = read_tree.stderr.decode("utf-8", errors="replace").strip()
errors.append(f"cannot materialize canonical release index: {detail}")
return
apply_check = subprocess.run(
[
"git",
"apply",
"--cached",
"--check",
"--whitespace=nowarn",
str(patch_path),
],
cwd=REPO_ROOT,
env=index_env,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=False,
)
if apply_check.returncode != 0:
detail = apply_check.stderr.decode("utf-8", errors="replace").strip()
errors.append(
"release patch does not apply to canonical base index: " + detail[:1000]
)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--manifest",
type=Path,
default=DEFAULT_MANIFEST,
help="manifest path (default: repository canonical manifest)",
)
parser.add_argument(
"--json", action="store_true", help="emit machine-readable result"
)
return parser.parse_args()
def load_manifest(path: Path, errors: list[str]) -> dict[str, Any]:
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except FileNotFoundError:
errors.append(f"manifest missing: {path}")
return {}
except (OSError, json.JSONDecodeError) as exc:
errors.append(f"manifest unreadable: {exc}")
return {}
if not isinstance(payload, dict):
errors.append("manifest root must be an object")
return {}
return payload
def git_dirty_paths(errors: list[str]) -> dict[str, str]:
result = subprocess.run(
["git", "status", "--porcelain=v1", "-z", "--untracked-files=all"],
cwd=REPO_ROOT,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=False,
)
if result.returncode != 0:
detail = result.stderr.decode("utf-8", errors="replace").strip()
errors.append(f"git status failed ({result.returncode}): {detail}")
return {}
records = result.stdout.split(b"\0")
paths: dict[str, str] = {}
index = 0
while index < len(records):
record = records[index]
index += 1
if not record:
continue
decoded = record.decode("utf-8", errors="surrogateescape")
if len(decoded) < 4 or decoded[2] != " ":
errors.append(f"unexpected git porcelain record: {decoded!r}")
continue
status = decoded[:2]
path = decoded[3:].replace("\\", "/")
if status[0] in {"R", "C"} or status[1] in {"R", "C"}:
if index >= len(records) or not records[index]:
errors.append(f"rename/copy record has no source path: {decoded!r}")
continue
index += 1
paths[path] = "untracked" if status == "??" else "tracked-change"
return paths
def valid_repo_path(value: Any) -> bool:
if not isinstance(value, str) or not value or "\\" in value:
return False
candidate = PurePosixPath(value)
return (
not candidate.is_absolute()
and ".." not in candidate.parts
and str(candidate) == value
)
def flatten_classifications(
manifest: dict[str, Any], errors: list[str]
) -> tuple[dict[str, str], Counter[str], dict[str, list[str]]]:
classifications = manifest.get("classifications")
if not isinstance(classifications, dict):
errors.append("classifications must be an object")
return {}, Counter(), {}
declared: dict[str, str] = {}
counts: Counter[str] = Counter()
goals_to_paths: dict[str, list[str]] = {goal: [] for goal in EXPECTED_GOALS}
for classification in ("related", "mixed", "excluded"):
entries = classifications.get(classification)
if not isinstance(entries, list):
errors.append(f"classifications.{classification} must be an array")
continue
if classification == "mixed" and not entries:
errors.append(
"mixed classification must be non-empty in this dirty worktree"
)
for entry_index, entry in enumerate(entries):
label = f"classifications.{classification}[{entry_index}]"
if not isinstance(entry, dict):
errors.append(f"{label} must be an object")
continue
if entry.get("disposition") != EXPECTED_DISPOSITIONS[classification]:
errors.append(
f"{label}.disposition must be "
f"{EXPECTED_DISPOSITIONS[classification]!r}"
)
rationale = entry.get("rationale")
if not isinstance(rationale, str) or not rationale.strip():
errors.append(f"{label}.rationale must be non-empty")
goals = entry.get("goals", [])
if not isinstance(goals, list) or any(
not isinstance(goal, str) for goal in goals
):
errors.append(f"{label}.goals must be an array of strings")
goals = []
goal_set = set(goals)
if classification in {"related", "mixed"} and not goal_set:
errors.append(
f"{label}.goals must identify G0-G8 or release-governance"
)
if classification == "excluded" and goal_set:
errors.append(f"{label}.goals must be empty for unrelated work")
unknown_goals = goal_set - ALLOWED_RELATED_GOALS
if unknown_goals:
errors.append(
f"{label}.goals contains unknown values: {sorted(unknown_goals)}"
)
if classification == "mixed":
if entry.get("release_eligible") is not False:
errors.append(f"{label}.release_eligible must be false")
required_action = entry.get("required_action")
if not isinstance(required_action, str) or not required_action.strip():
errors.append(f"{label}.required_action must be non-empty")
paths_value = entry.get("paths")
if not isinstance(paths_value, list) or not paths_value:
errors.append(f"{label}.paths must be a non-empty array")
continue
for path_index, path in enumerate(paths_value):
if not valid_repo_path(path):
errors.append(
f"{label}.paths[{path_index}] is not a canonical repo path: {path!r}"
)
continue
if path in declared:
errors.append(
f"duplicate path {path!r}: {declared[path]} and {classification}"
)
continue
declared[path] = classification
counts[classification] += 1
for goal in goal_set & EXPECTED_GOALS:
goals_to_paths[goal].append(path)
return declared, counts, goals_to_paths
def validate_manifest(manifest: dict[str, Any], errors: list[str]) -> dict[str, Any]:
if manifest.get("schema_version") != "1.0":
errors.append("schema_version must be '1.0'")
if manifest.get("manifest_id") != "outcome-os-clean-release-2026-08-07":
errors.append("manifest_id is not canonical")
scope = manifest.get("scope")
if not isinstance(scope, dict):
errors.append("scope must be an object")
scope = {}
if set(scope.get("goals", [])) != EXPECTED_GOALS:
errors.append("scope.goals must contain exactly G0-G8")
if scope.get("release_readiness") not in {"blocked", "ready"}:
errors.append("scope.release_readiness must be 'blocked' or 'ready'")
if scope.get("selection_mode") != "fail-closed":
errors.append("scope.selection_mode must be 'fail-closed'")
declared, counts, goals_to_paths = flatten_classifications(manifest, errors)
for goal, paths in sorted(goals_to_paths.items()):
if not paths:
errors.append(f"{goal} has no related or mixed file coverage")
current = git_dirty_paths(errors)
missing = sorted(set(current) - set(declared))
stale = sorted(set(declared) - set(current))
if missing:
errors.append("unclassified dirty paths: " + ", ".join(missing))
if stale:
errors.append("manifest paths not currently dirty: " + ", ".join(stale))
for path, classification in declared.items():
if classification != "excluded" and not (REPO_ROOT / path).exists():
errors.append(f"declared {classification} path does not exist: {path}")
captured = manifest.get("captured_status")
if not isinstance(captured, dict):
errors.append("captured_status must be an object")
captured = {}
expected_counts = {
"dirty_files": len(declared),
"related": counts["related"],
"mixed": counts["mixed"],
"excluded": counts["excluded"],
}
for key, value in expected_counts.items():
if captured.get(key) != value:
errors.append(f"captured_status.{key} must be {value}")
gates = manifest.get("release_gates")
if not isinstance(gates, list):
errors.append("release_gates must be an array")
gates = []
gate_map = {
gate.get("id"): gate
for gate in gates
if isinstance(gate, dict) and isinstance(gate.get("id"), str)
}
g7_gate = gate_map.get(G7_GATE_ID)
if g7_gate is None:
errors.append(f"required release gate missing: {G7_GATE_ID}")
g7_status = "missing"
else:
g7_status = g7_gate.get("status")
if g7_status not in G7_ALLOWED_STATUSES:
errors.append(
f"release gate {G7_GATE_ID} must be one of "
f"{sorted(G7_ALLOWED_STATUSES)!r}"
)
if g7_status == "satisfied-by-validated-g7-proof":
evidence = g7_gate.get("evidence")
if not isinstance(evidence, str) or not evidence.strip():
errors.append(f"release gate {G7_GATE_ID} must record evidence")
elif "check-g7-external-proof.py" not in evidence:
errors.append(
f"release gate {G7_GATE_ID} evidence must name the canonical checker"
)
expected_readiness = (
"ready" if g7_status == "satisfied-by-validated-g7-proof" else "blocked"
)
if scope.get("release_readiness") != expected_readiness:
errors.append(
"scope.release_readiness must match the G7 external proof gate: "
f"{expected_readiness!r}"
)
for gate_id, expected_status in sorted(REQUIRED_SATISFIED_GATES.items()):
gate = gate_map.get(gate_id)
if gate is None:
errors.append(f"required release gate missing: {gate_id}")
elif gate.get("status") != expected_status:
errors.append(f"release gate {gate_id} must be {expected_status!r}")
elif not isinstance(gate.get("evidence"), str) or not gate["evidence"].strip():
errors.append(f"release gate {gate_id} must record non-empty evidence")
assembly = manifest.get("release_assembly")
if not isinstance(assembly, dict):
errors.append("release_assembly must be an object")
else:
expected_assembly = {
"status": "verified",
"base_commit": "76d0b9ae9bb11b62af86ccd9a55e798bbedc9e31",
"hunk_map": "docs/ops/outcome-os-release-hunk-map-2026-08-07.json",
"builder": "scripts/build-outcome-os-release-patch.py",
"output_patch": "docs/ops/evidence/outcome-os-release-only-2026-08-07.patch",
"git_apply_check": "passed-on-clean-temporary-head",
"git_apply_cached_check": "passed-on-canonical-clean-index",
"patch_line_endings": "lf-only",
"artifact_publish": "atomic-replace-after-all-guards",
"source_worktree": "unchanged-except-output-patch",
"mixed_whole_files_release_eligible": False,
}
for key, value in expected_assembly.items():
if assembly.get(key) != value:
errors.append(f"release_assembly.{key} must be {value!r}")
if assembly.get("deterministic_runs", 0) < 2:
errors.append("release_assembly.deterministic_runs must be at least 2")
patch_path_value = assembly.get("output_patch")
if isinstance(patch_path_value, str):
patch_path = REPO_ROOT / patch_path_value
if not patch_path.is_file():
errors.append(f"release patch missing: {patch_path_value}")
else:
patch_bytes = patch_path.read_bytes()
actual_hash = hashlib.sha256(patch_bytes).hexdigest()
if assembly.get("patch_sha256") != actual_hash:
errors.append(
"release_assembly.patch_sha256 does not match output patch"
)
if assembly.get("patch_bytes") != len(patch_bytes):
errors.append(
f"release_assembly.patch_bytes must be {len(patch_bytes)}"
)
actual_patch_files = count_patch_files(patch_bytes)
if assembly.get("patch_files") != actual_patch_files:
errors.append(
f"release_assembly.patch_files must be {actual_patch_files}"
)
if b"\r\n" in patch_bytes or b"\r" in patch_bytes:
errors.append(
"release patch must use canonical LF-only line endings"
)
base_commit = assembly.get("base_commit")
if isinstance(base_commit, str):
verify_patch_against_base(patch_path, base_commit, errors)
validate_satisfied_gate_evidence(gate_map, assembly, errors)
verification = manifest.get("verification")
if not isinstance(verification, dict):
errors.append("verification must be an object")
else:
if (
verification.get("command")
!= "py -3.11 -X utf8 scripts/check-outcome-os-release-manifest.py --json"
):
errors.append("verification.command is not canonical")
if verification.get("result") != "passed":
errors.append("verification.result must record 'passed'")
return {
"manifest": str(DEFAULT_MANIFEST.relative_to(REPO_ROOT)).replace("\\", "/"),
"dirty_files": len(current),
"classified_files": len(declared),
"counts": dict(sorted(counts.items())),
"blocking_gates": (
[G7_GATE_ID] if g7_status != "satisfied-by-validated-g7-proof" else []
),
"satisfied_gates": dict(sorted(REQUIRED_SATISFIED_GATES.items())),
"errors": errors,
}
def main() -> int:
args = parse_args()
errors: list[str] = []
manifest_path = args.manifest
if not manifest_path.is_absolute():
manifest_path = REPO_ROOT / manifest_path
manifest = load_manifest(manifest_path, errors)
report = (
validate_manifest(manifest, errors)
if manifest
else {
"manifest": str(manifest_path),
"dirty_files": 0,
"classified_files": 0,
"counts": {},
"blocking_gates": [G7_GATE_ID],
"satisfied_gates": dict(sorted(REQUIRED_SATISFIED_GATES.items())),
"errors": errors,
}
)
report["ok"] = not errors
if args.json:
print(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True))
elif errors:
print("FAIL outcome-os release manifest", file=sys.stderr)
for error in errors:
print(f"- {error}", file=sys.stderr)
else:
counts = report["counts"]
print(
"PASS outcome-os release manifest: "
f"{report['classified_files']} dirty files covered "
f"(related={counts.get('related', 0)}, mixed={counts.get('mixed', 0)}, "
f"excluded={counts.get('excluded', 0)}); release remains blocked"
)
return 0 if not errors else 1
if __name__ == "__main__":
raise SystemExit(main())