Execution modes

Stay on Aggregate unless you have a reason not to. That is the default: one root test job, parallel Once gates (fmt, workflow-check, advisories), and one publish job. It is enough for most libraries and for many multi-module repos.

GitHub Actions charges per job and per minute. More jobs means more YAML to review and more checks to keep green. Aggregate is how zipx keeps CI cheaper and calmer. Escalate to Layer or Graph only when the workflow needs extra boundaries (ordered waves, per-module status, path-based skipping). Those modes are documented below.

ModeTest / publish / dockerDeployBest for
Aggregate (default)1 test job + parallel Once gates; 1 publish / docker1 per Target (modules batched)Most repos, including multi-service monorepos: one stage, still incremental
Layer1 per toposort wave1 per wave × Target (Aggregate-by-target per wave)Ordered waves without N JVMs; per-env approval without Graph's module×target cost
Graph1 per module (± matrix / targets)1 per module × TargetPer-module / multi-env boundaries; path gating; matrices

Why one job is not slow

Aggregate is not "rebuild the world in one job." On sbt 2.x, a root .aggregate session already parallelizes independent subprojects and incrementally recompiles only invalidated sources (Zinc). 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 compile results across pushes in the same epoch. Remote cache backends push that reuse across machines. The plugin default is testFull, so CI still runs every suite; Zinc and the task cache are what skip compile (and a whole-task cache hit can skip redo). 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.

Two kinds of affected

sbt and zipx answer different questions:

or Graph
LayerQuestionWho decides
sbt 2 (inside Aggregate)Which sources need work, given cached task digests?Incremental compiler + cross-run task cache (Verify default is testFull, so suites still run)
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). After zipx restores the epoch cache (or a remote cache hits), sbt may compile almost nothing. The plugin default is zipxTestTask := zipxTasks.of(testFull), so CI still runs every suite; Zinc is what skips compile. Graph can skip entire module jobs when their reverse-dep closure is untouched, and it can show a green check per module. 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.

When Graph (or Aggregate/Layer target fan-out) is right but the Actions UI is too noisy, see Matrix collapse: same job parallelism, one expandable matrix node. That is a presentation opt-in, not a fourth execution mode.

API cheat sheet

Capability.test          // Once: root zipxTestTask (plugin default testFull)
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

The plugin's builtin test job uses zipxTestTask (default testFull). Planner snippets below call core Capability.test / testGraph, so the YAML shows sbt 'test' / schema/test.

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: 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'
---
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'

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~5 sbt starts (test + fmt + workflow-check + advisories + optional publish)JVM start; cache hits skip compiles
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.