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 산출물은 커밋에서 제외했다.
724 lines
28 KiB
Python
724 lines
28 KiB
Python
#!/usr/bin/env python3
|
|
"""Assemble and dry-run a clean Outcome/Alliance OS release patch.
|
|
|
|
The source worktree is read-only except for the declared patch artifact. A
|
|
clean HEAD tree is materialized under the system temporary directory, related
|
|
whole files are overlaid, mixed documents are reconstructed from an exact hunk
|
|
map, and api.gen.ts is regenerated with the repository's official npm script.
|
|
The candidate uses canonical LF hunks, must apply to both a clean checkout and
|
|
an isolated canonical index, and replaces the prior artifact only after every
|
|
guard passes.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import difflib
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import zipfile
|
|
from dataclasses import dataclass
|
|
from pathlib import Path, PurePosixPath
|
|
from typing import Any
|
|
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
DEFAULT_MAP = REPO_ROOT / "docs/ops/outcome-os-release-hunk-map-2026-08-07.json"
|
|
HUNK_HEADER = re.compile(
|
|
r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@"
|
|
)
|
|
|
|
|
|
class AssemblyError(RuntimeError):
|
|
"""Release assembly failed closed."""
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Hunk:
|
|
header: str
|
|
raw: str
|
|
old_start: int
|
|
old_count: int
|
|
new_start: int
|
|
new_count: int
|
|
removed: tuple[str, ...]
|
|
added: tuple[str, ...]
|
|
|
|
@property
|
|
def sha256(self) -> str:
|
|
return hashlib.sha256(self.raw.encode("utf-8")).hexdigest()
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--map", type=Path, default=DEFAULT_MAP)
|
|
parser.add_argument("--json", action="store_true", help="emit JSON result")
|
|
return parser.parse_args()
|
|
|
|
|
|
def run(
|
|
argv: list[str],
|
|
*,
|
|
cwd: Path = REPO_ROOT,
|
|
env: dict[str, str] | None = None,
|
|
allow_failure: bool = False,
|
|
) -> subprocess.CompletedProcess[bytes]:
|
|
result = subprocess.run(
|
|
argv,
|
|
cwd=cwd,
|
|
env=env,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
check=False,
|
|
)
|
|
if result.returncode and not allow_failure:
|
|
stdout = result.stdout.decode("utf-8", errors="replace")[-2000:]
|
|
stderr = result.stderr.decode("utf-8", errors="replace")[-4000:]
|
|
raise AssemblyError(
|
|
f"command failed ({result.returncode}): {' '.join(argv)}\n"
|
|
f"stdout:\n{stdout}\nstderr:\n{stderr}"
|
|
)
|
|
return result
|
|
|
|
|
|
def load_json(path: Path) -> dict[str, Any]:
|
|
try:
|
|
value = json.loads(path.read_text(encoding="utf-8"))
|
|
except (OSError, json.JSONDecodeError) as exc:
|
|
raise AssemblyError(f"cannot read JSON {path}: {exc}") from exc
|
|
if not isinstance(value, dict):
|
|
raise AssemblyError(f"JSON root must be an object: {path}")
|
|
return value
|
|
|
|
|
|
def canonical_path(value: Any) -> str:
|
|
if not isinstance(value, str) or not value or "\\" in value:
|
|
raise AssemblyError(f"path is not canonical: {value!r}")
|
|
candidate = PurePosixPath(value)
|
|
if candidate.is_absolute() or ".." in candidate.parts or str(candidate) != value:
|
|
raise AssemblyError(f"path escapes repository: {value!r}")
|
|
return value
|
|
|
|
|
|
def current_head() -> str:
|
|
return run(["git", "rev-parse", "HEAD"]).stdout.decode("ascii").strip()
|
|
|
|
|
|
def git_dirty_paths() -> set[str]:
|
|
payload = run(
|
|
["git", "status", "--porcelain=v1", "-z", "--untracked-files=all"]
|
|
).stdout
|
|
records = payload.split(b"\0")
|
|
paths: set[str] = set()
|
|
index = 0
|
|
while index < len(records):
|
|
record = records[index]
|
|
index += 1
|
|
if not record:
|
|
continue
|
|
text = record.decode("utf-8", errors="surrogateescape")
|
|
if len(text) < 4 or text[2] != " ":
|
|
raise AssemblyError(f"unexpected git status record: {text!r}")
|
|
status = text[:2]
|
|
path = text[3:].replace("\\", "/")
|
|
paths.add(path)
|
|
if status[0] in {"R", "C"} or status[1] in {"R", "C"}:
|
|
if index >= len(records) or not records[index]:
|
|
raise AssemblyError(f"rename/copy lacks source path: {text!r}")
|
|
index += 1
|
|
return paths
|
|
|
|
|
|
def file_digest(path: Path) -> str | None:
|
|
if not path.exists():
|
|
return None
|
|
if not path.is_file():
|
|
raise AssemblyError(f"dirty path is not a regular file: {path}")
|
|
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
|
|
|
|
def snapshot_worktree() -> dict[str, str | None]:
|
|
return {
|
|
path: file_digest(REPO_ROOT / path)
|
|
for path in sorted(git_dirty_paths())
|
|
}
|
|
|
|
|
|
def verify_worktree_unchanged(
|
|
before: dict[str, str | None], output_path: str
|
|
) -> None:
|
|
after = snapshot_worktree()
|
|
before_without_output = {k: v for k, v in before.items() if k != output_path}
|
|
after_without_output = {k: v for k, v in after.items() if k != output_path}
|
|
if before_without_output != after_without_output:
|
|
before_keys = set(before_without_output)
|
|
after_keys = set(after_without_output)
|
|
added = sorted(after_keys - before_keys)
|
|
removed = sorted(before_keys - after_keys)
|
|
changed = sorted(
|
|
key
|
|
for key in before_keys & after_keys
|
|
if before_without_output[key] != after_without_output[key]
|
|
)
|
|
raise AssemblyError(
|
|
"source worktree changed during assembly: "
|
|
f"added={added}, removed={removed}, changed={changed}"
|
|
)
|
|
|
|
|
|
def parse_hunks(path: str) -> list[Hunk]:
|
|
raw = run(
|
|
[
|
|
"git",
|
|
"-c",
|
|
"color.ui=false",
|
|
"diff",
|
|
"--unified=0",
|
|
"--no-ext-diff",
|
|
"--",
|
|
path,
|
|
]
|
|
).stdout.decode("utf-8")
|
|
starts = [match.start() for match in re.finditer(r"^@@ ", raw, re.MULTILINE)]
|
|
hunks: list[Hunk] = []
|
|
for index, start in enumerate(starts):
|
|
end = starts[index + 1] if index + 1 < len(starts) else len(raw)
|
|
chunk = raw[start:end]
|
|
lines = chunk.splitlines()
|
|
if not lines:
|
|
raise AssemblyError(f"empty diff hunk: {path}")
|
|
header = lines[0]
|
|
match = HUNK_HEADER.match(header)
|
|
if match is None:
|
|
raise AssemblyError(f"unparseable hunk header for {path}: {header}")
|
|
old_start = int(match.group(1))
|
|
old_count = int(match.group(2) or "1")
|
|
new_start = int(match.group(3))
|
|
new_count = int(match.group(4) or "1")
|
|
removed: list[str] = []
|
|
added: list[str] = []
|
|
for line in lines[1:]:
|
|
if line.startswith("-"):
|
|
removed.append(line[1:])
|
|
elif line.startswith("+"):
|
|
added.append(line[1:])
|
|
elif line == "\\ No newline at end of file":
|
|
raise AssemblyError(f"no-final-newline hunk is unsupported: {path} {header}")
|
|
else:
|
|
raise AssemblyError(
|
|
f"unexpected zero-context hunk line for {path} {header}: {line!r}"
|
|
)
|
|
if len(removed) != old_count or len(added) != new_count:
|
|
raise AssemblyError(
|
|
f"hunk count mismatch for {path} {header}: "
|
|
f"removed={len(removed)}/{old_count}, added={len(added)}/{new_count}"
|
|
)
|
|
hunks.append(
|
|
Hunk(
|
|
header=header,
|
|
raw=chunk,
|
|
old_start=old_start,
|
|
old_count=old_count,
|
|
new_start=new_start,
|
|
new_count=new_count,
|
|
removed=tuple(removed),
|
|
added=tuple(added),
|
|
)
|
|
)
|
|
return hunks
|
|
|
|
|
|
def git_base_text(base_commit: str, path: str) -> str:
|
|
result = run(["git", "show", f"{base_commit}:{path}"])
|
|
try:
|
|
return result.stdout.decode("utf-8")
|
|
except UnicodeDecodeError as exc:
|
|
raise AssemblyError(f"mixed document is not UTF-8 text: {path}") from exc
|
|
|
|
|
|
def selected_added_lines(hunk: Hunk, mapping: dict[str, Any]) -> list[str]:
|
|
decision = mapping.get("decision")
|
|
if decision == "include":
|
|
return list(hunk.added)
|
|
if decision == "exclude":
|
|
return []
|
|
if decision != "split-added-lines":
|
|
raise AssemblyError(f"unknown hunk decision: {decision!r}")
|
|
if hunk.old_count != 0 or hunk.removed:
|
|
raise AssemblyError(
|
|
f"split-added-lines only supports pure insertions: {hunk.header}"
|
|
)
|
|
include = mapping.get("include_added_line_numbers")
|
|
exclude = mapping.get("exclude_added_line_numbers")
|
|
if not isinstance(include, list) or not isinstance(exclude, list):
|
|
raise AssemblyError(f"split hunk needs include/exclude indexes: {hunk.header}")
|
|
include_set = set(include)
|
|
exclude_set = set(exclude)
|
|
expected = set(range(1, len(hunk.added) + 1))
|
|
if include_set & exclude_set or include_set | exclude_set != expected:
|
|
raise AssemblyError(
|
|
f"split indexes must partition every added line: {hunk.header}"
|
|
)
|
|
if any(not isinstance(index, int) for index in include + exclude):
|
|
raise AssemblyError(f"split indexes must be integers: {hunk.header}")
|
|
return [line for index, line in enumerate(hunk.added, 1) if index in include_set]
|
|
|
|
|
|
def assemble_document(
|
|
base_commit: str,
|
|
document: dict[str, Any],
|
|
) -> tuple[str, dict[str, int]]:
|
|
path = canonical_path(document.get("path"))
|
|
current_hunks = parse_hunks(path)
|
|
mapped = document.get("hunks")
|
|
if not isinstance(mapped, list):
|
|
raise AssemblyError(f"document.hunks must be an array: {path}")
|
|
if len(mapped) != len(current_hunks):
|
|
raise AssemblyError(
|
|
f"hunk coverage drift for {path}: mapped={len(mapped)}, current={len(current_hunks)}"
|
|
)
|
|
|
|
base_text = git_base_text(base_commit, path)
|
|
if not base_text.endswith("\n"):
|
|
raise AssemblyError(f"mixed document must end in newline: {path}")
|
|
base_lines = base_text[:-1].split("\n")
|
|
output: list[str] = []
|
|
cursor = 0
|
|
counts = {"include": 0, "exclude": 0, "split-added-lines": 0}
|
|
|
|
for index, (hunk, mapping) in enumerate(zip(current_hunks, mapped), 1):
|
|
if not isinstance(mapping, dict):
|
|
raise AssemblyError(f"hunk mapping must be an object: {path} #{index}")
|
|
if mapping.get("header") != hunk.header:
|
|
raise AssemblyError(
|
|
f"hunk header drift for {path} #{index}: "
|
|
f"mapped={mapping.get('header')!r}, current={hunk.header!r}"
|
|
)
|
|
if mapping.get("sha256") != hunk.sha256:
|
|
raise AssemblyError(
|
|
f"hunk hash drift for {path} {hunk.header}: "
|
|
f"mapped={mapping.get('sha256')}, current={hunk.sha256}"
|
|
)
|
|
rationale = mapping.get("rationale")
|
|
if not isinstance(rationale, str) or not rationale.strip():
|
|
raise AssemblyError(f"hunk rationale is empty: {path} {hunk.header}")
|
|
decision = mapping.get("decision")
|
|
if decision not in counts:
|
|
raise AssemblyError(f"invalid decision for {path}: {decision!r}")
|
|
counts[decision] += 1
|
|
|
|
if decision == "exclude":
|
|
continue
|
|
old_index = hunk.old_start if hunk.old_count == 0 else hunk.old_start - 1
|
|
if old_index < cursor:
|
|
raise AssemblyError(f"selected hunks overlap in {path}: {hunk.header}")
|
|
if hunk.old_count:
|
|
actual_removed = base_lines[old_index : old_index + hunk.old_count]
|
|
if actual_removed != list(hunk.removed):
|
|
raise AssemblyError(
|
|
f"base content does not match selected hunk in {path}: {hunk.header}"
|
|
)
|
|
output.extend(base_lines[cursor:old_index])
|
|
output.extend(selected_added_lines(hunk, mapping))
|
|
cursor = old_index + hunk.old_count
|
|
|
|
output.extend(base_lines[cursor:])
|
|
return "\n".join(output) + "\n", counts
|
|
|
|
|
|
def materialize_head(base_commit: str, destination: Path) -> None:
|
|
archive = destination.parent / f"{destination.name}.zip"
|
|
run(
|
|
[
|
|
"git",
|
|
"archive",
|
|
"--format=zip",
|
|
f"--output={archive}",
|
|
base_commit,
|
|
]
|
|
)
|
|
destination.mkdir(parents=True, exist_ok=True)
|
|
with zipfile.ZipFile(archive) as payload:
|
|
payload.extractall(destination)
|
|
|
|
|
|
def related_payload_paths(
|
|
manifest: dict[str, Any], hunk_map: dict[str, Any]
|
|
) -> list[str]:
|
|
classifications = manifest.get("classifications")
|
|
if not isinstance(classifications, dict):
|
|
raise AssemblyError("manifest classifications missing")
|
|
related = classifications.get("related")
|
|
if not isinstance(related, list):
|
|
raise AssemblyError("manifest related groups missing")
|
|
payload = hunk_map.get("payload")
|
|
if not isinstance(payload, dict):
|
|
raise AssemblyError("hunk map payload config missing")
|
|
excluded_groups = set(payload.get("excluded_related_group_ids", []))
|
|
excluded_paths = {
|
|
canonical_path(path) for path in payload.get("excluded_paths", [])
|
|
}
|
|
selected: set[str] = set()
|
|
seen_group_ids: set[str] = set()
|
|
for group in related:
|
|
if not isinstance(group, dict):
|
|
raise AssemblyError("related group must be an object")
|
|
group_id = group.get("id")
|
|
if not isinstance(group_id, str) or not group_id:
|
|
raise AssemblyError("related group id missing")
|
|
seen_group_ids.add(group_id)
|
|
if group_id in excluded_groups:
|
|
continue
|
|
paths = group.get("paths")
|
|
if not isinstance(paths, list):
|
|
raise AssemblyError(f"related group paths missing: {group_id}")
|
|
selected.update(canonical_path(path) for path in paths)
|
|
unknown_groups = excluded_groups - seen_group_ids
|
|
if unknown_groups:
|
|
raise AssemblyError(f"unknown excluded related groups: {sorted(unknown_groups)}")
|
|
return sorted(selected - excluded_paths)
|
|
|
|
|
|
def copy_related_files(paths: list[str], release_tree: Path) -> None:
|
|
for path in paths:
|
|
source = REPO_ROOT / path
|
|
if not source.is_file():
|
|
raise AssemblyError(f"related payload source missing: {path}")
|
|
target = release_tree / path
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(source, target)
|
|
|
|
|
|
def create_openapi_tool_link(temp_web: Path) -> Path:
|
|
source_modules = REPO_ROOT / "apps/web/node_modules"
|
|
source_tool = source_modules / "openapi-typescript"
|
|
source_cmd = source_modules / ".bin/openapi-typescript.cmd"
|
|
if not source_tool.is_dir() or not source_cmd.is_file():
|
|
raise AssemblyError("openapi-typescript is unavailable in apps/web/node_modules")
|
|
temp_modules = temp_web / "node_modules"
|
|
temp_bin = temp_modules / ".bin"
|
|
temp_bin.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(source_cmd, temp_bin / source_cmd.name)
|
|
junction = temp_modules / "openapi-typescript"
|
|
result = run(
|
|
["cmd.exe", "/d", "/c", "mklink", "/J", str(junction), str(source_tool)],
|
|
allow_failure=True,
|
|
)
|
|
if result.returncode:
|
|
detail = result.stderr.decode("utf-8", errors="replace").strip()
|
|
raise AssemblyError(f"cannot create temporary openapi-typescript junction: {detail}")
|
|
return junction
|
|
|
|
|
|
def regenerate_api(
|
|
release_tree: Path,
|
|
generation: dict[str, Any],
|
|
) -> dict[str, Any]:
|
|
path = canonical_path(generation.get("path"))
|
|
temp_web = release_tree / "apps/web"
|
|
junction = create_openapi_tool_link(temp_web)
|
|
env = os.environ.copy()
|
|
env["PYTHONUTF8"] = "1"
|
|
try:
|
|
generate_command = generation.get("generate_command")
|
|
check_command = generation.get("check_command")
|
|
if generate_command != "npm.cmd run generate:api-types":
|
|
raise AssemblyError("generated file command is not the canonical npm generator")
|
|
if check_command != "npm.cmd run check:api-types":
|
|
raise AssemblyError("generated file check is not canonical")
|
|
run(["npm.cmd", "run", "generate:api-types"], cwd=temp_web, env=env)
|
|
generated = release_tree / path
|
|
if not generated.is_file():
|
|
raise AssemblyError(f"API generator did not create {path}")
|
|
first_hash = hashlib.sha256(generated.read_bytes()).hexdigest()
|
|
run(["npm.cmd", "run", "check:api-types"], cwd=temp_web, env=env)
|
|
second_hash = hashlib.sha256(generated.read_bytes()).hexdigest()
|
|
if first_hash != second_hash:
|
|
raise AssemblyError("check:api-types changed generated output")
|
|
text = generated.read_text(encoding="utf-8")
|
|
missing = [
|
|
marker
|
|
for marker in generation.get("required_markers", [])
|
|
if marker not in text
|
|
]
|
|
forbidden = [
|
|
marker
|
|
for marker in generation.get("forbidden_markers", [])
|
|
if marker in text
|
|
]
|
|
if missing:
|
|
raise AssemblyError(f"regenerated API is missing required markers: {missing}")
|
|
if forbidden:
|
|
raise AssemblyError(f"regenerated API contains unrelated markers: {forbidden}")
|
|
return {
|
|
"path": path,
|
|
"sha256": first_hash,
|
|
"bytes": len(generated.read_bytes()),
|
|
"required_markers": len(generation.get("required_markers", [])),
|
|
"forbidden_markers_absent": len(generation.get("forbidden_markers", [])),
|
|
"official_generate": True,
|
|
"official_check": True,
|
|
}
|
|
finally:
|
|
if junction.exists():
|
|
os.rmdir(junction)
|
|
|
|
|
|
def text_lines(path: Path) -> list[str]:
|
|
payload = path.read_bytes()
|
|
if b"\0" in payload:
|
|
raise AssemblyError(f"binary release payload is unsupported: {path}")
|
|
try:
|
|
text = payload.decode("utf-8")
|
|
except UnicodeDecodeError as exc:
|
|
raise AssemblyError(f"release payload is not UTF-8: {path}") from exc
|
|
# ``git archive`` may materialize text according to checkout attributes and
|
|
# the source worktree may use a different EOL policy. A release patch must
|
|
# compare against the canonical Git blobs, not one machine's checkout EOL.
|
|
# Normalizing before ``difflib`` keeps the patch applicable both to the
|
|
# canonical index and to CRLF/LF clean worktrees handled by ``git apply``.
|
|
canonical = text.replace("\r\n", "\n").replace("\r", "\n")
|
|
return canonical.splitlines(keepends=True)
|
|
|
|
|
|
def build_patch(base_tree: Path, release_tree: Path, paths: list[str]) -> tuple[str, int]:
|
|
chunks: list[str] = []
|
|
changed_files = 0
|
|
for path in sorted(set(paths)):
|
|
base_path = base_tree / path
|
|
release_path = release_tree / path
|
|
if not release_path.is_file():
|
|
raise AssemblyError(f"assembled release file missing: {path}")
|
|
old_lines = text_lines(base_path) if base_path.is_file() else []
|
|
new_lines = text_lines(release_path)
|
|
if old_lines == new_lines:
|
|
raise AssemblyError(f"declared release payload has no diff from base: {path}")
|
|
changed_files += 1
|
|
chunks.append(f"diff --git a/{path} b/{path}\n")
|
|
if not base_path.exists():
|
|
chunks.append("new file mode 100644\n")
|
|
from_file = "/dev/null"
|
|
else:
|
|
from_file = f"a/{path}"
|
|
chunks.extend(
|
|
difflib.unified_diff(
|
|
old_lines,
|
|
new_lines,
|
|
fromfile=from_file,
|
|
tofile=f"b/{path}",
|
|
n=3,
|
|
lineterm="\n",
|
|
)
|
|
)
|
|
return "".join(chunks), changed_files
|
|
|
|
|
|
def validate_mixed_coverage(
|
|
manifest: dict[str, Any], hunk_map: dict[str, Any]
|
|
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
|
classifications = manifest.get("classifications", {})
|
|
mixed = classifications.get("mixed") if isinstance(classifications, dict) else None
|
|
if not isinstance(mixed, list):
|
|
raise AssemblyError("manifest mixed classification missing")
|
|
manifest_paths: set[str] = set()
|
|
for entry in mixed:
|
|
if not isinstance(entry, dict) or entry.get("disposition") != "hunk-review-required":
|
|
raise AssemblyError("every manifest mixed entry must remain hunk-review-required")
|
|
paths = entry.get("paths")
|
|
if not isinstance(paths, list):
|
|
raise AssemblyError("manifest mixed paths missing")
|
|
manifest_paths.update(canonical_path(path) for path in paths)
|
|
|
|
generated = hunk_map.get("generated_files")
|
|
documents = hunk_map.get("documents")
|
|
if not isinstance(generated, list) or not isinstance(documents, list):
|
|
raise AssemblyError("hunk map generated_files/documents must be arrays")
|
|
mapped_paths = {
|
|
canonical_path(item.get("path"))
|
|
for item in [*generated, *documents]
|
|
if isinstance(item, dict)
|
|
}
|
|
if manifest_paths != mapped_paths:
|
|
raise AssemblyError(
|
|
f"mixed path coverage drift: manifest={sorted(manifest_paths)}, "
|
|
f"map={sorted(mapped_paths)}"
|
|
)
|
|
return generated, documents
|
|
|
|
|
|
def main() -> int:
|
|
args = parse_args()
|
|
hunk_map_path = args.map if args.map.is_absolute() else REPO_ROOT / args.map
|
|
before: dict[str, str | None] = {}
|
|
output_path = ""
|
|
prior_output: bytes | None = None
|
|
prior_output_existed = False
|
|
output_replaced = False
|
|
try:
|
|
hunk_map = load_json(hunk_map_path)
|
|
if hunk_map.get("schema_version") != "1.0":
|
|
raise AssemblyError("hunk map schema_version must be 1.0")
|
|
base_commit = hunk_map.get("base_commit")
|
|
if not isinstance(base_commit, str) or current_head() != base_commit:
|
|
raise AssemblyError(
|
|
f"HEAD drift: expected {base_commit}, current {current_head()}"
|
|
)
|
|
manifest_path = REPO_ROOT / canonical_path(hunk_map.get("manifest"))
|
|
manifest = load_json(manifest_path)
|
|
generated, documents = validate_mixed_coverage(manifest, hunk_map)
|
|
if len(generated) != 1:
|
|
raise AssemblyError("exactly one generated mixed file is expected")
|
|
output_path = canonical_path(hunk_map.get("output_patch"))
|
|
destination = REPO_ROOT / output_path
|
|
prior_output_existed = destination.is_file()
|
|
if prior_output_existed:
|
|
prior_output = destination.read_bytes()
|
|
before = snapshot_worktree()
|
|
|
|
document_outputs: dict[str, str] = {}
|
|
document_counts: dict[str, dict[str, int]] = {}
|
|
for document in documents:
|
|
if not isinstance(document, dict):
|
|
raise AssemblyError("document mapping must be an object")
|
|
path = canonical_path(document.get("path"))
|
|
assembled, counts = assemble_document(base_commit, document)
|
|
document_outputs[path] = assembled
|
|
document_counts[path] = counts
|
|
|
|
payload_paths = related_payload_paths(manifest, hunk_map)
|
|
mixed_paths = [
|
|
canonical_path(item.get("path"))
|
|
for item in [*generated, *documents]
|
|
]
|
|
release_paths = sorted(set(payload_paths) | set(mixed_paths))
|
|
|
|
with tempfile.TemporaryDirectory(prefix="vignette-outcome-release-") as temp_name:
|
|
temp_root = Path(temp_name)
|
|
base_tree = temp_root / "base"
|
|
release_tree = temp_root / "release"
|
|
materialize_head(base_commit, base_tree)
|
|
materialize_head(base_commit, release_tree)
|
|
copy_related_files(payload_paths, release_tree)
|
|
for path, content in document_outputs.items():
|
|
target = release_tree / path
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
target.write_text(content, encoding="utf-8", newline="\n")
|
|
api_report = regenerate_api(release_tree, generated[0])
|
|
patch_text, changed_files = build_patch(
|
|
base_tree, release_tree, release_paths
|
|
)
|
|
if not patch_text:
|
|
raise AssemblyError("assembled patch is empty")
|
|
candidate_patch = temp_root / "release-candidate.patch"
|
|
candidate_patch.write_text(patch_text, encoding="utf-8", newline="\n")
|
|
apply_check = run(
|
|
[
|
|
"git",
|
|
"apply",
|
|
"--check",
|
|
"--whitespace=nowarn",
|
|
str(candidate_patch),
|
|
],
|
|
cwd=base_tree,
|
|
)
|
|
if apply_check.returncode != 0:
|
|
raise AssemblyError("git apply --check failed")
|
|
|
|
# Prove the same patch against a canonical clean index as well.
|
|
# This catches checkout-only patches whose CRLF hunk context works
|
|
# on Windows but fails in CI/Linux or with ``git apply --cached``.
|
|
canonical_index = temp_root / "canonical.index"
|
|
index_env = os.environ.copy()
|
|
index_env["GIT_INDEX_FILE"] = str(canonical_index)
|
|
run(["git", "read-tree", base_commit], env=index_env)
|
|
cached_apply_check = run(
|
|
[
|
|
"git",
|
|
"apply",
|
|
"--cached",
|
|
"--check",
|
|
"--whitespace=nowarn",
|
|
str(candidate_patch),
|
|
],
|
|
env=index_env,
|
|
)
|
|
if cached_apply_check.returncode != 0:
|
|
raise AssemblyError("canonical-index git apply --cached --check failed")
|
|
|
|
# Do not replace the last verified artifact until every validation
|
|
# and the source snapshot guard passes. A concurrent edit must
|
|
# leave the previous patch intact instead of publishing a partial
|
|
# or stale candidate.
|
|
verify_worktree_unchanged(before, output_path)
|
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
# ``tempfile`` may live on C: while the repository lives on D:.
|
|
# Windows cannot atomically replace across volumes, so first copy
|
|
# the fully verified candidate beside the destination and only
|
|
# then perform the same-volume atomic swap.
|
|
publish_candidate = destination.with_name(
|
|
f".{destination.name}.{os.getpid()}.tmp"
|
|
)
|
|
try:
|
|
shutil.copyfile(candidate_patch, publish_candidate)
|
|
os.replace(publish_candidate, destination)
|
|
finally:
|
|
if publish_candidate.exists():
|
|
publish_candidate.unlink()
|
|
output_replaced = True
|
|
|
|
verify_worktree_unchanged(before, output_path)
|
|
patch_bytes = (REPO_ROOT / output_path).read_bytes()
|
|
if b"\r\n" in patch_bytes or b"\r" in patch_bytes:
|
|
raise AssemblyError("release patch must use canonical LF-only line endings")
|
|
report = {
|
|
"ok": True,
|
|
"base_commit": base_commit,
|
|
"output_patch": output_path,
|
|
"patch_sha256": hashlib.sha256(patch_bytes).hexdigest(),
|
|
"patch_bytes": len(patch_bytes),
|
|
"patch_files": changed_files,
|
|
"api_generation": api_report,
|
|
"document_hunks": document_counts,
|
|
"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",
|
|
}
|
|
if args.json:
|
|
print(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True))
|
|
else:
|
|
print(
|
|
"PASS outcome-os release patch: "
|
|
f"files={changed_files}, bytes={len(patch_bytes)}, "
|
|
f"sha256={report['patch_sha256']}; git apply --check passed"
|
|
)
|
|
return 0
|
|
except (AssemblyError, OSError, zipfile.BadZipFile) as exc:
|
|
if output_replaced and output_path:
|
|
destination = REPO_ROOT / output_path
|
|
if prior_output_existed and prior_output is not None:
|
|
destination.write_bytes(prior_output)
|
|
elif destination.exists():
|
|
destination.unlink()
|
|
if before and output_path:
|
|
try:
|
|
verify_worktree_unchanged(before, output_path)
|
|
except AssemblyError as mutation_error:
|
|
exc = AssemblyError(f"{exc}; {mutation_error}")
|
|
report = {"ok": False, "error": str(exc)}
|
|
if args.json:
|
|
print(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True))
|
|
else:
|
|
print(f"FAIL outcome-os release patch: {exc}", file=sys.stderr)
|
|
return 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|