Alfa A11y CI/CD

GitHub Actions · Accessibility CI

Add an accessibility gate to your CI

Render every page in a real browser, audit the DOM against WCAG with the open-source Siteimprove Alfa engine, and fail the build on violations — so accessibility regressions never reach your main branch.

Prerequisites

You need three things before you start. The whole setup assumes you can serve your app at a local URL — if it runs in a browser, Alfa can audit it.

  • Node 20+ — check with node -v
  • Your app runs locally at a URL — note the start command and port (e.g. npm run dev on 5173)
  • Code is in a GitHub repository with Actions enabled (the default)

The one thing you customize in this whole guide is Step 2 — how your app is served. Everything else is copy-paste.

Install dependencies

Three dev dependencies: the Playwright test runner, the Alfa audit engine, and the bridge between them. Then download the one browser Alfa audits in.

terminal
npm install -D @playwright/test @siteimprove/alfa-playwright @siteimprove/alfa-test-utils
npx playwright install chromium

Using pnpm, yarn, or bun? Swap npm install -D for your equivalent.

Add playwright.config.ts

This boots your app before the audit and points the browser at it. Paste it into your project root, then change the two ⬅ lines to match your app.

playwright.config.ts
import { defineConfig, devices } from "@playwright/test";

const PORT = 5173;                          // ⬅ your app's port
const BASE_URL = `http://localhost:${PORT}`;

export default defineConfig({
  testDir: "./tests",
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 1 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: [["html", { open: "never" }], ["list"]],
  use: { baseURL: BASE_URL, trace: "on-first-retry" },
  projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
  webServer: {
    command: "npm run dev",                 // ⬅ how to start YOUR app
    url: BASE_URL,
    reuseExistingServer: !process.env.CI,
    timeout: 120_000,
  },
});

Pick your command and PORT:

Stackcommandport
Vitenpm run dev5173
Next.jsnpm run build && npm run start3000
Create React Appnpm start3000
Angularnpm start4200
SvelteKitnpm run build && npm run preview4173
Astronpm run build && npm run preview4321
Static filesnpx serve . -l 51735173
Already deploy a preview URL per PR (Netlify, Vercel, Pages)? Drop webServer entirely and point baseURL at that URL instead.

Add the audit test

This is the gate itself: render the page, hand the live DOM to Alfa, and fail if any rule reports a failure. It's the same for every framework.

tests/accessibility.spec.ts
import { test, expect } from "@playwright/test";
import { Playwright } from "@siteimprove/alfa-playwright";
import { Audit, Logging, Rules } from "@siteimprove/alfa-test-utils";

// Enforce WCAG 2.1 A/AA + Best Practices + ARIA. (Change scope at the bottom.)
const conformanceTarget: typeof Rules.wcag21aaFilter = (rule) =>
  Rules.wcag21aaFilter(rule) ||
  Rules.bestPracticesFilter(rule) ||
  Rules.ARIAFilter(rule);

test("home page has no accessibility violations", async ({ page }) => {
  await page.goto("/");

  const alfaPage = await Playwright.toPage(await page.evaluateHandle("document"));
  const result = await Audit.run(alfaPage, { rules: { include: conformanceTarget } });

  Logging.fromAudit(result).print(); // readable report in the job log

  const failing = result.resultAggregates.filter((a) => a.failed > 0);
  expect(failing.size).toBe(0);
});

Guard more pages: copy the test(...) block and change the page.goto() path.

Add the npm script

One script so the same command runs locally and in CI.

package.json
{
  "scripts": {
    "test:a11y": "playwright test"
  }
}

Run it locally

Confirm the gate works on your machine before wiring CI. Playwright starts your server, Alfa audits it, and the command exits non-zero on any violation.

terminal
npm run test:a11y
  • It passes — or it lists real violations to fix (or narrow the scope in Step ±)
  • Open the full report: npx playwright show-report

Add the GitHub Actions workflow

This runs the exact same audit on every push and pull request, and uploads the HTML report as an artifact.

.github/workflows/accessibility.yml
name: Accessibility

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
  workflow_dispatch:

concurrency:
  group: a11y-${{ github.ref }}
  cancel-in-progress: true

