From 2175c9fc1d0872a3a2de55f04025c65e58ead458 Mon Sep 17 00:00:00 2001 From: Crash0v3rrid3 Date: Wed, 8 Jul 2026 13:02:43 +0530 Subject: [PATCH 1/8] feat(DEVA11Y-463): composite GitHub Action (v1 scan+report) Scaffold the public browserstack-accessibility-devtools-action repo per ADR-0035 Appendix A (GitHub App + Service Account model). - action.yml: composite action. Inputs username/access-key (required, secret), comment, check-gate, fail-on-severity, inline-suggestions, sarif, comment-mode, remediation, ai-agent (validated ^[A-Za-z0-9-]+(\[bot\])?$). Outputs result, error-count, warning-count, findings-count, comment-url, sarif-file. Hardcoded: @AccessibilityDevTools trigger, diff scope, deva11y.config.* inference, commenter write/maintain/admin permission gate. Steps implement fork-PR "diff, don't build" safety (no install/postinstall of head code; skip when OIDC/secrets restricted), pinned npx CLI (placeholder 1.3.0), Actions OIDC mint (aud placeholder browserstack-accessibility-devtools), and POST to ci-review-service /v1/findings with {platform,repo,prNumber,headSha,findings,scanProof,oidcToken} + X-CI-Platform header. The Action holds no GitHub write credential; the App posts server-side. - examples/browserstack-a11y.yml: reference consumer workflow (issue_comment, read-only token + id-token: write, concurrency, if-guard). - .github/workflows/release.yml: release stub (esbuild dist build guard, action.yml validation, manual org-admin Marketplace publish note). - README.md: public customer README (v1 = scan + report; agent hand-off preview off by default; homepage browserstack.com). - LICENSE (MIT), .gitignore. Placeholders pending provisioning: CLI version 1.3.0, OIDC audience and service URL (https://devtools-a11y-linter.browserstack.com), GitHub App. Draft PR. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/release.yml | 76 ++++++ .gitignore | 6 + LICENSE | 21 ++ README.md | 168 +++++++++++- action.yml | 455 +++++++++++++++++++++++++++++++++ examples/browserstack-a11y.yml | 47 ++++ 6 files changed, 771 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 action.yml create mode 100644 examples/browserstack-a11y.yml 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..5b5d092 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: + # Pin the action to a commit SHA (shown as @v1 for brevity). + - uses: browserstack/browserstack-accessibility-devtools-action@ + 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..78188e9 --- /dev/null +++ b/action.yml @@ -0,0 +1,455 @@ +# 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 gate (hardcoded — real permission check, NOT + # author_association). The commenter must be write/maintain/admin + # (ADR-0035 §5, review #3). + # --------------------------------------------------------------------------- + - 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 + perm="$(gh api "repos/${repo}/collaborators/${actor}/permission" --jq '.permission' 2>/dev/null || echo "none")" + 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 + + # --------------------------------------------------------------------------- + # 3. Fork-PR / restricted-token safety (ADR-0035 §7): "diff, don't build". + # If the SA creds or the Actions OIDC endpoint are unavailable (e.g. a fork + # PR with a restricted token), degrade to a clear SKIP — 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 (likely a fork PR with a restricted token). Skipping scan." + ready=false + fi + + # The OIDC request env is only present when the job has id-token: write + # AND the token isn't restricted (forks). Belt-and-suspenders vs §7. + if [ -z "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" ] || [ -z "${ACTIONS_ID_TOKEN_REQUEST_TOKEN:-}" ]; then + echo "::warning::Actions OIDC token endpoint unavailable (fork PR / missing id-token: write). 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. `1.3.0` is a PLACEHOLDER pending the CLI PR-scan release. + # 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 + # PLACEHOLDER version — pinned by the Action release (§2). Do NOT use @latest. + CLI_VERSION="1.3.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 is a PLACEHOLDER pending + # provisioning of the ci-review-service verifier. + # --------------------------------------------------------------------------- + - id: oidc + if: steps.preflight.outputs.ready == 'true' + shell: bash + run: | + set -euo pipefail + # PLACEHOLDER audience — must match the ci-review-service verifier config. + 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 a PLACEHOLDER, 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 + # PLACEHOLDER base URL — resolved server-side 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..5e22c92 --- /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. +# +# 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: + # Pin the action to a commit SHA at release time (shown as a placeholder here). + - 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" From 222bd37983c20e1284d68f430c65f15ccb3469ae Mon Sep 17 00:00:00 2001 From: Crash0v3rrid3 Date: Thu, 9 Jul 2026 15:22:47 +0530 Subject: [PATCH 2/8] fix(DEVA11Y-657): don't hard-fail the commenter gate under a read-only token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The commenter permission pre-check called GET /collaborators/{user}/permission, which requires push access. The documented least-privilege workflow token (contents: read) cannot call it, so the request 403s, the `|| echo none` fallback yielded "none", and the action refused to scan for EVERY commenter — non-functional under its own recommended setup. Make the client-side check best-effort: fast-refuse only on a DEFINITIVE non-privileged role; on an inconclusive call (the read-only-token case) proceed and rely on the authoritative server-side fail-closed permission gate in the ci-review-service (ADR-0035 §5). The runner is the customer's and was never the security boundary. Also correct the preflight fork-safety comments: issue_comment workflows run in base-repo context with secrets available even for fork PRs, so cred-absence is not what protects fork PRs — the permission gate + "diff, don't build" are. Co-Authored-By: Claude Opus 4.8 --- action.yml | 65 ++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 44 insertions(+), 21 deletions(-) diff --git a/action.yml b/action.yml index 78188e9..14d7f93 100644 --- a/action.yml +++ b/action.yml @@ -129,9 +129,18 @@ runs: echo "proceed=$proceed" >> "$GITHUB_OUTPUT" # --------------------------------------------------------------------------- - # 2. Commenter permission gate (hardcoded — real permission check, NOT - # author_association). The commenter must be write/maintain/admin - # (ADR-0035 §5, review #3). + # 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' @@ -146,22 +155,36 @@ runs: echo "::error::Could not resolve the commenter login." exit 1 fi - # GET /repos/{owner}/{repo}/collaborators/{username}/permission - perm="$(gh api "repos/${repo}/collaborators/${actor}/permission" --jq '.permission' 2>/dev/null || echo "none")" - 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 + # 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. Fork-PR / restricted-token safety (ADR-0035 §7): "diff, don't build". - # If the SA creds or the Actions OIDC endpoint are unavailable (e.g. a fork - # PR with a restricted token), degrade to a clear SKIP — never run with - # reduced trust. + # 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' @@ -174,14 +197,14 @@ runs: ready=true if [ -z "${BSTACK_USERNAME}" ] || [ -z "${BSTACK_ACCESS_KEY}" ]; then - echo "::warning::Service Account credentials are not available (likely a fork PR with a restricted token). Skipping scan." + echo "::warning::Service Account credentials are not available (secrets not configured on this repo). Skipping scan." ready=false fi - # The OIDC request env is only present when the job has id-token: write - # AND the token isn't restricted (forks). Belt-and-suspenders vs §7. + # 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 (fork PR / missing id-token: write). Skipping scan — will not run with reduced trust." + echo "::warning::Actions OIDC token endpoint unavailable (missing 'id-token: write' permission). Skipping scan — will not run with reduced trust." ready=false fi From 337b4cdd2a9448303bc794501225c72e810217ad Mon Sep 17 00:00:00 2001 From: Crash0v3rrid3 Date: Fri, 10 Jul 2026 10:45:17 +0530 Subject: [PATCH 3/8] =?UTF-8?q?chore(DEVA11Y-657):=20ready=20action=20for?= =?UTF-8?q?=20merge=20=E2=80=94=20@v1=20refs=20+=20PR=20CI=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - example workflow + README now reference @v1 (with a note to SHA-pin for production) instead of the @ placeholder - add .github/workflows/ci.yml: PR gate validating action.yml structure + YAML well-formedness (the repo previously had no PR checks) CLI version pin (action.yml) stays a documented release-time value — no pr-scan CLI version is published to npm yet; the release step pins the real version. GitHub App id / service URL remain provisioning placeholders. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/ci.yml | 38 ++++++++++++++++++++++++++++++++++ README.md | 4 ++-- examples/browserstack-a11y.yml | 5 +++-- 3 files changed, 43 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/ci.yml 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/README.md b/README.md index 5b5d092..c3f45f6 100644 --- a/README.md +++ b/README.md @@ -59,8 +59,8 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 15 steps: - # Pin the action to a commit SHA (shown as @v1 for brevity). - - uses: browserstack/browserstack-accessibility-devtools-action@ + # @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 }} diff --git a/examples/browserstack-a11y.yml b/examples/browserstack-a11y.yml index 5e22c92..1fd809c 100644 --- a/examples/browserstack-a11y.yml +++ b/examples/browserstack-a11y.yml @@ -32,8 +32,9 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 15 steps: - # Pin the action to a commit SHA at release time (shown as a placeholder here). - - uses: browserstack/browserstack-accessibility-devtools-action@ # v1 + # 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 }} From 6b8840814d44f9570afc465cb39eaa130a7a2cd0 Mon Sep 17 00:00:00 2001 From: Rishabh Jain <43724509+Crash0v3rrid3@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:56:34 +0530 Subject: [PATCH 4/8] feat(DEVA11Y-657): pin CLI to published 1.5.0; finalize audience/base-URL comments @browserstack/accessibility-devtools 1.5.0 is published (npmjs `latest`) and includes PR-scan mode, so pin CLI_VERSION 1.3.0 -> 1.5.0. The OIDC audience (browserstack-accessibility-devtools) already matches the deployed ci-review-service verifier config, and SERVICE_URL is the real global ingress; drop the stale PLACEHOLDER comments accordingly. Co-Authored-By: Claude Opus 4.8 --- action.yml | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/action.yml b/action.yml index 14d7f93..2924fa8 100644 --- a/action.yml +++ b/action.yml @@ -284,7 +284,7 @@ runs: # 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. `1.3.0` is a PLACEHOLDER pending the CLI PR-scan release. + # 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. # --------------------------------------------------------------------------- @@ -300,8 +300,8 @@ runs: HEAD_SHA: ${{ steps.pr.outputs.head-sha }} run: | set -euo pipefail - # PLACEHOLDER version — pinned by the Action release (§2). Do NOT use @latest. - CLI_VERSION="1.3.0" + # 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)…" @@ -320,15 +320,15 @@ runs: # --------------------------------------------------------------------------- # 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 is a PLACEHOLDER pending - # provisioning of the ci-review-service verifier. + # 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 - # PLACEHOLDER audience — must match the ci-review-service verifier config. + # 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}" \ @@ -345,7 +345,7 @@ runs: # 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 a PLACEHOLDER, NOT a customer input. + # The service base URL is fixed (global ingress), NOT a customer input. # --------------------------------------------------------------------------- - id: submit if: steps.preflight.outputs.ready == 'true' @@ -366,8 +366,8 @@ runs: OPT_AI_AGENT: ${{ inputs.ai-agent }} run: | set -euo pipefail - # PLACEHOLDER base URL — resolved server-side per data-residency region - # (ADR-0035 §7); NOT a customer input. + # 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" From 56301a69593d83b269486fb0935fa753e8edb0f2 Mon Sep 17 00:00:00 2001 From: Crash0v3rrid3 Date: Mon, 13 Jul 2026 11:10:04 +0530 Subject: [PATCH 5/8] docs(DEVA11Y-657): address @aveek-bs README review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Title -> "BrowserStack Accessibility DevTools for GitHub"; workflow name -> "BrowserStack Accessibility DevTools". - Learn-more link -> /docs/accessibility-dev-tools/features/remediate-github. - Drop the internal-sounding "BrowserStack runs no AI model…what works today" copy from the intro bullet. - Remove the Service-Account "no user license…Enterprise plan" aside. - Inline the full ready-to-copy workflow in Quick start; drop the separate examples/ link. - Agent hand-off: replace the "what works today" internal note with a generic preview disclaimer, and add the bot allow-list as a prerequisite. - Remove Privacy & security and License sections. - Support -> single https://www.browserstack.com/support link. - examples/: use @main (always latest) instead of @v1; match workflow name. Co-Authored-By: Claude Opus 4.8 --- README.md | 60 ++++++++++------------------------ examples/browserstack-a11y.yml | 8 ++--- 2 files changed, 21 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index c3f45f6..736db9a 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# BrowserStack Accessibility for GitHub +# BrowserStack Accessibility DevTools for GitHub Catch accessibility issues on your pull requests. Comment **`@AccessibilityDevTools`** on a PR and BrowserStack scans the changed code, @@ -9,12 +9,9 @@ suggestions, and an optional merge check. - 💬 **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). + agent you already use, which then acts under _your_ credentials. -Learn more: +Learn more: --- @@ -27,15 +24,12 @@ Learn more: - `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 +name: BrowserStack Accessibility DevTools on: issue_comment: types: [created] @@ -59,18 +53,20 @@ jobs: 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 + - uses: browserstack/browserstack-accessibility-devtools-action@main 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 + # comment-mode: update + # remediation: true # preview; off by default + # ai-agent: coderabbitai # bare name; the App posts "@coderabbitai" ``` -A ready-to-copy version lives at [`examples/browserstack-a11y.yml`](examples/browserstack-a11y.yml). - Then, on any pull request, comment: ``` @@ -124,6 +120,9 @@ 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. +**Prerequisite:** if your agent ignores bot authors by default, allow‑list the +**BrowserStack Accessibility** bot in its configuration. + ```yaml with: username: ${{ secrets.BROWSERSTACK_USERNAME }} @@ -132,35 +131,10 @@ with: 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. +> This feature is a preview and is off by default. Hand‑off is supported only 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 -- Product & docs: -- Accessibility API reference (including rate limits): - -## License - -[MIT](LICENSE) +- diff --git a/examples/browserstack-a11y.yml b/examples/browserstack-a11y.yml index 1fd809c..84fcad0 100644 --- a/examples/browserstack-a11y.yml +++ b/examples/browserstack-a11y.yml @@ -2,7 +2,7 @@ # Copy this to `.github/workflows/browserstack-a11y.yml` in your repository. # # Trigger: comment `@AccessibilityDevTools` on any pull request. -name: BrowserStack Accessibility +name: BrowserStack Accessibility DevTools on: issue_comment: @@ -32,9 +32,9 @@ jobs: 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 + # 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 }} From af30f19bbe7c8023689abce76ee6bfb34dfc27a0 Mon Sep 17 00:00:00 2001 From: Crash0v3rrid3 Date: Mon, 13 Jul 2026 16:13:56 +0530 Subject: [PATCH 6/8] fix(DEVA11Y-657): use CLI 1.5.0 --pr-scan contract (fixes 'unknown option --head') MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The action called a nonexistent `scan` subcommand with --head/--changed-files/ --output and then minted OIDC + POSTed itself. Published CLI 1.5.0 does the whole PR-scan via `--pr-scan`: resolves the base..head diff locally (git), gets linter-server to normalize + HMAC-sign, mints the Actions OIDC token, and submits to the ci-review-service (POST /v1/findings) which posts as the App. - scan step -> `--pr-scan --include '**/*' --base --head-sha --repo --pr-number --ci-service-url https://devtools-a11y-linter.browserstack.com` (--head-sha, not --head; drop --changed-files/--output/--format — the CLI derives the diff and submits itself). fail-on-severity=none -> --non-strict. - Remove the now-redundant `oidc` and `submit` steps (the CLI does both). - `report` gates on the CLI exit code (0 pass; 1/3/4/other fail). Known 1.5.0 limits (documented inline): --pr-scan does not forward the comment/ inline/sarif/remediation options or return per-severity counts / comment URL — the ci-review-service applies defaults; those outputs need a CLI enhancement. NOTE: --ci-service-url uses the shared host, so POST /v1/findings must be routed to ci-review-service on devtools-a11y-linter.browserstack.com (currently only /github is routed) for the submit to land. Co-Authored-By: Claude Opus 4.8 --- action.yml | 224 +++++++++++++++-------------------------------------- 1 file changed, 64 insertions(+), 160 deletions(-) diff --git a/action.yml b/action.yml index 2924fa8..0891a0d 100644 --- a/action.yml +++ b/action.yml @@ -281,198 +281,102 @@ runs: 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. + # 7. Run the pinned, published CLI in PR-scan mode. + # In --pr-scan the CLI does the whole flow itself: resolves the base..head + # diff LOCALLY (git), sends only the changed files' contents to linter-server + # (which normalizes + HMAC-signs the findings as a scanProof), mints the + # Actions OIDC token from ACTIONS_ID_TOKEN_REQUEST_* (needs id-token: write), + # and submits to the ci-review-service (POST /v1/findings), + # which verifies origin + content and posts AS THE APP. No project build / + # postinstall of PR-head code ever runs. + # + # CLI_VERSION is pinned by THIS Action release (never @latest at runtime). # --------------------------------------------------------------------------- - id: scan if: steps.preflight.outputs.ready == 'true' shell: bash env: - # SA creds — passed to the CLI, which authenticates over WSS to linter-server. + # SA creds — the CLI authenticates over WSS to linter-server with these. BROWSERSTACK_USERNAME: ${{ inputs.username }} BROWSERSTACK_ACCESS_KEY: ${{ inputs.access-key }} - CHANGED_FILES: ${{ steps.pr.outputs.changed-files }} + # scanProof context: {repo, prNumber, headSha}. repo/pr-number are required + # by --pr-scan; base is the diff baseline (CI checkouts can't infer it); + # head-sha binds the proof to this PR head (defaults to git HEAD otherwise). + REPO: ${{ github.repository }} + PR_NUMBER: ${{ steps.pr.outputs.pr-number }} BASE_SHA: ${{ steps.pr.outputs.base-sha }} HEAD_SHA: ${{ steps.pr.outputs.head-sha }} + FAIL_ON: ${{ inputs.fail-on-severity }} + # ACTIONS_ID_TOKEN_REQUEST_URL/TOKEN are inherited from the job env + # (permissions: id-token: write); the CLI reads them to mint the OIDC token. run: | set -euo pipefail - # Pinned published CLI version (npmjs latest = 1.5.0; includes PR-scan mode). Do NOT use @latest. + # Pinned published CLI (npmjs latest = 1.5.0; ships --pr-scan). 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 \ + # fail-on-severity=none -> --non-strict: report findings but never fail the + # job. `error` is the CLI default (errors -> exit 1). NOTE: CLI 1.5.0 has no + # separate "warning" gate, so `warning` currently behaves like `error`. + NON_STRICT=() + if [ "${FAIL_ON}" = "none" ]; then NON_STRICT=(--non-strict); fi + + echo "Running @browserstack/accessibility-devtools@${CLI_VERSION} --pr-scan (diff scope)…" + set +e + npx --yes "@browserstack/accessibility-devtools@${CLI_VERSION}" \ + --pr-scan \ + --include '**/*' \ --base "${BASE_SHA}" \ - --head "${HEAD_SHA}" \ - --changed-files "${CHANGED_FILES}" \ - --output "${FINDINGS_OUT}" \ - --format json - - echo "findings-file=${FINDINGS_OUT}" >> "$GITHUB_OUTPUT" + --head-sha "${HEAD_SHA}" \ + --repo "${REPO}" \ + --pr-number "${PR_NUMBER}" \ + --ci-service-url "https://devtools-a11y-linter.browserstack.com" \ + "${NON_STRICT[@]}" + code=$? + set -e + echo "scan-exit=${code}" >> "$GITHUB_OUTPUT" + echo "CLI exited ${code}" # --------------------------------------------------------------------------- - # 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. + # 8. Report + gate. The CLI already submitted the findings and the App posts + # them; here we only translate the CLI exit code into the job result and the + # optional merge gate. (CLI 1.5.0 --pr-scan does not return per-severity + # counts / comment URL to the runner, nor forward the comment/inline/sarif/ + # remediation options — the ci-review-service applies its defaults. Surfacing + # those needs a CLI enhancement.) # --------------------------------------------------------------------------- - id: report if: always() shell: bash env: - RESP_FILE: ${{ steps.submit.outputs.response-file }} + SCAN_EXIT: ${{ steps.scan.outputs.scan-exit }} 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="" - + result="pass" 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" + case "${SCAN_EXIT:-2}" in + 0) result="pass" ;; + 1) result="fail"; echo "::warning::Accessibility errors found." ;; + 3) result="fail"; echo "::error::Fair-Usage wait budget exhausted (rate limited)." ;; + 4) result="fail"; echo "::error::Permission denied by the server for this account/commenter." ;; + *) result="fail"; 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" - echo "sarif-file=$sarif_file" + echo "result=${result}" + echo "error-count=" + echo "warning-count=" + echo "findings-count=" + echo "comment-url=" + echo "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)." + echo "Result: ${result} (CLI exit ${SCAN_EXIT:-n/a})" + if [ "${OPT_CHECK_GATE}" = "true" ] && [ "${result}" = "fail" ]; then + echo "::error::Accessibility check failed (check-gate)." exit 1 fi From 4902ccebebd3531a73dafcc3673d7270fbdfe899 Mon Sep 17 00:00:00 2001 From: Crash0v3rrid3 Date: Tue, 14 Jul 2026 11:49:33 +0530 Subject: [PATCH 7/8] feat(DEVA11Y-657): automatic PR trigger, honest inputs, real outputs + severity gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review feedback (inputs must be real, not for show; guide users to the automatic detection flow; VS Code output format) and the flag audit. action.yml: - Automatic flow (primary): trigger on pull_request opened/synchronize/reopened/ ready_for_review (actor = PR author, no commenter gate). Manual @mention stays as the secondary flow (with the commenter permission pre-check). - Trim inputs to the ones that do something: username, access-key, fail-on-severity. Removed comment / check-gate / comment-mode / inline-suggestions / sarif (now always-on server defaults) and remediation / ai-agent (deferred preview). check-gate is folded into fail-on-severity (none = never fail). - Real gate + outputs: parse the CLI's `[deva11y-result]` line into error-count / warning-count / findings-count / comment-url, and fail the run per fail-on-severity (a real `warning` gate, and `error` actually fails now — previously --pr-scan always exited 0 so the check never failed). Operational errors (FUP/permission/error) always fail. Dropped the sarif-file output (SARIF is uploaded server-side; there is no local file). README + example: - Automatic-first quick start (on: pull_request + issue_comment), @main (no v1 pin). - Inputs/outputs tables reflect reality; describe what actually gets posted (sticky comment, inline comments in VS Code format, Check Run, SARIF). - Inline are review comments, not one-click suggestion blocks (findings carry how-to-fix text, not literal replacement code) — corrected the earlier claim. - Single support link; removed the internal-note remediation section. Co-Authored-By: Claude Opus 4.8 --- README.md | 119 +++++------ action.yml | 363 ++++++++++++++------------------- examples/browserstack-a11y.yml | 35 ++-- 3 files changed, 218 insertions(+), 299 deletions(-) diff --git a/README.md b/README.md index 736db9a..42c7dfc 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,15 @@ # BrowserStack Accessibility DevTools 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. +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 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. +- 💬 **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: @@ -18,7 +18,7 @@ Learn more: - github.event.issue.pull_request && - contains(github.event.comment.body, '@AccessibilityDevTools') + github.event_name == 'pull_request' || + (github.event.issue.pull_request && + contains(github.event.comment.body, '@AccessibilityDevTools')) runs-on: ubuntu-latest timeout-minutes: 15 steps: @@ -57,84 +61,53 @@ jobs: 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 - # comment-mode: update - # remediation: true # preview; off by default - # ai-agent: coderabbitai # bare name; the App posts "@coderabbitai" -``` - -Then, on any pull request, comment: - -``` -@AccessibilityDevTools ``` -The scan runs and results appear on the PR within a couple of minutes. +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. 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. +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 +> **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). | -| `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. | +| 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). | ## Outputs | Output | Description | | ---------------- | ------------------------------------ | | `result` | `pass` or `fail`. | -| `error-count` | Number of error‑severity findings. | -| `warning-count` | Number of warning‑severity findings. | +| `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. - -**Prerequisite:** if your agent ignores bot authors by default, allow‑list the -**BrowserStack Accessibility** bot in its configuration. - -```yaml -with: - username: ${{ secrets.BROWSERSTACK_USERNAME }} - access-key: ${{ secrets.BROWSERSTACK_ACCESS_KEY }} - remediation: true - ai-agent: coderabbitai # BrowserStack posts "@coderabbitai" -``` - -> This feature is a preview and is off by default. Hand‑off is supported only 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. +| `comment-url` | Link to the posted PR summary comment. | ## Support -- + diff --git a/action.yml b/action.yml index 0891a0d..23e44cf 100644 --- a/action.yml +++ b/action.yml @@ -1,11 +1,12 @@ # 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. +# auth) — 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); +# 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); @@ -13,8 +14,9 @@ # 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. +# 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.' @@ -31,38 +33,16 @@ inputs: 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.' + 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' - 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: '' + +# 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 behavioural +# knob (it subsumes the old check-gate). AI remediation hand-off is a separate +# preview and is intentionally not exposed here yet. outputs: result: @@ -78,116 +58,115 @@ outputs: description: 'Total findings.' value: ${{ steps.report.outputs.findings-count }} comment-url: - description: 'Link to the posted PR comment.' + description: 'Link to the posted PR summary 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. + # 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: guard + - id: context 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 + event="${GITHUB_EVENT_NAME:-}" + proceed=false + mode="" - # Comment body must mention the trigger phrase. - if [ "$proceed" = "true" ]; then - body="$(jq -r '.comment.body // ""' "$GITHUB_EVENT_PATH")" - case "$body" in - *"$TRIGGER_MENTION"*) : ;; - *) + 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." - proceed=false - ;; - esac + 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" >> "$GITHUB_OUTPUT" + { + echo "proceed=$proceed" + echo "mode=$mode" + echo "pr-number=$pr_number" + echo "actor=$actor" + } >> "$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. + # 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.guard.outputs.proceed == 'true' + 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 - actor="$(jq -r '.comment.user.login // ""' "$GITHUB_EVENT_PATH")" - repo="$GITHUB_REPOSITORY" - if [ -z "$actor" ]; then + 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 - # 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}" + 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" ;; + 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 "::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). 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. + # 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.guard.outputs.proceed == 'true' && steps.permission.outputs.allowed == 'true' + if: steps.context.outputs.proceed == 'true' && steps.permission.outputs.allowed == 'true' shell: bash env: BSTACK_USERNAME: ${{ inputs.username }} @@ -195,82 +174,44 @@ runs: 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." + echo "::warning::Service Account credentials are not available (secrets not configured). 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." + 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" # --------------------------------------------------------------------------- - # 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). + # 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 - 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" + 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 "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)." + echo "Resolved PR #${PR_NUMBER} head=${head_sha}." # --------------------------------------------------------------------------- - # 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. + # 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 }} - # Enough history for a base…head diff. fetch-depth: 0 persist-credentials: false @@ -281,102 +222,108 @@ runs: node-version: '20' # --------------------------------------------------------------------------- - # 7. Run the pinned, published CLI in PR-scan mode. - # In --pr-scan the CLI does the whole flow itself: resolves the base..head - # diff LOCALLY (git), sends only the changed files' contents to linter-server - # (which normalizes + HMAC-signs the findings as a scanProof), mints the - # Actions OIDC token from ACTIONS_ID_TOKEN_REQUEST_* (needs id-token: write), - # and submits to the ci-review-service (POST /v1/findings), - # which verifies origin + content and posts AS THE APP. No project build / - # postinstall of PR-head code ever runs. - # - # CLI_VERSION is pinned by THIS Action release (never @latest at runtime). + # 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: - # SA creds — the CLI authenticates over WSS to linter-server with these. BROWSERSTACK_USERNAME: ${{ inputs.username }} BROWSERSTACK_ACCESS_KEY: ${{ inputs.access-key }} - # scanProof context: {repo, prNumber, headSha}. repo/pr-number are required - # by --pr-scan; base is the diff baseline (CI checkouts can't infer it); - # head-sha binds the proof to this PR head (defaults to git HEAD otherwise). REPO: ${{ github.repository }} - PR_NUMBER: ${{ steps.pr.outputs.pr-number }} + PR_NUMBER: ${{ steps.context.outputs.pr-number }} BASE_SHA: ${{ steps.pr.outputs.base-sha }} HEAD_SHA: ${{ steps.pr.outputs.head-sha }} - FAIL_ON: ${{ inputs.fail-on-severity }} - # ACTIONS_ID_TOKEN_REQUEST_URL/TOKEN are inherited from the job env - # (permissions: id-token: write); the CLI reads them to mint the OIDC token. run: | set -euo pipefail - # Pinned published CLI (npmjs latest = 1.5.0; ships --pr-scan). Do NOT use @latest. CLI_VERSION="1.5.0" - - # fail-on-severity=none -> --non-strict: report findings but never fail the - # job. `error` is the CLI default (errors -> exit 1). NOTE: CLI 1.5.0 has no - # separate "warning" gate, so `warning` currently behaves like `error`. - NON_STRICT=() - if [ "${FAIL_ON}" = "none" ]; then NON_STRICT=(--non-strict); fi - echo "Running @browserstack/accessibility-devtools@${CLI_VERSION} --pr-scan (diff scope)…" set +e - npx --yes "@browserstack/accessibility-devtools@${CLI_VERSION}" \ + 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" \ - "${NON_STRICT[@]}" + --ci-service-url "https://devtools-a11y-linter.browserstack.com" 2>&1)" code=$? set -e - echo "scan-exit=${code}" >> "$GITHUB_OUTPUT" + 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}" # --------------------------------------------------------------------------- - # 8. Report + gate. The CLI already submitted the findings and the App posts - # them; here we only translate the CLI exit code into the job result and the - # optional merge gate. (CLI 1.5.0 --pr-scan does not return per-severity - # counts / comment URL to the runner, nor forward the comment/inline/sarif/ - # remediation options — the ci-review-service applies its defaults. Surfacing - # those needs a CLI enhancement.) + # 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 }} - PROCEED: ${{ steps.guard.outputs.proceed }} + RESULT_JSON: ${{ steps.scan.outputs.result-json }} + PROCEED: ${{ steps.context.outputs.proceed }} ALLOWED: ${{ steps.permission.outputs.allowed }} READY: ${{ steps.preflight.outputs.ready }} - OPT_CHECK_GATE: ${{ inputs.check-gate }} + 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 (guard/permission/preflight skip); reporting a neutral pass." + 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 - case "${SCAN_EXIT:-2}" in - 0) result="pass" ;; - 1) result="fail"; echo "::warning::Accessibility errors found." ;; - 3) result="fail"; echo "::error::Fair-Usage wait budget exhausted (rate limited)." ;; - 4) result="fail"; echo "::error::Permission denied by the server for this account/commenter." ;; - *) result="fail"; echo "::error::Scan failed (CLI exit ${SCAN_EXIT:-unknown})." ;; + # 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=" - echo "warning-count=" - echo "findings-count=" - echo "comment-url=" - echo "sarif-file=" + 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} (CLI exit ${SCAN_EXIT:-n/a})" - if [ "${OPT_CHECK_GATE}" = "true" ] && [ "${result}" = "fail" ]; then - echo "::error::Accessibility check failed (check-gate)." + 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 index 84fcad0..7d112ce 100644 --- a/examples/browserstack-a11y.yml +++ b/examples/browserstack-a11y.yml @@ -1,34 +1,38 @@ # 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. +# 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, check, SARIF) is posted by the BrowserStack Accessibility App -# server-side — never by GITHUB_TOKEN. +# (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 + changed files + 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. +# Supersede stale runs on the same PR (either trigger). concurrency: - group: a11y-${{ github.event.issue.number }} + group: a11y-${{ github.event.pull_request.number || 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. + # 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.issue.pull_request && - contains(github.event.comment.body, '@AccessibilityDevTools') + github.event_name == 'pull_request' || + (github.event.issue.pull_request && + contains(github.event.comment.body, '@AccessibilityDevTools')) runs-on: ubuntu-latest timeout-minutes: 15 steps: @@ -38,11 +42,6 @@ jobs: with: username: ${{ secrets.BROWSERSTACK_USERNAME }} access-key: ${{ secrets.BROWSERSTACK_ACCESS_KEY }} - comment: true - check-gate: true + # Fail the check when findings at/above this severity are present: + # error (default) | warning | none. 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" From 0b9eca579cfab12a4ebdd83b711f33b8b434430f Mon Sep 17 00:00:00 2001 From: Crash0v3rrid3 Date: Tue, 14 Jul 2026 12:06:21 +0530 Subject: [PATCH 8/8] feat(DEVA11Y-657): re-add AI remediation hand-off via a single ai-agent input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review: keep remediation with a generic disclaimer (rather than dropping it). - action.yml: `ai-agent` input (optional, bare name); a validate-agent step (charset ^[A-Za-z0-9-]+(\[bot\])?$, fail fast); passes `--ai-agent` to the CLI only when set. Single input — presence enables the hand-off (no separate `remediation` boolean). - README: ai-agent row + a short "AI remediation hand-off (preview)" section with the generic disclaimer (supported if your agent accepts bot/App triggers). Co-Authored-By: Claude Opus 4.8 --- README.md | 11 +++++++++++ action.yml | 38 ++++++++++++++++++++++++++++++++++---- 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 42c7dfc..7fc5168 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,7 @@ On every scan the App posts, automatically: | `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 @@ -108,6 +109,16 @@ On every scan the App posts, automatically: | `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 index 23e44cf..18c756b 100644 --- a/action.yml +++ b/action.yml @@ -37,12 +37,20 @@ inputs: 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 behavioural -# knob (it subsumes the old check-gate). AI remediation hand-off is a separate -# preview and is intentionally not exposed here yet. +# 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: @@ -184,6 +192,23 @@ runs: 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). @@ -238,9 +263,13 @@ runs: 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}" \ @@ -250,7 +279,8 @@ runs: --head-sha "${HEAD_SHA}" \ --repo "${REPO}" \ --pr-number "${PR_NUMBER}" \ - --ci-service-url "https://devtools-a11y-linter.browserstack.com" 2>&1)" + --ci-service-url "https://devtools-a11y-linter.browserstack.com" \ + "${AGENT_ARGS[@]}" 2>&1)" code=$? set -e printf '%s\n' "$out"