Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Set update schedule for GitHub Actions
# Set update schedule for GitHub Actions and npm dependencies

version: 2
updates:
Expand All @@ -8,3 +8,8 @@ updates:
schedule:
# Check for updates to GitHub Actions every week
interval: "weekly"

- package-ecosystem: "npm"
directory: "/runner"
schedule:
interval: "weekly"
33 changes: 33 additions & 0 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
name: CodeQL

on:
push:
branches:
- main
pull_request:
schedule:
# Weekly scan to catch newly published CVEs against the existing codebase
- cron: "0 8 * * 1"

permissions:
contents: read

jobs:
codeql:
name: CodeQL SAST
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write # Required to upload SARIF results

steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

- uses: github/codeql-action/init@03e4368ac7daa2bd82b3e85262f3bf87ee112f57 # v3.36.0
with:
languages: javascript-typescript
queries: security-extended

- uses: github/codeql-action/autobuild@03e4368ac7daa2bd82b3e85262f3bf87ee112f57 # v3.36.0

- uses: github/codeql-action/analyze@03e4368ac7daa2bd82b3e85262f3bf87ee112f57 # v3.36.0
12 changes: 6 additions & 6 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ jobs:
steps:
- name: Determine version bump from merged PR
id: bump
uses: actions/github-script@v9
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
// Find the PR whose merge commit matches this push
Expand Down Expand Up @@ -54,7 +54,7 @@ jobs:

