G7 증명과 G8 clean-head 승격 준비

This commit is contained in:
Yun Chan 2026-08-09 18:22:03 +09:00
parent 94c681d450
commit 5221f79e3f
52 changed files with 6876 additions and 506 deletions

View file

@ -2,7 +2,8 @@
"""Fail-closed G8 release agent for the isolated Vignette NAS preview.
The default is a read-only dry run. ``--execute`` is intentionally narrow:
it accepts only the dedicated Tailnet preview, builds a clean HEAD+patch
it accepts only the dedicated Tailnet preview, builds either the default
verified HEAD+patch candidate or an explicitly requested clean-HEAD archive
candidate, runs release/API/Web/session gates, snapshots the current API and
Web images, promotes only a changed deterministic SHA, and rolls back the
previous images when any post-promotion proof fails.
@ -54,6 +55,7 @@ SSOT_REQUIRED_PATHS = (
# operator surface has exercised its browser contract. Keep this list explicit
# so a newly added goal cannot disappear behind a broad directory glob.
RELEASE_UI_E2E_SPECS = (
"e2e/insecure-context-uuid.spec.ts",
"e2e/session-layout.spec.ts",
"e2e/session-persistence.spec.ts",
"e2e/self-directed-learning-loop.spec.ts",
@ -66,6 +68,36 @@ RELEASE_UI_E2E_SPECS = (
"e2e/multimodal-alliance.spec.ts",
"e2e/continuous-improvement-admin.spec.ts",
)
# The UUID source-contract spec intentionally imports ``/src/lib/uuid.ts`` and
# scans the local source tree, so it belongs to the Vite candidate gate above.
# A production NAS build serves the SPA HTML for ``/src/*``. The postdeploy
# gate therefore exercises the exact built review routes that previously
# crashed on insecure HTTP, while retaining every production-origin-compatible
# release spec. The real returned-practice DB closed loop remains an explicit
# disposable-DB-only gate and must never be silently skipped against NAS.
POSTDEPLOY_NAS_E2E_SPECS = (
"e2e/session-layout.spec.ts",
"e2e/session-persistence.spec.ts",
"e2e/self-directed-learning-loop.spec.ts",
"e2e/alliance-pulse.spec.ts",
"e2e/outcome-trajectory.spec.ts",
"e2e/rupture-repair.spec.ts",
"e2e/deliberate-practice.spec.ts",
"e2e/calibration-transfer.spec.ts",
"e2e/supervision-research.spec.ts",
"e2e/multimodal-alliance.spec.ts",
"e2e/continuous-improvement-admin.spec.ts",
)
POSTDEPLOY_SOURCE_ONLY_E2E_SPECS = ("e2e/insecure-context-uuid.spec.ts",)
POSTDEPLOY_DISPOSABLE_DB_E2E_SPECS = (
"e2e/returned-practice-db-closed-loop.spec.ts",
)
SOURCE_ONLY_E2E_PORT = 5198
RELEASE_BROWSER_PROJECTS = (
"chromium-desktop",
"chromium-mobile",
"chromium-single-run",
)
RELEASE_E2E_TIMEOUT_SECONDS = 15 * 60
# Existing preview volumes do not replay docker-entrypoint-initdb.d. These
@ -100,8 +132,10 @@ REQUIRED_OPENAPI_PATHS = {
IMAGE_ID_RE = re.compile(r"^sha256:[a-f0-9]{64}$")
SHA256_RE = re.compile(r"^[a-f0-9]{64}$")
GIT_OBJECT_RE = re.compile(r"^(?:[a-f0-9]{40}|[a-f0-9]{64})$")
SSH_TARGET_RE = re.compile(r"^(?:[A-Za-z0-9._-]+@)?100\.116\.83\.60$")
RELEASE_KEY_RE = re.compile(r"^[a-f0-9]{16}-\d{8}T\d{6}Z-\d+$")
SOURCE_MODES = ("patch", "clean-head")
@dataclass(frozen=True)
@ -147,6 +181,7 @@ class ReleaseAgentConfig:
repo_root: Path = REPO_ROOT
manifest_path: Path = DEFAULT_MANIFEST
hunk_map_path: Path = DEFAULT_HUNK_MAP
source_mode: str = "patch"
execute: bool = False
milestone: MaterialMilestone | None = None
target: DeploymentTarget = NAS_PREVIEW_TARGET
@ -215,6 +250,8 @@ class RuntimeProbe(Protocol):
class CandidateManager(Protocol):
def materialize(self, base_commit: str, patch_path: str) -> Path: ...
def materialize_archive(self, archive_path: Path, expected_sha256: str) -> Path: ...
def cleanup(self) -> None: ...
@ -226,6 +263,16 @@ def sha256_bytes(value: bytes) -> str:
return hashlib.sha256(value).hexdigest()
def sha256_file(path: Path) -> tuple[str, int]:
digest = hashlib.sha256()
size = 0
with path.open("rb") as source:
while chunk := source.read(1024 * 1024):
digest.update(chunk)
size += len(chunk)
return digest.hexdigest(), size
def validate_target(target: DeploymentTarget) -> None:
if target != NAS_PREVIEW_TARGET:
raise ValueError(
@ -237,6 +284,15 @@ def validate_target(target: DeploymentTarget) -> None:
raise ValueError("isolated NAS preview root must be an absolute canonical path")
def validate_source_repo_root(repo_root: Path) -> None:
if not repo_root.is_absolute():
raise StageFailure("source_repository", "source repository root must be absolute")
if not repo_root.exists():
raise StageFailure("source_repository", "source repository root does not exist")
if not repo_root.is_dir():
raise StageFailure("source_repository", "source repository root is not a directory")
def validate_required_release_payload(manifest: dict[str, Any]) -> None:
classifications = manifest.get("classifications")
related = classifications.get("related") if isinstance(classifications, dict) else None
@ -437,6 +493,45 @@ class CleanCandidateManager:
_normalize_candidate_file_to_lf(shell_script, "shell script")
return root
def materialize_archive(self, archive_path: Path, expected_sha256: str) -> Path:
"""Extract an exact clean-HEAD archive without applying a patch."""
if not SHA256_RE.fullmatch(expected_sha256):
raise StageFailure("candidate_archive_binding", "expected archive SHA is invalid")
try:
actual_sha256, _ = sha256_file(archive_path)
except OSError as exc:
raise StageFailure("candidate_archive_binding", str(exc)) from exc
if actual_sha256 != expected_sha256:
raise StageFailure(
"candidate_archive_binding",
f"archive SHA drifted: expected={expected_sha256} actual={actual_sha256}",
)
self._temp = tempfile.TemporaryDirectory(prefix="vignette-release-agent-")
root = Path(self._temp.name) / "candidate"
try:
root.mkdir(parents=True)
with tarfile.open(archive_path, "r:") as archive:
_safe_extract(archive, root)
generated_api = root / "apps/web/src/lib/api.gen.ts"
if not generated_api.is_file():
raise StageFailure("candidate_archive", "generated API contract is missing")
# git archive stores canonical LF blobs, while a Windows checkout can
# materialize this generated file with CRLF. openapi-typescript
# --check compares bytes, so apply the same platform normalization as
# patch-mode after the archive SHA has already been verified.
_normalize_candidate_file_to_lf(generated_api, "generated API contract")
for shell_script in sorted(root.rglob("*.sh")):
_normalize_candidate_file_to_lf(shell_script, "shell script")
except StageFailure:
self.cleanup()
raise
except (OSError, tarfile.TarError) as exc:
self.cleanup()
raise StageFailure("candidate_archive", str(exc)) from exc
return root
def cleanup(self) -> None:
if self._temp is not None:
self._temp.cleanup()
@ -970,6 +1065,8 @@ class ReleaseAgent:
self.runtime_probe = runtime_probe
self.candidate_manager = candidate_manager
self.stage_evidence: list[dict[str, Any]] = []
self.source_evidence: dict[str, Any] | None = None
self._source_temp: tempfile.TemporaryDirectory[str] | None = None
def _run(
self,
@ -1089,6 +1186,152 @@ class ReleaseAgent:
raise StageFailure("release_determinism", "builder patch SHA is invalid")
return reports[1]
def _clean_head_identity(self, suffix: str) -> tuple[str, str]:
status = self._run(
f"clean_head_worktree_{suffix}",
["git", "status", "--porcelain=v1", "--untracked-files=no"],
cwd=self.config.repo_root,
timeout=120,
)
if status.stdout.strip():
raise StageFailure(
"clean_head_worktree",
"tracked worktree/index changes are forbidden in clean-head mode",
)
head_result = self._run(
f"clean_head_head_{suffix}",
["git", "rev-parse", "--verify", "HEAD"],
cwd=self.config.repo_root,
timeout=120,
)
tree_result = self._run(
f"clean_head_tree_{suffix}",
["git", "rev-parse", "--verify", "HEAD^{tree}"],
cwd=self.config.repo_root,
timeout=120,
)
head = head_result.stdout.strip()
tree = tree_result.stdout.strip()
if not GIT_OBJECT_RE.fullmatch(head):
raise StageFailure("clean_head_identity", "Git HEAD is invalid")
if not GIT_OBJECT_RE.fullmatch(tree):
raise StageFailure("clean_head_identity", "Git tree is invalid")
return head, tree
def _validate_clean_head_worktree_root(self) -> None:
result = self._run(
"clean_head_repository",
["git", "rev-parse", "--show-toplevel"],
cwd=self.config.repo_root,
timeout=120,
)
reported_root = result.stdout.strip()
if not reported_root:
raise StageFailure("clean_head_repository", "Git worktree root is missing")
try:
actual_root = Path(reported_root).resolve(strict=True)
expected_root = self.config.repo_root.resolve(strict=True)
except OSError as exc:
raise StageFailure("clean_head_repository", str(exc)) from exc
if actual_root != expected_root:
raise StageFailure(
"clean_head_repository",
f"source repository is not the Git worktree root: {actual_root}",
)
def _build_clean_head_source_twice(self) -> dict[str, Any]:
self._validate_clean_head_worktree_root()
before_head, before_tree = self._clean_head_identity("before")
self._source_temp = tempfile.TemporaryDirectory(prefix="vignette-release-source-")
source_root = Path(self._source_temp.name)
archives: list[Path] = []
archive_reports: list[dict[str, Any]] = []
for run_number in (1, 2):
archive_path = source_root / f"clean-head-{run_number}.tar"
self._run(
f"clean_head_archive_run_{run_number}",
[
"git",
"archive",
"--format=tar",
"--output",
str(archive_path),
before_head,
],
cwd=self.config.repo_root,
timeout=900,
)
try:
archive_sha256, archive_size = sha256_file(archive_path)
except OSError as exc:
raise StageFailure("clean_head_archive", str(exc)) from exc
archives.append(archive_path)
archive_reports.append(
{
"sha256": archive_sha256,
"bytes": archive_size,
}
)
after_head, after_tree = self._clean_head_identity("after")
if (before_head, before_tree) != (after_head, after_tree):
raise StageFailure(
"clean_head_identity",
"Git HEAD/tree changed while clean-head archives were built",
)
if archive_reports[0] != archive_reports[1]:
raise StageFailure(
"clean_head_determinism",
f"two clean-head archives differ: {archive_reports}",
)
archive_sha256 = archive_reports[1]["sha256"]
if not SHA256_RE.fullmatch(archive_sha256):
raise StageFailure("clean_head_determinism", "archive SHA is invalid")
return {
"source_mode": "clean-head",
"head": after_head,
"tree": after_tree,
"archive_sha256": archive_sha256,
"archive_bytes": archive_reports[1]["bytes"],
"archive_path": archives[1],
"deterministic_runs": 2,
}
def _prepare_source(self) -> tuple[dict[str, Any], str]:
if self.config.source_mode == "patch":
release = self._build_release_twice()
self._check_manifest_binding(release)
self.source_evidence = {
"repo_root": str(self.config.repo_root),
"base_commit": release["base_commit"],
"patch": {
"sha256": release["patch_sha256"],
"bytes": release["patch_bytes"],
"files": release["patch_files"],
"output": release["output_patch"],
"deterministic_runs": 2,
},
}
return release, release["patch_sha256"]
if self.config.source_mode == "clean-head":
release = self._build_clean_head_source_twice()
self.source_evidence = {
"repo_root": str(self.config.repo_root),
"head": release["head"],
"tree": release["tree"],
"archive": {
"sha256": release["archive_sha256"],
"bytes": release["archive_bytes"],
"deterministic_runs": release["deterministic_runs"],
},
}
return release, release["archive_sha256"]
raise StageFailure(
"configuration",
f"unsupported source mode: {self.config.source_mode!r}",
)
def _check_manifest_binding(self, release: dict[str, Any]) -> None:
result = self._run(
"release_manifest",
@ -1169,6 +1412,33 @@ class ReleaseAgent:
self._run("web_typecheck", ["npm.cmd", "run", "typecheck"], cwd=web, timeout=300)
self._run("web_build", ["npm.cmd", "run", "build"], cwd=web, timeout=600)
# This spec imports /src/lib/uuid.ts and therefore must run against a
# source Vite server, not the production Compose build (which correctly
# serves the SPA document for /src/*). CI=1 prevents reuse of an
# unrelated long-lived Vite process on the workstation.
source_e2e_env = os.environ.copy()
source_e2e_env.pop("PLAYWRIGHT_BASE_URL", None)
source_e2e_env.pop("PLAYWRIGHT_SKIP_WEB_SERVER", None)
source_e2e_env["PLAYWRIGHT_HOST"] = "127.0.0.1"
source_e2e_env["PLAYWRIGHT_PORT"] = str(SOURCE_ONLY_E2E_PORT)
source_e2e_env["CI"] = "1"
self._run(
"source_insecure_context_e2e",
[
"node.exe",
str(web / "node_modules/@playwright/test/cli.js"),
"test",
*POSTDEPLOY_SOURCE_ONLY_E2E_SPECS,
"--project=chromium-desktop",
"--project=chromium-mobile",
"--workers=1",
"--reporter=line",
],
cwd=web,
env=source_e2e_env,
timeout=300,
)
stack_attempted = False
stack_env: Path | None = None
project = candidate_compose_project(desired_sha)
@ -1207,7 +1477,7 @@ class ReleaseAgent:
"node.exe",
str(web / "node_modules/@playwright/test/cli.js"),
"test",
*RELEASE_UI_E2E_SPECS,
*POSTDEPLOY_NAS_E2E_SPECS,
"--project=chromium-desktop",
"--project=chromium-mobile",
"--project=chromium-single-run",
@ -1288,20 +1558,29 @@ class ReleaseAgent:
"node.exe",
str(web / "node_modules/@playwright/test/cli.js"),
"test",
"e2e/session-persistence.spec.ts",
"--project=chromium-single-run",
*POSTDEPLOY_NAS_E2E_SPECS,
*(f"--project={project}" for project in RELEASE_BROWSER_PROJECTS),
"--workers=1",
"--grep",
"persists the browser SSE stream into DB-backed review",
"--reporter=line",
],
cwd=web,
env=env,
timeout=300,
timeout=RELEASE_E2E_TIMEOUT_SECONDS,
)
return {
"status": "passed",
"test": "session-persistence: browser SSE stream -> DB-backed review",
"base_url": self.config.target.base_url,
"specs": list(POSTDEPLOY_NAS_E2E_SPECS),
"projects": list(RELEASE_BROWSER_PROJECTS),
"source_only_candidate_specs": list(POSTDEPLOY_SOURCE_ONLY_E2E_SPECS),
"separate_disposable_db_specs": list(POSTDEPLOY_DISPOSABLE_DB_E2E_SPECS),
"uuid_runtime_route_specs": [
"e2e/session-persistence.spec.ts",
"e2e/self-directed-learning-loop.spec.ts",
"e2e/rupture-repair.spec.ts",
"e2e/deliberate-practice.spec.ts",
"e2e/calibration-transfer.spec.ts",
],
"physical_microphone_used": False,
"stdout_sha256": sha256_bytes(result.stdout.encode("utf-8")),
}
@ -1312,6 +1591,8 @@ class ReleaseAgent:
"captured_at": utc_now(),
"ok": False,
"mode": "execute" if self.config.execute else "dry-run",
"source_mode": self.config.source_mode,
"source": self.source_evidence,
"target": asdict(self.config.target),
"milestone": asdict(self.config.milestone) if self.config.milestone else None,
"desired_sha": desired_sha,
@ -1342,10 +1623,9 @@ class ReleaseAgent:
desired_sha: str | None = None
try:
validate_target(self.config.target)
validate_source_repo_root(self.config.repo_root)
validate_milestone(self.config.milestone)
release = self._build_release_twice()
desired_sha = release["patch_sha256"]
self._check_manifest_binding(release)
release, desired_sha = self._prepare_source()
active_state = self.deployment.read_active_state()
active_sha = active_state.get("active_sha") if active_state else None
if active_sha is not None and (
@ -1391,9 +1671,14 @@ class ReleaseAgent:
"remote release-agent state is untracked; adopt it only after an image/Compose rollback snapshot audit",
)
candidate = self.candidate_manager.materialize(
release["base_commit"], release["output_patch"]
)
if self.config.source_mode == "clean-head":
candidate = self.candidate_manager.materialize_archive(
release["archive_path"], release["archive_sha256"]
)
else:
candidate = self.candidate_manager.materialize(
release["base_commit"], release["output_patch"]
)
self._run_candidate_gates(candidate, desired_sha)
snapshot = self.deployment.snapshot()
self._validate_snapshot_binding(active_state, snapshot)
@ -1472,6 +1757,9 @@ class ReleaseAgent:
finally:
if candidate is not None:
self.candidate_manager.cleanup()
if self._source_temp is not None:
self._source_temp.cleanup()
self._source_temp = None
def load_milestone(path: Path) -> MaterialMilestone:
@ -1502,6 +1790,18 @@ def build_parser() -> argparse.ArgumentParser:
parser.add_argument("--milestone", type=Path, required=True)
parser.add_argument("--manifest", type=Path, default=DEFAULT_MANIFEST)
parser.add_argument("--hunk-map", type=Path, default=DEFAULT_HUNK_MAP)
parser.add_argument(
"--source-mode",
choices=SOURCE_MODES,
default="patch",
help="Release source: verified patch (default) or exact clean Git HEAD archive",
)
parser.add_argument(
"--source-repo-root",
type=Path,
default=REPO_ROOT,
help="Absolute Git worktree root used as the release source",
)
parser.add_argument("--nas-env-file", type=Path, default=DEFAULT_NAS_ENV)
parser.add_argument("--evidence-out", type=Path)
parser.add_argument("--ssh-target", help="Required for live state inspection/execution")
@ -1560,9 +1860,10 @@ def main(argv: list[str] | None = None) -> int:
else:
deployment = StaticDeploymentState(args.current_deployment_sha)
config = ReleaseAgentConfig(
repo_root=REPO_ROOT,
repo_root=args.source_repo_root,
manifest_path=args.manifest,
hunk_map_path=args.hunk_map,
source_mode=args.source_mode,
execute=args.execute,
milestone=milestone,
target=NAS_PREVIEW_TARGET,
@ -1575,7 +1876,7 @@ def main(argv: list[str] | None = None) -> int:
runner=runner,
deployment=deployment,
runtime_probe=HttpRuntimeProbe(NAS_PREVIEW_TARGET.base_url),
candidate_manager=CleanCandidateManager(REPO_ROOT, runner),
candidate_manager=CleanCandidateManager(args.source_repo_root, runner),
)
report = agent.run()
if args.json: