Runbook — accessibility regression gate
This runbook covers the CI accessibility gate that scans every route in
scripts/a11y/routes.json (every page in plans/accessibility.md §4)
through @axe-core/playwright plus a pa11y cross-check.
The gate is wired in vetrix-ci.yml as the a11y-gate job. It runs on
every push and merge request, and fails the pipeline on any Serious
or Critical axe violation.
What the gate touches
vetrix-ci.yml—a11y-gatejob (Vetrix schema; no GitLab keys)docker-compose.yml(repo root) — dev stack withpostgres,api,webservices. Aliased todbandserverso the prod override (deployments/docker/docker-compose.prod.yml) still applies.scripts/a11y/gate.mjs— the driverscripts/a11y/routes.json— the route list (101 routes; §4.1 – §4.14)web/package.json—npm run test:a11yweb/a11y-report/— output artifact directory (uploaded by CI)
Bringing the stack up locally
The gate scans whatever A11Y_BASE_URL resolves to. For a local run
against https://www.gitvetrix.test:
# From the repo root
docker compose -f docker-compose.yml up -d web api postgres
# Wait for the web service to answer
for i in $(seq 1 60); do
curl -skf https://www.gitvetrix.test/privacy >/dev/null && break
sleep 2
done
# Run the gate (axe + pa11y, all 101 routes)
cd web && npm ci && npx playwright install --with-deps chromium
npm run test:a11y
The docker-compose.yml at the repo root is the base file — the
production override (deployments/docker/docker-compose.prod.yml)
extends it. Bringing the stack up without the base file fails because
the prod override targets services named db and server. The base
file aliases postgres → db and api → server so both names
resolve.
Output artifacts
The driver writes three files under web/a11y-report/:
| File | Purpose |
|---|---|
summary.json |
machine-readable per-route axe + pa11y findings |
summary.html |
human-readable report (open in a browser) |
junit.xml |
JUnit-formatted failure list for downstream tooling |
docker.log |
docker-compose logs for web/api/postgres, ALWAYS |
| captured (success and failure) so an a11y regression | |
| can be told apart from a stack bring-up failure |
docker.log lives inside web/a11y-report/ rather than at the
repo root. The Vetrix pipeline schema only supports artifacts.paths:
(no artifacts.when:, no artifacts.reports.junit:, no
artifacts.expire_in:); shipping the log alongside the rest of the
report avoids needing extra artifact paths the parser would reject.
Why the CI YAML looks the way it does
vetrix-ci.yml carries no top-level version: key, so it is parsed
against the v1 schema (internal/cicd/parser/v1.go; the version
dispatcher that selects v1 vs v2 is internal/cicd/parser/parser.go).
Everything in this section describes v1.
The v1 parser accepts only:
- top-level:
stages,variables,branch_order,version, plus job-name keys - per-job:
stage,image,commands,variables,only,except,secret,environment,allow_failure,artifacts.paths
It explicitly rejects before_script, after_script, script,
services, artifacts.when, artifacts.reports,
artifacts.expire_in, the top-level GitLab keys workflows,
include, and default (incompatibleTopLevelKeys in
internal/cicd/parser/v1.go), plus the GitHub-Actions keys on,
runs-on, steps, jobs. Using any of these GitLab- or
GitHub-isms in vetrix-ci.yml causes the parser to reject the
pipeline at trigger time.
allow_failure: is not on that rejection list. It is a first-class
boolean job field, valid under both the v1 and the v2 schema: parsed
by both (rawJob.AllowFailure in internal/cicd/parser/v1.go,
rawJobV2.AllowFailure in internal/cicd/parser/v2.go), defaulting to
false in both, persisted to the pipeline_jobs.allow_failure column,
and read back by the engine for the soft- vs hard-failure split
(jobOutcome, defined in internal/cicd/engine_webhook.go and called
from internal/cicd/engine.go). A non-boolean value is a parse error
under both schemas. For the full soft-failure semantics see
pipeline-reference.md § Tolerating job failures.
Three quirks of the rewrite to be aware of when you edit it:
- No
before_script. Stack bring-up, the health-check loop,npm ci, andplaywright installare inlined into the job'scommands:list. - No
after_script. Diagnostics capture (docker compose logs) is collapsed into the same shell expression as the gate run, withset +e+exit $rcso the gate's exit code propagates while the logs are still always captured. Vetrix's worker joinscommands:with&&(buildShellScriptincmd/worker/docker_executor.go), so a bare line after a failing command would be skipped. - No
services: docker:dind. The runner image must already provide a workingdocker composeCLI against a reachable daemon; the default Vetrix runner mounts the host docker socket.
Environment overrides
All inputs are env-var driven on the gate side:
| Env var | Default | Effect |
|---|---|---|
A11Y_BASE_URL |
from routes.json |
Override the scan target |
A11Y_USER |
kcoder |
Login username |
A11Y_PASS |
(dev seed fixture — see below) | Login password |
A11Y_REPORT_DIR |
a11y-report |
Output directory |
A11Y_FAIL_ON |
serious,critical |
Comma-list of axe impact levels that gate |
A11Y_ROUTES |
(empty — all) | Comma-list of route ids to scan |
A11Y_SKIP_PA11Y |
(unset) | 1 to skip pa11y (axe-only fast iteration) |
A11Y_TIMEOUT_MS |
30000 |
Per-route navigation timeout |
A11Y_PASS has no literal default in this table on purpose. The value is
a development seed fixture — not a live credential — committed across the
backend's test/bench fixtures (vetrix backend repo: bench/,
scripts/transfer-fixtures/, tests/web/selenium/, and an authapi
integration test) for local development and CI use. This page follows the
pattern in authoring-vetrix-pipelines.md § Variables, interpolation &
secrets:
never inline a secret-shaped value, even a fixture one, so a reader cannot
copy a stale or wrong value out of the docs. Set A11Y_PASS from the
runner-host secret binding (CI) or the dev-stack bootstrap seed (local) —
see playwright-stack-target.md §
Credentials
for how the equivalent Playwright-side credential is sourced.
For a quick local pass against three routes:
A11Y_ROUTES=login,welcome,repositories A11Y_SKIP_PA11Y=1 \
npm --prefix web run test:a11y
Triage workflow
- Pipeline failure on
a11y-gate— open the artifact bundle, readweb/a11y-report/summary.html. Failing routes carry a red badge. - Suspect a stack bring-up issue, not a real a11y regression —
read
web/a11y-report/docker.log. Look for non-2xx responses on/privacyor postgres init errors. - A pa11y-only finding — pa11y is a cross-check; its issues are
reported in
summary.jsonunder each route'spa11yIssuesbut do NOT gate. They land in the report for triage only. - Want to land a fix and re-run — push the fix, the gate runs on the next CI build. To run locally first, follow the "Bringing the stack up locally" section.
Adding a new §4 route
- Add an entry to
scripts/a11y/routes.jsonunder eitherauthenticatedorunauthenticated. Required fields:id(kebab-case, unique),path(rooted with/, may use{placeholder}tokens against thefixturesblock), andsection(the4.Nsubsection it inherits from). - If the new path uses a placeholder not yet in
fixtures, add one that resolves against the deterministic seed repo. The schema-guard test (web/src/__tests__/lib/a11yRoutesSchema.test.ts) fails any PR that adds a{placeholder}without a matching fixture. - Run the schema-guard test:
npm --prefix web test -- a11yRoutesSchema. - Optionally do a one-route smoke pass:
A11Y_ROUTES=<your-id> A11Y_SKIP_PA11Y=1 npm --prefix web run test:a11y.