The problem
Every UI change on this portfolio, whether it's a new section label, a font-size tweak, or a layout restructure, risks unintended visual side effects on other pages. Manual visual QA doesn't scale, especially when a single PR might touch shared components that show up across dozens of routes.
I needed a pipeline that:
- Catches visual regressions automatically on every PR
- Updates baselines with one click when changes are intentional
- Re-runs CI automatically after baseline updates (no manual re-trigger)
- Works with Nx affected so only changed projects get tested
The architecture
The pipeline is two jobs in a single workflow file, toggled by a
workflow_dispatch input:
on:
pull_request:
branches-ignore: [main]
workflow_dispatch:
inputs:
update-snapshots:
description: 'Regenerate VR baselines'
type: boolean
default: false
snapshot-branch:
description: 'Branch to update snapshots on'
type: stringNormal CI (update-snapshots: false): Runs lint, typecheck, unit tests,
build, and E2E tests including visual regression. If screenshots don't match
baselines, Playwright fails the job.
Snapshot update (update-snapshots: true): Skips normal CI entirely. Builds
the app, regenerates all Playwright screenshots with --update-snapshots,
commits them to the branch, and pushes.
The PAT token trick
Here's the subtle part. GitHub Actions commits made with GITHUB_TOKEN don't
trigger any subsequent workflow runs. That's a security feature to keep you out
of infinite loops, but it also means that once the snapshot job commits updated
baselines, CI won't re-run to verify them.
The fix is to check out the repo with a Personal Access Token (PAT) instead:
update-snapshots:
permissions:
contents: write
steps:
- uses: actions/checkout@v5
with:
ref: ${{ inputs.snapshot-branch || github.ref }}
token: ${{ secrets.PAT_TOKEN }}When the snapshot job pushes with the PAT, GitHub treats it as a "real" push rather than a bot push and fires the CI workflow on the updated branch. That closes the loop:
- PR opened → CI runs → E2E fails (screenshot mismatch)
- Developer triggers
update-snapshotsworkflow dispatch - Snapshots regenerated → committed → pushed with PAT
- Push triggers CI → E2E passes → PR is green
Concurrency control
Without concurrency groups, the snapshot push in step 3 can kick off a new CI run while the old, failed one is still queued for retry, which burns runner minutes and leaves confusing status checks behind.
concurrency:
group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: trueThe concurrency key keeps exactly one CI run per PR active, so when the snapshot push triggers a new run, any in-progress run for the same PR gets cancelled automatically.
Playwright configuration
Visual regression tests run only on Chromium in CI (all browsers plus mobile locally), and the screenshots live alongside the test file:
apps/root-e2e/src/
├── visual-regression.spec.ts
└── visual-regression.spec.ts-snapshots/
├── about-chromium-linux.png
├── experience-detail-chromium-linux.png
├── project-detail-chromium-linux.png
└── projects-listing-chromium-linux.png
The snapshots are committed to the repo rather than stashed as artifacts, so anyone can review the visual diffs right in the PR using GitHub's image comparison tools.
Nx integration
The CI pipeline leans on nx affected to test only the projects that changed:
- name: Run affected tasks
run: yarn nx affected -t lint test build typecheck --nxBail
- name: Run e2e tests
run: yarn nx affected -t e2e --nxBailIf a PR only touches the audit service, the E2E tests for the root app get skipped entirely. But if shared UI components change, both the root app and its E2E suite are marked affected, so the visual regression tests run exactly when they matter.
The commit-and-push step
The snapshot update job is careful about empty commits:
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add apps/root-e2e/src/visual-regression.spec.ts-snapshots/
if git diff --cached --quiet; then
echo "No snapshot changes to commit"
else
git commit -m "chore(e2e): update visual regression snapshots from CI"
git push
fiIf the snapshots haven't actually changed, maybe the test was flaky or someone already updated them locally, no commit gets created, which keeps the git history free of noise.
Results
- One-click baseline updates: no local Playwright install needed
- Automatic CI re-verification: PAT-based push triggers a fresh run
- Zero wasted runner time: concurrency groups cancel stale jobs
- Git-tracked baselines: visual diffs reviewable in PR
- Nx-aware: E2E only runs when the app is actually affected
The pipeline has already caught a handful of regressions: a max-w-3xl that
should have been max-w-5xl, an overflow-hidden quietly clipping content, and
a SectionLabel addition that shifted card layouts. Each one got caught before
merge, fixed in the same PR, and verified automatically.
