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

@ -5,9 +5,9 @@ from __future__ import annotations
import importlib.util
import json
import sys
import tarfile
import tempfile
import unittest
import tarfile
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
@ -31,12 +31,25 @@ class FakeRunner:
module: Any
fail_stage: str | None = None
builder_shas: tuple[str, str] = ("a" * 64, "a" * 64)
tracked_status: str = ""
clean_archive_payloads: tuple[bytes, bytes] = (b"clean archive", b"clean archive")
clean_head: str = "b" * 40
clean_tree: str = "c" * 40
calls: list[str] = field(default_factory=list)
commands: dict[str, list[str]] = field(default_factory=dict)
working_directories: dict[str, Path | None] = field(default_factory=dict)
environments: dict[str, dict[str, str] | None] = field(default_factory=dict)
timeouts: dict[str, int | None] = field(default_factory=dict)
event_log: list[str] | None = None
def run(self, stage: str, argv: list[str], **_: Any):
def run(self, stage: str, argv: list[str], **kwargs: Any):
self.calls.append(stage)
self.commands[stage] = list(argv)
self.working_directories[stage] = kwargs.get("cwd")
self.environments[stage] = kwargs.get("env")
self.timeouts[stage] = kwargs.get("timeout")
if self.event_log is not None:
self.event_log.append(f"runner:{stage}")
if stage == self.fail_stage:
raise self.module.StageFailure(stage, "synthetic failure")
if stage.startswith("release_patch_run_"):
@ -57,6 +70,19 @@ class FakeRunner:
return self.module.CommandResult(0, json.dumps(payload), "")
if stage == "release_manifest":
return self.module.CommandResult(0, json.dumps({"ok": True}), "")
if stage == "clean_head_repository":
return self.module.CommandResult(0, str(kwargs["cwd"].resolve()) + "\n", "")
if stage.startswith("clean_head_worktree_"):
return self.module.CommandResult(0, self.tracked_status, "")
if stage.startswith("clean_head_head_"):
return self.module.CommandResult(0, self.clean_head + "\n", "")
if stage.startswith("clean_head_tree_"):
return self.module.CommandResult(0, self.clean_tree + "\n", "")
if stage.startswith("clean_head_archive_run_"):
run_index = int(stage.rsplit("_", 1)[1]) - 1
output_index = argv.index("--output")
Path(argv[output_index + 1]).write_bytes(self.clean_archive_payloads[run_index])
return self.module.CommandResult(0, "", "")
return self.module.CommandResult(0, f"{stage}: passed", "")
@ -67,9 +93,15 @@ class FakeDeployment:
rollback_matches: bool = True
tracked_api_image: str = "sha256:" + "1" * 64
calls: list[str] = field(default_factory=list)
event_log: list[str] | None = None
def _record(self, event: str) -> None:
self.calls.append(event)
if self.event_log is not None:
self.event_log.append(f"deployment:{event}")
def read_active_state(self):
self.calls.append("read_active_state")
self._record("read_active_state")
if self.active_sha is None:
return None
return {
@ -84,7 +116,7 @@ class FakeDeployment:
}
def snapshot(self):
self.calls.append("snapshot")
self._record("snapshot")
return self.module.DeploymentSnapshot(
api_image="sha256:" + "1" * 64,
web_image="sha256:" + "2" * 64,
@ -94,7 +126,7 @@ class FakeDeployment:
)
def promote(self, candidate: Path, desired_sha: str, snapshot: Any):
self.calls.append("promote")
self._record("promote")
return {
"active_sha": desired_sha,
"api_image": "sha256:" + "3" * 64,
@ -104,16 +136,16 @@ class FakeDeployment:
}
def commit_active_state(self, state: dict[str, Any]):
self.calls.append("commit_active_state")
self._record("commit_active_state")
self.active_sha = state["active_sha"]
def rollback(self, snapshot: Any):
self.calls.append("rollback")
self._record("rollback")
self.active_sha = snapshot.active_sha
return {"api_image": snapshot.api_image, "web_image": snapshot.web_image}
def verify_rollback(self, snapshot: Any):
self.calls.append("verify_rollback")
self._record("verify_rollback")
return {
"ok": self.rollback_matches,
"api_image": snapshot.api_image if self.rollback_matches else "sha256:" + "9" * 64,
@ -164,9 +196,20 @@ class FakeCandidate:
def __init__(self, root: Path):
self.root = root
self.cleaned = False
self.materialization_mode: str | None = None
self.archive_sha256: str | None = None
self.archive_bytes: bytes | None = None
def materialize(self, base_commit: str, patch_path: str):
del base_commit, patch_path
self.materialization_mode = "patch"
self.root.mkdir(parents=True, exist_ok=True)
return self.root
def materialize_archive(self, archive_path: Path, expected_sha256: str):
self.materialization_mode = "clean-head"
self.archive_sha256 = expected_sha256
self.archive_bytes = archive_path.read_bytes()
self.root.mkdir(parents=True, exist_ok=True)
return self.root
@ -268,17 +311,38 @@ class OutcomeReleaseAgentTests(unittest.TestCase):
execute: bool,
fail_stage: str | None = None,
builder_shas: tuple[str, str] = ("a" * 64, "a" * 64),
source_mode: str = "patch",
tracked_status: str = "",
clean_archive_payloads: tuple[bytes, bytes] = (
b"clean archive",
b"clean archive",
),
source_repo_root: Path | None = None,
runtime_fail_on_call: int | None = None,
rollback_matches: bool = True,
):
runner = FakeRunner(self.agent_module, fail_stage, builder_shas)
deployment = FakeDeployment(self.agent_module, active_sha, rollback_matches)
event_log: list[str] = []
runner = FakeRunner(
module=self.agent_module,
fail_stage=fail_stage,
builder_shas=builder_shas,
tracked_status=tracked_status,
clean_archive_payloads=clean_archive_payloads,
event_log=event_log,
)
deployment = FakeDeployment(
self.agent_module,
active_sha,
rollback_matches,
event_log=event_log,
)
runtime = FakeRuntimeProbe(self.agent_module, runtime_fail_on_call)
candidate = FakeCandidate(self.root / "candidate")
config = self.agent_module.ReleaseAgentConfig(
repo_root=self.root,
repo_root=source_repo_root or self.root,
manifest_path=self.manifest_path,
hunk_map_path=self.root / "docs/ops/hunk-map.json",
source_mode=source_mode,
execute=execute,
milestone=self.milestone,
target=self.agent_module.NAS_PREVIEW_TARGET,
@ -340,6 +404,157 @@ class OutcomeReleaseAgentTests(unittest.TestCase):
runner.calls,
)
def test_parser_keeps_patch_and_controller_repo_defaults(self) -> None:
parser = self.agent_module.build_parser()
default_args = parser.parse_args(["--milestone", "milestone.json"])
detached_root = self.root / "detached"
clean_args = parser.parse_args(
[
"--milestone",
"milestone.json",
"--source-mode",
"clean-head",
"--source-repo-root",
str(detached_root),
]
)
self.assertEqual("patch", default_args.source_mode)
self.assertEqual(self.agent_module.REPO_ROOT, default_args.source_repo_root)
self.assertEqual("clean-head", clean_args.source_mode)
self.assertEqual(detached_root, clean_args.source_repo_root)
def test_source_repo_root_must_be_an_absolute_existing_directory(self) -> None:
relative = Path("detached-source")
missing = self.root / "missing"
source_file = self.root / "source-file"
source_file.write_text("not a directory\n", encoding="utf-8")
for source_root, message in (
(relative, "must be absolute"),
(missing, "does not exist"),
(source_file, "not a directory"),
):
with self.subTest(source_root=source_root):
with self.assertRaisesRegex(self.agent_module.StageFailure, message):
self.agent_module.validate_source_repo_root(source_root)
def test_clean_head_override_runs_every_git_command_in_source_repo(self) -> None:
detached_root = self.root / "detached-source"
detached_root.mkdir()
archive_payload = b"detached clean-head archive"
agent, runner, _, _, candidate = self.make_agent(
active_sha="e" * 64,
execute=True,
source_mode="clean-head",
clean_archive_payloads=(archive_payload, archive_payload),
source_repo_root=detached_root,
)
report = agent.run()
git_stages = [
stage
for stage in runner.calls
if stage == "clean_head_repository"
or stage.startswith("clean_head_worktree_")
or stage.startswith("clean_head_head_")
or stage.startswith("clean_head_tree_")
or stage.startswith("clean_head_archive_run_")
]
self.assertEqual(9, len(git_stages))
self.assertEqual(
{detached_root},
{runner.working_directories[stage] for stage in git_stages},
)
self.assertEqual(str(detached_root), report["source"]["repo_root"])
self.assertEqual("clean-head", candidate.materialization_mode)
def test_clean_head_rejects_tracked_changes_and_ignores_untracked_in_query(self) -> None:
agent, runner, deployment, _, candidate = self.make_agent(
active_sha="e" * 64,
execute=True,
source_mode="clean-head",
tracked_status=" M docs/TODO.md\n",
)
with self.assertRaises(self.agent_module.ReleaseAgentFailure) as raised:
agent.run()
self.assertEqual("clean_head_worktree", raised.exception.report["failed_stage"])
self.assertEqual(
["clean_head_repository", "clean_head_worktree_before"],
runner.calls,
)
self.assertIn(
"--untracked-files=no",
runner.commands["clean_head_worktree_before"],
)
self.assertEqual([], deployment.calls)
self.assertIsNone(candidate.materialization_mode)
def test_clean_head_non_git_source_fails_before_identity_or_deployment(self) -> None:
agent, runner, deployment, _, candidate = self.make_agent(
active_sha="e" * 64,
execute=True,
source_mode="clean-head",
fail_stage="clean_head_repository",
)
with self.assertRaises(self.agent_module.ReleaseAgentFailure) as raised:
agent.run()
self.assertEqual("clean_head_repository", raised.exception.report["failed_stage"])
self.assertEqual(["clean_head_repository"], runner.calls)
self.assertEqual([], deployment.calls)
self.assertIsNone(candidate.materialization_mode)
def test_clean_head_archive_runs_must_be_byte_identical(self) -> None:
agent, runner, deployment, _, candidate = self.make_agent(
active_sha="e" * 64,
execute=True,
source_mode="clean-head",
clean_archive_payloads=(b"first archive", b"second archive"),
)
with self.assertRaises(self.agent_module.ReleaseAgentFailure) as raised:
agent.run()
self.assertEqual("clean_head_determinism", raised.exception.report["failed_stage"])
self.assertEqual(2, len([stage for stage in runner.calls if "archive_run" in stage]))
self.assertEqual([], deployment.calls)
self.assertIsNone(candidate.materialization_mode)
def test_clean_head_evidence_binds_exact_head_tree_and_archive(self) -> None:
archive_payload = b"deterministic clean-head archive"
expected_sha = self.agent_module.sha256_bytes(archive_payload)
agent, runner, deployment, _, candidate = self.make_agent(
active_sha="e" * 64,
execute=True,
source_mode="clean-head",
clean_archive_payloads=(archive_payload, archive_payload),
)
report = agent.run()
self.assertTrue(report["ok"])
self.assertEqual(expected_sha, report["desired_sha"])
self.assertEqual("clean-head", report["source_mode"])
self.assertEqual("b" * 40, report["source"]["head"])
self.assertEqual("c" * 40, report["source"]["tree"])
self.assertEqual(expected_sha, report["source"]["archive"]["sha256"])
self.assertEqual(len(archive_payload), report["source"]["archive"]["bytes"])
self.assertEqual(2, report["source"]["archive"]["deterministic_runs"])
self.assertEqual("clean-head", candidate.materialization_mode)
self.assertEqual(expected_sha, candidate.archive_sha256)
self.assertEqual(archive_payload, candidate.archive_bytes)
self.assertNotIn("release_manifest", runner.calls)
self.assertFalse(any(stage.startswith("release_patch_run_") for stage in runner.calls))
self.assertIn("promote", deployment.calls)
evidence = json.loads((self.root / "evidence.json").read_text(encoding="utf-8"))
self.assertEqual(report["source"], evidence["source"])
self.assertEqual("clean-head", evidence["source_mode"])
def test_same_deployment_sha_is_a_runtime_checked_noop(self) -> None:
agent, runner, deployment, runtime, candidate = self.make_agent(
active_sha="a" * 64,
@ -402,6 +617,8 @@ class OutcomeReleaseAgentTests(unittest.TestCase):
report = agent.run()
self.assertTrue(report["ok"])
self.assertEqual("deployed_ssot_sync_required", report["status"])
self.assertEqual("patch", report["source_mode"])
self.assertEqual("a" * 64, report["source"]["patch"]["sha256"])
self.assertEqual("a" * 64, report["active_sha"])
self.assertEqual("e" * 64, report["previous_sha"])
self.assertEqual(
@ -415,6 +632,7 @@ class OutcomeReleaseAgentTests(unittest.TestCase):
"web_api_contract",
"web_typecheck",
"web_build",
"source_insecure_context_e2e",
"session_e2e",
"postdeploy_browser_review",
],
@ -426,12 +644,87 @@ class OutcomeReleaseAgentTests(unittest.TestCase):
)
self.assertEqual(1, runtime.calls)
self.assertTrue(candidate.cleaned)
postdeploy_command = runner.commands["postdeploy_browser_review"]
test_index = postdeploy_command.index("test")
first_flag = next(
index
for index in range(test_index + 1, len(postdeploy_command))
if postdeploy_command[index].startswith("--")
)
self.assertEqual(
self.agent_module.POSTDEPLOY_NAS_E2E_SPECS,
tuple(postdeploy_command[test_index + 1 : first_flag]),
)
self.assertNotIn("--grep", postdeploy_command)
self.assertEqual(
{f"--project={project}" for project in self.agent_module.RELEASE_BROWSER_PROJECTS},
{value for value in postdeploy_command if value.startswith("--project=")},
)
self.assertEqual(
self.agent_module.RELEASE_E2E_TIMEOUT_SECONDS,
runner.timeouts["postdeploy_browser_review"],
)
self.assertEqual(
self.agent_module.NAS_PREVIEW_TARGET.base_url,
runner.environments["postdeploy_browser_review"]["PLAYWRIGHT_BASE_URL"],
)
self.assertEqual(
"1",
runner.environments["postdeploy_browser_review"]["PLAYWRIGHT_SKIP_WEB_SERVER"],
)
self.assertIn(
"e2e/self-directed-learning-loop.spec.ts",
self.agent_module.POSTDEPLOY_NAS_E2E_SPECS,
)
self.assertIn(
"e2e/deliberate-practice.spec.ts",
self.agent_module.POSTDEPLOY_NAS_E2E_SPECS,
)
self.assertNotIn(
"e2e/insecure-context-uuid.spec.ts",
self.agent_module.POSTDEPLOY_NAS_E2E_SPECS,
)
self.assertNotIn(
"e2e/returned-practice-db-closed-loop.spec.ts",
self.agent_module.POSTDEPLOY_NAS_E2E_SPECS,
)
self.assertEqual(
set(self.agent_module.RELEASE_UI_E2E_SPECS),
set(self.agent_module.POSTDEPLOY_NAS_E2E_SPECS)
| set(self.agent_module.POSTDEPLOY_SOURCE_ONLY_E2E_SPECS),
)
self.assertLess(
runner.event_log.index("runner:postdeploy_browser_review"),
runner.event_log.index("deployment:commit_active_state"),
)
self.assertTrue(
set(self.agent_module.RELEASE_UI_E2E_SPECS).issubset(
set(self.agent_module.POSTDEPLOY_NAS_E2E_SPECS).issubset(
set(runner.commands["session_e2e"])
)
)
self.assertNotIn(
"e2e/insecure-context-uuid.spec.ts",
runner.commands["session_e2e"],
)
source_command = runner.commands["source_insecure_context_e2e"]
self.assertIn("e2e/insecure-context-uuid.spec.ts", source_command)
self.assertIn("--project=chromium-desktop", source_command)
self.assertIn("--project=chromium-mobile", source_command)
source_environment = runner.environments["source_insecure_context_e2e"]
self.assertNotIn("PLAYWRIGHT_BASE_URL", source_environment)
self.assertNotIn("PLAYWRIGHT_SKIP_WEB_SERVER", source_environment)
self.assertEqual("127.0.0.1", source_environment["PLAYWRIGHT_HOST"])
self.assertEqual(
str(self.agent_module.SOURCE_ONLY_E2E_PORT),
source_environment["PLAYWRIGHT_PORT"],
)
self.assertEqual("1", source_environment["CI"])
self.assertEqual(15 * 60, self.agent_module.RELEASE_E2E_TIMEOUT_SECONDS)
self.assertIn(
"e2e/insecure-context-uuid.spec.ts",
self.agent_module.RELEASE_UI_E2E_SPECS,
)
self.assertEqual("patch", candidate.materialization_mode)
self.assertEqual(
(
"14_continuous_improvement.sql",
@ -445,6 +738,18 @@ class OutcomeReleaseAgentTests(unittest.TestCase):
self.assertEqual("e" * 64, evidence["previous_sha"])
self.assertEqual("a" * 64, evidence["deployment"]["desired_sha"])
self.assertEqual("required", evidence["ssot_sync"]["status"])
self.assertEqual(
list(self.agent_module.POSTDEPLOY_NAS_E2E_SPECS),
evidence["browser_review_proof"]["specs"],
)
self.assertEqual(
["e2e/insecure-context-uuid.spec.ts"],
evidence["browser_review_proof"]["source_only_candidate_specs"],
)
self.assertEqual(
["e2e/returned-practice-db-closed-loop.spec.ts"],
evidence["browser_review_proof"]["separate_disposable_db_specs"],
)
self.assertEqual(
[
"docs/dev_dashboard.html",
@ -480,7 +785,7 @@ class OutcomeReleaseAgentTests(unittest.TestCase):
self.assertTrue(candidate.cleaned)
def test_rollback_image_mismatch_remains_a_hard_failure(self) -> None:
agent, _, deployment, _, _ = self.make_agent(
agent, runner, deployment, _, _ = self.make_agent(
active_sha="e" * 64,
execute=True,
fail_stage="postdeploy_browser_review",
@ -491,6 +796,11 @@ class OutcomeReleaseAgentTests(unittest.TestCase):
self.assertEqual("failed", raised.exception.report["rollback"]["status"])
self.assertIn("rollback image proof mismatch", raised.exception.report["rollback"]["error"])
self.assertIn("verify_rollback", deployment.calls)
self.assertNotIn("commit_active_state", deployment.calls)
self.assertLess(
runner.event_log.index("runner:postdeploy_browser_review"),
runner.event_log.index("deployment:rollback"),
)
def test_postpromotion_runtime_retries_only_transient_readiness_failures(self) -> None:
agent, _, deployment, _, candidate = self.make_agent(
@ -653,6 +963,50 @@ class OutcomeReleaseAgentTests(unittest.TestCase):
self.assertEqual(b"candidate\r\n", (candidate / "README.md").read_bytes())
manager.cleanup()
def test_clean_head_candidate_is_archive_only_with_platform_normalization(self) -> None:
runner = CandidateArchiveRunner(self.agent_module)
manager = self.agent_module.CleanCandidateManager(self.root, runner)
archive_path = self.root / "clean-head.tar"
source = self.root / "source.txt"
source.write_bytes(b"exact committed bytes\r\n")
generated_api = self.root / "api.gen.ts"
generated_api.write_bytes(b"export type Exact = true;\r\n")
shell_script = self.root / "99_app_role.sh"
shell_script.write_bytes(b"#!/usr/bin/env bash\r\necho ready\r\n")
with tarfile.open(archive_path, "w") as archive:
archive.add(source, arcname="source.txt")
archive.add(generated_api, arcname="apps/web/src/lib/api.gen.ts")
archive.add(shell_script, arcname="infra/db/init/99_app_role.sh")
expected_sha = self.agent_module.sha256_bytes(archive_path.read_bytes())
candidate = manager.materialize_archive(archive_path, expected_sha)
self.assertEqual(b"exact committed bytes\r\n", (candidate / "source.txt").read_bytes())
self.assertEqual(
b"export type Exact = true;\n",
(candidate / "apps/web/src/lib/api.gen.ts").read_bytes(),
)
self.assertEqual(
b"#!/usr/bin/env bash\necho ready\n",
(candidate / "infra/db/init/99_app_role.sh").read_bytes(),
)
self.assertEqual([], runner.calls)
manager.cleanup()
def test_clean_head_candidate_rejects_archive_sha_drift(self) -> None:
runner = CandidateArchiveRunner(self.agent_module)
manager = self.agent_module.CleanCandidateManager(self.root, runner)
archive_path = self.root / "clean-head.tar"
archive_path.write_bytes(b"not the bound archive")
with self.assertRaisesRegex(
self.agent_module.StageFailure,
"archive SHA drifted",
):
manager.materialize_archive(archive_path, "a" * 64)
self.assertEqual([], runner.calls)
def test_active_state_rejects_noncanonical_compose_path_before_snapshot(self) -> None:
root = "/volume1/docker/vignette-preview-20260807"
runner = ActiveStateRunner(