Configuring GitHub Actions for Automated WCAG Checks

The first accessibility workflow in a repository has one job: run green, on every pull request, without anyone having to think about it. This guide is part of GitHub Actions a11y Pipeline Setup, and it is the from-zero version — a single YAML file that checks out the code, installs with a warm cache, builds, starts a preview server, waits until that server actually answers, scans a short list of routes against the WCAG 2.2 AA rule tags, and uploads the report as an artifact. It reports and does not block, because a workflow nobody trusts yet should not be able to stop a merge.

Root Cause

Almost every first attempt fails for a reason unrelated to accessibility. The scanner needs a running HTTP origin: @axe-core/cli and every Playwright-based runner load a URL in a real browser, so pointing either at ./dist/index.html or at a directory produces an error that reads like a tool bug and is actually a missing server. The workflow that gets copied from a blog post assumes a server already exists — a staging URL, a preview deployment, a dev server somebody started — and in a fresh repository none of those are there.

The second failure is a race. Starting a server in one step and scanning in the next looks sequential, but the server needs a second or two to bind its port while the runner has already moved on, so the scan connects to nothing. The usual patch is sleep 5, which converts a deterministic failure into an intermittent one: it holds on an idle runner and fails on a busy one, and the resulting flake gets attributed to the accessibility tooling rather than to the wait. Polling the URL until it returns a success status is the same three lines of shell and never needs tuning.

The third is the debugging loop. A workflow can only be tested by pushing, and each push costs three or four minutes, so the natural way to fix a broken pipeline is guess-and-push — which is how a repository ends up with fourteen commits titled “fix ci”. Every step in the file below has a local equivalent that runs in seconds, and working through them in order before the first push turns a morning of pushes into one commit. The one thing that cannot be reproduced locally is the runner’s font set, which is why the first CI run sometimes shows contrast findings a laptop does not; that difference is a real environment gap rather than a false positive, and pinning the environment is what Docker-based pipeline execution exists to solve.

Every workflow step has a local rehearsal Each row pairs one step of the workflow file with a command that verifies the same thing on a developer machine, from checkout through dependency install, build, server start, scan and artifact write. workflow step rehearse it locally actions/checkout@v4 git status — clean tree, right branch actions/setup-node@v4, cache: npm node -v — matches .nvmrc exactly npm ci rm -rf node_modules && npm ci npm run build ls dist/index.html start preview server, wait for 200 curl -sI 127.0.0.1:4321 | head -1 node scripts/a11y-scan.mjs the same command, unchanged actions/upload-artifact@v4 ls -l a11y-report.json
Working down the right-hand column before the first push replaces the guess-and-push loop with one commit that runs green.

Configuration

Three files: the workflow, a scan script, and one line in package.json. The workflow pins the runner image and the Node version rather than using floating labels, because a silently rotated image changes the Chromium build and the font set underneath the scan.

# .github/workflows/wcag-checks.yml
name: wcag-checks
on:
  pull_request:
    branches: [main]
permissions:
  contents: read            # nothing here writes to the repository
jobs:
  wcag:
    runs-on: ubuntu-24.04
    timeout-minutes: 15     # without this a hung server burns six hours
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 20.18.1
          cache: npm        # keys on package-lock.json; needs it committed

      - run: npm ci         # never 'npm install' in CI: it can rewrite the lockfile

      - run: npm run build  # produces dist/, the bytes the scan will see

      - name: Install Chromium for the scanner
        run: npx playwright install --with-deps chromium

      - name: Serve dist and wait for it to answer
        run: |
          npx --yes http-server dist -p 4321 --silent > server.log 2>&1 &
          for i in $(seq 1 60); do
            # -f makes a 4xx or 5xx status a non-zero exit, so only a real 200 passes
            curl -sf -o /dev/null http://127.0.0.1:4321/ && break
            sleep 1
          done
          curl -sf -o /dev/null http://127.0.0.1:4321/ || {
            echo "server never answered"; tail -n 30 server.log; exit 1; }

      - name: Scan the route list
        run: node scripts/a11y-scan.mjs

      - name: Upload the report
        if: always()        # the report is most useful when the scan found things
        uses: actions/upload-artifact@v4
        with:
          name: a11y-report
          path: a11y-report.json
          retention-days: 14

The scan script keeps its route list in one visible array so adding a page is a one-line change that a reviewer can reason about. Four routes is the right size for a first version: the home page, one content page, one form and one authenticated-looking layout cover most of the shared chrome, and the whole run finishes inside half a minute. It prints a human-readable summary, writes the JSON, and exits zero regardless of what it found — turning the count into a merge decision is a separate, later change, made deliberately with the rules described in blocking pull requests on critical accessibility violations.

