#!/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())