diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 225c0adc..55344276 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -422,6 +422,10 @@ jobs: # would find nothing and silently fall back to the dev-box path. chromium=$(nix build --no-link --print-out-paths \ -f tools/toolchain/chromium-e2e-env.nix chromium) + [ -n "$chromium" ] || { + echo "::error::chromium-e2e-env.nix chromium produced no store path" + exit 1 + } echo "PLAYWRIGHT_CHROMIUM_PATH=$chromium/bin/chromium" >>"$GITHUB_ENV" # secretspec: the CLI the Go secrets WRITE path spawns BY NAME # (go/internal/secrets/resolver.go's `defaultCLI = "secretspec"`, @@ -441,6 +445,27 @@ jobs: -f tools/toolchain/secretspec-env.nix secretspec) echo "$secretspec/bin" >>"$GITHUB_PATH" + # The visual-regression gate's fonts, from the same pinned helper. + # Without this the runner rasterizes with whatever faces its image + # ships, making the committed baselines a function of the runner + # image rather than of the repo — a base-image bump would then red + # every apps/ui PR at once, and no agent could reproduce it locally. + # The config deliberately sees ONLY the pinned faces, so a box with + # extra fonts installed renders identically to one without. + fonts=$(nix build --no-link --print-out-paths \ + -f tools/toolchain/chromium-e2e-env.nix fontconfig) + # Fail closed: the two malformed values fail in opposite directions + # and BOTH stay green. An EMPTY FONTCONFIG_FILE is not "use the + # system config" — fontconfig reads it as a config with no font dirs + # at all, and every string then measures zero width. A path that does + # not exist falls back to the full host set, which is the undeclared + # input this whole step exists to remove. + [ -n "$fonts" ] || { + echo "::error::chromium-e2e-env.nix fontconfig produced no store path" + exit 1 + } + echo "FONTCONFIG_FILE=$fonts" >>"$GITHUB_ENV" + - name: Toolchain parity if: matrix.run == 'true' # Runs BEFORE the battery, though `:ci` schedules it again (it takes under @@ -2700,7 +2725,27 @@ jobs: run: | chromium=$(nix build --no-link --print-out-paths \ -f tools/toolchain/chromium-e2e-env.nix chromium) + [ -n "$chromium" ] || { + echo "::error::chromium-e2e-env.nix chromium produced no store path" + exit 1 + } echo "PLAYWRIGHT_CHROMIUM_PATH=$chromium/bin/chromium" >>"$GITHUB_ENV" + # The same pinned fontconfig the moon job's gate replays with. The + # capture lane MUST rasterize with it too: the gate compares these + # bytes against a run that has it set, so a capture without it bakes + # the runner image's own faces into the baselines and reds every + # text-bearing shot permanently. + fonts=$(nix build --no-link --print-out-paths \ + -f tools/toolchain/chromium-e2e-env.nix fontconfig) + # Fail closed HERE above all: an empty or bogus value in the capture + # lane writes zero-width or host-font pixels into committed baselines, + # and the bot PR then presents them for image review as a legitimate + # capture. A red gate is recoverable; a poisoned baseline is not. + [ -n "$fonts" ] || { + echo "::error::chromium-e2e-env.nix fontconfig produced no store path" + exit 1 + } + echo "FONTCONFIG_FILE=$fonts" >>"$GITHUB_ENV" - name: Install the workspace JS dependencies # Unlike the forge regen lane — whose payload is `go test` and needs no diff --git a/devenv.nix b/devenv.nix index 85029f95..5aa5e0c0 100644 --- a/devenv.nix +++ b/devenv.nix @@ -265,6 +265,7 @@ in # toolchain. Platform-independent, so it lives in the base set, not the # Linux-only merge below. MOON_TOOLCHAIN_FORCE_GLOBALS = "true"; + } # The Compass native app (Wails v3, go/cmd/compass-app) links the Linux # GTK4/WebKitGTK stack through cgo. pkg-config (in `packages` above) finds each @@ -298,6 +299,20 @@ in (lib.makeSearchPathOutput "dev" "lib/pkgconfig" pcClosure) (lib.makeSearchPathOutput "dev" "share/pkgconfig" pcClosure) ]; + + # The visual-regression gate (apps/ui:visual-gate) rasterizes text with + # these faces, pinned to the same devenv.lock nixpkgs CI resolves, and the + # config also pins hinting/antialiasing, so a local run and a CI run + # rasterize identically and a red gate is reproducible off-CI. The config + # sees ONLY the pinned faces, so the host's own font set — which previously + # decided which substitute the CSS stack fell through to, with no declared + # cause — cannot affect the output. + # + # Linux-only despite the fonts themselves building everywhere: chromium is + # Linux-only in nixpkgs, so a darwin shell cannot run this gate at all, and + # FONTCONFIG_FILE is process-tree-wide — it would replace the font universe + # for everything launched from the shell in exchange for nothing. + FONTCONFIG_FILE = (import tools/toolchain/chromium-e2e-env.nix).fontconfig; }; enterShell = '' diff --git a/tools/toolchain/chromium-e2e-env.nix b/tools/toolchain/chromium-e2e-env.nix index 1f0b6cda..d94552a5 100644 --- a/tools/toolchain/chromium-e2e-env.nix +++ b/tools/toolchain/chromium-e2e-env.nix @@ -19,11 +19,13 @@ # Pins nixpkgs to the SAME devenv.lock revision the dev shell and gate-tools.nix # resolve, so CI drives byte-for-byte the Chromium a Linux dev box does. # -# One output the ci.yml step consumes, realized with `nix build` (never +# Two outputs the ci.yml steps consume, realized with `nix build` (never # `nix eval`, which strips the store context that would build the derivation): # -# chromium the nix-wrapped Chromium derivation; the step reads its -# bin/chromium out-path into PLAYWRIGHT_CHROMIUM_PATH. +# chromium the nix-wrapped Chromium derivation; the step reads its +# bin/chromium out-path into PLAYWRIGHT_CHROMIUM_PATH. +# fontconfig a self-contained fontconfig read into FONTCONFIG_FILE by both +# pixel-touching lanes and by the Linux dev shell (devenv.nix). let lock = builtins.fromJSON (builtins.readFile ../../devenv.lock); node = lock.nodes.nixpkgs.locked; @@ -32,6 +34,25 @@ let sha256 = node.narHash; }; pkgs = import nixpkgsSrc { }; + + # The two branded faces the design tokens name (`--rigel-mono`, + # `--rigel-display` in apps/ui/src/design/tokens.css), then a coverage + # fallback: the branded pair leaves 21 of the UI's 49 non-ASCII glyphs + # uncovered, and an uncovered glyph bakes tofu into a baseline. + # + # Unifont, not a stock system font. DejaVu pulled DejaVu Math TeX Gyre in + # behind it, putting a proportional math face in a monospace grid; Unifont is + # 1-bit 16x16, so it reads as pixel-grid instead of as a foreign sans. It is + # dual-width though (fontconfig spacing=90): 17 fallback glyphs sit at 0.5em + # and 4 — including the gear — at 1.0em, against Space Mono's 0.612em cell, + # so these glyphs do not land on the grid. RIG-3603 retires them to + # BadgeGlyph-style dot-matrix SVG, which is what actually fixes that. + fontDirs = [ + "${pkgs.google-fonts.override { fonts = [ "SpaceMono" ]; }}/share/fonts/truetype" + "${pkgs.departure-mono}/share/fonts/otf" + "${pkgs.unifont}/share/fonts/opentype" + "${pkgs.unifont_upper}/share/fonts/opentype" + ]; in { # Referenced by its own store path, never merged into a buildEnv: the @@ -39,4 +60,38 @@ in # its own bin/ wrapper scripts and store-relative references that a # symlink-merge would break. ci.yml reads `bin/chromium` off this out-path. chromium = pkgs.chromium; + + # A self-contained fontconfig: the pinned faces, and the rasterization + # parameters. Both halves are load-bearing. + # + # The