Execution modes

This is the main CI cost and isolation lever. Aggregate is not "rebuild the world in one job." On sbt 2.x, a root .aggregate sbt test already parallelizes independent subprojects, incrementally recompiles only invalidated sources, and reruns only suites that failed, are new, or transitively depend on recompiled code (including code in another project). Task results are content-addressed and survive JVM restarts: sbt's machine-wide cache, plus zipx's epoch-keyed CI restore (zipxCacheEpoch), means a cold runner still hits prior task/test results across pushes in the same epoch. Remote cache backends push that reuse across machines. You get a large share of "don't redo unaffected work" from one Aggregate job, without paying for N runners. Pair that with a CI-hydrated remote cache so teammate laptops share the same digests (see Remote cache for teams).

Graph mode buys a different kind of selectivity: path-based affected gating, per-module needs, Scala matrix isolation, and independent logs/statuses. Pick Graph when the workflow needs those boundaries, not merely to avoid compile/test work that sbt (and the restored/remote cache) can already skip.

ModeTest / publish / dockerDeployBest for
Aggregate (default)1 per stage (Verify = root sbt test)1 per Target (modules batched)Most repos, including multi-service monorepos: one stage, still incremental
Layer1 per toposort waveSame as Aggregate-by-targetOrdered waves without N JVMs
Graph1 per module (± matrix / targets)1 per module × TargetPer-module / multi-env boundaries; path gating; matrices

Two kinds of affected

sbt and zipx answer different questions:

or Graph
LayerQuestionWho decides
sbt 2 (inside Aggregate)Which sources and test suites need work, given cached task digests?Incremental compiler + incremental test + cross-run task cache
zipx GraphWhich GitHub jobs should run at all for this PR diff?git diff → owning module → reverse-dep closure

Aggregate always starts the stage command (one root test job). That is fine: after zipx restores the epoch cache (or a remote cache hits), sbt may compile almost nothing and rerun almost no suites even on a cold JVM. Graph can skip entire module jobs when their reverse-dep closure is untouched, and it can show a green check per module. Use testFull (zipxTestTask := "testFull") when CI must run every suite every time, uncached. See Caching and Affected (fail-open handoff, who is gated).

When to use which

defaultordered wavesisolation
Need CI from the sbt graph. Does the workflow need per-module isolation, matrices, or path gating?

zipx is built for all sorts of sbt repos, and especially monorepos: the same typed capabilities scale from a single library to many services. Modes choose how work is scheduled in GitHub Actions, not whether zipx understands your graph.

  • Aggregate — the default, and often enough even for multi-service monorepos. One root test job, one publish / ZipxCentral.release (modules batched where that makes sense). Lean on sbt 2 incrementality and epoch/remote caching; escalate only when the workflow needs Graph's boundaries.

  • Layer — dependency-ordered waves (L0 → L1 → L2) with fewer sbt starts than Graph. Inspect with zipxGraph / zipxPublishOrder.

  • Graph — when CI itself needs per-module or per-destination isolation: multi-environment deploys, independent approvals/logs/status, per-module Scala matrices, or path-based affected gating at the job level. See examples/monorepo.

API cheat sheet

Capability.test          // Once: root zipxTestTask (default "test")
Capability.testJoined    // Aggregate escape hatch: join module/<testTask>
Capability.publish
Capability.docker
Capability.deploy(participates, command, targets)

Capability.testLayers / publishLayers / dockerLayers
Capability.testGraph / publishGraph / dockerGraph
Capability.deployGraph(participates, command, targets)

ZipxCentral.release                              // Aggregate Central
ZipxCentral.publishSigned + ZipxCentral.releaseOnce  // Graph + staging

Same-name override: a user capability whose name matches a built-in replaces it.

Aggregate vs Graph job shape

zipxCapabilities += Capability.test        // one root job
// or
zipxCapabilities += Capability.testGraph   // one job per module
DocsRender.jobs("test")(Capability.test) + "\n---\n" +
  DocsRender.jobs("test-schema", "test-api", "test-service")(Capability.testGraph)
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'
---
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'

Modules batch; targets do not

For docker/deploy, participants still come from the graph. Targets (GitHub environment: and per-destination env:) always fan out: you cannot merge staging and prod into one job without losing independent approval.

  • Aggregate deploy → deploy-staging, deploy-prod (modules joined inside each)

  • Graph deploy → deploy-service-staging, deploy-service-prod

Cost intuition

For a 4-module library with cross-Scala and release publish (leaf-only PR vs full fan-out):

ModeTypical PR shapeWhat you pay for
Aggregate~2 sbt starts (test + optional gates)JVM start; cache hits skip suites
Graph (full)N modules × Scala matrix + affected setupIsolation, matrices, per-module status
Graph (affected leaf)setup + closure jobs onlySame isolation, fewer runners when the diff is narrow

Escalate to Graph when the workflow needs job boundaries (matrices, path gating, independent logs), not merely to avoid compile work Aggregate + remote cache already skip. Details and fail-open policy: Affected.