- name: Get latest release tag
id: latest
uses: actions/github-script@v9
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
try {
Expand All @@ -76,10 +76,10 @@ jobs:
LATEST_TAG: ${{ steps.latest.outputs.tag }}
BUMP_TYPE: ${{ steps.bump.outputs.type }}
run: |
# Validate and normalise the tag — fall back to v0.0.0 if unexpected format
# Validate the tag — fail if unexpected format
if [[ ! "$LATEST_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Tag '${LATEST_TAG}' does not match vMAJOR.MINOR.PATCH — falling back to v0.0.0"
LATEST_TAG="v0.0.0"
echo "Error: Tag '${LATEST_TAG}' does not match vMAJOR.MINOR.PATCH — refusing to release."
exit 1
fi

TAG="${LATEST_TAG#v}"
Expand All @@ -104,7 +104,7 @@ jobs:
echo "tag=${NEW_TAG}" >> "$GITHUB_OUTPUT"

- name: Create GitHub release
uses: actions/github-script@v9
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
NEW_TAG: ${{ steps.version.outputs.tag }}
with:
Expand Down
5 changes: 4 additions & 1 deletion .github/workflows/self-test.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
name: Self-test

on:
workflow_dispatch:
inputs:
Expand Down Expand Up @@ -28,6 +27,10 @@ jobs:
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
with:
version: "11"

- name: Run accessibility check
id: a11y
# The test URL may have existing violations; that means the action ran correctly,
Expand Down
17 changes: 17 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,19 @@
node_modules/
dist/

# Private keys and certificates — prevent accidental commits
*.pem
*.key
*.p12
*.pfx
*.cer
*.crt
id_rsa
id_rsa.pub
id_ed25519
id_ed25519.pub

# Environment files — use .env.example for documentation
.env
.env.local
.env.*.local
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,26 @@ Run [axe-core](https://github.com/dequelabs/axe-core#axe-core) accessibility tes

On average, [57%](https://www.deque.com/automated-accessibility-coverage-report/) of accessibility issues can be detected with automated testing, and fixing them as early as possible saves time and effort. Use this action to quick-start your accessibility journey and catch regressions before they reach production. Add 4 lines to your testing workflow, and get a detailed report of accessibility violations with links to the exact failing elements and rules documentation.

## Requirements

pnpm (≥ 9) must be available in the job before the action runs — the action's internal Playwright runner is a pnpm project. If your workflow doesn't already set it up:

```yaml
- uses: pnpm/action-setup@v6
with:
version: "11"
```

If your workflow already uses pnpm (e.g. to build your app), no extra step is needed.

## Quick start

```yaml
# In your workflow — after your app is built and started:
- uses: pnpm/action-setup@v6
with:
version: "11"

- uses: leekeh/axtion@92dac76ca6d585203df89b544d037392c325f9d8 # v 0.0.1
with:
base-url: http://localhost:3000
Expand Down
12 changes: 12 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Security Policy

## Reporting a Vulnerability

Please **do not** open a public GitHub issue for security vulnerabilities.

To report a vulnerability, reach out privately:

- **Email:** hello@leekeh.com
- **Bluesky DM:** [@leekeh.com](https://bsky.app/profile/leekeh.com)

Reports are taken seriously and will be responded to in a timely manner. You will receive an acknowledgement and be kept informed of progress toward a fix.
151 changes: 2 additions & 149 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -91,156 +91,9 @@ inputs:
outputs:
violations-found:
description: "'true' if accessibility violations were found"
value: ${{ steps.run-tests.outcome == 'failure' && 'true' || 'false' }}
report-url:
description: "URL to the Actions run where artifacts are available (empty if generate-report is false or tests passed)"
value: ${{ steps.upload-reports.outputs.run-url }}

runs:
using: "composite"
steps:
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "24"

- name: Set up pnpm
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8

- name: Install dependencies
shell: bash
env:
A11Y_BROWSER: ${{ inputs.browser }}
run: |
node --experimental-strip-types "${{ github.action_path }}/runner/print-header.ts" "📦 Installing dependencies"
pnpm install --frozen-lockfile --dir "${{ github.action_path }}/runner"
BROWSER="$A11Y_BROWSER"
case "$BROWSER" in
chromium|firefox|webkit) ;;
*) echo "::error::Invalid browser '${BROWSER}'. Must be chromium, firefox, or webkit."; exit 1 ;;
esac
pnpm --dir "${{ github.action_path }}/runner" exec playwright install "$BROWSER"

- name: Prepare configuration
id: prepare-routes
shell: bash
env:
ROUTES_INPUT: ${{ inputs.routes }}
ROUTES_FILE_INPUT: ${{ inputs.routes-file }}
BASE_URL: ${{ inputs.base-url }}
A11Y_BROWSER: ${{ inputs.browser }}
A11Y_WORKERS: ${{ inputs.workers }}
A11Y_WAIT_STRATEGY: ${{ inputs.wait-strategy }}
A11Y_RULES: ${{ inputs.rules }}
A11Y_RULESETS: ${{ inputs.rulesets }}
A11Y_DISABLED_RULES: ${{ inputs.disabled-rules }}
A11Y_EXCLUSIONS: ${{ inputs.exclusions }}
run: |
node --experimental-strip-types "${{ github.action_path }}/runner/print-header.ts" "⚙️ Preparing configuration"

MANIFEST_PATH="${RUNNER_TEMP}/a11y-routes.json"
echo "manifest-path=${MANIFEST_PATH}" >> "$GITHUB_OUTPUT"

if [ -n "${ROUTES_FILE_INPUT}" ]; then
# Guard against path traversal: reject paths that escape GITHUB_WORKSPACE
ROUTES_FILE_REAL="$(realpath -m "${ROUTES_FILE_INPUT}")"
WORKSPACE_REAL="$(realpath "${GITHUB_WORKSPACE}")"
if [[ "${ROUTES_FILE_REAL}" != "${WORKSPACE_REAL}" && "${ROUTES_FILE_REAL}" != "${WORKSPACE_REAL}/"* ]]; then
echo "::error::routes-file '${ROUTES_FILE_INPUT}' must be a path within GITHUB_WORKSPACE."
exit 1
fi
cp "${ROUTES_FILE_INPUT}" "${MANIFEST_PATH}"
elif [ -n "${ROUTES_INPUT}" ]; then
printf '%s' "${ROUTES_INPUT}" > "${MANIFEST_PATH}"
else
echo "::error::Either the 'routes' or 'routes-file' input is required."
exit 1
fi

MANIFEST_PATH="$MANIFEST_PATH" node --experimental-strip-types "${{ github.action_path }}/runner/print-config.ts"
node --experimental-strip-types "${{ github.action_path }}/runner/print-resolved-rules.ts"

- name: Run tests
id: run-tests
shell: bash
continue-on-error: true
env:
BASE_URL: ${{ inputs.base-url }}
ROUTES_FILE: ${{ steps.prepare-routes.outputs.manifest-path }}
A11Y_BROWSER: ${{ inputs.browser }}
A11Y_WORKERS: ${{ inputs.workers }}
A11Y_EXCLUSIONS: ${{ inputs.exclusions }}
A11Y_WAIT_STRATEGY: ${{ inputs.wait-strategy }}
A11Y_GENERATE_REPORT: ${{ inputs.generate-report }}
A11Y_RULES: ${{ inputs.rules }}
A11Y_RULESETS: ${{ inputs.rulesets }}
A11Y_DISABLED_RULES: ${{ inputs.disabled-rules }}
A11Y_REPORT_DIR: a11y-reports
A11Y_RESULTS_FILE: ${{ runner.temp }}/a11y-results.json
run: |
node --experimental-strip-types "${{ github.action_path }}/runner/print-header.ts" "🧪 Running tests"
cd "$GITHUB_WORKSPACE"
"${{ github.action_path }}/runner/node_modules/.bin/playwright" test \
--config="${{ github.action_path }}/runner/playwright.config.ts"
PLAYWRIGHT_EXIT=$?

node --experimental-strip-types "${{ github.action_path }}/runner/print-header.ts" "📊 Results"
A11Y_PLAYWRIGHT_EXIT="$PLAYWRIGHT_EXIT" node --experimental-strip-types "${{ github.action_path }}/runner/print-results.ts"
exit "$PLAYWRIGHT_EXIT"

- name: Check for report files
id: check-reports
if: steps.run-tests.outcome == 'failure' && inputs.generate-report == 'true'
shell: bash
env:
REPORT_DIR: ${{ github.workspace }}/a11y-reports
run: |
node --experimental-strip-types "${{ github.action_path }}/runner/print-header.ts" "📊 Creating output"
if [ -d "$REPORT_DIR" ] && [ -n "$(ls -A "$REPORT_DIR" 2>/dev/null)" ]; then
echo "has-reports=true" >> "$GITHUB_OUTPUT"
else
echo "has-reports=false" >> "$GITHUB_OUTPUT"
fi

- name: Stage upload action
if: steps.check-reports.outputs.has-reports == 'true'
shell: bash
run: |
# `uses: ./path` in composite actions resolves relative to GITHUB_WORKSPACE
# (the consumer's repo), not to this action's own directory. We stage the
# sub-action files there so the runner can find them.
dst="$GITHUB_WORKSPACE/.axtion-upload"
mkdir -p "$dst"
cp "${{ github.action_path }}/runner/upload-action/action.yml" "$dst/"
cp "${{ github.action_path }}/runner/upload-action/upload.ts" "$dst/"
ln -sfn "${{ github.action_path }}/runner/node_modules" "$dst/node_modules"

- name: Upload reports
id: upload-reports
if: steps.check-reports.outputs.has-reports == 'true'
uses: ./.axtion-upload
env:
NODE_OPTIONS: --experimental-strip-types
REPORT_DIR: ${{ github.workspace }}/a11y-reports
ARTIFACT_URLS_FILE: ${{ runner.temp }}/a11y-artifact-urls.json

- name: Post PR comment
if: inputs.post-comment == 'true' && github.event_name == 'pull_request'
shell: bash
env:
GH_TOKEN: ${{ inputs.github-token }}
GITHUB_PR_NUMBER: ${{ github.event.pull_request.number }}
A11Y_OUTCOME: ${{ steps.run-tests.outcome }}
REPORT_URL: ${{ steps.upload-reports.outputs.run-url }}
ARTIFACT_URLS_FILE: ${{ runner.temp }}/a11y-artifact-urls.json
REPORT_DIR: ${{ github.workspace }}/a11y-reports
RESULTS_FILE: ${{ runner.temp }}/a11y-results.json
CUSTOM_TEMPLATE_SUCCESS: ${{ inputs.comment-template-success }}
CUSTOM_TEMPLATE_FAILURE: ${{ inputs.comment-template-failure }}
ACTION_PATH: ${{ github.action_path }}
run: node --experimental-strip-types "${{ github.action_path }}/runner/post-comment.ts"

- name: Fail if violations found
if: steps.run-tests.outcome == 'failure'
shell: bash
run: exit 1
using: "node24"
main: "runner/boot.cjs"
28 changes: 11 additions & 17 deletions runner/a11y.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,18 @@ import {
type ReadinessStrategy,
type WaitUntilStrategy,
} from "./normalize-routes.ts";
import * as fs from "node:fs";
import { existsSync, readFileSync } from "node:fs";

// ── Load routes manifest ─────────────────────────────────────────────────────

const routesFile = process.env.ROUTES_FILE;
if (!routesFile || !fs.existsSync(routesFile)) {
if (!routesFile || !existsSync(routesFile)) {
throw new Error(
`ROUTES_FILE env var must point to a valid routes manifest. Got: ${routesFile}`,
);
}

const rawManifest = JSON.parse(fs.readFileSync(routesFile, "utf-8"));
const rawManifest = JSON.parse(readFileSync(routesFile, "utf-8"));
const routes = normalizeRoutes(rawManifest);

// ── Global default wait strategy ─────────────────────────────────────────────
Expand Down Expand Up @@ -56,7 +56,7 @@ if (routes.length === 0) {
function buildTestName(entry: RouteEntry): string {
if (entry.name) return entry.name;
const [pathPart, query] = entry.path.split("?");
let name = pathPart.replace(/^\//, "").replaceAll("/", " / ") || "home";
let name = pathPart.replace(/^\//, "").replaceAll("/", " / ") || "index";
if (query) name += ` (${query})`;
return name;
}
Expand All @@ -72,17 +72,15 @@ function getWaitUntil(
fallback: WaitUntilStrategy,
): WaitUntilStrategy {
if (!readiness) return fallback;
if (
readiness.type === "networkidle" ||
readiness.type === "load" ||
readiness.type === "domcontentloaded"
) {
return readiness.type;
}
const type = readiness.type;
// For selector/js strategies, navigate with "load" first, then apply the
// custom wait. Don't combine with networkidle to avoid long timeouts on
// apps that keep background polling active.
return "load";
if (type === "js" || type === "selector") {
return "load";
}

return type;
}

/**
Expand Down Expand Up @@ -116,11 +114,7 @@ async function applyReadiness(
const timeout = readiness.timeout ?? 30_000;
try {
// Poll until the expression is truthy.
// Security note: this expression is evaluated by Playwright inside the browser
// page context — not in the Node.js runner process. It is sandboxed by the
// browser engine and has no access to the runner filesystem, environment
// variables, or secrets. The caller fully controls the routes manifest, so
// this is intentional and the risk is scoped to the browser sandbox.
// Expression is evaluated in the browser sandbox and has no access to the runner environment.
await page.waitForFunction(readiness.expression, null, { timeout });
// Then wait for the event loop to drain (hydration, deferred renders, etc.)
await page.evaluate(
Expand Down
Loading
Loading