108 lines
4.8 KiB
Python
108 lines
4.8 KiB
Python
"""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"]
|
|
|
|
|
|
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:
|
|
from smoke import verify
|
|
run("docker", "run", "-d", "--name", name, "-p", "127.0.0.1::3000", image)
|
|
try:
|
|
port = run("docker", "port", name, "3000/tcp", capture=True).rsplit(":", 1)[1]
|
|
for _ in range(60):
|
|
state = json.loads(run("docker", "inspect", name, capture=True))[0]["State"]
|
|
if state.get("Health", {}).get("Status") == "healthy":
|
|
verify(f"http://127.0.0.1:{port}")
|
|
return
|
|
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", "-f", name)
|
|
|
|
|
|
def main() -> None:
|
|
os.chdir(ROOT)
|
|
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()
|