"""Build on ces-control; reuse only verified images with identical build inputs.""" import fcntl import hashlib import json import os from pathlib import Path import re import shutil import subprocess import sys import time ROOT = Path(__file__).resolve().parents[1] APP = "endorphin-tabularium-docs" INPUTS = ["Dockerfile", "Dockerfile.ci", ".dockerignore", ".npmrc", "package.json", "pnpm-lock.yaml", "pnpm-workspace.yaml", "apps/docs", "content", "tools/figures", "scripts/check-container.sh", "scripts/ci.py", "scripts/summarize-ai-income.py", "scripts/audit-ai-research.py", "scripts/export-legacy-documents.py"] def run(*args: str, capture: bool = False) -> str: result = subprocess.run(args, cwd=ROOT, text=True, check=True, stdout=subprocess.PIPE if capture else None) return result.stdout.strip() if capture else "" def fingerprint() -> str: files = run("git", "ls-files", "--cached", "--others", "--exclude-standard", "-z", "--", *INPUTS, capture=True) digest = hashlib.sha256() for name in sorted(set(files.split("\0"))): if not name: continue data = (ROOT / name).read_bytes() digest.update(name.encode() + b"\0" + str(len(data)).encode() + b"\0" + data) return digest.hexdigest() def smoke_image(image: str, name: str) -> None: run("docker", "run", "-d", "--name", name, "--mount", "type=volume,dst=/auth", "-e", "DOCUMENT_SMOKE_TEST=1", "-p", "127.0.0.1::3000", image) try: run("docker", "exec", name, "node", "apps/docs/auth-cli.cjs", "init") subprocess.run(["docker", "exec", "-i", name, "node", "apps/docs/docs-cli.cjs", "import"], input=(ROOT / ".artifacts/documents-migration.json").read_bytes(), check=True) run("docker", "exec", name, "node", "apps/docs/docs-cli.cjs", "export", "/auth/migrated-documents.json") migrated = json.loads(run("docker", "exec", name, "cat", "/auth/migrated-documents.json", capture=True)) expected = json.loads((ROOT / ".artifacts/documents-migration.json").read_text()) if migrated != expected: raise RuntimeError("Migration changed document content or catalog metadata") run("docker", "exec", name, "node", "apps/docs/docs-cli.cjs", "backup", "/auth/smoke-backup.sqlite") for _ in range(60): state = json.loads(run("docker", "inspect", name, capture=True))[0]["State"] if state.get("Health", {}).get("Status") == "healthy": run("docker", "exec", name, "node", "apps/docs/auth-cli.cjs", "exercise") run("docker", "restart", name) # Counter/replay state is on the same durable mount after restart. run("docker", "exec", name, "node", "apps/docs/auth-cli.cjs", "state-check") for _ in range(30): state = json.loads(run("docker", "inspect", name, capture=True))[0]["State"] if state.get("Health", {}).get("Status") == "healthy": run("docker", "exec", name, "node", "apps/docs/auth-cli.cjs", "verify") return time.sleep(1) raise RuntimeError("Authenticated image failed restart verification") if not state["Running"]: break time.sleep(1) run("docker", "logs", "--tail", "80", name) raise RuntimeError("Production image did not become healthy") finally: run("docker", "rm", "-fv", name) def main() -> None: os.chdir(ROOT) migration = ROOT / ".artifacts/documents-migration.json" migration.parent.mkdir(exist_ok=True) migration.unlink(missing_ok=True) run(sys.executable, "scripts/export-legacy-documents.py", "--output", str(migration)) run(sys.executable, "scripts/summarize-ai-income.py", "--check") run(sys.executable, "scripts/audit-ai-research.py", "--check") run(sys.executable, "-m", "unittest", "discover", "-s", "scripts", "-p", "test_*.py") scope = os.environ.get("CI_CACHE_SCOPE", "main") if not re.fullmatch(r"main|pr-[0-9]+", scope): raise ValueError("Unsupported cache scope") with open(f"/var/lock/{APP}-{scope}-build.lock", "w") as lock: fcntl.flock(lock, fcntl.LOCK_EX) key = fingerprint() verified = f"{APP}:verified-{scope}-{key}" result = subprocess.run(["docker", "image", "inspect", verified], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) smoke_name = f"tabularium-smoke-{scope}-{os.getpid()}" if result.returncode == 0: print(f"CACHE HIT: reuse {verified}; no dependency install or next build", flush=True) smoke_image(verified, smoke_name) else: print(f"CACHE MISS: {key}; dependency and Next.js caches remain persistent", flush=True) checks = f"{APP}-checks:{scope}" run("docker", "build", "-f", "Dockerfile.ci", "--target", "checks", "-t", checks, ".") name = f"tabularium-checks-{scope}-{os.getpid()}" output = ROOT / ".artifacts/runtime" if output.exists(): shutil.rmtree(output) output.mkdir(parents=True) try: run("docker", "run", "--name", name, "--mount", f"type=volume,src={APP}-{scope}-pnpm,dst=/pnpm/store", "--mount", f"type=volume,src={APP}-{scope}-uv,dst=/root/.cache/uv", "--mount", f"type=volume,src={APP}-{scope}-next,dst=/workspace/apps/docs/.next/cache", "--mount", f"type=volume,src={APP}-{scope}-swc,dst=/root/.cache/next-swc", checks) run("docker", "cp", f"{name}:/workspace/apps/docs/.next/standalone/.", str(output)) app = output / "apps/docs" (app / ".next").mkdir(parents=True, exist_ok=True) run("docker", "cp", f"{name}:/workspace/apps/docs/.next/static", str(app / ".next/static")) run("docker", "cp", f"{name}:/workspace/apps/docs/public", str(app / "public")) candidate = f"{APP}:candidate-{scope}" run("docker", "build", "-f", str(ROOT / "Dockerfile"), "--label", f"io.entroia.tabularium.fingerprint={key}", "-t", candidate, str(output)) smoke_image(candidate, smoke_name) run("docker", "tag", candidate, verified) finally: subprocess.run(["docker", "rm", "-f", name], check=False) (ROOT / ".artifacts").mkdir(exist_ok=True) (ROOT / ".artifacts/verified-image").write_text(verified + "\n") print(f"VERIFIED IMAGE: {verified}", flush=True) if __name__ == "__main__": main()