From 2175c9fc1d0872a3a2de55f04025c65e58ead458 Mon Sep 17 00:00:00 2001 From: Crash0v3rrid3 Date: Wed, 8 Jul 2026 13:02:43 +0530 Subject: [PATCH 1/4] 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/4] 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/4] =?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/4] 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"