All posts

Automate Accessibility Checks in CI

Tags:
Automated accessibility tools with a magnifying glass over a gear and scan results

You know that automated tools exist. Now let’s wire some of them into our development workflow so every push and every pull request gets checked automatically — no manual clicks, no forgotten scans.

We will look into 2 levels of automated checks:

  • HTML validation on every push (pre-push hook)
  • WCAG 2.2 AA accessibility checks on every pull request (GitHub Actions)

Both running against our actual built pages.

This guide uses an Astro project with pnpm as an example, but html-validate and pa11y-ci work with any static site or framework. Adapt the build and serve commands to your stack.

Install the tools

You need 3 packages:

  • html-validate — lints built HTML files for structural issues, invalid ARIA, and spec violations
  • pa11y-ci — runs WCAG (Web Content Accessibility Guidelines) accessibility checks against rendered pages using a headless browser
  • husky — manages git hooks (pre-push, pre-commit)
pnpm add -D html-validate pa11y-ci husky

pa11y-ci needs a running server to check pages. Since Astro has a built-in preview server (astro preview), we use that. Adapt the serve command to your framework’s preview server.

Initialize husky:

npx husky init

This creates a .husky/ directory and adds a prepare script to your package.json.

Configure html-validate

Create .htmlvalidate.json in your project root:

{
"extends": ["html-validate:recommended"],
"rules": {
  "no-inline-style": "off",
  "no-trailing-whitespace": "off",
  "long-title": "warn",
  "void-style": "off",
  "no-raw-characters": "off",
  "attr-case": ["error", { "ignoreForeign": true }]
  }
}

Why these overrides?

RuleWhy off/warn
no-inline-styleSyntax highlighting (Shiki, Prism) generates inline styles in code blocks
no-trailing-whitespaceBuild tools and formatters may leave trailing spaces — not worth blocking on
long-titleSome page titles exceed the recommended length — warn instead of error
void-styleMDX renderers may output <hr/> instead of <hr> — both are valid
no-raw-charactersMarkdown-rendered content may contain > in text — valid in HTML
attr-caseSVG attributes like viewBox use camelCase — ignoreForeign allows this

Run it against your build output:

pnpm build
npx html-validate 'dist/**/*.html'

When you first run html-validate, expect it to find real issues — like aria-label on elements that don’t support it, missing type on buttons, or invalid nesting. Fix these before configuring rules to suppress them.

Configure pa11y-ci

Create .pa11yci.json in your project root:

{
"defaults": {
  "standard": "WCAG2AA",
  "timeout": 10000
},
"sitemap": "http://localhost:4321/sitemap-0.xml",
"sitemapFind": "https://your-domain.com",
"sitemapReplace": "http://localhost:4321"
}

Instead of listing URLs manually, pa11y-ci reads your sitemap and discovers all pages automatically. sitemapFind and sitemapReplace rewrite the production URLs to point at your local server.

WCAG2AA is the standard most accessibility laws require (EU’s EN 301 549, US Section 508). Change to WCAG2AAA if you want stricter checks.

To test locally:

pnpm build
pnpm preview & npx pa11y-ci

To check a single page during development, you can run pa11y directly without the CI config:

npx pa11y http://localhost:4321/blog/your-page/

Set up the hook to validate HTML on every push

Create .husky/pre-push:

pnpm build && npx html-validate 'dist/**/*.html'

Every git push now builds your site and validates the HTML. If validation fails, the push is blocked.

Why only html-validate in the hook?

pa11y-ci needs to start a server, launch a headless browser, and crawl every page — noticeably slower than html-validate, which just reads files from disk. Running the full a11y check in CI is my personal preference — you can also add it to the pre-push hook. Either way, keep it in CI — hooks can be skipped, CI is the last line of defense before code gets merged.

Create the GitHub Actions workflow

Create .github/workflows/validate.yml:

name: HTML & Accessibility Validation

on:
pull_request:
  branches: [main]

jobs:
validate:
  runs-on: ubuntu-latest
  steps:
    - uses: actions/checkout@v4

    - uses: pnpm/action-setup@v4

    - uses: actions/setup-node@v4
      with:
        node-version: 22
        cache: pnpm

    - run: pnpm install

    - name: Build website
      run: pnpm build

    - name: HTML validation
      run: pnpm validate:html

    - name: Start server & run a11y checks
      run: |
        # Override pa11y-ci config to use system Chrome
        cat > .pa11yci.json <<EOF
        {
          "defaults": {
            "standard": "WCAG2AA",
            "timeout": 10000,
            "chromeLaunchConfig": {
              "executablePath": "/usr/bin/google-chrome-stable",
              "args": ["--no-sandbox"]
            }
          },
          "sitemap": "http://localhost:4321/sitemap-0.xml",
          "sitemapFind": "https://your-domain.com",
          "sitemapReplace": "http://localhost:4321"
        }
        EOF

        pnpm preview &
        SERVER_PID=$!

        # Wait for server to be ready
        for i in $(seq 1 10); do
          curl -s http://localhost:4321/ > /dev/null && break
          sleep 1
        done

        pnpm validate:a11y
        EXIT_CODE=$?

        kill $SERVER_PID
        exit $EXIT_CODE

What this does:

  1. Builds the website
  2. Runs html-validate against all HTML files
  3. Overrides the pa11y-ci config to use system Chrome (needed in CI)
  4. Starts the preview server, waits for it, runs pa11y-ci against all pages from the sitemap, then cleans up

Add the npm scripts to your package.json:

{
  "scripts": {
    "validate:html": "html-validate 'dist/**/*.html'",
    "validate:a11y": "pa11y-ci"
  }
}

What you now have

  • html-validate catches structural HTML issues on every git push (pre-push hook)
  • pa11y-ci runs WCAG 2.2 AA checks against every page on every pull request (GitHub Actions)
  • Both run against your actual built pages, not source code — so they catch what users will see

Remember: This won’t replace manual testing with keyboard and assistive technologies. But they catch them consistently, on every change, without anyone needing to remember.

FAQ

Can I use axe-core instead of pa11y-ci?

Yes. @axe-core/cli is a good alternative. pa11y-ci has stronger multi-page CI support out of the box, including URL-list and sitemap-based runs with CI-friendly failure behavior, while axe-core is the widely adopted accessibility engine behind Deque’s axe tooling and influences Lighthouse accessibility scoring.

What about Lighthouse CI?

Lighthouse CI is strong when you want Lighthouse’s broader audit coverage — performance, SEO, best practices, and accessibility — in one CI workflow. It can be more involved than an accessibility-only runner, especially if you want uploaded reports, historical tracking, or GitHub status checks, but those are optional. For accessibility-only checks, pa11y-ci is typically simpler.

See also: