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..c3f45f6 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,166 @@ -# browserstack-accessibility-devtools-action -A package to support Accessibility Devtools Action +# BrowserStack Accessibility for GitHub + +Catch accessibility issues on your pull requests. Comment +**`@AccessibilityDevTools`** on a PR and BrowserStack scans the changed code, +then posts the findings right back on the PR — as a summary comment, inline +suggestions, and an optional merge check. + +- 🔎 **Scans only what changed** in the PR (fast, even on large repos). +- 💬 **Results on the PR** — a summary comment and inline, one‑click suggestions. +- ✅ **Optional merge gate** — fail the check when accessibility errors are found. +- 🤖 **Optional agent hand‑off (preview)** — the findings comment can `@mention` a PR + agent you already use, which then acts under _your_ credentials. BrowserStack runs no + AI model and never handles your AI keys. **v1 is scan + report**; whether the agent + reviews or fixes is up to that agent (see "Optional: agent hand-off" below for what + works today). + +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` + + A Service Account is a non‑personal, admin‑managed key built for CI — it uses + no user license and can be rotated or revoked independently. (Enterprise plan.) + +## Quick start + +Add `.github/workflows/browserstack-a11y.yml`: + +```yaml +name: BrowserStack Accessibility +on: + 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.issue.number }} + cancel-in-progress: true + +jobs: + a11y: + # Fires on a PR comment mentioning @AccessibilityDevTools. The action then verifies + # the commenter has write/maintain/admin permission before scanning. + if: > + github.event.issue.pull_request && + contains(github.event.comment.body, '@AccessibilityDevTools') + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + # @v1 tracks the latest v1.x; pin to a full commit SHA for supply-chain hardening. + - uses: browserstack/browserstack-accessibility-devtools-action@v1 + with: + username: ${{ secrets.BROWSERSTACK_USERNAME }} + access-key: ${{ secrets.BROWSERSTACK_ACCESS_KEY }} + comment: true + check-gate: true + fail-on-severity: error +``` + +A ready-to-copy version lives at [`examples/browserstack-a11y.yml`](examples/browserstack-a11y.yml). + +Then, on any pull request, comment: + +``` +@AccessibilityDevTools +``` + +The scan runs and results appear on the PR within a couple of minutes. + +## How it works + +1. You comment **`@AccessibilityDevTools`** on the PR. +2. The workflow runs BrowserStack's accessibility CLI against the PR's changed + files, authenticated by your Service Account key. +3. 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. + +## Inputs + +| Input | Default | Description | +| -------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| `username` | — | **Required.** Service Account username (store as a secret). | +| `access-key` | — | **Required.** Service Account access key (store as a secret). | +| `comment` | `true` | Post a summary findings comment on the PR. | +| `check-gate` | `true` | Publish a Check Run / commit status for the PR. | +| `fail-on-severity` | `error` | Fail the check at this severity and above: `error`, `warning`, or `none`. | +| `inline-suggestions` | `false` | Add one‑click suggestion blocks on offending lines. | +| `sarif` | `false` | Publish results to GitHub code scanning (Security tab). Uploaded by the App server-side — your workflow token needs no extra permission. | +| `comment-mode` | `update` | `update` a single sticky comment across runs, or post a `new` one each time. | +| `remediation` | `false` | Enable the optional agent hand‑off (preview; see below). | +| `ai-agent` | — | Required when `remediation: true`. The agent's name (e.g. `coderabbitai`, `claude`); we @‑mention it. | + +## 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 comment. | +| `sarif-file` | Path to the SARIF file (if enabled). | + +## Optional: agent hand‑off (preview) + +Set `remediation: true` and name your agent with `ai-agent`. When findings are +posted, the App `@mentions` that agent on the PR (for example, `@coderabbitai`), and +**your** agent acts under **your** credentials and billing. + +```yaml +with: + username: ${{ secrets.BROWSERSTACK_USERNAME }} + access-key: ${{ secrets.BROWSERSTACK_ACCESS_KEY }} + remediation: true + ai-agent: coderabbitai # BrowserStack posts "@coderabbitai" +``` + +What works today (honest status — this is a preview, off by default): + +- BrowserStack **runs no AI model and never stores your AI credentials.** +- The agent must accept a trigger from a **bot/App** comment. + - **CodeRabbit** — responds to the mention, but produces a **review**, not a fix PR. + - **Claude** (`claude-code-action` / Anthropic Claude GitHub App) — a genuine fix + path, but **not yet reliable** for bot-authored triggers (upstream issues; being + validated). Treat as experimental. + - **GitHub Copilot's coding agent** — **not supported** (it only acts on comments from + a human with write access). +- If your agent ignores bot authors by default, allow‑list the **BrowserStack + Accessibility** bot in its config. + +> **v1 is scan + report.** A guaranteed AI-_fix_ path is not part of v1; the hand-off +> above is a preview and is a **silent no-op** if the agent isn't configured to accept it. + +## Privacy & security + +- Uses a **Service Account** key (not a personal login); store it as an encrypted + Actions secret and rotate via your admin. +- The App's credentials stay on BrowserStack's side — they are **never** placed on + your runner. +- The scan reads only the pull request's changed files. + +## Support + +- Product & docs: +- Accessibility API reference (including rate limits): + +## License + +[MIT](LICENSE) diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..2924fa8 --- /dev/null +++ b/action.yml @@ -0,0 +1,478 @@ +# BrowserStack Accessibility DevTools — composite GitHub Action (v1: scan + report) +# +# Design of record: ADR-0035 (CI GitHub App integration), ADR-0036 (Service Account +# auth), ADR-0037 (AI remediation hand-off) — DEVA11Y-463. +# +# This is a THIN WRAPPER (ADR-0035 §2). It: +# 1. gates on a PR comment mentioning @AccessibilityDevTools + a real commenter +# permission check (write/maintain/admin); +# 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, 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 + comment: + description: 'Publish findings as a PR comment, authored by the App.' + required: false + default: 'true' + check-gate: + description: 'Publish a Check Run / commit status and fail the run per fail-on-severity.' + required: false + default: 'true' + fail-on-severity: + description: 'Severity at/above which check-gate fails the run: error | warning | none.' + required: false + default: 'error' + inline-suggestions: + description: 'Post one-click suggestion blocks on offending lines (small/single-line fixes).' + required: false + default: 'false' + sarif: + description: 'Upload SARIF to GitHub code-scanning (Security tab). Uploaded by the App server-side; the workflow token needs no extra permission.' + required: false + default: 'false' + comment-mode: + description: 'update a single sticky comment across runs, or post a new one each time: update | new.' + required: false + default: 'update' + remediation: + description: 'Enable the optional AI hand-off (preview; ADR-0037).' + required: false + default: 'false' + ai-agent: + description: 'Required when remediation: true. The BARE agent name (e.g. claude, coderabbitai) — the service prepends @. Validated against ^[A-Za-z0-9-]+(\[bot\])?$.' + required: false + default: '' + +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 comment.' + value: ${{ steps.report.outputs.comment-url }} + sarif-file: + description: 'Path to the SARIF file (if enabled).' + value: ${{ steps.report.outputs.sarif-file }} + +runs: + using: 'composite' + steps: + # --------------------------------------------------------------------------- + # 1. Guard (belt-and-suspenders: the example workflow also gates in `if:`). + # Only proceed for a PR comment that mentions the hardcoded trigger phrase. + # --------------------------------------------------------------------------- + - id: guard + shell: bash + env: + # Hardcoded, not an input (ADR-0035 Appendix A). + TRIGGER_MENTION: '@AccessibilityDevTools' + run: | + set -euo pipefail + proceed=true + + if [ "${GITHUB_EVENT_NAME:-}" != "issue_comment" ]; then + echo "::notice::Not an issue_comment event (${GITHUB_EVENT_NAME:-unknown}); skipping." + proceed=false + fi + + # Must be a comment on a PR (issue.pull_request present). + if [ "$proceed" = "true" ]; then + is_pr="$(jq -r '.issue.pull_request // empty' "$GITHUB_EVENT_PATH")" + if [ -z "$is_pr" ]; then + echo "::notice::Comment is not on a pull request; skipping." + proceed=false + fi + fi + + # Comment body must mention the trigger phrase. + if [ "$proceed" = "true" ]; then + body="$(jq -r '.comment.body // ""' "$GITHUB_EVENT_PATH")" + case "$body" in + *"$TRIGGER_MENTION"*) : ;; + *) + echo "::notice::Comment does not mention ${TRIGGER_MENTION}; skipping." + proceed=false + ;; + esac + fi + + echo "proceed=$proceed" >> "$GITHUB_OUTPUT" + + # --------------------------------------------------------------------------- + # 2. Commenter permission pre-check (best-effort — the AUTHORITATIVE, fail-closed + # permission check is done server-side by the ci-review-service before it + # posts anything as the App (ADR-0035 §5). This client-side pre-check is a + # fast, quota-saving reject for commenters we can definitively see are not + # write/maintain/admin — it is NOT the security boundary (the runner is the + # customer's and cannot be trusted). + # + # Crucially it must degrade, not hard-fail: GET .../collaborators/{user}/ + # permission requires push access, which the least-privilege workflow token + # (contents: read) deliberately lacks — so the call 403s under the documented + # read-only setup. Treat an inconclusive read as "proceed" and let the + # server-side gate enforce; only a DEFINITIVE non-privileged role refuses. + # --------------------------------------------------------------------------- + - id: permission + if: steps.guard.outputs.proceed == 'true' + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + actor="$(jq -r '.comment.user.login // ""' "$GITHUB_EVENT_PATH")" + repo="$GITHUB_REPOSITORY" + if [ -z "$actor" ]; then + echo "::error::Could not resolve the commenter login." + exit 1 + fi + # GET /repos/{owner}/{repo}/collaborators/{username}/permission needs push + # access. Distinguish a definitive answer from an inconclusive call: on a + # read-only token the call fails (gh exits non-zero) and we must NOT refuse. + if perm="$(gh api "repos/${repo}/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 + # Could not read the commenter's permission (expected with the documented + # read-only GITHUB_TOKEN, which cannot call the collaborators API). Do not + # hard-refuse — proceed and rely on the server-side fail-closed gate, which + # rejects unauthorized commenters before the App posts (ADR-0035 §5). + echo "::warning::Could not read ${actor}'s permission with the read-only workflow token; relying on the server-side permission gate." + echo "allowed=true" >> "$GITHUB_OUTPUT" + fi + + # --------------------------------------------------------------------------- + # 3. Preflight (ADR-0035 §7). Fork-PR safety does NOT come from this step: + # issue_comment workflows run in the BASE-repo context, so SA secrets and + # id-token are available even when the comment is on a fork PR. Fork safety + # comes from (a) the commenter permission gate above + the server-side + # fail-closed gate, and (b) "diff, don't build" — the secret-bearing job + # never executes PR head code (step 6). This step only ensures the scan has + # what it needs and skips cleanly when it does not (creds not configured, or + # id-token: write not granted) — never run with reduced trust. + # --------------------------------------------------------------------------- + - id: preflight + if: steps.guard.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 on this repo). Skipping scan." + ready=false + fi + + # ACTIONS_ID_TOKEN_REQUEST_* are present only when the job was granted + # id-token: write. Required to prove the run is genuine CI (ADR-0035 §5). + 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' permission). Skipping scan — will not run with reduced trust." + ready=false + fi + + echo "ready=$ready" >> "$GITHUB_OUTPUT" + + # --------------------------------------------------------------------------- + # 4. Validate the optional ai-agent input (ADR-0037; charset per review #20). + # --------------------------------------------------------------------------- + - id: validate-agent + if: steps.preflight.outputs.ready == 'true' && inputs.remediation == 'true' + shell: bash + env: + AI_AGENT: ${{ inputs.ai-agent }} + run: | + set -euo pipefail + if [ -z "${AI_AGENT}" ]; then + echo "::error::remediation is true but ai-agent is empty. Provide the bare agent name (e.g. coderabbitai)." + exit 1 + fi + # Bare name; the service prepends "@". Optional [bot] suffix. + if ! printf '%s' "${AI_AGENT}" | grep -Eq '^[A-Za-z0-9-]+(\[bot\])?$'; then + echo "::error::ai-agent '${AI_AGENT}' is invalid. Must match ^[A-Za-z0-9-]+(\[bot\])?$ (bare name, no @)." + exit 1 + fi + echo "Validated ai-agent: ${AI_AGENT} (service will post @${AI_AGENT})." + + # --------------------------------------------------------------------------- + # 5. Resolve the PR head SHA and changed files (diff scope, hardcoded). + # --------------------------------------------------------------------------- + - id: pr + if: steps.preflight.outputs.ready == 'true' + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + pr_number="$(jq -r '.issue.number' "$GITHUB_EVENT_PATH")" + repo="$GITHUB_REPOSITORY" + + # Authoritative head SHA for the issue_comment event (the event payload has + # no PR head SHA, so resolve it — ADR-0035 §5 caveat). + head_sha="$(gh api "repos/${repo}/pulls/${pr_number}" --jq '.head.sha')" + base_sha="$(gh api "repos/${repo}/pulls/${pr_number}" --jq '.base.sha')" + + # Changed files (paginated), one per line, into a workspace file. + gh api --paginate "repos/${repo}/pulls/${pr_number}/files" --jq '.[].filename' \ + > "${RUNNER_TEMP}/changed-files.txt" + + echo "pr-number=$pr_number" >> "$GITHUB_OUTPUT" + echo "head-sha=$head_sha" >> "$GITHUB_OUTPUT" + echo "base-sha=$base_sha" >> "$GITHUB_OUTPUT" + echo "changed-files=${RUNNER_TEMP}/changed-files.txt" >> "$GITHUB_OUTPUT" + echo "Resolved PR #${pr_number} head=${head_sha} ($(wc -l < "${RUNNER_TEMP}/changed-files.txt") changed files)." + + # --------------------------------------------------------------------------- + # 6. Check out ONLY the base…head diff. "diff, don't build": no npm install, + # no postinstall — the secret-bearing job never executes fork head code + # (ADR-0035 §7). We fetch the head tree read-only for the CLI to read file + # contents; deva11y.config.* is data-only and safe to read. + # --------------------------------------------------------------------------- + - 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 }} + # Enough history for a base…head diff. + fetch-depth: 0 + persist-credentials: false + + - name: Setup Node + if: steps.preflight.outputs.ready == 'true' + uses: actions/setup-node@v4 + with: + node-version: '20' + + # --------------------------------------------------------------------------- + # 7. Run the PINNED published CLI in PR-scan mode over the diff. + # IMPORTANT: the CLI version is pinned by THIS Action release (no @latest at + # runtime — ADR-0035 §2, review #4). Update CLI_VERSION when cutting a new + # Action release. Pinned to the published 1.5.0 (npmjs `latest`), which ships PR-scan mode. + # We run the pinned package directly with npx --yes so no project install / + # postinstall of the PR head executes. + # --------------------------------------------------------------------------- + - id: scan + if: steps.preflight.outputs.ready == 'true' + shell: bash + env: + # SA creds — passed to the CLI, which authenticates over WSS to linter-server. + BROWSERSTACK_USERNAME: ${{ inputs.username }} + BROWSERSTACK_ACCESS_KEY: ${{ inputs.access-key }} + CHANGED_FILES: ${{ steps.pr.outputs.changed-files }} + BASE_SHA: ${{ steps.pr.outputs.base-sha }} + HEAD_SHA: ${{ steps.pr.outputs.head-sha }} + run: | + set -euo pipefail + # Pinned published CLI version (npmjs latest = 1.5.0; includes PR-scan mode). Do NOT use @latest. + CLI_VERSION="1.5.0" + FINDINGS_OUT="${RUNNER_TEMP}/findings.json" + + echo "Running @browserstack/accessibility-devtools@${CLI_VERSION} in PR-scan mode (diff scope)…" + # --scope=diff (ADR-0022): scan only changed ranges over base…head. + # The CLI reads file contents; it does NOT execute the project. + npx --yes "@browserstack/accessibility-devtools@${CLI_VERSION}" scan \ + --scope=diff \ + --base "${BASE_SHA}" \ + --head "${HEAD_SHA}" \ + --changed-files "${CHANGED_FILES}" \ + --output "${FINDINGS_OUT}" \ + --format json + + echo "findings-file=${FINDINGS_OUT}" >> "$GITHUB_OUTPUT" + + # --------------------------------------------------------------------------- + # 8. Request the Actions OIDC token with the DevA11y audience (ADR-0035 §5). + # Proves "this request came from a real Actions run in repo X" — repo + # granularity, no user resolved. Audience matches the deployed + # ci-review-service verifier config. + # --------------------------------------------------------------------------- + - id: oidc + if: steps.preflight.outputs.ready == 'true' + shell: bash + run: | + set -euo pipefail + # DevA11y OIDC audience — matches the ci-review-service verifier config (oidc.audience). + AUDIENCE="browserstack-accessibility-devtools" + token="$(curl -sf \ + -H "Authorization: bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \ + "${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=${AUDIENCE}" | jq -r '.value')" + if [ -z "$token" ] || [ "$token" = "null" ]; then + echo "::error::Failed to mint the Actions OIDC token." + exit 1 + fi + # Mask so it never lands in logs. + echo "::add-mask::$token" + echo "token=$token" >> "$GITHUB_OUTPUT" + + # --------------------------------------------------------------------------- + # 9. Submit findings + scanProof + OIDC token to the ci-review-service. + # The service verifies origin (OIDC) + content (scanProof) and posts AS THE + # APP. The Action never posts to GitHub directly (ADR-0035 §3, §5). + # The service base URL is fixed (global ingress), NOT a customer input. + # --------------------------------------------------------------------------- + - id: submit + if: steps.preflight.outputs.ready == 'true' + shell: bash + env: + OIDC_TOKEN: ${{ steps.oidc.outputs.token }} + FINDINGS_FILE: ${{ steps.scan.outputs.findings-file }} + REPO: ${{ github.repository }} + PR_NUMBER: ${{ steps.pr.outputs.pr-number }} + HEAD_SHA: ${{ steps.pr.outputs.head-sha }} + OPT_COMMENT: ${{ inputs.comment }} + OPT_CHECK_GATE: ${{ inputs.check-gate }} + OPT_FAIL_ON: ${{ inputs.fail-on-severity }} + OPT_INLINE: ${{ inputs.inline-suggestions }} + OPT_SARIF: ${{ inputs.sarif }} + OPT_COMMENT_MODE: ${{ inputs.comment-mode }} + OPT_REMEDIATION: ${{ inputs.remediation }} + OPT_AI_AGENT: ${{ inputs.ai-agent }} + run: | + set -euo pipefail + # Service base URL (global ingress); server-side geo-routes per data-residency + # region (ADR-0035 §7). NOT a customer input. + SERVICE_URL="https://devtools-a11y-linter.browserstack.com" + RESP_OUT="${RUNNER_TEMP}/service-response.json" + + # The CLI emits { findings, scanProof } — findings are normalized and the + # scanProof is HMAC-signed by linter-server (key never on the runner). + findings="$(jq -c '.findings // .' "${FINDINGS_FILE}")" + scan_proof="$(jq -r '.scanProof // ""' "${FINDINGS_FILE}")" + + # Assemble the request body (ADR-0035 §5 wire shape). + body="$(jq -cn \ + --arg repo "$REPO" \ + --argjson prNumber "$PR_NUMBER" \ + --arg headSha "$HEAD_SHA" \ + --argjson findings "$findings" \ + --arg scanProof "$scan_proof" \ + --arg oidcToken "$OIDC_TOKEN" \ + --arg comment "$OPT_COMMENT" \ + --arg checkGate "$OPT_CHECK_GATE" \ + --arg failOnSeverity "$OPT_FAIL_ON" \ + --arg inlineSuggestions "$OPT_INLINE" \ + --arg sarif "$OPT_SARIF" \ + --arg commentMode "$OPT_COMMENT_MODE" \ + --arg remediation "$OPT_REMEDIATION" \ + --arg aiAgent "$OPT_AI_AGENT" \ + '{ + platform: "github", + repo: $repo, + prNumber: $prNumber, + headSha: $headSha, + findings: $findings, + scanProof: $scanProof, + oidcToken: $oidcToken, + options: { + comment: ($comment == "true"), + checkGate: ($checkGate == "true"), + failOnSeverity: $failOnSeverity, + inlineSuggestions: ($inlineSuggestions == "true"), + sarif: ($sarif == "true"), + commentMode: $commentMode, + remediation: ($remediation == "true"), + aiAgent: $aiAgent + } + }')" + + http_code="$(curl -sS -o "${RESP_OUT}" -w '%{http_code}' \ + -X POST "${SERVICE_URL}/v1/findings" \ + -H "Content-Type: application/json" \ + -H "X-CI-Platform: github" \ + --data-binary "${body}")" + + echo "ci-review-service responded HTTP ${http_code}" + if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then + echo "::error::ci-review-service rejected the submission (HTTP ${http_code})." + cat "${RESP_OUT}" || true + exit 1 + fi + echo "response-file=${RESP_OUT}" >> "$GITHUB_OUTPUT" + + # --------------------------------------------------------------------------- + # 10. Report: surface outputs from the service response and apply the gate. + # --------------------------------------------------------------------------- + - id: report + if: always() + shell: bash + env: + RESP_FILE: ${{ steps.submit.outputs.response-file }} + PROCEED: ${{ steps.guard.outputs.proceed }} + ALLOWED: ${{ steps.permission.outputs.allowed }} + READY: ${{ steps.preflight.outputs.ready }} + OPT_CHECK_GATE: ${{ inputs.check-gate }} + run: | + set -euo pipefail + + # Default (skipped / not-run) outputs. + result="pass"; error_count="0"; warning_count="0"; findings_count="0" + comment_url=""; sarif_file="" + + if [ "${PROCEED}" != "true" ] || [ "${ALLOWED:-false}" != "true" ] || [ "${READY:-false}" != "true" ]; then + echo "::notice::Scan did not run (guard/permission/preflight skip); reporting a neutral pass." + elif [ -n "${RESP_FILE:-}" ] && [ -f "${RESP_FILE}" ]; then + result="$(jq -r '.result // "pass"' "${RESP_FILE}")" + error_count="$(jq -r '.errorCount // 0' "${RESP_FILE}")" + warning_count="$(jq -r '.warningCount // 0' "${RESP_FILE}")" + findings_count="$(jq -r '.findingsCount // 0' "${RESP_FILE}")" + comment_url="$(jq -r '.commentUrl // ""' "${RESP_FILE}")" + sarif_file="$(jq -r '.sarifFile // ""' "${RESP_FILE}")" + else + echo "::warning::No service response available; reporting fail." + result="fail" + fi + + { + echo "result=$result" + echo "error-count=$error_count" + echo "warning-count=$warning_count" + echo "findings-count=$findings_count" + echo "comment-url=$comment_url" + echo "sarif-file=$sarif_file" + } >> "$GITHUB_OUTPUT" + + echo "Result: ${result} (errors=${error_count}, warnings=${warning_count}, total=${findings_count})" + [ -n "$comment_url" ] && echo "Comment: ${comment_url}" || true + + # Gate: fail the job when check-gate is on and the service verdict is fail. + if [ "${OPT_CHECK_GATE}" = "true" ] && [ "$result" = "fail" ]; then + echo "::error::Accessibility check failed (fail-on-severity gate)." + exit 1 + fi diff --git a/examples/browserstack-a11y.yml b/examples/browserstack-a11y.yml new file mode 100644 index 0000000..1fd809c --- /dev/null +++ b/examples/browserstack-a11y.yml @@ -0,0 +1,48 @@ +# Reference consumer workflow for the BrowserStack Accessibility DevTools Action. +# Copy this to `.github/workflows/browserstack-a11y.yml` in your repository. +# +# Trigger: comment `@AccessibilityDevTools` on any pull request. +name: BrowserStack Accessibility + +on: + issue_comment: + types: [created] + +# Least privilege: the workflow's GITHUB_TOKEN stays read-only. Every write +# (comment, 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 + changed files + id-token: write # mint the Actions OIDC token that proves the run is genuine CI + +# Supersede stale runs on the same PR. +concurrency: + group: a11y-${{ github.event.issue.number }} + cancel-in-progress: true + +jobs: + a11y: + # PR comments only, mentioning @AccessibilityDevTools. The action then + # enforces the real commenter permission check (write/maintain/admin) — + # author_association is NOT a permission check. + if: > + github.event.issue.pull_request && + contains(github.event.comment.body, '@AccessibilityDevTools') + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + # Use the v1 major tag for automatic patch/minor updates, or pin to a + # full commit SHA for supply-chain hardening (recommended for production). + - uses: browserstack/browserstack-accessibility-devtools-action@v1 + with: + username: ${{ secrets.BROWSERSTACK_USERNAME }} + access-key: ${{ secrets.BROWSERSTACK_ACCESS_KEY }} + comment: true + check-gate: true + fail-on-severity: error + # inline-suggestions: true + # sarif: true # uploaded by the App server-side; no security-events perm needed here + # comment-mode: update + # remediation: true # preview; off by default + # ai-agent: coderabbitai # bare name; the service posts "@coderabbitai"