Why zipx

Hand-written GitHub Actions YAML is a second copy of your build: module lists, job order, JDK setup. It drifts. zipx generates that YAML from build.sbt so CI stays honest without you learning a second language.

If you are new to CI, Quick start is enough. Come back here when you want the "why," or when you already maintain a painful workflow.

If your team has lived through hand-maintained job lists, a second BUILD graph, or a cache product that made tasks faster while humans still juggled two sources of truth: the rest of this page is the recovery story.

When you are ready for those migration stories, see From Bazel, Caching, and Remote cache for teams.

Three paths, one that heals

Most teams we meet are somewhere on this triangle. None of the first two are foolish; they were reasonable responses to real pain. zipx is the path that keeps your Scala mental model: one build.sbt, generated CI.

oror
ApproachSource of truthWhat drifts (the bruise)
Disconnected CIbuild.sbt and hand YAMLModule lists, needs, publish order
Bazel second graphBUILD (+ often CI)Edges restated outside sbt
zipxbuild.sbt / .dependsOnCI is derived; zipxWorkflowCheck catches drift early

You do not need a second graph to feel safe. You need one honest graph, and a check that fails when CI lies.

Versions were the other stringly mess

CI YAML is not the only second copy. Scala versions usually live as "org" %% "name" % "1.2.3" strings, and the bump tools search the repo with regex. That is how most of the ecosystem still works, and it is why dependency PRs feel brittle.

zipx keeps Lib / Plugin / Pin / Action vals in one object you extend from ZipxVersions. Every val is a catalog row; there is no second list to keep in sync. MyVersions.settings is the usual sbt wiring (scalaVersion, catalog keys, zipxCheckDeps). Apply rewrites those constructors. Generate owns plugins.sbt. A raw coordinate that is not in the catalog fails generate. Other plugins extend the same trait. See Versions. Plugin authors: Extending Versions.

Outbound versions are a fourth collection: Ship / ShipGroup when a monorepo publishes libraries on different cadences. Merge to main is the release signal. zipx-the-product stays lockstep on a v* tag. See Independent versions.

import zipx.*
object MyVersions extends ZipxVersions:
  val sbt: SbtVersion     = SbtVersion("2.1.0-M1")
  val scala: ScalaVersion = ScalaVersion("3.9.0")
  val zio                 = Lib("dev.zio", "zio", "2.1.26")

Faster tasks are not the same as kinder CI

Acceleration layers (Develocity-class remote build cache, build scans, predictive test selection) can be wonderful at making an existing build feel snappier. They earn their keep. They are also a different category of tool: they rarely remove the second maintenance surface of hand YAML or restated edges.

Acceleration layerzipx
Primary artifactAgent/plugin + server + scan UIPlanner + generated workflow from the sbt graph
What you maintainBuild + CI lists + cache config (often independent)Modules + typed zipxCapabilities; CI is derived
How you know you’re safeFaster greens / scan insightszipxWorkflowCheck + docs-as-tests + live cache IT
ScopeSpeed / observability of tasksCI topology + cache wiring + packs as one system

If mornings still start with “did we update the workflow?”, caching alone will not heal that. zipx retires disconnected CI (and skips restating the graph in BUILD), then leans on sbt 2’s cache so Aggregate stays light to live with.

What you open on a good day

The hard years: a hand ci.yml module matrix, or BUILD files plus CI glue, plus cache product config. Every “add a module” meant a scavenger hunt.

The recovery: build.sbt and a small typed capability list. Generated .github/workflows/ci.yml plus .github/actions/zipx-* composites are outputs you commit and drift-gate. Reviewers (and future you) see intent, not archaeology: short jobs that call local composites, matrix collapse when safe, and named packs instead of pasted secret/step soup.

Default Aggregate Verify is a handful of build-wide jobs (test, fmt, workflow-check, advisories), not one job per module. Reach for Graph when the workflow needs isolation, not when you are only trying to make caching feel less lonely. MatrixCollapse.Auto keeps Graph / multi-target fan-out readable when legs are isomorphic.

{
  val aggregate = DocsRender.body(Capability.test, Capability.publish)
  val graph     = DocsRender.body(Capability.testGraph)
  aggregate + "\n---\n" + graph
}
name: CI
"on":
  push:
    branches:
      - main
    tags:
      - v[0-9]+.[0-9]+.[0-9]+
  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'
---
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'

Kind to humans and AI teammates

A self-documenting, single-graph build is easier to nurture, whether the reviewer is a person or an agent:

  • One place to edit when adding a module (no “also update workflow / BUILD”).

  • A contract that cares: zipxWorkflowCheck + Specular DocSpecs fail when examples drift from planner output.

  • Narrow diffs: capability and graph changes are typed Scala; generated YAML is regeneratable when you want a clean re-diff. Composites and Auto collapse keep that YAML short enough to actually review.

  • Named paved paths: packs like ZipxCentral, ZipxDocs, and AWS login say what you meant, instead of a paste of secret/step soup.

The everyday loop: edit build.sbtzipxWorkflowGenerate → the PR shows the graph and a regeneratable workflow. That is the experience we are trying to give you back.

Cache that travels with the topology

Remote backends are not a side confessional. The same planner that emits jobs also emits services and env. Shared proof pins:

DocsRender.job("test")(Capability.test)(using
  libGraph,
  config.copy(cache = RemoteCacheProof.sidecar),
)
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'