A11yRisk/Docs/CI/CD Integration

CI/CD Integration

Add an accessibility gate to your pipeline. Submit a scan, wait for the result, and fail the build if thresholds are not met.

Approach 1 — Stream events (recommended)

Use the SSE endpoint GET /v1/ci/scans/{scan_id}/events to open a single connection that streams progress updates and closes automatically with the final result. This is more efficient than polling and works well for scans of any size.

The stream sends keepalive comments every 15 seconds (lines starting with :) to keep proxies alive. Filter them out with grep '^data:'. The last data: line is always the terminal event containing score, violation counts, and pass/fail verdict.

GitHub Actions

jobs:
  accessibility:
    runs-on: ubuntu-latest
    steps:
      - name: Submit scan
        id: scan
        run: |
          RESPONSE=$(curl -sf -X POST https://api.a11yrisk.eu/v1/ci/scans \
            -H "X-API-Key: ${{ secrets.A11YRISK_API_KEY }}" \
            -H "Content-Type: application/json" \
            -d '{
              "url": "https://your-staging-url.com",
              "max_pages": 10,
              "min_score": 80,
              "fail_on_severity": "critical"
            }')
          echo "scan_id=$(echo $RESPONSE | jq -r .scan_id)" >> $GITHUB_OUTPUT

      - name: Wait for result (SSE)
        run: |
          SCAN_ID=${{ steps.scan.outputs.scan_id }}
          # curl blocks until the server closes the stream (terminal event reached).
          # grep strips keepalive lines; tail -1 gives the terminal event.
          TERMINAL=$(curl -sfN \
            -H "X-API-Key: ${{ secrets.A11YRISK_API_KEY }}" \
            "https://api.a11yrisk.eu/v1/ci/scans/$SCAN_ID/events" \
            | grep "^data:" | tail -1)
          DATA="${TERMINAL#data: }"
          EVENT=$(echo "$DATA" | jq -r '.event')
          PASSED=$(echo "$DATA" | jq -r '.passed // "null"')
          case "$EVENT:$PASSED" in
            completed:true|completed:null)
              echo "Scan passed. Score: $(echo "$DATA" | jq '.score')"
              ;;
            completed:*)
              echo "Scan failed: $(echo "$DATA" | jq -r '.message')"
              exit 1
              ;;
            *)
              echo "Scan did not complete (event: $EVENT)"
              exit 1
              ;;
          esac

GitLab CI

accessibility:
  stage: test
  image: alpine
  before_script:
    - apk add --no-cache curl jq bash
  script:
    - |
      bash -c '
        SCAN_ID=$(curl -sf -X POST https://api.a11yrisk.eu/v1/ci/scans \
          -H "X-API-Key: $A11YRISK_API_KEY" \
          -H "Content-Type: application/json" \
          -d "{"url":"https://your-staging-url.com","max_pages":10,"min_score":80}" \
          | jq -r .scan_id)
        TERMINAL=$(curl -sfN \
          -H "X-API-Key: $A11YRISK_API_KEY" \
          "https://api.a11yrisk.eu/v1/ci/scans/$SCAN_ID/events" \
          | grep "^data:" | tail -1)
        DATA="${TERMINAL#data: }"
        EVENT=$(echo "$DATA" | jq -r ".event")
        PASSED=$(echo "$DATA" | jq -r ".passed // "null"")
        case "$EVENT:$PASSED" in
          completed:true|completed:null) echo "Passed. Score: $(echo "$DATA" | jq .score)" ;;
          completed:*) echo "Failed: $(echo "$DATA" | jq -r .message)"; exit 1 ;;
          *) echo "Did not complete (event: $EVENT)"; exit 1 ;;
        esac
      '

Approach 2 — Poll for result

Poll GET /v1/ci/scans/{scan_id} every 5–10 seconds. Returns 202 while in progress, 200 on pass, 422 on fail. Use this as a fallback if your environment blocks long-lived HTTP connections.

