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.
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"
}
}
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.
Edge Cases and Conditional Guards
- Client-side routing: a static file server returns 404 for
/pricingunless the framework emittedpricing/index.html, and a single-page application needs a fallback so unknown paths serveindex.html. Add--proxy http://127.0.0.1:4321?to thehttp-serverinvocation 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 installrather thannpm 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.
Related
- GitHub Actions a11y Pipeline Setup — how this single job becomes a build, scan, report and gate graph.
- Blocking Pull Requests on Critical Accessibility Violations — the next change, once the baseline from this report is known.
- Web Accessibility Testing: Fundamentals & Tools — choosing between axe-core, Lighthouse CI and pa11y before wiring any of them into a workflow.