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" <