Affected

Skip this page unless you opted into Graph mode. Affected means: only run GitHub jobs for modules this PR touched. Aggregate (the default) does not skip jobs this way; Zinc and the task cache skip compile inside the one test job instead (see Execution modes). Verify still defaults to testFull.

Graph Verify jobs are path-gated by default. Graph Publish and Graph Deploy can be, under the zipxAffectedPublish and zipxAffectedDeploy opt-ins below. Aggregate and Layer jobs never are.

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 the module or modules that own it:

  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 owned-path prefix. A module owns its baseDir and its source directories (sbt's unmanagedSourceDirectories, Compile and Test). A file is owned by every module whose longest matching prefix is the longest match overall. 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's owned paths are ignored (unless step 1 applies). Aggregators with empty baseDir never own files.

Step 2 returns a set, not a single module. One file can genuinely belong to more than one module; the next section is the case where it always does.

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: zipx sbt setup
        uses: ./.github/actions/zipx-sbt-setup
        with:
          java-version: "21"
          runner-os: ubuntu-latest
          cache-key-suffix: affected
          node-version: ""
          sbt-disk-cache: "false"
          local-cache: "false"
          cache-epoch: "0.1.0-ci"
      - 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: zipx sbt setup
        uses: ./.github/actions/zipx-sbt-setup
        with:
          java-version: "21"
          runner-os: ubuntu-latest
          cache-key-suffix: test-schema
          node-version: ""
          sbt-disk-cache: "false"
          local-cache: "true"
          cache-epoch: "0.1.0-ci"
      - 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: zipx sbt setup
        uses: ./.github/actions/zipx-sbt-setup
        with:
          java-version: "21"
          runner-os: ubuntu-latest
          cache-key-suffix: test-api
          node-version: ""
          sbt-disk-cache: "false"
          local-cache: "true"
          cache-epoch: "0.1.0-ci"
      - 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: zipx sbt setup
        uses: ./.github/actions/zipx-sbt-setup
        with:
          java-version: "21"
          runner-os: ubuntu-latest
          cache-key-suffix: test-service
          node-version: ""
          sbt-disk-cache: "false"
          local-cache: "true"
          cache-epoch: "0.1.0-ci"
      - name: test
        run: sbt 'service/test'

Cross-built modules (projectMatrix)

sbt 2 has projectMatrix built in, and a cross-built module is where baseDir stops being able to answer at all. Each platform row is a real project with its own id (core, coreJS), but sbt bases every row at a synthetic directory:

core / baseDirectory     = .sbt/matrix/core
coreJS / baseDirectory   = .sbt/matrix/coreJS

No source file is ever under those. A baseDir-only rule maps core/src/main/scala/Foo.scala to no module, so both rows skip and the PR is green having compiled nothing. That is why a module owns its source directories too:

core   / Compile / unmanagedSourceDirectories = core/src/main/{scala, scala-3, scalajvm, scalajvm-3, java, javajvm}
coreJS / Compile / unmanagedSourceDirectories = core/src/main/{scala, scala-3, scalajs,  scalajs-3,  java, javajs}

Those directories also carry the platform distinction, which is what makes the answer precise rather than merely non-empty:

Changed pathOwning modulesWhy
core/src/main/scala/Foo.scalacore and coreJSshared: on both rows' source dirs
core/src/main/scalajs/Foo.scalacoreJSon the JS row alone
core/src/main/scalajvm/Foo.scalacoreon the JVM row alone
core/README.mdnoneunder no row's baseDir or source dirs

A shared change reaching both rows is the property worth stating plainly: picking one would leave half of a cross-built module untested behind a green check, and which half you got would depend on iteration order. Ownership is a set for exactly this reason.

target/ and a row's .sbt/matrix/<id> are excluded from the owned paths: nobody edits them, and a generated-source directory under target/ would make every module affected on every commit.

Nothing changes for an ordinary project. Its source dirs are all under its baseDir, so recording them only ever adds ownership; baseDir still answers for a module's non-source files (a README, a Dockerfile, a test fixture).

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

Capability shapePath-affected?Why
Capability.testGraph (and other Graph + Verify)Yes, by defaultPer-module jobs can skip
Graph Publish (publishGraph, dockerGraph)Only under zipxAffectedPublishSee the next section: the two risks are not symmetric
Graph Deploy (deployGraph)Only under zipxAffectedDeploySo a deploy skips exactly when the publish it consumes did
Aggregate / Layer, any phaseNeverOne sbt session over every module: there is nothing in it to skip

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

Narrowing Publish (zipxAffectedPublish)

Rebuilding and pushing eight images because one module changed is the cost this setting removes. It is a separate switch from zipxAffectedOnPR rather than a widening of it, and that is a deliberate asymmetry:

Under-verifying is silently unsafe. Under-publishing is loudly broken.

A Verify job that wrongly skips gives you a green PR whose code was never tested, and nothing tells you. A Publish job that wrongly skips gives you a deploy that fails immediately for a missing artifact. One switch for both would price Publish's narrowing at Verify's risk, so Verify's gating is on by default and Publish's has to be asked for:

zipxAffectedOnPR    := true   // default: Graph Verify is narrowed on PRs
zipxAffectedPublish := true   // opt-in: Graph Publish is narrowed too

Three properties carry over unchanged, and each is what makes the opt-in safe rather than merely cheap:

  1. A release tag publishes everything. There is no base ref to diff a tag against, so affected emits ["all"] for a tag push without taking a diff at all (see the table above). The || contains(…, 'all') clause in every gated job is the other half. The question "affected relative to what, after a series of merges?" therefore does not arise.

  2. Fail open. A diff that could not run emits ["all"], so a bad base ref publishes too much rather than too little.

  3. A skipped image never silently skips its deploy. This is the trap the feature opens, so the planner closes it (see the next section).

The affected job itself now runs on tag pushes and on merged-PR pushes when a Publish or Deploy capability reads it, where a Verify-only setup skips there. That is free: on a non-PR event it takes no diff, and it emits ["all"].

{
  given PlanConfig = config.copy(affected = AffectedMode.AffectedOnPR, affectedPublish = true)
  DocsRender.jobs("publish-schema", "publish-api")(Capability.publishGraph)
}
publish-schema:
  name: publish schema
  runs-on: ubuntu-latest
  needs:
    - affected
  if: "!cancelled() && startsWith(github.ref, 'refs/tags/v') && (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: zipx sbt setup
      uses: ./.github/actions/zipx-sbt-setup
      with:
        java-version: "21"
        runner-os: ubuntu-latest
        cache-key-suffix: publish-schema
        node-version: ""
        sbt-disk-cache: "false"
        local-cache: "true"
        cache-epoch: "0.1.0-ci"
    - name: publish
      run: sbt 'schema/publish'
publish-api:
  name: publish api
  runs-on: ubuntu-latest
  needs:
    - affected
    - publish-schema
  if: "!cancelled() && startsWith(github.ref, 'refs/tags/v') && (contains(fromJson(needs.affected.outputs.modules), 'api') || contains(fromJson(needs.affected.outputs.modules), 'all')) && needs.publish-schema.result != 'failure'"
  steps:
    - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
      with:
        fetch-depth: "0"
        fetch-tags: "true"
    - name: zipx sbt setup
      uses: ./.github/actions/zipx-sbt-setup
      with:
        java-version: "21"
        runner-os: ubuntu-latest
        cache-key-suffix: publish-api
        node-version: ""
        sbt-disk-cache: "false"
        local-cache: "true"
        cache-epoch: "0.1.0-ci"
    - name: publish
      run: sbt 'api/publish'

With the switch off (the default), the same jobs carry the release gate alone and never mention affected, so turning it on is the only thing that changes a committed ci.yml:

{
  given PlanConfig = config.copy(affected = AffectedMode.AffectedOnPR)
  DocsRender.job("publish-api")(Capability.publishGraph)
}
publish-api:
  name: publish api
  runs-on: ubuntu-latest
  needs:
    - publish-schema
  if: startsWith(github.ref, 'refs/tags/v')
  steps:
    - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
      with:
        fetch-depth: "0"
        fetch-tags: "true"
    - name: zipx sbt setup
      uses: ./.github/actions/zipx-sbt-setup
      with:
        java-version: "21"
        runner-os: ubuntu-latest
        cache-key-suffix: publish-api
        node-version: ""
        sbt-disk-cache: "false"
        local-cache: "true"
        cache-epoch: "0.1.0-ci"
    - name: publish
      run: sbt 'api/publish'

Tolerating a skipped need

Narrowing Publish means a job can now skip in a phase where nothing skipped before, and that changes what its dependents see. GitHub's implicit success() treats a skipped need exactly like a failed one: the dependent is skipped too. So every job that needs something narrowable gains two kinds of clause:

ClausePurpose
!cancelled()Makes the job reachable at all once a need can skip, by displacing the implicit success()
needs.<id>.result != 'failure'Restores the blocking that !cancelled() just removed, per need

!= 'failure' rather than == 'success', because skipped is the answer being tolerated. Two needs are excluded from the guard because each already has a clause of its own: affected (read through its output) and verify-gate (fail-open by design, where a skipped gate means "run").

A failed need still blocks, which is the property worth checking after any change here: a red fmt job must not be let through by the same !cancelled() that lets a skipped publish through.

{
  given PlanConfig = config.copy(affected = AffectedMode.AffectedOnPR, affectedPublish = true)
  DocsRender.job("announce")(
    Capability.publishGraph,
    Capability.once(
      CapabilityName("announce"),
      SbtCommand.unsafeTask("announce"),
      phase = Phase.Deploy,
      gate = Gate.OnReleaseTag,
      needsCapabilities = List(Capability.PublishName),
    ),
  )
}
announce:
  name: announce
  runs-on: ubuntu-latest
  needs:
    - publish-api
    - publish-schema
  if: (!cancelled() && needs.publish-api.result != 'failure' && needs.publish-schema.result != 'failure') && (startsWith(github.ref, 'refs/tags/v'))
  steps:
    - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
      with:
        fetch-depth: "0"
        fetch-tags: "true"
    - name: zipx sbt setup
      uses: ./.github/actions/zipx-sbt-setup
      with:
        java-version: "21"
        runner-os: ubuntu-latest
        cache-key-suffix: announce
        node-version: ""
        sbt-disk-cache: "false"
        local-cache: "true"
        cache-epoch: "0.1.0-ci"
    - name: announce
      run: sbt 'announce'

The one shape that is refused instead of tolerated

Tolerance is right for a job whose command does not name the skipped module: a build-wide announce loses nothing when one module did not publish. It is wrong for a job that names it.

Capability.deploy is exactly that, and it is Aggregate by default while needing docker. Under zipxAffectedPublish alone, an affected-skipped docker-<module> would leave the deploy running and pulling an image tag that run never pushed: a 404 on main, from a ci.yml in which nothing looks wrong. There is no way to drop one module's command from an already-joined sbt session at generate time, so the planner refuses the combination rather than generating it:

zipx: capability 'deploy' is Aggregate and needs 'docker', which zipxAffectedPublish lets skip per module. One
'docker' job skipping would leave 'deploy' running against an artifact nobody built, so this is refused rather than
generated. Fixes, in order of preference: give 'deploy' CapabilityScope.Graph so it skips with its own 'docker' job;
make its command resolve a moving tag that a skipped 'docker' cannot invalidate; or turn zipxAffectedPublish off.

Narrowing Deploy (zipxAffectedDeploy)

The first fix the error above recommends is the one to reach for, and zipxAffectedDeploy is what makes it hold: a Graph deploy carries the same per-module affected expression as its own docker-<module> job, so the two skip together. deploy-<module>-prod runs exactly when docker-<module> did.

Its own switch rather than a widening of zipxAffectedPublish, because narrowing image pushes while still reconciling every destination on every run is a legitimate combination, and one switch would take it away:

zipxAffectedPublish := true   // one changed module, one image pushed
zipxAffectedDeploy  := true   // and one destination reconciled, not all of them

Off by default: a deploy that does not run leaves a destination on its previous version, which is correct only when that module's artifacts really are unchanged. The cost of Graph over Aggregate is job count, one per (module × target), and with it one approval per module per environment.

Both safety properties carry over. A release tag deploys everything: the affected job is forced onto tag pushes and merged-PR pushes when a Deploy capability reads it, and on a non-PR event it emits ["all"] without taking a diff. And an unusable diff fails open to ["all"] the same way.

{
  given PlanConfig =
    config.copy(affected = AffectedMode.AffectedOnPR, affectedPublish = true, affectedDeploy = true)
  DocsRender.job("deploy-service-prod")(
    Capability.dockerGraph,
    Capability.deployGraph(
      participates = _.docker,
      command = n => SbtCommand.module(n, SbtCommand.unsafeTask("deployTask")),
      targets = _ => List(Target(TargetName("prod"), environment = Some("production"))),
      gate = Gate.Always,
      condition = Some(JobCondition.refIs("refs/heads/main")),
    ),
  )
}
deploy-service-prod:
  name: deploy service (prod)
  runs-on: ubuntu-latest
  needs:
    - affected
    - docker-service
  if: (!cancelled() && (contains(fromJson(needs.affected.outputs.modules), 'service') || contains(fromJson(needs.affected.outputs.modules), 'all')) && needs.docker-service.result != 'failure') && (github.ref == 'refs/heads/main')
  environment: production
  steps:
    - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
      with:
        fetch-depth: "0"
        fetch-tags: "true"
    - name: zipx sbt setup
      uses: ./.github/actions/zipx-sbt-setup
      with:
        java-version: "21"
        runner-os: ubuntu-latest
        cache-key-suffix: deploy-service-prod
        node-version: ""
        sbt-disk-cache: "false"
        local-cache: "true"
        cache-epoch: "0.1.0-ci"
    - name: deploy
      run: sbt 'service/deployTask'

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: zipx sbt setup
        uses: ./.github/actions/zipx-sbt-setup
        with:
          java-version: "21"
          runner-os: ubuntu-latest
          cache-key-suffix: test
          node-version: ""
          sbt-disk-cache: "false"
          local-cache: "true"
          cache-epoch: "0.1.0-ci"
      - 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: zipx sbt setup
        uses: ./.github/actions/zipx-sbt-setup
        with:
          java-version: "21"
          runner-os: ubuntu-latest
          cache-key-suffix: test
          node-version: ""
          sbt-disk-cache: "false"
          local-cache: "true"
          cache-epoch: "0.1.0-ci"
      - name: test
        run: sbt 'test'