Scanning Only Affected Packages in a Turborepo Monorepo
The cheapest route scan is the one that never runs because nothing the pull request touched can reach that route. This guide is part of Monorepo & Parallel Test Sharding, and it covers selection rather than division: how to ask the workspace dependency graph which packages a diff can affect, how to make the task cache trustworthy enough that a “pass” replayed from it means something, and when to throw the selection away and scan everything.
Root Cause
Workflow path filters are the usual first attempt, and they are wrong in both directions. paths: ['apps/checkout/**'] on the checkout scan job means a pull request that changes packages/ui/src/Dialog.tsx — removing the aria-modal attribute from the component every application uses for its dialogs — does not trigger the checkout scan at all. Meanwhile a pull request that only edits apps/docs/content/changelog.mdx triggers every job whose path filter happens to include a shared file, and the pipeline spends 38 minutes proving that a changelog edit did not break the checkout flow.
The information needed to get this right is already in the repository: the workspace dependency graph. apps/checkout depends on @acme/forms, which depends on @acme/ui, so a change to @acme/ui can change the DOM of apps/checkout and must trigger its scan. apps/docs depends on neither, so it cannot. Turborepo exposes exactly that query through its filter microsyntax: [origin/main] selects packages whose files changed relative to a git ref, and the leading ... widens the selection to include everything that depends on those packages. turbo run a11y --filter=...[origin/main] therefore means “scan the packages that changed and everyone downstream of them”.
The subtlety that turns this from an optimisation into a hazard is the task cache. Turborepo hashes each task’s inputs and, on a match, replays the stored result instead of executing. That is exactly what you want for an unchanged package — and exactly what you do not want if the hash omits something that changes the rendered DOM. A scan task that declares inputs: ["src/**"] and no dependency on the shared package’s build will hash identically before and after @acme/ui changes, so the cache serves last week’s pass for a page that now ships a dialog with no accessible name. The scan did not fail; it never ran, and the gate reported success.
Configuration
The task definition is where correctness lives. dependsOn pulls the dependency’s build hash into this task’s key, inputs narrows what counts as a change without narrowing it below the truth, and outputs is what makes a cache hit restore the report file rather than only the log.
{
"globalDependencies": [
"pnpm-lock.yaml",
"a11y/axe.config.json",
"playwright.config.ts"
],
"globalEnv": ["AXE_CORE_VERSION", "PLAYWRIGHT_CHROMIUM_REVISION"],
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**", "!.next/cache/**"]
},
"a11y": {
"dependsOn": ["build", "^build"],
"inputs": [
"src/**",
"app/**",
"a11y/routes.json",
"tests/a11y/**",
"package.json"
],
"outputs": ["a11y-report/**"],
"env": ["A11Y_BASE_URL", "A11Y_IMPACT_THRESHOLD"],
"outputLogs": "new-only"
}
}
}
Four lines in that file each prevent a specific false pass. "dependsOn": ["build", "^build"] makes this package’s own build and every upstream build part of the hash, so a @acme/ui change moves the key of every consumer’s scan. globalDependencies covers the files that are not inside any package but change every scan’s meaning: the lockfile, the shared axe configuration, the Playwright config. globalEnv covers versions that live outside the repository entirely — a browser revision or an axe-core version pinned by the runner image rather than by the lockfile. And outputs declares a11y-report/** so a cache hit restores the report; without it, a hit produces no file at all, and the merge step downstream sees a report missing for a package it expected.
Scope selection is a shell step, because it has to compute a merge base and inspect a diff before turbo is invoked.
#!/usr/bin/env bash
# scripts/select-a11y-scope.sh — chooses between an affected scan and a full one.
set -euo pipefail
BASE_REF="${BASE_REF:-origin/main}"
# The merge base, not the tip of main: diffing against the tip attributes other
# people's merges to this pull request and selects packages it never touched.
BASE="$(git merge-base "$BASE_REF" HEAD)"
# Changes to these files make every cached hash incomparable, so selection and
# cache reuse both stop being safe.
FULL_PATHS='pnpm-lock\.yaml|turbo\.json|playwright\.config\.ts|a11y/axe\.config\.json'
CHANGED="$(git diff --name-only "$BASE" HEAD)"
if echo "$CHANGED" | grep -Eq "$FULL_PATHS"; then
SCOPE=full
REASON="a shared input changed; cached results are not comparable"
elif [ "${GITHUB_EVENT_NAME:-pull_request}" != "pull_request" ]; then
SCOPE=full
REASON="scheduled and release runs always scan every package"
else
SCOPE=affected
REASON="scanning packages affected since ${BASE:0:8}"
fi
OUT="${GITHUB_OUTPUT:-/dev/stdout}"
printf 'scope=%s\n' "$SCOPE" >> "$OUT"
printf 'base=%s\n' "$BASE" >> "$OUT"
printf '%s\n' "$REASON" >> "${GITHUB_STEP_SUMMARY:-/dev/stderr}"
The workflow needs the full history for that merge base, audits the selection before acting on it, and only then runs the scan.
name: a11y-affected
on:
pull_request:
branches: [main]
schedule:
- cron: '20 3 * * *' # nightly full scan, no filter, cache bypassed
permissions:
contents: read
jobs:
affected-scan:
runs-on: ubuntu-24.04
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # merge-base needs history; a shallow clone selects everything
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: npx playwright install --with-deps chromium
- id: scope
run: bash scripts/select-a11y-scope.sh
- name: Audit the selection before running it
env:
SCOPE: ${{ steps.scope.outputs.scope }}
BASE: ${{ steps.scope.outputs.base }}
run: |
if [ "$SCOPE" = "full" ]; then
pnpm turbo run a11y --dry=json > a11y-selection.json
else
pnpm turbo run a11y --filter="...[$BASE]" --dry=json > a11y-selection.json
fi
jq -r '.tasks[] | "\(.package)\t\(.cache.status)\t\(.hash)"' a11y-selection.json \
| tee -a "$GITHUB_STEP_SUMMARY"
- name: Run the accessibility scan
env:
SCOPE: ${{ steps.scope.outputs.scope }}
BASE: ${{ steps.scope.outputs.base }}
A11Y_IMPACT_THRESHOLD: serious
run: |
if [ "$SCOPE" = "full" ]; then
# --force ignores cache hits: when a global input moved, a hit is a guess.
pnpm turbo run a11y --force
else
pnpm turbo run a11y --filter="...[$BASE]"
fi
- uses: actions/upload-artifact@v4
if: always()
with:
name: a11y-affected-reports
path: |
a11y-selection.json
packages/*/a11y-report/
apps/*/a11y-report/
retention-days: 14
Validation
Prove the fan-out first. Commit a change to the shared package and ask turbo what it would do, without running anything:
git commit -am 'tighten Dialog focus trap' # touches packages/ui only
BASE="$(git merge-base origin/main HEAD)"
pnpm turbo run a11y --filter="...[$BASE]" --dry=json \
| jq -r '.tasks[] | "\(.package)\t\(.cache.status)"'
@acme/ui MISS
@acme/forms MISS
web MISS
checkout MISS
admin MISS
Five packages, and apps/docs and @acme/analytics are absent because nothing connects them to @acme/ui. Then prove the cache key actually moves, which is the assertion that protects against a replayed pass. Ask for one consumer’s hash, change the shared package, and ask again:
pnpm turbo run a11y --filter=web --dry=json | jq -r '.tasks[0].hash'
# 8f13c0a2ee4b7d16
printf '\n/* spacing tweak */\n' >> packages/ui/src/Button.tsx
pnpm turbo run a11y --filter=web --dry=json | jq -r '.tasks[0].hash'
# 2a9d61ff40c8be03 <- different, so web's stored pass will not be reused
If those two hashes match, the task definition is wrong and every cached pass in the repository is suspect. The usual cause is a missing ^build in dependsOn. Finally, confirm that a genuine hit restores the report rather than only the log, because that is what downstream aggregation depends on:
rm -rf apps/web/a11y-report
pnpm turbo run a11y --filter=web # second run of an unchanged package
# cache hit, replaying logs 8f13c0a2ee4b7d16
ls apps/web/a11y-report/
# report.json summary.md
An empty directory after a hit means outputs is missing from the task definition. The report is then absent for a package the run claims it scanned, which the missing-report check in merging sharded accessibility reports into one artifact will correctly treat as an incomplete run.
Edge Cases and Conditional Guards
- A package with no
a11ytask. Turborepo runs nothing for a package that does not declare the task and says nothing about it, so a newly scaffolded application is silently unscanned from the day it is created. Add a coverage check that comparespnpm turbo ls --output=jsonagainst the packages declaring ana11yscript, and fail the pipeline when an application appears in the first list and not the second. - Routes owned by one package but rendered by another. A docs application that renders MDX from a content package, or an application whose routes come from a CMS, breaks the assumption that a route belongs to the package whose directory it lives in. Keep the route-to-package mapping explicit in
a11y/routes.json— each entry carries its owning package — and derive the scan’s route list from the selected package set rather than from the filesystem. - DOM changes with no file changes. A feature flag flipped in a dashboard, a CMS publish, or a design-token package consumed from an external registry all change the rendered output while the diff stays empty. Selection cannot see any of it, which is why the nightly unfiltered run exists and why its results belong on the same dashboard as the pull-request results rather than in a separate report nobody compares.
Pipeline Impact
Selection changes the shape of everything downstream. On this repository, the median pull request selects two packages and 96 routes out of 480, which takes the shard plan from four shards to one and the gate from seven minutes to two and a half. That is the win, and it comes with an obligation: the merged report must record what was in scope. A run that scanned 96 routes and found nothing is not the same claim as a run that scanned 480 and found nothing, and a dashboard that plots them on the same axis will show a violation count dropping to zero every time someone opens a small pull request. Write scope, selectedPackages and routesScanned into the report and have the trend view read the nightly full runs only.
Cache hits interact with the required status check in one specific way worth planning for. When every selected package hits the cache, the scan job finishes in around twenty seconds and produces reports restored from storage. That is a legitimate pass, but only because outputs was declared and the reports exist; a hit with no restored artifact is indistinguishable from a scan that never produced one. Keep the report path in outputs, and keep remote cache signing enabled if the cache is shared across CI and developer machines, so a poisoned entry cannot serve a fabricated pass to the gate.
For the pull-request experience, print the selection into the step summary before the scan runs. A reviewer who can see “5 packages selected because @acme/ui changed” understands why the job took six minutes; a reviewer looking at an opaque green check has no way to tell an affected scan from a skipped one. Threshold behaviour is unchanged by selection — the same serious and above rule applies to whatever was scanned, as described in progressive threshold management — and the per-package axe setup for a workspace of this shape is covered in setting up axe-core in a Next.js monorepo.
Common Pitfalls
- Checking out with the default shallow clone, so
git merge-basefails and the filter either errors or quietly selects every package on every pull request. - Diffing against the tip of
origin/maininstead of the merge base, which attributes other people’s merged changes to this pull request and inflates the selection on every rebase-averse branch. - Omitting
^buildfrom the scan task’sdependsOn, so a shared component change never moves a consumer’s hash and the cache replays a pass for markup that no longer exists. - Leaving
outputsundeclared, which makes cache hits produce logs without reports and turns aggregation into a guessing game about which packages actually ran. - Using
--filter=[origin/main]without the leading ellipsis, which scans the changed package and none of its consumers — the exact failure the graph was supposed to prevent. - Treating a nightly full scan as optional once selection works, leaving flag-driven and content-driven regressions with nothing that ever looks for them.
FAQ
Does --filter=...[origin/main] include the changed package itself or only its dependents?
Both: the selection is the changed packages plus everything that depends on them, transitively. The leading ellipsis widens the set rather than replacing it, so a change confined to a leaf application selects exactly that application, and a change to a package eight consumers depend on selects nine packages. Verify it on a real branch with --dry=json rather than trusting the mental model, because a devDependencies-only edge behaves differently from a runtime one.
Should the affected scan use the remote cache on pull requests?
Read from it, and be careful about writing to it. Reading turns an unchanged package into a twenty-second no-op, which is most of the speed win. Writing from pull-request runs lets an unmerged branch populate entries that later runs on main will consume, so restrict write access to the default branch or key the cache by branch. A cache entry produced by a build nobody reviewed is a supply-chain shaped problem, not just a performance one.
How does package selection combine with route sharding? Selection runs first and produces the route list; sharding divides whatever that list contains. On a two-package pull request the list is short enough that one shard beats the overhead of four, so let the planner decide the shard count from the selected total rather than fixing it in YAML — the arithmetic is in sharding axe-core scans across parallel CI jobs. The two mechanisms never need to know about each other beyond that hand-off.
Related
- Monorepo & Parallel Test Sharding — the wider model this selection step feeds, including the budget arithmetic.
- Sharding axe-core Scans Across Parallel CI Jobs — how the selected route list gets divided across runners.
- Testing Fundamentals & Tool Selection — the scanner configuration each package’s
a11ytask actually executes.