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.

How a change to a shared package fans out packages/ui is changed in the pull request. Its dependents packages/forms, apps/web and apps/admin are selected, and apps/checkout is selected transitively through packages/forms. packages/analytics and apps/docs depend on none of them and are skipped. One shared-package change, five scans selected packages/ui changed in this PR packages/forms direct dependent apps/web 184 routes apps/admin 96 routes apps/checkout transitive dependent packages/analytics not selected apps/docs not selected arrows point from a package to the packages that depend on it — selection follows them
Selection walks the arrows outward from every changed package, which is why one shared component change costs four application scans and a package change nobody consumes costs one.

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
Why an under-declared cache key produces a false pass In the upper lane the task declares only its own source files, the hash is unchanged after a shared package moves, the cache reports a hit, and the replayed pass is stale. In the lower lane global dependencies and an upstream build dependency change the hash, the cache misses, the scan executes and the verdict is real. Same shared-package change, two cache keys Under-declared: the package changed, the hash did not inputs: src/** no dependsOn hash unchanged ui bump invisible cache HIT replays old log stale PASS scan never ran Correctly declared: the upstream build hash is part of the key inputs + globalDeps dependsOn: ^build hash changes new upstream hash cache MISS task executes real verdict report in outputs a cache hit is only safe when the key covers everything that can change the DOM declare outputs as well, or a hit restores no report and the merge sees a gap
Both lanes describe the same pull request; the only difference is whether the task definition told Turborepo that a shared dependency matters.

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.

When to abandon selection and scan everything Four questions asked in order: has the lockfile changed, has the scanner version changed, has the root accessibility config changed, and is this a nightly or release run. Any yes routes to a forced full scan; four noes route to a filtered scan against the merge base. When to abandon selection and scan everything 1 Lockfile changed? 2 Scanner version changed? 3 Root a11y config changed? 4 Nightly or release run? full scan turbo run a11y --force affected packages only --filter=...[merge-base] yes yes yes yes no no no no any yes short-circuits: thirty wasted minutes are cheaper than one false pass
The four questions are ordered by how often they fire, and each one is a case where the assumption "unaffected means unchanged" stops holding.

Edge Cases and Conditional Guards

  • A package with no a11y task. 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 compares pnpm turbo ls --output=json against the packages declaring an a11y script, 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-base fails and the filter either errors or quietly selects every package on every pull request.
  • Diffing against the tip of origin/main instead 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 ^build from the scan task’s dependsOn, so a shared component change never moves a consumer’s hash and the cache replays a pass for markup that no longer exists.
  • Leaving outputs undeclared, 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.