The GitHub Action
Runs axe-core against your URLs on every pull request and fails the build only on violations you have not already accepted.
# .github/workflows/a11y.yml
name: Accessibility
on: pull_request
jobs:
a11y:
runs-on: ubuntu-latest
permissions:
pull-requests: write # for the report comment
steps:
- uses: actions/checkout@v4
- uses: skiplink/action@v1
with:
urls: https://staging.example.com
baseline: .skiplink/baseline.json
token: ${{ secrets.SKIPLINK_TOKEN }}
That is the whole setup.
Create the baseline first
Without baseline: the gate fails on every violation it finds, which on an
existing site means every build. Run this once, locally:
npx @skiplink/cli scan https://staging.example.com \
--baseline .skiplink/baseline.json --update-baseline
git add .skiplink/baseline.json
git commit -m "Accept existing accessibility violations"
Read the diff before committing. Every entry is a violation the gate will now ignore, and reviewing that list is the entire point of keeping it in the repository.
What your team sees
- A check on the pull request, red only when something new appeared.
- A comment listing the new violations with rule, impact, page and selector — one comment that edits itself on each push, not a new one every time. A bot that spams a thread is a bot people mute.
- Annotations on the run for each new violation.
- A job summary with the same report, so it is there even without comment permission.
Inputs
| Input | Default | Notes |
|---|---|---|
| urls | — | Required. One per line, or space-separated. |
| token | — | Required. Store as a repository secret. |
| baseline | — | Path to the committed baseline file. |
| fail-on | new | new, critical, serious, moderate, minor, never |
| standard | wcag22aa | Rule set. See the standards table. |
| wait-for | — | CSS selector to wait for. For apps that render late. |
| comment | true | Post and update the PR comment. |
| cli-version | latest | Pin for reproducible builds. |
Outputs
| Output | Notes |
|---|---|
| passed | true when the gate passed. |
| new-violations | Count of violations not in the baseline. |
| report | Path to the markdown report, for a later step. |
Recipes
Several pages
- uses: skiplink/action@v1
with:
urls: |
https://staging.example.com/
https://staging.example.com/pricing
https://staging.example.com/checkout
baseline: .skiplink/baseline.json
token: ${{ secrets.SKIPLINK_TOKEN }}
Preview deployments
Baseline entries key on path rather than hostname, so a baseline recorded against one preview URL keeps matching on the next one. Feed it whatever URL your deploy step produced:
- id: deploy
run: ./deploy-preview.sh # sets outputs.preview-url
- uses: skiplink/action@v1
with:
urls: ${{ steps.deploy.outputs.preview-url }}
baseline: .skiplink/baseline.json
token: ${{ secrets.SKIPLINK_TOKEN }}
Warn without blocking
While a team gets used to it. The report still appears; nothing goes red.
- uses: skiplink/action@v1
continue-on-error: true
with:
fail-on: never
urls: https://staging.example.com
token: ${{ secrets.SKIPLINK_TOKEN }}
An app that renders late
- uses: skiplink/action@v1
with:
urls: https://staging.example.com/app
wait-for: "[data-testid=dashboard]"
token: ${{ secrets.SKIPLINK_TOKEN }}
Notes
This is a composite action that runs the skiplink
npm package. Everything it executes is readable in action.yml, rather than a
committed dist/ bundle nobody reviews. It needs Node 20+, which
ubuntu-latest already has.
A scan that could not run — site down, network blocked — exits with a code distinct from a gate failure and is reported as "could not complete", never as a pass.
What a green check means
No automatically detectable violations were found. Automated testing covers a subset of WCAG, so a green check is a floor, not a conformance claim. The long version.