Affected

Affected answers a GitHub Actions question Graph mode can ask: which jobs should run for this PR's diff? That is different from sbt 2 incrementality inside Aggregate (see Execution modes). Today only Graph Verify jobs are path-gated. Publish and Deploy stay release-gated; Deploy is never affected-gated.

Closure flow

From git diff to owning module

The affected job takes the PR's changed paths (repo-root-relative, from git diff against the base ref) and maps each file to at most one module:

  1. Build files force everything. If any path ends in .sbt or sits under a project/ directory (root or nested), the whole module set is affected. Plugins and the graph may have changed, so nothing is safe to skip.

  2. Otherwise: longest baseDir prefix. Each module's baseDir is a path prefix. A file is owned by the matching module whose baseDir is longest (most specific). Matching is directory-aware: core/ owns core/src/X.scala, but not core-lib/… or core-extra/…. Nested bases win: mods/inner/X.scala belongs to mods/inner, not mods.

  3. Unowned paths seed nothing. README.md, .github/…, and other files outside every module baseDir are ignored (unless step 1 applies). Aggregators with empty baseDir never own files.

Those owning modules are the seeds. Step 3 of the chart expands them to the reverse-dependency closure; step 5 gates each Graph Verify job on whether its id (or all) appears in the published JSON.

Changed pathOwning moduleAfter closure (example)
client/src/…client (leaf)just client
models/src/…modelsmodels + every transitive dependent
mods/inner/X.scalainner (longer than mods)inner + dependents
README.mdnoneempty (no Graph Verify)
build.sbt / project/plugins.sbt(build file)all modules
zipxAffectedOnPR   := true   // default; emits `affected` only when Graph Verify is present
zipxAffectedOnPush := false  // opt-in: also scope branch pushes via before-sha
{
  given PlanConfig = config.copy(affected = AffectedMode.AffectedOnPR)
  DocsRender.body(Capability.testGraph)
}
name: CI
"on":
  push:
    branches:
      - main
  pull_request: null
concurrency:
  group: CI-${{ github.ref }}
  cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }}
