From 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, and it does not ask you to pretend the second graph never happened. 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 recovery story (disconnected CI and acceleration layers), 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

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 test job
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: 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'
---
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: 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 }}-publish
        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: 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 calm Verify job).

  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: Setup JDK 21
      uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95
      with:
        distribution: temurin
        java-version: "21"
    - uses: sbt/setup-sbt@d059c39de700f4cc5cb64f9f56577315e44a984e
    - 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'

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.