deckhearth/.github/workflows/visual-diff.yml
Randall Stillwell 1b6d0d58db ci: slash GitHub Actions minutes via paths-ignore + consolidation + caching
Standalone infrastructure PR (no convoy ceremony needed — single-file
scope). Triggered by the GitHub Actions billing block that gated
PRs #124 + #125 today.

Three layers of savings applied per the user's max-savings option:

1. paths-ignore on ci.yml + preview-smoke.yml
   - Doc-only PRs (.convoys/**, **/*.md, docs/**, AGENTS.md,
     .cursor/**, README.md) now trigger ZERO Actions jobs.
   - Vercel still builds (it's not on the Actions billing).
   - visual-diff.yml unchanged — it was already cost-conscious via a
     positive paths: allowlist (pages/**, components/**, styles/**,
     etc.).

2. Consolidate 6 grep-only forbidden-* jobs into 1
   - Previously 6 independent jobs each ran their own
     actions/checkout (~3s × 6 = 18s of redundant checkout).
   - Merged into a single forbidden-patterns job with 6 sequential
     ::group:: sections, one FAIL flag at the bottom — preserves
     "see all violations in one run" diagnostic behavior. Per-file
     ::error file=...::msg annotations work the same way.
   - Removed jobs: forbidden-endpoints, forbidden-cors-headers,
     forbidden-client-side-llm-keys,
     forbidden-modal-shell-without-primitive,
     forbidden-deprecated-color-aliases, forbidden-stale-strings.
   - pr-health-rollup.yml only looks up "Lint" and "Schema map up to
     date" by name — unaffected.

3. Cache node_modules + Playwright browsers
   - actions/cache@v4 for node_modules keyed by package-lock.json
     hash, applied to lint / test / migrate / preview-smoke /
     visual-diff. Cuts npm ci from ~30-45s to ~3-5s on cache hit.
     setup-node@v4's built-in cache: npm stays (caches ~/.npm) —
     both layered.
   - actions/cache@v4 for ~/.cache/ms-playwright keyed by the
     resolved @playwright/test version. Cache invalidates on any
     Playwright version bump. On cache hit, only system deps install
     runs (npx playwright install-deps chromium) — saves ~15-25s/run.

AGENTS.md updates:
- § 6 Testing § CI behavior: appended "CI minute optimizations" subsection
  documenting all three layers.
- § Product vocabulary table caption: updated "CI job
  forbidden-stale-strings" reference to "CI check Forbidden patterns
  (6 checks) → Check 6/6" with a historical pointer.
- Gotcha #5: updated the standalone forbidden-endpoints reference
  similarly.

Estimated savings per typical convoy mix (~30% doc PRs based on
repo history):
- Doc-only PRs: 100% reduction (was ~6-7 min, now 0 Actions min).
- Code-touching PRs: ~30-50% reduction (cache hits for npm + Playwright +
  no redundant 6× checkout).
- Weighted average: ~50-60% reduction.

This is short of the 70-80% I floated in chat — the real ceiling is
limited by lint / vitest / migrate / Playwright runtime itself, all of
which are kept on code-touching PRs (they're high-signal).

Verification:
- All 3 workflow YAMLs parse (python3 -c "yaml.safe_load(...)").
- npm run lint passes (1 pre-existing unrelated warning).
- npm run test:run: 118/118 tests pass.
- forbidden-patterns logic is byte-equivalent to the 6 original jobs'
  bash bodies — the differences are: per-check ::group::/::endgroup::
  framing, a shared FAIL flag instead of per-job exit 1, and renamed
  local arrays (FOUND → LLM_FOUND / MODAL_FOUND / STRING_FOUND) to
  avoid clobbering across the single job's scope.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 16:32:29 -05:00

149 lines
5.5 KiB
YAML

name: Visual diff
# Same as preview-smoke — waits for Vercel's Preview deployment, then captures
# Playwright screenshots against it. UI-paths-only trigger to keep cost down.
# Paths are tcg-vault-specific (pages router, JS).
on:
pull_request:
branches: [main]
paths:
- 'pages/**'
# Exclude API-only edits — they don't render UI, so they can't move
# any visual-diff pixels. Order matters: GitHub Actions evaluates the
# `paths:` list with minimatch and applies `!`-prefixed exclusions
# only after they've already matched a prior include. Keep this entry
# immediately AFTER `pages/**`.
# Surfaced by `tighten-visual-diff-path-filter` after PR #19
# (cors-tighten) and PR #20 (add-rate-limiting) both falsely
# triggered Screenshot diff at ~55s/PR.
- '!pages/api/**'
- 'components/**'
- 'styles/**'
- 'tailwind.config.js'
- 'postcss.config.js'
# Baseline-only PRs (specs + committed snapshots) should still run
# visual diff now that home.png is in tests/visual/__screenshots__/.
- 'tests/visual/**'
concurrency:
group: visual-diff-${{ github.event.pull_request.number }}
cancel-in-progress: true
# `deployments: read` + `statuses: read` are required by wait-for-vercel-preview
# (see preview-smoke.yml for context). `pull-requests: write` is required by the
# final github-script step that posts the "Visual Diff" comment back to the PR;
# without it the API returns 403 even though the screenshots upload fine.
permissions:
contents: read
deployments: read
pull-requests: write
statuses: read
jobs:
gate:
name: Should run?
runs-on: ubuntu-latest
outputs:
should_run: ${{ steps.check.outputs.should_run }}
steps:
- id: check
env:
# See preview-smoke.yml for the rationale: ${{ github.event.* }}
# inlined into shell is a syntax-error + injection vector.
PR_BODY: ${{ github.event.pull_request.body }}
PR_IS_FORK: ${{ github.event.pull_request.head.repo.fork }}
run: |
if [[ "$PR_IS_FORK" == "true" ]]; then
echo "should_run=false" >> $GITHUB_OUTPUT
echo "::notice::Visual diff skipped on fork PR (bypass secret unavailable to forks)"
elif echo "$PR_BODY" | grep -qE 'pipeline:.*skip.*\bvisual\b'; then
echo "should_run=false" >> $GITHUB_OUTPUT
echo "::notice::Visual diff skipped via pipeline directive"
else
echo "should_run=true" >> $GITHUB_OUTPUT
fi
visual:
name: Screenshot diff
needs: gate
if: needs.gate.outputs.should_run == 'true'
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- name: Wait for Vercel Preview deployment
id: vercel
uses: patrickedqvist/wait-for-vercel-preview@v1.3.2
with:
token: ${{ secrets.GITHUB_TOKEN }}
max_timeout: 120
# See preview-smoke.yml for the no-`set-bypass-cookie` rationale.
path: /?x-vercel-protection-bypass=${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }}
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm
- name: Cache node_modules
id: cache-node-modules
uses: actions/cache@v4
with:
path: node_modules
key: node-modules-${{ runner.os }}-node20-${{ hashFiles('package-lock.json') }}
- run: npm ci
if: steps.cache-node-modules.outputs.cache-hit != 'true'
# Cache Playwright browsers — see preview-smoke.yml for rationale.
- name: Resolve Playwright version
id: pw-version
run: |
VERSION=$(node -p "require('./package-lock.json').packages['node_modules/@playwright/test'].version")
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- name: Cache Playwright browsers
id: cache-playwright
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ steps.pw-version.outputs.version }}-chromium
- name: Install Playwright browsers
run: |
if [ "${{ steps.cache-playwright.outputs.cache-hit }}" = "true" ]; then
npx playwright install-deps chromium
else
npx playwright install --with-deps chromium
fi
- name: Capture screenshots (PR)
run: npx playwright test --project=visual --update-snapshots=none
env:
BASE_URL: ${{ steps.vercel.outputs.url }}
VERCEL_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }}
continue-on-error: true
- name: Upload screenshots + diffs
if: always()
uses: actions/upload-artifact@v4
with:
name: visual-diff
path: |
tests/visual/__screenshots__/
test-results/
retention-days: 7
- name: Comment on PR with diff link
if: always()
uses: actions/github-script@v7
with:
script: |
const run = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: `## Visual Diff\n\nScreenshots and diffs uploaded as artifacts: [view run](${run})\n\nIf intentional changes: update snapshots locally with \`npx playwright test --project=visual --update-snapshots\` and commit.`
});