jobs:
  affected:
    name: affected
    runs-on: ubuntu-latest
    if: "!startsWith(github.ref, 'refs/tags/') && github.event_name != 'workflow_dispatch'"
    outputs:
      modules: ${{ steps.compute.outputs.modules }}
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
        with:
          fetch-depth: "0"
          fetch-tags: "true"
      - name: Setup JDK 21
        uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95
        with:
          distribution: temurin
          java-version: "21"
      - uses: sbt/setup-sbt@d059c39de700f4cc5cb64f9f56577315e44a984e
        with:
          disk-cache: "false"
      - name: Compute affected modules
        id: compute
        run: |
          if [ "${{ github.event_name }}" = "pull_request" ]; then
            BASE="${{ github.event.pull_request.base.sha }}"
            sbt -batch --error "zipxAffectedModules $BASE"
            modules=$(cat target/zipx-affected.json)
          else
            modules='["all"]'
          fi
          echo "modules=$modules" >> "$GITHUB_OUTPUT"
  test-schema:
    name: test schema
    runs-on: ubuntu-latest
    needs:
      - affected
    if: (!startsWith(github.ref, 'refs/tags/') && github.event_name != 'workflow_dispatch') && (!cancelled() && (contains(fromJson(needs.affected.outputs.modules), 'schema') || contains(fromJson(needs.affected.outputs.modules), 'all')))
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
        with:
          fetch-depth: "0"
          fetch-tags: "true"
      - name: Setup JDK 21
        uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95
        with:
          distribution: temurin
          java-version: "21"
      - uses: sbt/setup-sbt@d059c39de700f4cc5cb64f9f56577315e44a984e
        with:
          disk-cache: "false"
      - name: Cache sbt
        uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
        with:
          path: |
            ~/.sbt
            ~/.cache/sbt
            ~/.cache/coursier
            target
          key: ubuntu-latest-jdk21-sbt-0.1.0-ci-${{ github.run_id }}-test-schema
          restore-keys: |
            ubuntu-latest-jdk21-sbt-0.1.0-ci-${{ github.run_id }}-
            ubuntu-latest-jdk21-sbt-0.1.0-ci-
            ubuntu-latest-jdk21-sbt-0.1.0-
            ubuntu-latest-jdk21-sbt-
      - name: test
        run: sbt 'schema/test'
  test-api:
    name: test api
    runs-on: ubuntu-latest
    needs:
      - affected
      - test-schema
    if: (!startsWith(github.ref, 'refs/tags/') && github.event_name != 'workflow_dispatch') && (!cancelled() && (contains(fromJson(needs.affected.outputs.modules), 'api') || contains(fromJson(needs.affected.outputs.modules), 'all')) && needs.test-schema.result != 'failure')
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
        with:
          fetch-depth: "0"
          fetch-tags: "true"
      - name: Setup JDK 21
        uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95
        with:
          distribution: temurin
          java-version: "21"
      - uses: sbt/setup-sbt@d059c39de700f4cc5cb64f9f56577315e44a984e
        with:
          disk-cache: "false"
      - name: Cache sbt
        uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
        with:
          path: |
            ~/.sbt
            ~/.cache/sbt
            ~/.cache/coursier
            target
          key: ubuntu-latest-jdk21-sbt-0.1.0-ci-${{ github.run_id }}-test-api
          restore-keys: |
            ubuntu-latest-jdk21-sbt-0.1.0-ci-${{ github.run_id }}-
            ubuntu-latest-jdk21-sbt-0.1.0-ci-
            ubuntu-latest-jdk21-sbt-0.1.0-
            ubuntu-latest-jdk21-sbt-
      - name: test
        run: sbt 'api/test'
  test-service:
    name: test service
    runs-on: ubuntu-latest
    needs:
      - affected
      - test-api
    if: (!startsWith(github.ref, 'refs/tags/') && github.event_name != 'workflow_dispatch') && (!cancelled() && (contains(fromJson(needs.affected.outputs.modules), 'service') || contains(fromJson(needs.affected.outputs.modules), 'all')) && needs.test-api.result != 'failure')
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
        with:
          fetch-depth: "0"
          fetch-tags: "true"
      - name: Setup JDK 21
        uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95
        with:
          distribution: temurin
          java-version: "21"
      - uses: sbt/setup-sbt@d059c39de700f4cc5cb64f9f56577315e44a984e
        with:
          disk-cache: "false"
      - name: Cache sbt
        uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
        with:
          path: |
            ~/.sbt
            ~/.cache/sbt
            ~/.cache/coursier
            target
          key: ubuntu-latest-jdk21-sbt-0.1.0-ci-${{ github.run_id }}-test-service
          restore-keys: |
            ubuntu-latest-jdk21-sbt-0.1.0-ci-${{ github.run_id }}-
            ubuntu-latest-jdk21-sbt-0.1.0-ci-
            ubuntu-latest-jdk21-sbt-0.1.0-
            ubuntu-latest-jdk21-sbt-
      - name: test
        run: sbt 'service/test'

Fail open, not closed

The affected handoff must never turn a broken git diff into a green, untested PR. Two outcomes used to look the same ([]): “diff succeeded and found nothing” versus “diff could not run.” Empty JSON makes every contains(..., '<id>') false, so every Graph Verify job skips.

yesempty Nil
Diff outcomeValueEmittedCI result
Succeeded, no changesSome(Nil)[]Skip Graph Verify (deliberate)
Could not run (bad ref, no git, …)None["all"]Run everything
Succeeded with filesSome(files)affected closureGate per module

A broken base ref costs runner minutes, not coverage. The affected job logs a warning when it disables gating for that run.

Who is gated today