// scripts/a11y-scan.mjs — run: node scripts/a11y-scan.mjs
import { writeFileSync } from 'node:fs';
import { chromium } from 'playwright';
import AxeBuilder from '@axe-core/playwright';

const BASE = process.env.A11Y_BASE_URL ?? 'http://127.0.0.1:4321';
const ROUTES = ['/', '/pricing', '/docs/getting-started', '/contact'];
// wcag22aa includes the newer criteria such as SC 2.5.8 Target Size (Minimum).
const TAGS = ['wcag2a', 'wcag2aa', 'wcag21aa', 'wcag22aa'];

const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1280, height: 900 } });
const report = { base: BASE, tags: TAGS, scannedAt: new Date().toISOString(), pages: [] };

for (const route of ROUTES) {
  const response = await page.goto(`${BASE}${route}`, { waitUntil: 'load' });
  if (!response?.ok()) {
    // A 404 that scans clean is the most misleading green run there is.
    throw new Error(`${route} returned ${response?.status()}; fix the route list`);
  }
  // Wait for something the page owns rather than for a fixed delay.
  await page.getByRole('main').waitFor({ state: 'attached', timeout: 10_000 });
  const { violations } = await new AxeBuilder({ page }).withTags(TAGS).analyze();
  report.pages.push({ route, violations });

  const nodes = violations.reduce((sum, v) => sum + v.nodes.length, 0);
  console.log(`${route}: ${violations.length} rule(s), ${nodes} failing node(s)`);
  for (const v of violations) {
    console.log(`  ${v.id} (${v.impact}) x${v.nodes.length}${v.help}`);
  }
}

await browser.close();
writeFileSync('a11y-report.json', JSON.stringify(report, null, 2));
const total = report.pages.reduce((sum, p) => sum + p.violations.length, 0);
console.log(
  `wrote a11y-report.json — ${total} violation(s) over ${ROUTES.length} route(s)`,
);

One line in package.json makes the script discoverable to anyone who did not write it, which is most of the value of putting it there.

{
  "scripts": {
    "build": "vite build",
    "a11y": "node scripts/a11y-scan.mjs"
  },
  "devDependencies": {
    "@axe-core/playwright": "^4.10.0",
    "playwright": "^1.48.0"
  }
}
Waiting for the preview server instead of sleeping The server is spawned in the background, then a poll loop requests the base URL. A success status proceeds to the scan; a failure retries after one second until sixty attempts have been used, at which point the step prints the tail of the server log and exits non-zero. spawn http-server stdout to server.log curl -sf base URL attempt n success status? n < 60? run the scan 4 routes tail server.log exit 1 yes no yes: sleep 1s no Sixty attempts is a one-minute ceiling that costs nothing when the server binds in two seconds.
The loop exits as soon as the port answers, so the fast path costs a second and the failure path explains itself with the server's own output.

Validation

Run the whole thing on a laptop first, in the same order the workflow does. Every command below is the local twin of one workflow step, and the point of the exercise is that a failure here costs eight seconds rather than four minutes.

# 1. Clean install, exactly as the runner does it.
rm -rf node_modules && npm ci

# 2. Build, then prove the output exists where the workflow expects it.
npm run build && ls -l dist/index.html

# 3. Start the server the same way, in the background, logging to a file.
npx --yes http-server dist -p 4321 --silent > server.log 2>&1 &

# 4. Confirm it answers before scanning anything.
curl -sI http://127.0.0.1:4321/ | head -1
# HTTP/1.1 200 OK

# 5. Confirm every route in the list is real — a 404 scans suspiciously clean.
for r in / /pricing /docs/getting-started /contact; do
  printf '%s -> ' "$r"
  curl -s -o /dev/null -w '%{http_code}\n' "http://127.0.0.1:4321$r"
done
# / -> 200
# /pricing -> 200
# /docs/getting-started -> 200
# /contact -> 200

# 6. Run the scan itself.
npm run a11y
# /: 2 rule(s), 5 failing node(s)
#   color-contrast (serious) x4 — Elements must meet minimum contrast ratio
#   region (moderate) x1 — All page content should be contained by landmarks
# /pricing: 0 rule(s), 0 failing node(s)
# /docs/getting-started: 1 rule(s), 3 failing node(s)
#   heading-order (moderate) x3 — Heading levels should only increase by one
# /contact: 1 rule(s), 2 failing node(s)
#   label (critical) x2 — Form elements must have labels
# wrote a11y-report.json — 4 violation(s) over 4 route(s)

# 7. Check the artifact the workflow will upload.
node -e "const r=require('./a11y-report.json');
console.log(r.pages.length,'pages',r.scannedAt);"
# 4 pages 2026-07-25T09:14:52.108Z