- name: Poll for result
  run: |
    SCAN_ID=${{ steps.scan.outputs.scan_id }}
    for i in $(seq 1 60); do
      RESULT=$(curl -sf https://api.a11yrisk.eu/v1/ci/scans/$SCAN_ID \
        -H "X-API-Key: ${{ secrets.A11YRISK_API_KEY }}" \
        -w "\n%{http_code}")
      HTTP_CODE=$(echo "$RESULT" | tail -1)
      BODY=$(echo "$RESULT" | head -1)
      if [ "$HTTP_CODE" = "200" ]; then
        echo "Passed. Score: $(echo $BODY | jq .score)"
        exit 0
      elif [ "$HTTP_CODE" = "422" ]; then
        echo "Failed: $(echo $BODY | jq -r .message)"
        exit 1
      fi
      echo "Still running (attempt $i/60)…"
      sleep 10
    done
    echo "Timed out"
    exit 1

Approach 3 — Submit now, check later

For large sites (100+ pages) or nightly audits, blocking your pipeline for 30–60 minutes is impractical. Instead, decouple submission from result checking: submit the scan early in the pipeline, then check the result in a later step after other work has run.

This works because the scan continues running in the background regardless of whether your pipeline is connected. The scan_id is stable — you can check it minutes or hours later.

GitHub Actions — two-job pattern

jobs:
  # Job 1: Submit scan at the start of your workflow, alongside other work.
  submit-a11y-scan:
    runs-on: ubuntu-latest
    outputs:
      scan_id: ${{ steps.submit.outputs.scan_id }}
    steps:
      - name: Submit scan
        id: submit
        run: |
          SCAN_ID=$(curl -sf -X POST https://api.a11yrisk.eu/v1/ci/scans \
            -H "X-API-Key: ${{ secrets.A11YRISK_API_KEY }}" \
            -H "Content-Type: application/json" \
            -d '{"url":"https://your-staging-url.com","max_pages":200,"min_score":75}' \
            | jq -r .scan_id)
          echo "scan_id=$SCAN_ID" >> $GITHUB_OUTPUT

  # ... other jobs run in parallel (build, unit tests, deploy to staging, etc.)

  # Job 2: Gate on the a11y result. Runs after other jobs, giving the scan time to finish.
  check-a11y-result:
    runs-on: ubuntu-latest
    needs: [submit-a11y-scan, deploy-staging]  # waits for deploy before checking
    steps:
      - name: Wait for scan result (SSE)
        run: |
          SCAN_ID=${{ needs.submit-a11y-scan.outputs.scan_id }}
          TERMINAL=$(curl -sfN \
            -H "X-API-Key: ${{ secrets.A11YRISK_API_KEY }}" \
            "https://api.a11yrisk.eu/v1/ci/scans/$SCAN_ID/events" \
            | grep "^data:" | tail -1)
          DATA="${TERMINAL#data: }"
          PASSED=$(echo "$DATA" | jq -r '.passed // "null"')
          [ "$PASSED" = "false" ] && echo "Failed: $(echo "$DATA" | jq -r .message)" && exit 1
          echo "Passed. Score: $(echo "$DATA" | jq .score)"

By the time check-a11y-result starts, the scan has likely already finished (it ran while your deploy was happening). The SSE connection returns immediately if the scan is already in a terminal state.

Pass/fail thresholds

All thresholds are optional. Omit them to run a scan without a pass/fail verdict (passed will be null).

ParameterTypeEffect
min_score0–100Fail if overall accessibility score is below this value.
max_violationsintegerFail if total violation count exceeds this number.
fail_on_severitystringFail if any violation exists at this severity or higher. Values: critical, serious, moderate, minor.
fail_on_truncationbooleanDefault true. Return 402 if your credit balance can't cover max_pages. Set false to run a partial scan instead.

Credit budgeting

Each scan reserves one credit per page requested (max_pages). Plan accordingly: