diff --git a/Makefile b/Makefile index 355d4e6..351347d 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ GITLEAKS := gitleaks PYTHONPYCACHEPREFIX := /tmp/docforge-quality-pycache PYTEST_BASETEMP := /tmp/docforge-quality-pytest -.PHONY: accessibility adoption-m4 benchmark benchmark-m1 benchmark-m1-smoke benchmark-m2 benchmark-m2-smoke benchmark-m3 benchmark-m3-full benchmark-m3-smoke benchmark-m4 benchmark-m4-full benchmark-m4-smoke benchmark-smoke build command-reference-check compatibility-m5 compile concurrency-m5 contract dependencies docs-check format-check gate lint lock migration-m5 recovery-m5 release-artifacts release-gate release-posttag release-pretag secret-scan task-evidence-m5 task-evidence-m5-smoke test type version-check +.PHONY: accessibility adoption-m4 benchmark benchmark-full benchmark-m1 benchmark-m1-smoke benchmark-m2 benchmark-m2-smoke benchmark-m3 benchmark-m3-full benchmark-m3-smoke benchmark-m4 benchmark-m4-full benchmark-m4-smoke benchmark-smoke build command-reference-check compatibility-m5 compile concurrency-m5 contract dependencies docs-check format-check fresh-clone-m5 gate lint lock migration-m5 recovery-m5 release-artifacts release-gate release-posttag release-pretag secret-scan task-evidence-m5 task-evidence-m5-smoke test type version-check accessibility: $(NPM) run test:accessibility @@ -125,6 +125,7 @@ recovery-m5: tests/test_python_reference_adapter.py \ tests/test_javascript_reference_adapter.py \ tests/test_cpp_reference_adapter.py \ + tests/test_generation_diff.py \ tests/test_rendering.py \ tests/test_graph_publication.py \ tests/test_projection_fragments.py @@ -176,11 +177,17 @@ benchmark-m4: benchmark-m4-full: benchmark-m4 +benchmark-full: benchmark benchmark-m1 benchmark-m2 benchmark-m3-full benchmark-m4-full + gate: format-check lint type compile contract test accessibility lock dependencies build docs-check benchmark-smoke benchmark-m1-smoke benchmark-m2-smoke benchmark-m3-smoke benchmark-m4-smoke -release-gate: gate compatibility-m5 migration-m5 concurrency-m5 recovery-m5 task-evidence-m5 adoption-m4 version-check release-artifacts secret-scan +release-gate: gate compatibility-m5 migration-m5 concurrency-m5 recovery-m5 task-evidence-m5 adoption-m4 version-check release-artifacts secret-scan benchmark-full -release-pretag: release-gate +fresh-clone-m5: + $(PYTHON) tools/milestone5_fresh_clone.py \ + --output /tmp/docforge-milestone5-fresh-clone.json > /dev/null + +release-pretag: release-gate fresh-clone-m5 $(PYTHON) tools/check_release_identity.py --mode smoke --require-clean \ --tag-state absent > /dev/null diff --git a/tests/test_milestone5_fresh_clone.py b/tests/test_milestone5_fresh_clone.py new file mode 100644 index 0000000..2724d91 --- /dev/null +++ b/tests/test_milestone5_fresh_clone.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +import unittest + +from tools.milestone5_fresh_clone import ( + COMMIT_PATTERN, + EXPECTED_ORIGINS, + PUBLIC_REPOSITORY, +) + + +class Milestone5FreshCloneTests(unittest.TestCase): + def test_public_clone_route_has_no_embedded_credentials(self) -> None: + self.assertEqual( + "https://repo.andraxion.net/administrator/DocForge2.git", + PUBLIC_REPOSITORY, + ) + self.assertNotIn("@", PUBLIC_REPOSITORY) + self.assertIn(PUBLIC_REPOSITORY, EXPECTED_ORIGINS) + + def test_release_commit_requires_one_full_lowercase_sha1(self) -> None: + self.assertIsNotNone(COMMIT_PATTERN.fullmatch("a" * 40)) + for invalid in ("a" * 39, "A" * 40, "main", "v1.4.0", "../" + "a" * 40): + with self.subTest(invalid=invalid): + self.assertIsNone(COMMIT_PATTERN.fullmatch(invalid)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/milestone5_fresh_clone.py b/tools/milestone5_fresh_clone.py new file mode 100644 index 0000000..02703da --- /dev/null +++ b/tools/milestone5_fresh_clone.py @@ -0,0 +1,171 @@ +"""Run the complete release gate from an anonymous clone at one exact commit.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import re +import subprocess +import sys +import tempfile +import time +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +PUBLIC_REPOSITORY = "https://repo.andraxion.net/administrator/DocForge2.git" +EXPECTED_ORIGINS = { + "forgejo@repo.andraxion.net:administrator/DocForge2.git", + PUBLIC_REPOSITORY, + PUBLIC_REPOSITORY.removesuffix(".git"), +} +COMMIT_PATTERN = re.compile(r"^[0-9a-f]{40}$") +COMMAND_TIMEOUT_SECONDS = 30 * 60 + + +class FreshCloneError(RuntimeError): + """The anonymous exact-commit release rehearsal failed.""" + + +def _run( + arguments: list[str], + *, + cwd: Path, + environment: dict[str, str] | None = None, +) -> subprocess.CompletedProcess[str]: + completed = subprocess.run( + arguments, + cwd=cwd, + env=environment, + check=False, + capture_output=True, + text=True, + timeout=COMMAND_TIMEOUT_SECONDS, + ) + if completed.returncode != 0: + diagnostic = (completed.stdout + completed.stderr)[-4000:] + raise FreshCloneError(f"Command failed ({' '.join(arguments)}):\n{diagnostic.rstrip()}") + return completed + + +def _head_commit() -> str: + commit = _run(["git", "rev-parse", "HEAD"], cwd=ROOT).stdout.strip() + if COMMIT_PATTERN.fullmatch(commit) is None: + raise FreshCloneError("Current release commit is invalid") + return commit + + +def _verify_source(commit: str) -> None: + if _head_commit() != commit: + raise FreshCloneError("Requested fresh-clone commit is not the current candidate") + if _run(["git", "status", "--porcelain"], cwd=ROOT).stdout: + raise FreshCloneError("Fresh-clone proof requires a clean source candidate") + origin = _run(["git", "remote", "get-url", "origin"], cwd=ROOT).stdout.strip() + if origin not in EXPECTED_ORIGINS: + raise FreshCloneError("Origin is not the public DocForge2 successor repository") + remote = _run( + ["git", "ls-remote", PUBLIC_REPOSITORY, "refs/heads/main", "refs/heads/dev"], + cwd=ROOT, + ).stdout + remote_commits = {line.split()[0] for line in remote.splitlines() if line.split()} + if commit not in remote_commits: + raise FreshCloneError("Release candidate is not published on main or dev") + + +def _digest(value: str) -> str: + return hashlib.sha256(value.encode("utf-8")).hexdigest() + + +def run_fresh_clone_gate(commit: str) -> dict[str, object]: + """Clone the public successor anonymously and run its complete release gate.""" + + if COMMIT_PATTERN.fullmatch(commit) is None: + raise FreshCloneError("Fresh-clone commit must be one full lowercase SHA-1") + _verify_source(commit) + started = time.perf_counter_ns() + with tempfile.TemporaryDirectory(prefix="docforge-m5-fresh-clone-") as directory_name: + parent = Path(directory_name) + clone = parent / "DocForge2" + clone_result = _run( + ["git", "clone", "--no-tags", PUBLIC_REPOSITORY, str(clone)], + cwd=parent, + ) + _run(["git", "checkout", "--detach", commit], cwd=clone) + checked_out = _run(["git", "rev-parse", "HEAD"], cwd=clone).stdout.strip() + if checked_out != commit: + raise FreshCloneError("Anonymous clone did not check out the requested commit") + fsck = _run(["git", "fsck", "--full"], cwd=clone) + environment = dict(os.environ) + environment["UV_LINK_MODE"] = "copy" + sync = _run(["uv", "sync", "--frozen", "--offline"], cwd=clone, environment=environment) + npm = _run(["npm", "ci", "--offline"], cwd=clone, environment=environment) + gate = _run(["make", "release-gate"], cwd=clone, environment=environment) + status = _run(["git", "status", "--porcelain"], cwd=clone).stdout + if status: + raise FreshCloneError("Release gate left the anonymous clone dirty") + version = _run( + [str(clone / ".venv/bin/python"), "-m", "docforge.cli", "--version"], + cwd=clone, + environment=environment, + ).stdout.strip() + elapsed_ms = round((time.perf_counter_ns() - started) / 1_000_000, 3) + return { + "schema_version": 1, + "repository": PUBLIC_REPOSITORY, + "authentication": "anonymous_https", + "commit": commit, + "clean_after_gate": True, + "version_surface": version, + "elapsed_ms": elapsed_ms, + "logs": { + "clone_sha256": _digest(clone_result.stdout + clone_result.stderr), + "fsck_sha256": _digest(fsck.stdout + fsck.stderr), + "sync_sha256": _digest(sync.stdout + sync.stderr), + "npm_sha256": _digest(npm.stdout + npm.stderr), + "release_gate_sha256": _digest(gate.stdout + gate.stderr), + "release_gate_bytes": len((gate.stdout + gate.stderr).encode("utf-8")), + }, + } + + +def _write_output(path: Path, payload: bytes) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + descriptor, temporary_name = tempfile.mkstemp(prefix=".m5-fresh-clone-", dir=path.parent) + temporary = Path(temporary_name) + try: + with os.fdopen(descriptor, "wb") as handle: + handle.write(payload) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, path) + directory_fd = os.open(path.parent, os.O_RDONLY) + try: + os.fsync(directory_fd) + finally: + os.close(directory_fd) + except Exception: + temporary.unlink(missing_ok=True) + raise + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--commit") + parser.add_argument("--output", type=Path) + arguments = parser.parse_args() + try: + commit = arguments.commit or _head_commit() + evidence = run_fresh_clone_gate(commit) + except (FreshCloneError, OSError, subprocess.SubprocessError) as error: + print(str(error), file=sys.stderr) + return 2 + payload = json.dumps(evidence, sort_keys=True, separators=(",", ":")).encode("utf-8") + b"\n" + if arguments.output is not None: + _write_output(arguments.output, payload) + print(payload.decode("utf-8"), end="") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())