diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..884340b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,38 @@ +# PR gate for the composite action. Keeps action.yml + example/workflow YAML +# structurally valid on every change so a broken action can't merge. +name: CI + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Validate YAML is well-formed + shell: bash + run: | + set -euo pipefail + for f in action.yml examples/*.yml .github/workflows/*.yml; do + python3 -c "import sys,yaml; list(yaml.safe_load_all(open('$f')))" \ + && echo "ok: $f" || { echo "invalid YAML: $f"; exit 1; } + done + + - name: Validate action.yml structure + shell: bash + run: | + set -euo pipefail + test -f action.yml || { echo "action.yml missing"; exit 1; } + for key in name description runs inputs outputs; do + grep -qE "^${key}:" action.yml || { echo "action.yml missing '${key}:'"; exit 1; } + done + grep -qE "using: 'composite'|using: composite" action.yml \ + || { echo "action.yml must be a composite action"; exit 1; } + echo "action.yml is structurally valid." diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..d358bd2 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,76 @@ +# Release pipeline for the BrowserStack Accessibility DevTools Action. +# +# v1 is a composite action (action.yml) that shells out to bash + the published +# CLI, so there is nothing to bundle yet. This pipeline is a STUB that: +# - builds any bundled `dist/` if/when the action gains JS entrypoints (esbuild), +# - verifies action.yml is valid, +# - documents the (manual, admin-only) Marketplace publish step. +# +# Marketplace publishing needs a GitHub ORG ADMIN and is intentionally NOT +# automated here (see the "publish" job note below). +name: Release + +on: + push: + tags: + - 'v*' + workflow_dispatch: + inputs: + version: + description: 'Release version tag (e.g. v1.0.0)' + required: true + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '20' + + # Bundle a dist/ IF the action grows JS entrypoints (esbuild pattern). + # Guarded so the stub is a no-op for the current pure-composite action. + - name: Build bundled dist (esbuild) + shell: bash + run: | + set -euo pipefail + if [ -f package.json ] && jq -e '.scripts.build' package.json >/dev/null 2>&1; then + npm ci + # Example bundling target for a future JS action entrypoint: + # npx esbuild src/index.ts --bundle --platform=node \ + # --target=node20 --outfile=dist/index.js + npm run build + echo "Bundled dist/ produced." + else + echo "No package.json build script β€” pure composite action, nothing to bundle." + fi + + - name: Validate action.yml + shell: bash + run: | + set -euo pipefail + test -f action.yml || { echo "action.yml missing"; exit 1; } + # Minimal structural sanity: required top-level keys present. + for key in name description runs inputs outputs; do + grep -qE "^${key}:" action.yml || { echo "action.yml missing '${key}:'"; exit 1; } + done + echo "action.yml looks structurally valid." + + publish: + needs: build + runs-on: ubuntu-latest + steps: + - name: Marketplace publish (manual, org-admin only) + shell: bash + run: | + echo "GitHub Marketplace publishing is a MANUAL step and requires a GitHub org admin." + echo "Steps (performed in the GitHub UI by an admin):" + echo " 1. Draft a release for the pushed tag." + echo " 2. Tick 'Publish this Action to the GitHub Marketplace'." + echo " 3. Select category (Code quality / Utilities), accept the agreement, publish." + echo "This job is a placeholder and does not publish automatically." diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a76140d --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +*.log +.DS_Store +.env +# Bundled output is produced by the release pipeline when/if JS entrypoints land. +dist/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..20e0d1f --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 BrowserStack + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index f13155e..7fc5168 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,124 @@ -# browserstack-accessibility-devtools-action -A package to support Accessibility Devtools Action +# BrowserStack Accessibility DevTools for GitHub + +Catch accessibility issues on your pull requests. BrowserStack scans the changed +code on every PR and posts the findings right back on the PR β€” a summary comment, +inline comments on the offending lines, a merge check, and a SARIF upload to the +Security tab. + +- πŸ”Ž **Scans only what changed** in the PR (fast, even on large repos). +- πŸ’¬ **Results on the PR** β€” a sticky summary comment and inline comments. +- βœ… **Merge gate** β€” fail the check when accessibility issues are found (configurable). +- πŸ” **Zero write credentials on your runner** β€” every write is posted by the + BrowserStack Accessibility App; your workflow's `GITHUB_TOKEN` stays read-only. + +Learn more: + +--- + +## Prerequisites + +1. **Install the BrowserStack Accessibility GitHub App** on your organization or + repository (a one-time step, done by a repo/org admin). +2. **Add a BrowserStack Service Account key** as repository or organization + **Actions secrets**: + - `BROWSERSTACK_USERNAME` + - `BROWSERSTACK_ACCESS_KEY` + +## Quick start + +Add `.github/workflows/browserstack-a11y.yml`. It runs **automatically on every +pull request**, and can also be re-run on demand by commenting +`@AccessibilityDevTools` on the PR: + +```yaml +name: BrowserStack Accessibility DevTools + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + issue_comment: + types: [created] + +permissions: + contents: read # read the PR's changed files + pull-requests: read # resolve the PR + id-token: write # prove the run came from your CI (required) + +concurrency: + group: a11y-${{ github.event.pull_request.number || github.event.issue.number }} + cancel-in-progress: true + +jobs: + a11y: + if: > + github.event_name == 'pull_request' || + (github.event.issue.pull_request && + contains(github.event.comment.body, '@AccessibilityDevTools')) + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: browserstack/browserstack-accessibility-devtools-action@main + with: + username: ${{ secrets.BROWSERSTACK_USERNAME }} + access-key: ${{ secrets.BROWSERSTACK_ACCESS_KEY }} + fail-on-severity: error +``` + +That's it β€” open a PR and the results appear within a couple of minutes. To +re-run manually, comment `@AccessibilityDevTools` on the PR. + +## How it works + +1. On a pull request (or an `@AccessibilityDevTools` comment), the workflow runs + BrowserStack's accessibility CLI against the PR's changed files, authenticated + by your Service Account key. +2. Results are posted back to the PR **by the BrowserStack Accessibility App** (a + branded bot), so your workflow's default token never needs write access. + +> **Why `id-token: write`?** GitHub mints a short-lived, repo-scoped OpenID +> Connect token that proves the request genuinely came from this repository's CI +> run. BrowserStack verifies it before posting. It carries **no** personal +> identity and grants no standing access. + +## What gets posted + +On every scan the App posts, automatically: + +- a **sticky summary comment** (one per PR, updated in place across runs); +- **inline comments** on the offending lines, in the same format as the + BrowserStack VS Code extension: `(rule): ` followed by how to fix it; +- a **Check Run** with the pass/fail result; +- a **SARIF upload** to GitHub code scanning (Security tab). + +## Inputs + +| Input | Default | Description | +| ------------------ | ------- | ------------------------------------------------------------------------------------------------------- | +| `username` | β€” | **Required.** Service Account username (store as a secret). | +| `access-key` | β€” | **Required.** Service Account access key (store as a secret). | +| `fail-on-severity` | `error` | Fail the check when findings at/above this severity exist: `error`, `warning`, or `none` (never fails). | +| `ai-agent` | β€” | Optional (preview). Bare agent name to `@mention` for AI remediation hand-off β€” see below. | + +## Outputs + +| Output | Description | +| ---------------- | ------------------------------------ | +| `result` | `pass` or `fail`. | +| `error-count` | Number of error-severity findings. | +| `warning-count` | Number of warning-severity findings. | +| `findings-count` | Total findings. | +| `comment-url` | Link to the posted PR summary comment. | + +## AI remediation hand-off (preview) + +Set `ai-agent` to the bare name of an agent you already use (e.g. `coderabbitai`). +When findings are posted, the App `@mentions` it on the comment, and your agent +acts under **your** own credentials and billing. + +> This feature will be supported if your AI agent accepts triggers from a bot/App +> comment; behaviour varies by agent, and it is a silent no-op if the agent isn't +> configured to accept it. + +## Support + + diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..18c756b --- /dev/null +++ b/action.yml @@ -0,0 +1,359 @@ +# BrowserStack Accessibility DevTools β€” composite GitHub Action (v1: scan + report) +# +# Design of record: ADR-0035 (CI GitHub App integration), ADR-0036 (Service Account +# auth) β€” DEVA11Y-463. +# +# This is a THIN WRAPPER (ADR-0035 Β§2). It: +# 1. triggers automatically on pull_request (opened/synchronize/reopened/ +# ready_for_review), or manually when someone comments @AccessibilityDevTools +# on a PR (with a commenter write/maintain/admin check); +# 2. runs the PINNED, published CLI (@browserstack/accessibility-devtools) in +# PR-scan mode against the base…head DIFF β€” it never builds/installs the PR +# head code (fork-PR "diff, don't build" safety, ADR-0035 Β§7); +# 3. mints a repo-scoped Actions OIDC token (post authenticity, ADR-0035 Β§5); +# 4. submits the normalized findings + signed scanProof + OIDC token to the +# BrowserStack ci-review-service, which verifies and posts AS THE APP. +# +# The Action holds NO GitHub write credential β€” every write (comment, check, +# inline comments, SARIF) is done server-side by the App. The workflow's +# GITHUB_TOKEN stays read-only. + +name: 'BrowserStack Accessibility DevTools' +description: 'Scan a pull request for accessibility issues and report the findings back on the PR.' +author: 'BrowserStack' + +branding: + icon: 'check-circle' + color: 'orange' + +inputs: + username: + description: 'BrowserStack Service Account username (store as an encrypted Actions secret). Scan auth (ADR-0036).' + required: true + access-key: + description: 'BrowserStack Service Account access key (store as an encrypted Actions secret). Scan auth (ADR-0036).' + required: true + fail-on-severity: + description: 'Severity at/above which the check fails the run: error | warning | none. Default error. `none` reports without ever failing the run.' + required: false + default: 'error' + ai-agent: + description: > + Optional AI remediation hand-off (preview): the BARE name of an agent to + @mention on the findings comment (e.g. `coderabbitai`). The App posts + "@"; the agent then acts under your own credentials/billing. + Supported only if your agent accepts triggers from a bot/App comment. + Validated against ^[A-Za-z0-9-]+(\[bot\])?$. + required: false + default: '' + +# The App always posts a single sticky summary comment, inline comments on the +# offending lines, a Check Run, and a SARIF upload to code-scanning β€” these are +# on by default and need no inputs. `fail-on-severity` is the only gate knob (it +# subsumes the old check-gate); `ai-agent` is the only other, optional, control. + +outputs: + result: + description: 'pass or fail.' + value: ${{ steps.report.outputs.result }} + error-count: + description: 'Number of error-severity findings.' + value: ${{ steps.report.outputs.error-count }} + warning-count: + description: 'Number of warning-severity findings.' + value: ${{ steps.report.outputs.warning-count }} + findings-count: + description: 'Total findings.' + value: ${{ steps.report.outputs.findings-count }} + comment-url: + description: 'Link to the posted PR summary comment.' + value: ${{ steps.report.outputs.comment-url }} + +runs: + using: 'composite' + steps: + # --------------------------------------------------------------------------- + # 1. Resolve the trigger. Two entry points: + # - AUTOMATIC (primary): pull_request opened/synchronize/reopened/ + # ready_for_review β€” actor is the PR author, no commenter gate. + # - MANUAL (secondary): an issue_comment on a PR mentioning + # @AccessibilityDevTools β€” actor is the commenter, gated below. + # --------------------------------------------------------------------------- + - id: context + shell: bash + env: + TRIGGER_MENTION: '@AccessibilityDevTools' + run: | + set -euo pipefail + event="${GITHUB_EVENT_NAME:-}" + proceed=false + mode="" + + case "$event" in + pull_request|pull_request_target) + action="$(jq -r '.action // ""' "$GITHUB_EVENT_PATH")" + case "$action" in + opened|synchronize|reopened|ready_for_review) + proceed=true; mode="auto" ;; + *) + echo "::notice::pull_request action '${action}' is not scanned; skipping." ;; + esac + ;; + issue_comment) + is_pr="$(jq -r '.issue.pull_request // empty' "$GITHUB_EVENT_PATH")" + body="$(jq -r '.comment.body // ""' "$GITHUB_EVENT_PATH")" + if [ -z "$is_pr" ]; then + echo "::notice::Comment is not on a pull request; skipping." + elif ! printf '%s' "$body" | grep -qF "$TRIGGER_MENTION"; then + echo "::notice::Comment does not mention ${TRIGGER_MENTION}; skipping." + else + proceed=true; mode="comment" + fi + ;; + *) + echo "::notice::Unsupported event '${event}'; skipping. Use on: pull_request or on: issue_comment." + ;; + esac + + if [ "$mode" = "auto" ]; then + pr_number="$(jq -r '.pull_request.number' "$GITHUB_EVENT_PATH")" + actor="$(jq -r '.pull_request.user.login // ""' "$GITHUB_EVENT_PATH")" + elif [ "$mode" = "comment" ]; then + pr_number="$(jq -r '.issue.number' "$GITHUB_EVENT_PATH")" + actor="$(jq -r '.comment.user.login // ""' "$GITHUB_EVENT_PATH")" + else + pr_number=""; actor="" + fi + + { + echo "proceed=$proceed" + echo "mode=$mode" + echo "pr-number=$pr_number" + echo "actor=$actor" + } >> "$GITHUB_OUTPUT" + + # --------------------------------------------------------------------------- + # 2. Commenter permission pre-check β€” MANUAL flow only. Best-effort: the + # AUTHORITATIVE, fail-closed check is server-side (ADR-0035 Β§5). It must + # degrade, not hard-fail: the read-only workflow token cannot call the + # collaborators API, so an inconclusive read means "proceed" and let the + # server gate. The automatic flow skips this (the server authorizes the + # verified OIDC actor). + # --------------------------------------------------------------------------- + - id: permission + if: steps.context.outputs.proceed == 'true' + shell: bash + env: + GH_TOKEN: ${{ github.token }} + MODE: ${{ steps.context.outputs.mode }} + ACTOR: ${{ steps.context.outputs.actor }} + run: | + set -euo pipefail + if [ "$MODE" != "comment" ]; then + echo "allowed=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + if [ -z "$ACTOR" ]; then + echo "::error::Could not resolve the commenter login." + exit 1 + fi + if perm="$(gh api "repos/${GITHUB_REPOSITORY}/collaborators/${ACTOR}/permission" --jq '.permission' 2>/dev/null)"; then + echo "Commenter ${ACTOR} has permission: ${perm}" + case "$perm" in + admin|maintain|write) echo "allowed=true" >> "$GITHUB_OUTPUT" ;; + *) echo "::warning::${ACTOR} lacks write/maintain/admin permission; refusing to scan." + echo "allowed=false" >> "$GITHUB_OUTPUT" ;; + esac + else + echo "::warning::Could not read ${ACTOR}'s permission with the read-only token; relying on the server-side permission gate." + echo "allowed=true" >> "$GITHUB_OUTPUT" + fi + + # --------------------------------------------------------------------------- + # 3. Preflight (ADR-0035 Β§7): Service Account creds + Actions OIDC must be + # available, else skip cleanly β€” never run with reduced trust. + # --------------------------------------------------------------------------- + - id: preflight + if: steps.context.outputs.proceed == 'true' && steps.permission.outputs.allowed == 'true' + shell: bash + env: + BSTACK_USERNAME: ${{ inputs.username }} + BSTACK_ACCESS_KEY: ${{ inputs.access-key }} + run: | + set -euo pipefail + ready=true + if [ -z "${BSTACK_USERNAME}" ] || [ -z "${BSTACK_ACCESS_KEY}" ]; then + echo "::warning::Service Account credentials are not available (secrets not configured). Skipping scan." + ready=false + fi + if [ -z "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" ] || [ -z "${ACTIONS_ID_TOKEN_REQUEST_TOKEN:-}" ]; then + echo "::warning::Actions OIDC token endpoint unavailable (missing 'id-token: write'). Skipping scan β€” will not run with reduced trust." + ready=false + fi + echo "ready=$ready" >> "$GITHUB_OUTPUT" + + # --------------------------------------------------------------------------- + # 3b. Validate the optional ai-agent input (bare name; the service prepends + # "@"). Fail fast on a malformed value rather than submitting it. + # --------------------------------------------------------------------------- + - id: validate-agent + if: steps.preflight.outputs.ready == 'true' && inputs.ai-agent != '' + shell: bash + env: + AI_AGENT: ${{ inputs.ai-agent }} + run: | + set -euo pipefail + if ! printf '%s' "${AI_AGENT}" | grep -Eq '^[A-Za-z0-9-]+(\[bot\])?$'; then + echo "::error::ai-agent '${AI_AGENT}' is invalid. Use the bare agent name (no @), e.g. coderabbitai." + exit 1 + fi + echo "Validated ai-agent: ${AI_AGENT} (the App will post @${AI_AGENT})." + + # --------------------------------------------------------------------------- + # 4. Resolve the PR head + base SHAs (authoritative, via the API β€” the + # issue_comment payload has no head SHA, and this is uniform across modes). + # --------------------------------------------------------------------------- + - id: pr + if: steps.preflight.outputs.ready == 'true' + shell: bash + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ steps.context.outputs.pr-number }} + run: | + set -euo pipefail + repo="$GITHUB_REPOSITORY" + head_sha="$(gh api "repos/${repo}/pulls/${PR_NUMBER}" --jq '.head.sha')" + base_sha="$(gh api "repos/${repo}/pulls/${PR_NUMBER}" --jq '.base.sha')" + echo "head-sha=$head_sha" >> "$GITHUB_OUTPUT" + echo "base-sha=$base_sha" >> "$GITHUB_OUTPUT" + echo "Resolved PR #${PR_NUMBER} head=${head_sha}." + + # --------------------------------------------------------------------------- + # 5. Check out ONLY the base…head diff. "diff, don't build": no npm install / + # postinstall β€” the secret-bearing job never executes PR head code. + # --------------------------------------------------------------------------- + - name: Checkout PR head (read-only, no build) + if: steps.preflight.outputs.ready == 'true' + uses: actions/checkout@v4 + with: + ref: ${{ steps.pr.outputs.head-sha }} + fetch-depth: 0 + persist-credentials: false + + - name: Setup Node + if: steps.preflight.outputs.ready == 'true' + uses: actions/setup-node@v4 + with: + node-version: '20' + + # --------------------------------------------------------------------------- + # 6. Run the pinned, published CLI in PR-scan mode. The CLI resolves the + # base..head diff locally, sends only changed-file contents to + # linter-server (which normalizes + HMAC-signs), mints the OIDC token, and + # submits to the ci-review-service, which posts AS THE APP. It prints one + # machine-readable stdout line the report step parses. + # --------------------------------------------------------------------------- + - id: scan + if: steps.preflight.outputs.ready == 'true' + shell: bash + env: + BROWSERSTACK_USERNAME: ${{ inputs.username }} + BROWSERSTACK_ACCESS_KEY: ${{ inputs.access-key }} + REPO: ${{ github.repository }} + PR_NUMBER: ${{ steps.context.outputs.pr-number }} + BASE_SHA: ${{ steps.pr.outputs.base-sha }} + HEAD_SHA: ${{ steps.pr.outputs.head-sha }} + AI_AGENT: ${{ inputs.ai-agent }} + run: | + set -euo pipefail + CLI_VERSION="1.5.0" + # Optional AI remediation hand-off (validated above). + AGENT_ARGS=() + if [ -n "${AI_AGENT}" ]; then AGENT_ARGS=(--ai-agent "${AI_AGENT}"); fi + echo "Running @browserstack/accessibility-devtools@${CLI_VERSION} --pr-scan (diff scope)…" + set +e + out="$(npx --yes "@browserstack/accessibility-devtools@${CLI_VERSION}" \ + --pr-scan \ + --include '**/*' \ + --base "${BASE_SHA}" \ + --head-sha "${HEAD_SHA}" \ + --repo "${REPO}" \ + --pr-number "${PR_NUMBER}" \ + --ci-service-url "https://devtools-a11y-linter.browserstack.com" \ + "${AGENT_ARGS[@]}" 2>&1)" + code=$? + set -e + printf '%s\n' "$out" + # The CLI prints `[deva11y-result] {json}` on success; capture the last one. + result_json="$(printf '%s\n' "$out" | grep -o '\[deva11y-result\] .*' | tail -1 | sed 's/^\[deva11y-result\] //')" + { + echo "scan-exit=${code}" + echo "result-json=${result_json}" + } >> "$GITHUB_OUTPUT" + echo "CLI exited ${code}" + + # --------------------------------------------------------------------------- + # 7. Report + gate. Populate outputs from the CLI's result line and fail the + # run per fail-on-severity (error | warning | none). A non-zero CLI exit + # that is not a completed scan (2 ERROR / 3 FUP / 4 PERMISSION) always + # fails. + # --------------------------------------------------------------------------- + - id: report + if: always() + shell: bash + env: + SCAN_EXIT: ${{ steps.scan.outputs.scan-exit }} + RESULT_JSON: ${{ steps.scan.outputs.result-json }} + PROCEED: ${{ steps.context.outputs.proceed }} + ALLOWED: ${{ steps.permission.outputs.allowed }} + READY: ${{ steps.preflight.outputs.ready }} + FAIL_ON: ${{ inputs.fail-on-severity }} + run: | + set -euo pipefail + error_count=""; warning_count=""; findings_count=""; comment_url="" + if [ -n "${RESULT_JSON:-}" ]; then + error_count="$(printf '%s' "$RESULT_JSON" | jq -r '.errorCount // ""' 2>/dev/null || echo "")" + warning_count="$(printf '%s' "$RESULT_JSON" | jq -r '.warningCount // ""' 2>/dev/null || echo "")" + findings_count="$(printf '%s' "$RESULT_JSON" | jq -r '.findingsCount // ""' 2>/dev/null || echo "")" + comment_url="$(printf '%s' "$RESULT_JSON" | jq -r '.commentUrl // ""' 2>/dev/null || echo "")" + fi + + result="pass" + gate_fail=false + if [ "${PROCEED}" != "true" ] || [ "${ALLOWED:-false}" != "true" ] || [ "${READY:-false}" != "true" ]; then + echo "::notice::Scan did not run (skipped); reporting a neutral pass." + elif [ "${SCAN_EXIT:-2}" = "0" ]; then + # Completed scan β€” gate by severity against the counts. + ec="${error_count:-0}"; wc="${warning_count:-0}" + case "${FAIL_ON}" in + none) : ;; + warning) if [ "${ec:-0}" -gt 0 ] || [ "${wc:-0}" -gt 0 ]; then result="fail"; fi ;; + *) if [ "${ec:-0}" -gt 0 ]; then result="fail"; fi ;; + esac + if [ "$result" = "fail" ]; then + echo "::warning::Accessibility findings at/above '${FAIL_ON}' (errors=${ec}, warnings=${wc})." + [ "${FAIL_ON}" != "none" ] && gate_fail=true + fi + else + # Operational failure (2 ERROR / 3 FUP / 4 PERMISSION) β€” always fail. + result="fail"; gate_fail=true + case "${SCAN_EXIT}" in + 3) echo "::error::Fair-Usage wait budget exhausted (rate limited)." ;; + 4) echo "::error::Permission denied by the server for this account/commenter." ;; + *) echo "::error::Scan failed (CLI exit ${SCAN_EXIT:-unknown})." ;; + esac + fi + + { + echo "result=${result}" + echo "error-count=${error_count}" + echo "warning-count=${warning_count}" + echo "findings-count=${findings_count}" + echo "comment-url=${comment_url}" + } >> "$GITHUB_OUTPUT" + echo "Result: ${result} (errors=${error_count:-n/a} warnings=${warning_count:-n/a} findings=${findings_count:-n/a})" + + if [ "$gate_fail" = "true" ]; then + echo "::error::Accessibility check failed (fail-on-severity=${FAIL_ON})." + exit 1 + fi diff --git a/examples/browserstack-a11y.yml b/examples/browserstack-a11y.yml new file mode 100644 index 0000000..7d112ce --- /dev/null +++ b/examples/browserstack-a11y.yml @@ -0,0 +1,47 @@ +# Reference consumer workflow for the BrowserStack Accessibility DevTools Action. +# Copy this to `.github/workflows/browserstack-a11y.yml` in your repository. +# +# Primary trigger: runs automatically on every pull request. +# Secondary trigger: comment `@AccessibilityDevTools` on a PR to re-run on demand. +name: BrowserStack Accessibility DevTools + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + issue_comment: + types: [created] + +# Least privilege: the workflow's GITHUB_TOKEN stays read-only. Every write +# (comment, inline comments, check, SARIF) is posted by the BrowserStack +# Accessibility App server-side β€” never by GITHUB_TOKEN. +permissions: + contents: read # read the PR's changed files (diff) + pull-requests: read # resolve the PR head SHA + id-token: write # mint the Actions OIDC token that proves the run is genuine CI + +# Supersede stale runs on the same PR (either trigger). +concurrency: + group: a11y-${{ github.event.pull_request.number || github.event.issue.number }} + cancel-in-progress: true + +jobs: + a11y: + # Automatic on pull_request; or a PR comment mentioning the App. The action + # enforces the real commenter permission check on the manual path, and the + # server enforces a fail-closed permission gate before it posts anything. + if: > + github.event_name == 'pull_request' || + (github.event.issue.pull_request && + contains(github.event.comment.body, '@AccessibilityDevTools')) + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + # Tracks the latest released Action. Pin to a full commit SHA for + # supply-chain hardening (recommended for production). + - uses: browserstack/browserstack-accessibility-devtools-action@main + with: + username: ${{ secrets.BROWSERSTACK_USERNAME }} + access-key: ${{ secrets.BROWSERSTACK_ACCESS_KEY }} + # Fail the check when findings at/above this severity are present: + # error (default) | warning | none. + fail-on-severity: error