Once that sequence passes locally, push the workflow on a branch and open a draft pull request. The first CI run is where genuine environment differences show up, and they are worth reading rather than suppressing: a contrast finding that exists only on the runner usually means a webfont did not load, which is a real production risk for anyone on a slow connection.

Where the two and a half minutes go Horizontal bars show each step's duration on a cold cache: checkout four seconds, node setup seven, npm ci forty-one, build twenty-eight, Chromium download thirty-four, server wait three, scan twenty-two and artifact upload six. First green run on a cold cache a warm cache removes the 34 s Chromium download and most of npm ci checkout 4 s setup-node + cache 7 s npm ci 41 s npm run build 28 s playwright install 34 s serve + wait for 200 3 s scan 4 routes 22 s upload artifact 6 s 0 30 s 60 s 90 s 120 s 150 s
The scan itself is under a fifth of the run; optimising it before caching the install and the browser download is effort spent in the wrong place.

Edge Cases and Conditional Guards

  • Client-side routing: a static file server returns 404 for /pricing unless the framework emitted pricing/index.html, and a single-page application needs a fallback so unknown paths serve index.html. Add --proxy http://127.0.0.1:4321? to the http-server invocation for the fallback case, and always keep the route-status check from step 5 of the validation sequence, because a 404 page scans almost perfectly clean.
  • Content that arrives after load: waitUntil: 'load' plus waiting for the main landmark covers a server-rendered page, but a page that fetches its content after mount needs a wait on something that only exists once the data is in. Waiting on network idle is not a rendering signal; for route-driven apps, use the signals described in waiting for route transitions before an axe scan.
  • Pages behind authentication: the four public routes here need no session, and the moment one does, the scan needs a storage-state file or a programmatic login before the loop. Keep the first version public-only — introducing credentials in the same change as the workflow makes both harder to debug.

Pipeline Impact

This workflow adds a check that always passes, which is the point: for the first week or two it exists to prove it runs reliably and to publish a number nobody has seen before. The artifact is the deliverable — download it, read the counts, and use them to decide which rule to fix first and what the blocking threshold should be. Adopting a gate before that baseline exists means picking a threshold by guesswork, and the guess is nearly always too strict on the first pull request that touches a legacy page.

Two upgrades follow naturally and should be separate changes. Turning the report into a merge requirement is the gate script and the branch-protection registration described in pull request gating and branch policies. Splitting this one job into a build, a matrix of scan shards and a reporting job is the architecture in the parent guide, and it is worth doing once the route list outgrows a handful of pages or the run passes about five minutes. Reducing the noise in the report before either upgrade — through scoped excludes and rule options rather than blanket disabling — is covered in reducing false positives in automated accessibility scanners.

Common Pitfalls

  • Pointing the scanner at a file path or a dist/ directory instead of an HTTP origin, which fails with an error that looks like a broken tool.
  • Scanning the dev server, whose error overlay, unminified stylesheets and extra root wrapper produce findings that do not exist in the shipped build.
  • Replacing the readiness poll with sleep 5, which passes on an idle runner and fails on a busy one for the rest of the workflow’s life.
  • Leaving the server’s stdout attached to the step, so the step waits forever on an open pipe instead of moving on.
  • Omitting if: always() from the upload step, so the report is missing in exactly the runs where somebody wants to read it.
  • Using npm install rather than npm ci, which can resolve different versions than the lockfile and makes CI results non-reproducible.
  • Listing routes that 404, producing a clean scan of an error page and a false sense that the site passes.
  • Making the first version block merges, so the team’s first experience of accessibility automation is an unexplained red check on an unrelated pull request.

FAQ

Is @axe-core/cli simpler than a Playwright script for a first version? It is shorter to invoke and it is a reasonable choice for a static marketing site: one command per URL, JSON on stdout, no script to maintain. The script wins as soon as you need a viewport, a wait condition, a login, or one report covering several routes — all of which arrive within about a month. Since the script is thirty lines and reuses the browser across routes, starting there avoids a rewrite.

Why pin ubuntu-24.04 instead of using ubuntu-latest? ubuntu-latest moves to a new image when GitHub promotes one, and the new image brings a different Chromium build, a different font package set and occasionally a different default locale. Any of those can change a contrast measurement or the accessible name of a control, which shows up as an accessibility finding appearing on an unrelated pull request. Pinning makes that change an explicit commit instead of a surprise.

How many routes should the first version scan? Enough to cover the shared chrome and one of each page archetype — typically three to five. The header, footer and navigation appear on every page, so scanning twenty routes mostly re-reports the same findings twenty times while making the job slower and the report harder to read. Grow the list when a route has markup nothing else has, not to increase coverage numbers.