GitHub Actions Workflows for Component Testing

GitHub Actions is where most teams first turn a component suite into an enforced gate, and it is the platform the parent CI/CD test gating guide treats as the reference implementation. This page builds a complete workflow from an empty .github/workflows/ directory: a fast Vitest job that fails cheap, a Playwright visual job that captures a built Storybook across a browser matrix, dependency and browser caching so runs stay fast, diff artifacts for triage, and a single aggregate job you require in branch protection so no pull request merges red.

Everything here assumes your tests already pass locally. The work is wiring them into a workflow whose verdict is binding and whose feedback stays under ten minutes as the suite grows.


Prerequisites


Anatomy of the workflow

The workflow is a small directed graph of jobs, not a single linear script. A cheap unit job runs first; the expensive visual job depends on it and fans out across three browsers; and a final gate job fans everything back in to a single required check.

GitHub Actions job dependency graph The unit job runs first and gates the visual job, which fans out across chromium, firefox, and webkit legs in parallel. A final gate job depends on the unit job and all three visual legs and serves as the single required status check. unit Vitest visual · chromium visual · firefox visual · webkit strategy.matrix · fail-fast false gate required check gate needs [unit, visual]

The steps below build this graph one job at a time.


Step-by-step implementation

Create .github/workflows/ci-gate.yml. Each step below adds one block; the closing step shows the assembled file in context through the aggregate job.

Step 1 — Trigger on pull requests and check out the code

The workflow must run on every pull request so no change reaches the protected branch unexamined. actions/checkout puts the source — including committed baselines — on the runner.

# .github/workflows/ci-gate.yml
name: CI Gate

on:
  pull_request:            # runs on every PR against any branch

jobs:
  unit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4   # fetches source + committed visual baselines

Verify: Push a branch and open a draft pull request. The Actions tab should show a unit job starting, and its checkout step should log the commit SHA of your branch tip.

Step 2 — Set up Node with dependency caching

actions/setup-node installs the pinned Node version and, with cache: 'npm', restores ~/.npm keyed on the hash of package-lock.json. The cache is reused until a dependency changes.

      - uses: actions/setup-node@v4
        with:
          node-version: '20'    # pin the major to match local dev
          cache: 'npm'          # restores ~/.npm keyed on package-lock.json

Verify: On the second run of the workflow, expand the “Setup Node” step. It should log Cache restored from key: ... rather than reporting a cache miss.

Step 3 — Install dependencies deterministically

Use npm ci, not npm install. npm ci installs the exact tree in the lockfile and fails loudly if package.json and the lockfile disagree, so every runner builds an identical dependency tree.

      - run: npm ci   # exact, lockfile-driven install; never mutates the lockfile
      - run: npx vitest run --coverage   # cheap unit failures return in under a minute

Verify: The npm ci step logs added N packages with no changed or removed lines, and the Vitest step prints a passing summary such as Test Files 42 passed.

Step 4 — Cache and install Playwright browsers

Browser binaries are downloaded by playwright install, not by npm ci, so they need their own cache. Key it on the installed Playwright version so the cache refreshes precisely when you upgrade — the full rationale and a version-extraction snippet are in caching Playwright browsers in GitHub Actions.

  visual:
    needs: unit                # only start the expensive job if unit tests pass
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci

      - name: Cache Playwright browsers
        uses: actions/cache@v4
        with:
          path: ~/.cache/ms-playwright
          # key on the resolved Playwright version so upgrades bust the cache
          key: pw-${{ runner.os }}-${{ hashFiles('package-lock.json') }}

      - run: npx playwright install --with-deps   # fetches only missing binaries

Verify: On a cache hit, the playwright install step logs browser is already installed for each engine instead of downloading. On a miss it downloads, then the next run hits.

Step 5 — Build Storybook and run tests across a browser matrix

Build the static Storybook the visual tests target, then fan the run out across Chromium, Firefox, and WebKit. fail-fast: false is essential — without it, the first engine to fail cancels the others and you never learn whether the regression is engine-specific. The matrix aligns with the cross-browser matrix your baselines were captured against.

    strategy:
      fail-fast: false                       # let every browser report its own result
      matrix:
        browser: [chromium, firefox, webkit]
    steps:
      # ... checkout, setup-node, npm ci, cache, playwright install (steps 1–4) ...
      - run: npm run build-storybook         # emits storybook-static/
      - run: npx playwright test --project=${{ matrix.browser }} --reporter=github

Verify: The Actions run shows three parallel visual legs — visual (chromium), visual (firefox), visual (webkit). Introduce a WebKit-only CSS regression and confirm only that leg turns red while the other two stay green.

Step 6 — Upload diff artifacts on failure

When a visual test fails, Playwright writes expected, actual, and diff PNGs plus an HTML report. Upload them so reviewers triage from the browser instead of re-running locally. Guard the upload with if: failure() so green runs waste no storage. Gating specifics for the visual leg are covered in gating visual regression tests in GitHub Actions.

      - name: Upload Playwright report on failure
        if: failure()                        # only on a red run
        uses: actions/upload-artifact@v4
        with:
          name: playwright-report-${{ matrix.browser }}
          path: |
            playwright-report/
            test-results/                    # contains *-diff.png, *-actual.png
          retention-days: 7

Verify: On a failing run, open the job summary. A downloadable playwright-report-webkit artifact should appear; unzipping it and opening index.html shows the side-by-side diff.

Step 7 — Gate the merge on an aggregate job

