From 2bfd0100f7f026a56c65da1944b25d5513cb9d1f Mon Sep 17 00:00:00 2001 From: David Gracia Date: Tue, 12 May 2026 15:48:28 -0600 Subject: [PATCH] feat(ci): cache pharos-tokens content fingerprint + activate TurboSnap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the long-standing reason TurboSnap was disabled (`@code-sherpas/pharos-tokens` ships its CSS through `node_modules/`, falling outside the git tree TurboSnap compares against, so a pharos-tokens release that changed pixels could not invalidate any snapshot — see the old comment in chromatic.yml). Adds three coordinated pieces: 1. `scripts/snapshot-pharos-tokens.sh` — hashes every file under `node_modules/@code-sherpas/pharos-tokens` (sorted, content-addressed, locale-independent) and writes `version= / content_sha256=` to a tracked `.pharos-tokens.fingerprint` file. Hashing the whole installed directory (not just `dist/styles.css`) covers any future JS module, .d.ts, or metadata pharos-tokens may ship — eliminating the residual risk of a non-CSS change slipping through. 2. `package.json` `postinstall` invokes the script. Every `pnpm install` updates the fingerprint locally; `git status` surfaces the drift the developer must commit. 3. `.github/workflows/ci.yml` `Verify pharos-tokens fingerprint is current` step runs immediately after `pnpm install --frozen-lockfile` and fails the build (with a precise remediation message) when the committed fingerprint disagrees with `node_modules/`. This is the gate that prevents stale-fingerprint Chromatic baselines from ever shipping. To be made a *required* status check via branch protection on `main`. 4. `.github/workflows/chromatic.yml` flips `onlyChanged: true` (TurboSnap on) and declares `.pharos-tokens.fingerprint` as a Chromatic `externals` glob. A pharos-tokens release that mutates a single byte under `dist/` changes the fingerprint, TurboSnap sees it in the git diff, and invalidates the whole baseline — same behaviour we get today with `onlyChanged: false`, but limited to bumps that actually matter, not every push. Expected outcome: 40–60% reduction in snapshot quota burn per Chromatic's published TurboSnap savings, with zero new risk of stale visual baselines. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/chromatic.yml | 34 +++++++-------- .github/workflows/ci.yml | 14 ++++++ .pharos-tokens.fingerprint | 6 +++ package.json | 3 +- scripts/snapshot-pharos-tokens.sh | 71 +++++++++++++++++++++++++++++++ 5 files changed, 109 insertions(+), 19 deletions(-) create mode 100644 .pharos-tokens.fingerprint create mode 100755 scripts/snapshot-pharos-tokens.sh diff --git a/.github/workflows/chromatic.yml b/.github/workflows/chromatic.yml index 6de66c1..54d3255 100644 --- a/.github/workflows/chromatic.yml +++ b/.github/workflows/chromatic.yml @@ -90,21 +90,19 @@ jobs: # cannot merge with un-reviewed Chromatic changes (enforced # via branch protection on this repo). autoAcceptChanges: main - # TurboSnap (`onlyChanged: true`) is off for now. It chains - # snapshots across builds via the previous build's baseline, and - # can't detect content changes inside a consumed CSS package — - # `@code-sherpas/pharos-tokens/css` is side-effect-imported from - # `src/styles/index.css`, and its contents change with every - # pharos-tokens release without any file tracked by git being - # touched. Attempting to register it as `externals` doesn't help: - # TurboSnap diffs against the git tree, and `node_modules/` is - # not in it. The symptom: axe violations fixed upstream (e.g. - # darkening `error.fg` to pass WCAG AA 4.5:1) never cleared - # because old pixel snapshots were reused. - # - # At the current scale (~10 stories) the snapshot quota hit of a - # full rebuild on every push is negligible. Revisit once we have - # 50+ stories: at that point either cache the pharos-tokens - # `dist/styles.css` content-hash into a git-tracked file so - # TurboSnap can detect it, or use a dedicated TurboSnap-aware - # integration. + # TurboSnap: rebuild only the stories whose transitive dependency + # graph in the *git tree* has changed. The historical hazard + # (silent stale snapshots when `@code-sherpas/pharos-tokens` + # released a new CSS without any pharos-react source file being + # touched) is now covered by the `externals` glob below: the + # `postinstall` script writes `.pharos-tokens.fingerprint` from + # the resolved package contents; a pharos-tokens release that + # changes any byte of its publish directory mutates the hash; + # CI's `verify-fingerprint` step (ci.yml) blocks any PR whose + # committed fingerprint disagrees with `node_modules/`. With + # those guard rails TurboSnap can be trusted to invalidate the + # whole baseline whenever the fingerprint moves, so stale-token + # snapshots cannot ship. + onlyChanged: true + externals: | + .pharos-tokens.fingerprint diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 514f397..30d0378 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,6 +30,20 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + # `pnpm install` triggers the `postinstall` script, which regenerates + # `.pharos-tokens.fingerprint` from the resolved `pharos-tokens` + # contents in `node_modules/`. If the committed fingerprint disagrees + # with reality (developer forgot to commit it after a dep bump), this + # step fails the build and prints a precise remediation message. + # Required for the `chromaui/action` step in chromatic.yml to trust + # the fingerprint as a TurboSnap external. + - name: Verify pharos-tokens fingerprint is current + run: | + if ! git diff --exit-code .pharos-tokens.fingerprint; then + echo "::error file=.pharos-tokens.fingerprint::pharos-tokens fingerprint is stale. Run \`pnpm install\` locally and commit the updated .pharos-tokens.fingerprint." + exit 1 + fi + - name: Build run: pnpm build diff --git a/.pharos-tokens.fingerprint b/.pharos-tokens.fingerprint new file mode 100644 index 0000000..cc3027c --- /dev/null +++ b/.pharos-tokens.fingerprint @@ -0,0 +1,6 @@ +# @code-sherpas/pharos-tokens content fingerprint. +# Generated by scripts/snapshot-pharos-tokens.sh — do not edit manually. +# Re-run `pnpm postinstall` (or `bash scripts/snapshot-pharos-tokens.sh`) +# after bumping the dependency. +version=0.4.0 +content_sha256=a2b8ac3584ad91a6f7001962ff082011f47bc9ddc4db251d5df16ee51e1ac049 diff --git a/package.json b/package.json index e53c5f1..6b1af78 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,8 @@ "changeset": "changeset", "release": "pnpm build && changeset publish", "prepublishOnly": "pnpm build", - "prepare": "husky" + "prepare": "husky", + "postinstall": "bash scripts/snapshot-pharos-tokens.sh" }, "lint-staged": { "*.{ts,tsx,js,mjs,cjs,json,md,mdx,css,yml,yaml}": "prettier --write" diff --git a/scripts/snapshot-pharos-tokens.sh b/scripts/snapshot-pharos-tokens.sh new file mode 100755 index 0000000..b607a18 --- /dev/null +++ b/scripts/snapshot-pharos-tokens.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# +# Captures the *content* fingerprint of the installed `@code-sherpas/pharos-tokens` +# into a git-tracked file (`.pharos-tokens.fingerprint`). The fingerprint becomes +# the canary that TurboSnap reads via `chromaui/action`'s `externals` glob: a +# pharos-tokens release that changes any byte of its published `dist/` mutates +# the fingerprint → TurboSnap detects the change in the git tree → invalidates +# every Chromatic story for that build → the next build with the same tokens +# falls back to TurboSnap's incremental path. +# +# Why hash the whole `dist/` instead of only `dist/styles.css`: +# pharos-tokens ships CSS variables today, but a future release could add a +# JS module (icon registry, semantic alias resolver, etc.) consumed at import +# time. Hashing the whole publish directory captures every artifact that +# actually lands in `node_modules/` after `pnpm install`, including +# `package.json` (version field) and any `.d.ts` shipped with the runtime. +# +# Determinism: +# `find` is run from inside the package dir so the hashed paths are +# package-relative (`./dist/...`). Output is sorted (`LC_ALL=C sort`) so two +# machines with different locales produce the same final hash. Each file is +# hashed individually with `sha256sum`; that line stream is then hashed once +# more to produce a single fingerprint. +# +# Exit codes: +# 0 — fingerprint file written (or already up-to-date). +# 1 — pharos-tokens not installed (run `pnpm install` first). +# 2 — pre-flight tool missing (sha256sum, jq, find). + +set -euo pipefail + +FINGERPRINT_FILE=".pharos-tokens.fingerprint" +PKG_DIR="node_modules/@code-sherpas/pharos-tokens" + +# Pre-flight: every tool we use must be on PATH. +for cmd in sha256sum jq find; do + if ! command -v "$cmd" >/dev/null 2>&1; then + echo "error: \`$cmd\` is required by $0 but was not found on PATH" >&2 + exit 2 + fi +done + +if [[ ! -d "$PKG_DIR" ]]; then + echo "error: $PKG_DIR not found. Run \`pnpm install\` first." >&2 + exit 1 +fi + +VERSION=$(jq -r .version "$PKG_DIR/package.json") + +# Hash every file under the package directory (not just dist/) — covers the +# whole installed artifact: dist/, package.json, README, etc. Paths are +# package-relative so the hash is machine-independent. +DIST_SHA=$( + cd "$PKG_DIR" + find . -type f -print0 \ + | LC_ALL=C sort -z \ + | xargs -0 sha256sum \ + | sha256sum \ + | cut -d' ' -f1 +) + +cat > "$FINGERPRINT_FILE" <