jobs:
  accessibility:
    name: Accessibility audit (Alfa)
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm

      - run: npm ci

      - name: Install Playwright browser (Chromium)
        run: npx playwright install --with-deps chromium

      - name: Run Alfa accessibility audit
        run: npm run test:a11y

      - name: Upload Playwright report
        if: ${{ !cancelled() }}
        uses: actions/upload-artifact@v4
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 30

The CI step uses --with-deps (installs Linux libraries); your local Step 1 doesn't need it.

Commit, push, and open a PR

Push it on a branch and open a pull request — the check runs on the PR automatically.

terminal
git checkout -b ci/alfa-a11y
git add playwright.config.ts tests/accessibility.spec.ts package.json package-lock.json .github/workflows/accessibility.yml
git commit -m "ci: add Alfa accessibility gate"
git push -u origin ci/alfa-a11y
  • Open a pull request into main and watch the Accessibility audit (Alfa) check run

Make the check required

A green check doesn't block anything on its own. To stop merges on violations, mark it required — in the GitHub UI, after the check has run once (it only appears in the list once it exists).

  1. Repo → Settings → Rules → Rulesets → New branch ruleset (or Settings → Branches → Add rule)
  2. Target branch: main
  3. Enable Require status checks to pass
  4. Search for and select Accessibility audit (Alfa)
  5. Enable Require a pull request before merging (recommended)
  6. Save
Now any PR with a WCAG violation is blocked from merging.

Prove the gate actually blocks

Worth doing once so you trust it. On a scratch branch, introduce a violation and watch CI go red.

an intentional violation
<!-- image with no alt text — fails WCAG 1.1.1 -->
<img src="logo.png">
  • Push it → confirm the check turns red and the merge is blocked
  • Delete the scratch branch — you're done ✅

Change the strictness

The audit's scope is just the conformanceTarget predicate from Step 3. Swap the filters to move the bar.

GoalReplace the body with
WCAG 2.1 AA onlyRules.wcag21aaFilter(rule)
Latest AA (WCAG 2.2)Rules.aaFilter(rule)
Everything, incl. AAAomit the rules option in Audit.run()
Retrofitting an existing site? Start at WCAG 2.1 AA only, get the gate green, mark it required, then ratchet the scope up. A gate that's red on day one gets disabled by day three.

Optional: publish results to Siteimprove

Everything above runs fully offline — the audit happens in your own CI runner and no page content or results leave your environment. You never need a Siteimprove account for the gate to work.

If your organisation does license the Siteimprove Intelligence Platform, you can additionally upload each audit to get dashboards and trends over time. This is purely opt-in and controlled by an API token.

Add three repository secrets under Settings → Secrets and variables → Actions:

SecretValue
SI_USER_EMAILYour Siteimprove account email
SI_API_KEYYour Siteimprove API token
SI_SITE_IDThe target site ID

Pass them to the audit step in the workflow:

.github/workflows/accessibility.yml
      - name: Run Alfa accessibility audit
        run: npm run test:a11y
        env:
          SI_USER_EMAIL: ${{ secrets.SI_USER_EMAIL }}
          SI_API_KEY: ${{ secrets.SI_API_KEY }}
          SI_SITE_ID: ${{ secrets.SI_SITE_ID }}

Then upload the result in the spec — only when all three are present:

tests/accessibility.spec.ts
import { Audit, Logging, Rules, SIP } from "@siteimprove/alfa-test-utils";
import { getCommitInformation } from "@siteimprove/alfa-test-utils/git";

const { SI_USER_EMAIL, SI_API_KEY, SI_SITE_ID } = process.env;

// No credentials? Skip the upload — the local gate still runs.
const reportUrl =
  SI_USER_EMAIL && SI_API_KEY && SI_SITE_ID
    ? await SIP.upload(result, {
        userName: SI_USER_EMAIL,
        apiKey: SI_API_KEY,
        siteID: Number(SI_SITE_ID),
        commitInformation: await getCommitInformation(),
      })
    : undefined;

Logging.fromAudit(result, reportUrl).print(); // prints a link when uploaded
  • Safe by default — if any secret is missing, the upload is skipped entirely and the pass/fail gate behaves exactly as before.
  • Fork pull requests are unaffected — GitHub doesn't expose secrets to forks, so the upload simply doesn't run.
  • Treat the API token as a credential — keep it in Actions secrets, never in the repo.
Check before you enable it. Turning this on means audit results leave your environment and reach a third-party service. If you're in an organisation with a security review process, clear it there first — the gate itself needs none of this.