Add full and fresh-clone release rehearsals
This commit is contained in:
parent
a901c9705b
commit
f41a45213b
3 changed files with 210 additions and 3 deletions
13
Makefile
13
Makefile
|
|
@ -6,7 +6,7 @@ GITLEAKS := gitleaks
|
||||||
PYTHONPYCACHEPREFIX := /tmp/docforge-quality-pycache
|
PYTHONPYCACHEPREFIX := /tmp/docforge-quality-pycache
|
||||||
PYTEST_BASETEMP := /tmp/docforge-quality-pytest
|
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:
|
accessibility:
|
||||||
$(NPM) run test:accessibility
|
$(NPM) run test:accessibility
|
||||||
|
|
@ -125,6 +125,7 @@ recovery-m5:
|
||||||
tests/test_python_reference_adapter.py \
|
tests/test_python_reference_adapter.py \
|
||||||
tests/test_javascript_reference_adapter.py \
|
tests/test_javascript_reference_adapter.py \
|
||||||
tests/test_cpp_reference_adapter.py \
|
tests/test_cpp_reference_adapter.py \
|
||||||
|
tests/test_generation_diff.py \
|
||||||
tests/test_rendering.py \
|
tests/test_rendering.py \
|
||||||
tests/test_graph_publication.py \
|
tests/test_graph_publication.py \
|
||||||
tests/test_projection_fragments.py
|
tests/test_projection_fragments.py
|
||||||
|
|
@ -176,11 +177,17 @@ benchmark-m4:
|
||||||
|
|
||||||
benchmark-m4-full: 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
|
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 \
|
$(PYTHON) tools/check_release_identity.py --mode smoke --require-clean \
|
||||||
--tag-state absent > /dev/null
|
--tag-state absent > /dev/null
|
||||||
|
|
||||||
|
|
|
||||||
29
tests/test_milestone5_fresh_clone.py
Normal file
29
tests/test_milestone5_fresh_clone.py
Normal file
|
|
@ -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()
|
||||||
171
tools/milestone5_fresh_clone.py
Normal file
171
tools/milestone5_fresh_clone.py
Normal file
|
|
@ -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())
|
||||||
Loading…
Add table
Add a link
Reference in a new issue