Capability shapePath-affected?Why
Capability.testGraph (and other Graph + Verify)YesPer-module jobs can skip
Aggregate / Layer VerifyNoOne (or few) jobs; sbt cache skips work inside
Publish / dockerNo (yet)Gate.OnReleaseTag only; Publish affected is an open seam
DeployNeverEnvironments and approvals are destination-driven

Gate.AffectedOnly is a design seam, not a shipped gate. Affected-gating is derived from phase + scope + zipxAffectedOnPR, not from Gate. The planner rejects Gate.AffectedOnly at generate time so it cannot silently mean Always.

Proving more affected value

Existing machinery already path-gates any phase = Verify + scope = Graph capability. The next proof is not a new Gate: put expensive Verify stages on Graph (scripted, MiMa, PR-local docker builds) and measure leaf-PR skips.

Partial monorepo publish on a tag is the headline Wave 2 win; until Gate can compose with release, keep Publish on OnReleaseTag alone.

Concurrency (cancel superseded runs)

Superseded PR pushes should not burn runners. zipx emits workflow-level concurrency by default:

concurrency:
  group: CI-${{ github.ref }}
  cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }}

A half-cancelled Central publish can leave a staged-but-unreleased bundle; that is worse than a wasted runner. Opt out with zipxCancelSupersededRuns := false.

DocsRender.body(Capability.test)
name: CI
"on":
  push:
    branches:
      - main
  pull_request: null
concurrency:
  group: CI-${{ github.ref }}
  cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }}
jobs:
  test:
    name: test
    runs-on: ubuntu-latest
    if: "!startsWith(github.ref, 'refs/tags/') && github.event_name != 'workflow_dispatch'"
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
        with:
          fetch-depth: "0"
          fetch-tags: "true"
      - name: Setup JDK 21
        uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95
        with:
          distribution: temurin
          java-version: "21"
      - uses: sbt/setup-sbt@d059c39de700f4cc5cb64f9f56577315e44a984e
        with:
          disk-cache: "false"
      - name: Cache sbt
        uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
        with:
          path: |
            ~/.sbt
            ~/.cache/sbt
            ~/.cache/coursier
            target
          key: ubuntu-latest-jdk21-sbt-0.1.0-ci-${{ github.run_id }}-test
          restore-keys: |
            ubuntu-latest-jdk21-sbt-0.1.0-ci-${{ github.run_id }}-
            ubuntu-latest-jdk21-sbt-0.1.0-ci-
            ubuntu-latest-jdk21-sbt-0.1.0-
            ubuntu-latest-jdk21-sbt-
      - name: test
        run: sbt 'test'
{
  given PlanConfig = config.copy(cancelSupersededRuns = false)
  DocsRender.body(Capability.test)
}
name: CI
"on":
  push:
    branches:
      - main
  pull_request: null
jobs:
  test:
    name: test
    runs-on: ubuntu-latest
    if: "!startsWith(github.ref, 'refs/tags/') && github.event_name != 'workflow_dispatch'"
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
        with:
          fetch-depth: "0"
          fetch-tags: "true"
      - name: Setup JDK 21
        uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95
        with:
          distribution: temurin
          java-version: "21"
      - uses: sbt/setup-sbt@d059c39de700f4cc5cb64f9f56577315e44a984e
        with:
          disk-cache: "false"
      - name: Cache sbt
        uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
        with:
          path: |
            ~/.sbt
            ~/.cache/sbt
            ~/.cache/coursier
            target
          key: ubuntu-latest-jdk21-sbt-0.1.0-ci-${{ github.run_id }}-test
          restore-keys: |
            ubuntu-latest-jdk21-sbt-0.1.0-ci-${{ github.run_id }}-
            ubuntu-latest-jdk21-sbt-0.1.0-ci-
            ubuntu-latest-jdk21-sbt-0.1.0-
            ubuntu-latest-jdk21-sbt-
      - name: test
        run: sbt 'test'