ci(DEVA11Y-735): PR smoke test for a11y-scan SPM plugin (end-to-end scan) - #35
Conversation
Runs an end-to-end accessibility scan on every PR: builds the plugin and executes a real scan against the tests/spm harness (sample SwiftUI sources with intentional a11y issues), reusing the repo's own gated integration test (testA11yScanPluginRuns) so the invocation stays in one place. The scan downloads the BrowserStack CLI and makes authenticated calls, so it is gated to same-repo PRs (secrets are never exposed to fork PRs) and manual dispatch. Without the BROWSERSTACK_USERNAME / BROWSERSTACK_ACCESS_KEY secrets configured the e2e test XCTSkips and the job still passes. actions/checkout pinned by SHA to match existing workflows (DEVA11Y-476). Co-Authored-By: Claude Opus 4.8 <[email protected]>
The repo root is a plugin-only package with no buildable target, so
`swift build` there fails ("does not contain a buildable target").
Building the tests/spm harness compiles the a11y-scan command plugin via
the path dependency plus the sample sources, so use that as the build step.
Verified locally on Swift 6.2: tests/spm `swift build` compiles the plugin,
and `swift test` passes with the e2e scan test skipping when RUN_A11Y_SCAN
is unset.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
run-a11y-scan.sh passed `--allow-network-connections 'all(ports: [])'` — that is PackageDescription API syntax, not a valid CLI value, and the empty port list did not satisfy the a11y-scan plugin's declared need for ports 80/443. SwiftPM therefore refused the scan: error: Plugin 'a11y-scan' wants permission to allow all network connections on ports: 80, 443. Use `--allow-network-connections all:80,443` to allow this. Surfaced by the new PR smoke-test job, which is the first thing to run the scan in CI. Fix per SwiftPM's own guidance: `all:80,443`. Applied to the SwiftPM and Xcode harness scripts and the tests/spm README (same bug in all three). Verified locally: the CLI now clears the permission gate and runs. Co-Authored-By: Claude Opus 4.8 <[email protected]>
Smoke gate is green ✅ — and it caught a real pre-existing bugThe first CI run failed at the scan step:
Root cause (not in the workflow): the harness scripts passed
After the fix, the end-to-end scan runs and passes (42s) with the repo secrets. (Non-blocking) The Node 20 deprecation warning is from |
Adds a second, secret-free job that syntax-checks all six launcher scripts under scripts/ (bash/zsh/fish x cli/spm) with `bash -n`. They are all bash scripts (the zsh/fish variants only differ in which login shell they source credentials from), so a single bash syntax gate covers them. Runs on all PRs including forks; scripts are not executed (they self-update, register git hooks and need credentials). Complements verify-selfupdate-checksums.yml, which covers checksum integrity but not syntax. Co-Authored-By: Claude Opus 4.8 <[email protected]>
Added:
|
Code-review follow-ups on the smoke workflow: - Graceful degradation was only claimed, not real. GitHub exposes an unset secret as an empty string (present, not nil), and the reused test skips only on `env[...] != nil`, so a repo without the secrets would run the scan with empty creds and fail (script's `:?` under set -euo pipefail), not skip. Guard the scan step on the secrets being non-empty so it is skipped when absent and the job stays green on the build step. Fix the header comment to match. - Correct the scripts-lint comment: the launchers' shebang is `#!/usr/bin/env bash -il`, not `#!/usr/bin/env bash`. - Drop the fork-controllable filename from the `::notice/::error file=` workflow commands (workflow-command injection vector on fork PRs); log plain lines instead. bash -n still prints the real error location. Co-Authored-By: Claude Opus 4.8 <[email protected]>
Addresses the two open code-review findings on the smoke workflow: - Fidelity (#2): the e2e test asserted only that --non-strict exits 0, so a scan that authenticated but found nothing (silent no-op) would pass green. Now run the scan twice and use the tool's own exit-code contract: strict mode must exit non-zero, proving the intentional issues in SampleViews.swift were actually detected — not just that the plugin ran. Uses exit codes, not brittle output matching. Also drains output for diagnostics and treats an empty credential value as absent (skip) to match the workflow guard. - Flakiness (#3): the scan hits BrowserStack (network + auth + CLI download) on every same-repo PR. Wrapped it in a bounded retry (3 attempts, 20s backoff) so a transient upstream hiccup doesn't red-block a PR; a consistent failure still fails the gate. swift test reuses the first build, so retries only re-run the scan. Verified locally: tests compile; no-creds path still skips cleanly. Co-Authored-By: Claude Opus 4.8 <[email protected]>
Both open review findings addressed ✅ — CI green (
|
Crash0v3rrid3
left a comment
There was a problem hiding this comment.
Claude Code Review (automated) — 6 inline finding(s). Full report in the PR comment below. Verdict: Passed.
| attempts=3 | ||
| for i in $(seq 1 "$attempts"); do | ||
| echo "::group::a11y-scan smoke attempt $i/$attempts" | ||
| if swift test; then |
There was a problem hiding this comment.
[Medium] Gate passes if the E2E test XCTSkips — nothing asserts it ran
swift test exits 0 when testA11yScanPluginRuns throws XCTSkip, and nothing here asserts the test actually executed. Today the guards line up and the scan provably runs, but any drift — the env var renamed on one side, the test renamed or moved, an extra guard added — silently turns this gate into a swift build check that still shows green. That is the exact silent-pass class this workflow exists to prevent.
Suggestion: scope and assert execution, e.g. swift test --filter 'A11yDemoLibTests/testA11yScanPluginRuns' 2>&1 | tee out.log, then fail the step if the log contains skipped or lacks a passed line for that test. A --filter that matches nothing also exits non-zero, which catches a rename.
Reviewer: stack:devtools-review-changes
| "a11y-scan did not run cleanly in --non-strict mode (exit \(clean.status)).\n\(clean.output)") | ||
|
|
||
| let strict = try runScan(script: script, packageDir: packageDir, strict: true) | ||
| XCTAssertNotEqual( |
There was a problem hiding this comment.
[Medium] Strict assertion accepts any non-zero exit, not just "issues found"
The plugin forwards several distinct non-zero codes — exit 2 (unwritable cache), exit 4 (RBAC denial), the curl status on a failed CLI download, exit 1 on abnormal termination — all of which satisfy != 0. Since the strict run is a separate process from the non-strict one, a transient failure in that second run is indistinguishable from "the planted issues were detected", so this fidelity check can pass for the wrong reason.
Suggestion: assert the specific lint-failure code (strict.status == 1) rather than any non-zero, and additionally assert strict.output references SampleViews.swift or a known rule id, so the test proves the planted issues caused the failure.
Reviewer: stack:devtools-review-changes
| run: | | ||
| set -uo pipefail | ||
| shopt -s globstar nullglob | ||
| scripts=(scripts/**/*.sh) |
There was a problem hiding this comment.
[Medium] scripts-lint glob misses the scripts this PR actually changes
scripts/**/*.sh matches only the 6 launchers under root scripts/. The two scripts this PR modifies — tests/spm/scripts/run-a11y-scan.sh and tests/xcode-app/scripts/run-a11y-scan.sh — fall outside the glob, so this job would not have caught a syntax error in the very files being changed. tests/xcode-app/scripts/run-a11y-scan.sh (wired into the Xcode build phase via project.yml) has no coverage from either job.
Suggestion: broaden to scripts=(scripts/**/*.sh tests/**/scripts/*.sh) or git ls-files '*.sh', and soften the comment above to match real coverage. Scoping to scripts/ was the original request, so widening is a judgement call.
Reviewer: stack:devtools-review-changes
| run: | | ||
| set -uo pipefail | ||
| attempts=3 | ||
| for i in $(seq 1 "$attempts"); do |
There was a problem hiding this comment.
[Medium] Retry re-runs deterministic assertion failures three times
The loop cannot distinguish a transient upstream hiccup from a deterministic assertion failure, so a genuine regression is retried three times. Each attempt runs two full scans (non-strict + strict), each downloading/exec'ing the CLI and making authenticated round trips, plus 2x20s backoff — all inside timeout-minutes: 25. If a scan pair exceeds ~8 minutes, a real regression surfaces as an opaque job timeout with no assertion message instead of the intended diagnostic.
Suggestion: retry only on transient signatures (grep the captured output for network/download/5xx markers) and fail fast on an XCTest assertion failure; or drop to 2 attempts and raise timeout-minutes so the retry budget provably fits.
Reviewer: stack:devtools-review-changes
| process.standardError = pipe | ||
|
|
||
| try process.run() | ||
| let collected = pipe.fileHandleForReading.readDataToEndOfFile() |
There was a problem hiding this comment.
[Low] Child process has no timeout; output only surfaces on failure
standardInput is left at the default (unlike the plugin's own runCLI, which wires it explicitly), so if the CLI stalls or waits on stdin, readDataToEndOfFile() blocks and the job emits zero log output until the 25-minute timeout — an empty ::group:: and no diagnostic to debug from.
Suggestion: set process.standardInput = FileHandle.nullDevice, add a watchdog that terminates the process after N minutes, and/or print the captured output unconditionally so the scan log reaches CI on success and on hang.
Reviewer: stack:devtools-review-changes
| name: SPM plugin smoke test | ||
|
|
||
| on: | ||
| pull_request: |
There was a problem hiding this comment.
[Low] No paths: filter and no push: main trigger (drift from the sibling workflow)
Two divergences from verify-selfupdate-checksums.yml: there is no paths: filter, so a 25-minute macOS job making real authenticated scans runs on every PR including docs-only ones; and there is no push: branches: [main], so nothing re-verifies the plugin after merge and main can break unnoticed until the next PR.
Suggestion: add a paths: filter (Plugins/**, tests/**, Package.swift, and this workflow) plus a push: branches: [main] trigger to match the sibling workflow.
Reviewer: stack:devtools-review-changes
Claude Code PR ReviewPR: #35 • Head: c24c577 • Reviewers: stack:devtools-review-changes SummaryAdds a PR-gating GitHub Actions workflow that smoke-tests the Review Table
Verification performedThe reviewer's headline claim — that
So the old value never granted anything for this plugin (the scan was refused outright), nothing was narrowed, and the fix in this PR is correct and necessary. The reviewer's port-22 egress probe used a throwaway plugin with different declared permissions, so it does not transfer. That finding is recorded as not confirmed. That test did, however, surface a real pre-existing defect — see Pre-existing below. Findings1.
2.
3.
4.
5.
6.
7.
8.
Pre-existing (not introduced by this PR; non-gating)
Raised by other reviewers (not independently confirmed)
Human reviewer signal@maunilm approved this PR on 2026-08-06T07:26:09Z with no inline comments and no unresolved review threads. Nothing to reconcile: no concerns to confirm, and no carried-forward findings to prune (this is the first Claude review on this PR). Verdict: PASS — no High-severity issues in this PR's own changes; the workflow demonstrably runs a real, authenticated scan and both jobs are green at c24c577. The four Medium items are hardening of a verification mechanism and are worth doing before this becomes a required check; the High item is pre-existing breakage on the shipped launcher path and deserves its own ticket. |
What
Adds
.github/workflows/spm-smoke-test.yml— a GitHub Actions workflow that smoke-tests thea11y-scanSwiftPM command plugin on every PR, with two jobs:a11y-scan end-to-end (SwiftPM)(macOS) — the credentialed scan.cd tests/spm && swift buildcompiles thea11y-scancommand plugin (via the path dependency) and the sample sources — the repo root is a plugin-only package with no buildable target, so it isn't built directly. Thencd tests/spm && swift testwithRUN_A11Y_SCAN=1+ credentials un-gates the repo's existingtestA11yScanPluginRuns, which drivesscripts/run-a11y-scan.sh— a real scan that downloads the BrowserStack CLI, authenticates, and scans the sample SwiftUI sources with intentional a11y issues. It reuses the harness the repo already ships rather than duplicating theswift package plugin … scaninvocation, so there's a single source of truth for the invocation.Launcher scripts (bash syntax)(Ubuntu, no secrets) —bash -nover every launcher underscripts/. The bash/zsh/fish variants are all bash scripts (they differ only in which login shell they source creds from), so one syntax gate covers them. Static-syntax only — the scripts self-update, register git hooks and need creds, so they aren't executed here; checksum integrity stays covered byverify-selfupdate-checksums.yml.This also corrects a latent bug in the harness the scan depends on:
run-a11y-scan.sh(both the spm and xcode-app copies) passed--allow-network-connections 'all(ports: [])'— PackageDescription API syntax that isn't a valid CLI value and doesn't satisfy the plugin's declared need for ports 80/443, so SwiftPM refused the scan. Corrected toall:80,443.Design notes
BROWSERSTACK_USERNAME/BROWSERSTACK_ACCESS_KEY, and secrets are never exposed to fork PRs, so the scan job is gated withgithub.event.pull_request.head.repo.fork == false(plusworkflow_dispatchfor manual runs). Thescripts-lintjob needs no secrets and runs on all PRs including forks.if: env.BROWSERSTACK_USERNAME != '' && …). GitHub exposes an unset secret as an empty string (present, not nil), so without the guard the scan would run with empty creds and fail; with it, an unconfigured repo simply skips the scan and the job stays green on the build step.testA11yScanPluginRunsruns the scan twice and checks the tool's own exit-code contract:--non-strictmust exit 0 (downloaded, authenticated, ran cleanly) and strict must exit non-zero (the intentional issues were actually detected). A clean-but-empty scan (silent no-op) fails the strict check.swift testreuses the first attempt's build, so retries only re-run the scan.actions/checkoutis pinned by SHA (v4.2.2) to matchSemgrep.yml/verify-selfupdate-checksums.yml(supply-chain hardening, DEVA11Y-476).Verified (CI + local)
tests/spmbuilds (Compiling plugin a11y-scan+ sample sources);swift testwith noRUN_A11Y_SCANskips the e2e test and passes, confirming the no-secrets path stays green.Notes / open questions
BROWSERSTACK_USERNAME/BROWSERSTACK_ACCESS_KEYare already configured on the repo (the credentialed scan runs green); no secret setup needed.tests/spm/). Thetests/xcode-app/build-phase harness is not wired up here — happy to add an Xcode job in a follow-up.actions/[email protected]emits a non-blocking Node 20 deprecation warning; left as-is to match the repo's existing pinned workflows.chore/spm-pr-smoke-test; rename/link as needed.🤖 Generated with Claude Code