#!/usr/bin/env python3 """Content-verified font stages; publish only outputs produced by this build.""" from __future__ import annotations import argparse from contextlib import contextmanager from dataclasses import dataclass import fcntl import hashlib import json import os import platform from pathlib import Path import subprocess import sys import tempfile from font_project import ROOT, STYLES SCHEMA = 1 KINDS = ("ttf", "woff2", "nerd") @dataclass(frozen=True, slots=True) class Stage: style: str kind: str relative_path: str key: str @dataclass(frozen=True, slots=True) class Plan: root: Path required: tuple[Stage, ...] pending: tuple[Stage, ...] receipts: dict[str, dict[str, str]] def sha256(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as handle: for block in iter(lambda: handle.read(1 << 20), b""): digest.update(block) return digest.hexdigest() def _digest(value: object) -> str: return hashlib.sha256(json.dumps(value, sort_keys=True).encode()).hexdigest() def input_keys(source_root: Path = ROOT, *, skip_nerd: bool = False) -> dict[str, str]: """Hash each relevant input once; presentation edits are not font inputs. The direct builder currently includes Nerd extents in the clean clipping box unless --skip-nerd is used. That mode therefore belongs to the base key, not merely to the requested-output list. """ common = [ source_root / "VERSION", source_root / "pyproject.toml", source_root / "uv.lock", source_root / "mise.toml", source_root / "features/code-forms.fea", source_root / "features/ligatures.json", source_root / "scripts/build_fonts.py", source_root / "scripts/build_cache.py", source_root / "scripts/optical_fitting.py", *sorted((source_root / "scripts").glob("font_*.py")), *sorted((source_root / "tools").rglob("*.py")), *sorted((source_root / "tools").rglob("*.toml")), *sorted((source_root / "upstream/fonts/ttf").glob("*.ttf")), *sorted((source_root / "upstream/fonts/variable").glob("*.ttf")), ] nerd_source = source_root / "vendor/nerd-fonts/SymbolsNerdFontMono-v3.4.0-NoFontLogos.ttf" if not skip_nerd: common.append(nerd_source) files = { str(path.relative_to(source_root)): sha256(path) for path in dict.fromkeys(common) } base = _digest({ "schema": SCHEMA, "inputs": files, "python": sys.version, "platform": sys.platform, "machine": platform.machine(), "epoch": os.environ.get("SOURCE_DATE_EPOCH", "0"), "nerd_metrics": not skip_nerd, }) return {kind: _digest([kind, base]) for kind in KINDS} def stages(style: str, keys: dict[str, str]) -> tuple[Stage, ...]: return ( Stage(style, "ttf", f"ttf/IltaMono-{style}.ttf", _digest([style, keys["ttf"]])), Stage(style, "woff2", f"woff2/IltaMono-{style}.woff2", _digest([style, keys["woff2"]])), Stage(style, "nerd", f"nerd-font/IltaMonoNerdFont-{style}.ttf", _digest([style, keys["nerd"]])), ) def read_receipts(root: Path) -> dict[str, dict[str, str]]: try: data = json.loads((root / ".build-cache.json").read_text(encoding="utf-8")) except (OSError, ValueError): return {} if not isinstance(data, dict) or data.get("schema") != SCHEMA or data.get("root") != str(root): return {} records = data.get("outputs") if not isinstance(records, dict): return {} for path, record in records.items(): if not isinstance(path, str) or not isinstance(record, dict): return {} for field in ("key", "sha256"): value = record.get(field) if not isinstance(value, str) or len(value) != 64 or any(c not in "0123456789abcdef" for c in value): return {} return records def plan( output_root: Path, keys: dict[str, str], *, force: bool = False, skip_woff2: bool = False, skip_nerd: bool = False, styles: tuple[str, ...] = tuple(style.name for style in STYLES), ) -> Plan: root = output_root.resolve() receipts = read_receipts(root) required = [] pending = [] for style in styles: base_valid = False for stage in stages(style, keys): if (stage.kind == "woff2" and skip_woff2) or (stage.kind == "nerd" and skip_nerd): continue required.append(stage) receipt = receipts.get(stage.relative_path, {}) path = root / stage.relative_path valid = ( not force and receipt.get("key") == stage.key and path.is_file() and receipt.get("sha256") == sha256(path) ) if stage.kind == "ttf": base_valid = valid elif not base_valid: valid = False if not valid: pending.append(stage) return Plan(root, tuple(required), tuple(pending), receipts) def _atomic_json(path: Path, payload: object) -> None: with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=path.parent, prefix=path.name, delete=False) as handle: temporary = Path(handle.name) try: json.dump(payload, handle, sort_keys=True, indent=2) handle.write("\n") handle.flush() os.fsync(handle.fileno()) except BaseException: temporary.unlink(missing_ok=True) raise try: os.replace(temporary, path) finally: temporary.unlink(missing_ok=True) def execute(current: Plan, *, jobs: int, skip_nerd: bool) -> bool: """Stage work privately, then publish complete outputs and their receipts. A missing WOFF2 uses only a hash-verified base. A missing Nerd derivative rebuilds that style with the direct builder; it never blesses stale files merely because they happen to exist. Skipped receipts retain their old key. """ if not current.pending: print("[cache] verified font outputs; no-op") return False current.root.mkdir(parents=True, exist_ok=True) rebuild = {stage.style for stage in current.pending if stage.kind != "woff2"} with tempfile.TemporaryDirectory(prefix=".font-build-", dir=current.root) as temporary: work = Path(temporary) if rebuild: command = [ sys.executable, str(ROOT / "scripts/build_fonts.py"), "--output-dir", str(work), "--jobs", str(jobs), "--styles", *sorted(rebuild), ] if skip_nerd: command.append("--skip-nerd") if not any(s.kind == "woff2" and s.style in rebuild for s in current.pending): command.append("--skip-woff2") subprocess.run(command, check=True) for stage in current.pending: if stage.style in rebuild: continue # A pending WOFF2 is the only path that can avoid the direct build. from font_output import save_woff2_from_ttf base = current.root / "ttf" / f"IltaMono-{stage.style}.ttf" save_woff2_from_ttf(base, work / stage.relative_path) print(f"[cache] regenerated {stage.relative_path} from verified TTF") # Check every expected file before replacing even the first old output. produced = { stage.relative_path: {"key": stage.key, "sha256": sha256(work / stage.relative_path)} for stage in current.pending } for stage in current.pending: destination = current.root / stage.relative_path destination.parent.mkdir(parents=True, exist_ok=True) os.replace(work / stage.relative_path, destination) receipts = {**current.receipts, **produced} # Even under --force with unchanged source keys, a newly produced # base invalidates skipped derivatives. Never assume its bytes match # the previous base just because the compiler should be deterministic. for base in current.pending: if base.kind != "ttf": continue for relative in ( f"woff2/IltaMono-{base.style}.woff2", f"nerd-font/IltaMonoNerdFont-{base.style}.ttf", ): if relative not in produced: receipts.pop(relative, None) _atomic_json(current.root / ".build-cache.json", { "schema": SCHEMA, "root": str(current.root), "outputs": receipts, }) return True @contextmanager def locked_output(root: Path): root.mkdir(parents=True, exist_ok=True) with (root / ".build.lock").open("a") as handle: fcntl.flock(handle, fcntl.LOCK_EX) yield def positive_integer(value: str) -> int: number = int(value) if number < 1: raise argparse.ArgumentTypeError("must be at least 1") return number def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--output-dir", type=Path, default=ROOT / "dist/fonts") parser.add_argument("--jobs", type=positive_integer, default=2) parser.add_argument("--force", action="store_true") parser.add_argument("--skip-woff2", action="store_true") parser.add_argument("--skip-nerd", action="store_true") parser.add_argument("--stamp", type=Path, help="update this Make stamp only after changed outputs or a missing stamp") args = parser.parse_args(argv) keys = input_keys(skip_nerd=args.skip_nerd) with locked_output(args.output_dir): current = plan(args.output_dir, keys, force=args.force, skip_woff2=args.skip_woff2, skip_nerd=args.skip_nerd) changed = execute(current, jobs=args.jobs, skip_nerd=args.skip_nerd) if args.stamp is not None and (changed or not args.stamp.exists()): args.stamp.parent.mkdir(parents=True, exist_ok=True) args.stamp.touch() return 0 if __name__ == "__main__": raise SystemExit(main())