The Problem
Every UI change on this portfolio — a new section label, a font size tweak, 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 used 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 has 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 subsequent workflow runs — this is a security feature to prevent infinite
loops. But it also means that after the snapshot job commits updated baselines,
CI wouldn't re-run to verify them.
The fix: 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 using the PAT, GitHub sees it as a "real" push (not a bot push) and triggers the CI workflow on the updated branch. The cycle completes:
- 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 could trigger a new CI run while the old (failed) one is still queued for retry. This leads to wasted runner minutes and confusing status checks.
concurrency:
group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: trueThe concurrency key ensures only one CI run per PR is active. When the snapshot push triggers a new run, any in-progress run for the same PR is cancelled automatically.
Playwright Configuration
Visual regression tests run only on Chromium in CI (all browsers + mobile locally). Screenshots are stored 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 — not stored as artifacts — so developers can review visual diffs in the PR itself using GitHub's image comparison tools.
Nx Integration
The CI pipeline uses nx affected to only test 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, E2E tests for the root app are skipped entirely. But if shared UI components change, both the root app and its E2E suite are marked as affected, ensuring visual regression tests run 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 the developer already updated them locally), no commit is created. This prevents noise in the git history.
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 caught several regressions already: a max-w-3xl that should
have been max-w-5xl, an overflow-hidden clipping content, and a SectionLabel
addition that shifted card layouts. Each was caught before merge, fixed in the
same PR, and verified automatically.