From Bazel

Skip unless you are leaving Bazel. If you adopted Bazel because sbt CI felt unsafe or slow, that impulse was understandable. Many teams found peace in hermeticity talk and remote cache, then discovered a quieter cost: a second graph in BUILD files while Scala engineers still thought in modules and dependsOn.

zipx is not Bazel-parity. It offers a different strategy for teams that already have the truth in sbt: one graph, generated CI, content-addressed reuse. Keep the vocabulary you learned; leave the duplicate edges behind.

For the broader story (hand-written YAML and cache products), see Why zipx. For shared digests across laptops, see Remote cache for teams.

Shared vocabulary, kinder boundaries

We speak Bazel-fluent on purpose. The mapping helps you translate without re-litigating the past:

Bazelsbt / zipx
ActionTask (executable step with digests)
TargetProject / module
Remote cachesbt 2 action cache over Bazel-compat gRPC
Remote executionOut of scope; use Graph for more runners
maven_install / MODULE.bazel version stringsLib / Plugin vals (collected, not listed twice); constructor rewrite; generate owns plugins.sbt

In Bazel, many small packages often improve hit rates because the target is the cache boundary. In sbt 2, compile and test already invalidate at class and suite digests inside a module. You do not need to shatter the repo into packages just to feel cacheable. Reach for zipx Graph when you need job isolation or path-affected PRs, not when you are only chasing hits.

What you maintain (before / after)

What hurt: BUILD files restating edges, plus CI glue, plus optional remote cache or RBE config. Adding a library meant updating more than one world.

What heals: modules in build.sbt, typed zipxCapabilities, regenerate the workflow. One graph again.

Maintenance surfaceDisconnected / Bazel-shapedzipx Aggregate
Add a library moduleEdit BUILD (and usually CI)Edit build.sbt; regenerate
Verify shapeOften N jobs or a matrix you listOne Aggregate test job plus parallel Once gates (fmt, workflow-check, advisories)
CacheSeparate product or RBESame planner (RemoteCacheProof / ManagedRemote)
{
  val agg = DocsRender.body(Capability.test)(using libGraph, config)
  val pub = DocsRender.job("publish")(Capability.publish)(using libGraph, config)
  agg + "\n---\n" + pub
}
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'
---
publish:
  name: publish
  runs-on: ubuntu-latest
  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
        node-version: ""
        sbt-disk-cache: "false"
        local-cache: "true"
        cache-epoch: "0.1.0-ci"
    - name: publish
      run: sbt 'schema/publish; api/publish'

A gentle migration path

You do not have to boil the ocean on day one:

  1. Add sbt-zipx; keep Aggregate defaults (one root test job plus parallel Once gates).

  2. zipxWorkflowGenerate / zipxWorkflowCheck so drift cannot sneak back in.

  3. Opt into ManagedRemote or RemoteCacheProof.sidecar when the team is ready; measure hits (RemoteCacheItSpec is the in-repo proof).

  4. Use Graph only when wall clock or isolation truly needs per-module jobs, matrices, or multi-env deploy.

That is the escape hatch as a ladder: safety first, fan-out when earned.

{
  val sidecar = DocsRender.job("test")(Capability.test)(using
    libGraph,
    config.copy(cache = RemoteCacheProof.sidecar),
  )
  val graph = DocsRender.jobs("test-schema", "test-api", "test-service")(Capability.testGraph)
  sidecar + "\n---\n" + graph
}
test:
  name: test
  runs-on: ubuntu-latest
  if: "!startsWith(github.ref, 'refs/tags/') && github.event_name != 'workflow_dispatch'"
  services:
    bazel-remote:
      image: buchgr/bazel-remote-cache:v2.6.1
      ports:
        - "9092:9092"
      options: --max_size=1
  env:
    ZIPX_REMOTE_CACHE: grpc://localhost:9092
  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: "true"
        local-cache: "false"
        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'

Easier to review, easier to help

One edit locus (build.sbt), checkable contracts (zipxWorkflowCheck, Specular docs-as-tests), regeneratable YAML, and named packs give humans and AI assistants the same gift: they can change the build without keeping a second graph honest. When the graph is the CI, nobody has to babysit a matrix that forgot a module.