Add one gate job that needs every test job and runs with if: always() so it evaluates even after a failure. It fails if any dependency failed. This single job — not the matrix legs — is what you require in branch protection.

  gate:
    needs: [unit, visual]      # depends on the unit job and all matrix legs
    if: always()               # run even when an upstream job failed
    runs-on: ubuntu-latest
    steps:
      - name: Block merge if any test job failed
        run: |
          if [ "${{ contains(needs.*.result, 'failure') }}" = "true" ]; then
            echo "A test job failed — blocking merge."
            exit 1
          fi
          echo "All test jobs passed."

Then, in Settings → Branches → Branch protection rules, add a rule for the trunk branch, enable Require status checks to pass before merging, and select the gate check. The mechanics of making this un-bypassable — including handling the “expected but never ran” edge case — are in making visual review a required status check.

Verify: Open a pull request containing a real visual regression. The gate check turns red, the merge button is disabled with “Required statuses must pass,” and the diff artifact is attached for review.


Configuration reference

Option Where Type Default Effect
on: pull_request workflow trigger event Runs the workflow on every pull request; the basis for gating
cache: 'npm' actions/setup-node string none Restores ~/.npm keyed on package-lock.json, skipping re-download of packages
strategy.fail-fast job strategy boolean true When false, a failing matrix leg does not cancel siblings, so every browser reports
strategy.matrix.browser job strategy list Fans the job out into one parallel leg per browser engine
needs job key string / list Declares job dependencies; a job starts only after its needs succeed (unless if: always())
if: always() step / job expression Forces evaluation even when a dependency failed; required for the aggregate gate
if: failure() step expression Runs the step only when a previous step in the job failed; used to upload diffs
path actions/cache string Directory to cache; use ~/.cache/ms-playwright for browser binaries
retention-days actions/upload-artifact number 90 How long uploaded diff artifacts are kept; 7 is ample for PR triage

Common pitfalls

1. Requiring individual matrix legs as status checks. Selecting visual (chromium) in branch protection breaks the moment you rename or extend the matrix, because the generated check name changes and branch protection waits forever for a check that no longer reports — silently un-gating the branch. Require the single aggregate gate job instead; its name is stable.

2. Using fail-fast: true (the default) on the browser matrix. With fail-fast on, the first engine to fail cancels the others, so a Chromium failure hides whether Firefox and WebKit also regressed. You lose exactly the cross-engine signal the matrix exists to provide. Set fail-fast: false on any matrix whose legs you want to compare.

3. Regenerating baselines inside the pull-request job. Running npx playwright test --update-snapshots in CI makes the runner compare each screenshot against one it just captured, so the diff is always zero and the gate passes on everything. Baselines must be committed and reviewed; the PR job only ever reads them. See baseline management.

4. Caching node_modules directly instead of ~/.npm. A restored node_modules can contain platform-specific binaries built for a different runner image, producing cryptic native-module errors. Cache the npm download cache (~/.npm, which cache: 'npm' handles) and let npm ci rebuild node_modules from it.

5. Forgetting --with-deps on playwright install. On a fresh Ubuntu runner the browsers need system libraries (fonts, GTK, audio) that are not preinstalled. Omitting --with-deps yields a browser that launches locally but crashes in CI with a missing-shared-library error. Always install with --with-deps unless you are using the preinstalled Playwright Docker image.


Integration point

This workflow is one platform’s expression of the pipeline the parent CI/CD test gating guide describes; the same stages appear on GitLab CI and Bitbucket Pipelines with different caching syntax. The gate job here is only as good as the failure thresholds that decide when the visual step fails and the required status check that makes it binding. As the suite grows past the point where a single runner finishes quickly, split the visual job with test parallelization and sharding, which slots directly into the matrix shown in Step 5.

Two detail pages go deeper on the trickiest parts of this workflow: gating visual regression tests in GitHub Actions for the failure-and-artifact mechanics of the visual leg, and caching Playwright browsers in GitHub Actions for a cache key that busts correctly on Playwright upgrades.


FAQ

Should I use npm ci or npm install in GitHub Actions?

Use npm ci. It installs exactly the tree described by package-lock.json, fails immediately if the lockfile and package.json have drifted apart, and is faster because it skips dependency resolution entirely. npm install can silently rewrite the lockfile mid-run, so two runners can end up with different dependency trees and therefore different test results — the opposite of what a deterministic gate needs.

Why cache ~/.cache/ms-playwright separately from node_modules?

Because the two are populated by different commands. npm ci installs the @playwright/test npm package but not the browser binaries; those are hundreds of megabytes fetched later by playwright install. If you only cache the npm layer, every run still pays the two-to-three-minute browser download. A separate cache on ~/.cache/ms-playwright, keyed on the Playwright version, skips that download while still refetching when you upgrade. The keying strategy is detailed in caching Playwright browsers in GitHub Actions.

How do I require a matrix job as a single status check?

Do not require the matrix legs directly — their names (visual (webkit)) are generated from the matrix and change whenever you edit it, which silently stops the gate. Instead add one aggregate gate job with needs on every leg and if: always(), so it runs even after a failure and fails if any leg failed. Require that single, stably-named job in branch protection. The full branch-protection walkthrough is in making visual review a required status check.

Where do Playwright diff images go when a CI run fails?

Playwright writes the expected, actual, and diff PNGs into test-results/ and a browsable HTML report into playwright-report/. Neither survives the ephemeral runner unless you upload it, so add an actions/upload-artifact step guarded by if: failure() that uploads both directories. Reviewers then download the artifact and open the report to see the side-by-side diff without reproducing the failure locally.