Overview

zipx is an sbt 2 plugin. You already describe your Scala project in build.sbt. zipx reads that and writes a GitHub Actions workflow, so you do not maintain a second copy in YAML.

GitHub Actions is GitHub's CI: a workflow file under .github/workflows/ that runs jobs (test, publish, and so on) when you push or open a pull request. Hand-writing that file means listing modules, job order, and JDK setup again. zipx generates it from the build you already have.

Day one: add the plugin, run zipxWorkflowGenerate, commit the files, open a PR. Defaults give you parallel Verify jobs (test, fmt, workflow-check, advisories) and a publish job that runs when you push a version tag. You do not write YAML, job matrices, or a hand-maintained needs: graph. See Quick start.

The everyday loop

forgot to regenerate?
  1. Edit build.sbt the way you already do (add a module, change dependsOn).

  2. sbt zipxWorkflowGenerate writes .github/workflows/ci.yml and .github/actions/zipx-*.

  3. Commit those files and open a pull request. GitHub runs the workflow.

zipxWorkflowCheck in CI regenerates and diffs. If you changed the build and forgot to regenerate, the PR goes red instead of shipping a stale workflow. That is the whole honesty story.

Default Aggregate shape

For a typical library, defaults are enough: parallel Verify jobs (test, fmt, workflow-check, advisories) and one publish job. Optional packs replace the built-in publish with a paved Central release (or add GitHub Packages alongside it).

// project/plugins.sbt
addSbtPlugin("rocks.earlyeffect" % "sbt-zipx" % "<version>")

// build.sbt
lazy val lib = project.settings(publishMavenStyle := true)

lazy val root = (project in file("."))
  .aggregate(lib)
  .settings(
    zipxCapabilities += ZipxCentral.release, // optional paved path
    zipxJavaVersion  := JdkVersion("25"),
  )

Generated Aggregate jobs (live output from the planner):

DocsRender.jobs("test", "fmt", "workflow-check", "advisories", "publish")(
  Capability.test,
  Capability.once(Capability.FmtName, SbtCommand.unsafeCommand("scalafmtCheckAll")),
  Capability.once(Capability.WorkflowCheckName, SbtCommand.unsafeTask("zipxWorkflowCheck")),
  Capability.once(Capability.AdvisoriesName, SbtCommand.unsafeTask("zipxAdvisoryCheck")),
  Capability.publish,
)
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'
fmt:
  name: fmt
  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: fmt
        node-version: ""
        sbt-disk-cache: "false"
        local-cache: "true"
        cache-epoch: "0.1.0-ci"
    - name: fmt
      run: sbt 'scalafmtCheckAll'
workflow-check:
  name: workflow-check
  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: workflow-check
        node-version: ""
        sbt-disk-cache: "false"
        local-cache: "true"
        cache-epoch: "0.1.0-ci"
    - name: workflow-check
      run: sbt 'zipxWorkflowCheck'
advisories:
  name: advisories
  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: advisories
        node-version: ""
        sbt-disk-cache: "false"
        local-cache: "true"
        cache-epoch: "0.1.0-ci"
    - name: advisories
      run: sbt 'zipxAdvisoryCheck'
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'

What you gain

You keep writing Scala, not YAML

Add a module the way you always do; regenerate; CI tracks the graph. No hand-maintained list of project ids, no forgotten needs: between jobs.

Libraries skip a separate release workflow

Even a small library gets parallel Verify plus a publish job gated on a version tag (or ZipxCentral.release / ZipxGitHubPackages). Docs Pages when you want them. Fork gates are Scala, not pasted if: strings.

Versions you can actually bump

Extend ZipxVersions, drop MyVersions.settings. Every Lib / Plugin / Pin / Action val is a catalog row (you do not list them again); each module picks a group (libraries, client, service). zipx rewrites those constructors, generates plugins.sbt, and fails generate if you sneak a raw coordinate in. Other plugins extend the same trait. See Versions.

import zipx.*
object MyVersions extends ZipxVersions:
  val sbt: SbtVersion     = SbtVersion("2.0.8")
  val scala: ScalaVersion = ScalaVersion("3.8.4")
  val zio                 = Lib("dev.zio", "zio", "2.1.26")
  val slf4j               = Lib("org.slf4j", "slf4j-simple", "2.0.18").java
  def libraries           = library(zio)
  def service             = library(zio, slf4j)

MyVersions.settings
lazy val lib     = project.settings(MyVersions.libraries)
lazy val service = project.settings(MyVersions.service)

The YAML is short enough to read in a PR

Generated CI is meant to be reviewed, not only executed. Two defaults keep the file short:

  • In-repo composites under .github/actions/zipx-* hold JDK / sbt / cache setup (and AWS login when you use those packs). Jobs call uses: ./.github/actions/…. You do not copy bootstrap steps into every job.

  • MatrixCollapse.Auto folds look-alike jobs into one GitHub matrix when that is safe. Stay on Aggregate and you may never notice this; see Matrix collapse if Graph makes the Actions UI noisy.

This repository's dogfood ci.yml is about 266 lines; the examples/monorepo sample is about 406. Both stay reviewable because setup lives in composites and look-alike Graph jobs collapse under Auto.

One graph, generated CI

ApproachWhen you add a module you…
Hand-written Actions YAMLEdit the workflow (and often a second config)
A second build graph (Bazel BUILD, …)Edit that graph (and usually CI too)
zipxEdit build.sbt; run zipxWorkflowGenerate

What to run is still your tasks, listed as typed capabilities in Scala: test, publish, docker, deploy, or stages you invent. Job order and gates are derived.

// project/plugins.sbt
addSbtPlugin("rocks.earlyeffect" % "sbt-zipx" % "<version>")

// build.sbt
lazy val schema  = project.settings(/* … */)
lazy val api     = project.dependsOn(schema)
lazy val service = project.dependsOn(api).enablePlugins(DockerPlugin)

lazy val root = (project in file("."))
  .aggregate(schema, api, service)
  .settings(
    // Built-in Aggregate test + publish; paved Central (and/or Packages) when you need it:
    zipxCapabilities ++= {
      val upstream = JobCondition.repositoryIs("acme/libs")
      Seq(
        ZipxCentral.release.withCondition(upstream),
        ZipxDocs.pages().andCondition(upstream),
      )
    },
    zipxJavaVersion      := JdkVersion("25"),
    zipxWorkflowDispatch := true,
  )

// Then: sbt zipxWorkflowGenerate && git add .github/workflows/ci.yml .github/actions/
// CI runs zipxWorkflowCheck so a graph change without regenerating fails the PR.

If you already maintain CI by hand

Skip this section if you are new to GitHub Actions. Quick start is enough. This is the recovery story for teams who already have a painful second copy of the build.

Typical bruises with a hand-written ci.yml:

  • Add, rename, or re-wire a module in sbt; CI silently keeps the old list.

  • Publish jobs fan out flat and hope the registry already has upstreams.

  • Every PR builds the world; cache is the only mitigation.

  • A typo'd module id is a green no-op, not a failed load.

Sketch of the “before”:

# .github/workflows/ci.yml (hand-maintained)
jobs:
  test-schema:
    runs-on: ubuntu-latest
    steps:
      - run: sbt 'schema/test'
  test-api:
    runs-on: ubuntu-latest
    # api depends on schema in build.sbt; CI forgot needs:
    steps:
      - run: sbt 'api/test'

Fuller recovery framing (including Bazel as a second graph): Why zipx and From Bazel.

What it derives

A map of later pages. On day one you can ignore everything except Aggregate Verify + publish.

SurfaceWhat you get
Execution modesAggregate (default): one root test job plus parallel Once gates. Layer / Graph only when you need waves or per-module jobs
Matrix collapseAuto by default; skip until Graph makes the Actions UI noisy
Composites.github/actions/zipx-sbt-setup (and zipx-aws-login if you use AWS packs)
CapabilitiesBuilt-in test / publish / docker / deploy; packs for Central, Packages, docs, AWS
Ordering and gatesPublish on a version tag; deploy destinations are never skipped by path
AffectedGraph only: skip jobs this PR did not touch
CachingRestore sbt's cache on the runner so test does not start from zero
Action pinsExact Action commits in the generated YAML; catalog rows when you want to bump without a zipx release
Pin feedsPins that are not Maven and not Actions; see Pin feeds
VersionsLib / Plugin / Pin / Action vals on a ZipxVersions object; MyVersions.settings; bump locally
Extending VersionsFor sbt plugins that sit on zipx (splice, a company catalog); skip unless you write one
Job conditionsOptional extra if: (fork, label, …). Skip until you need one
ValidationGenerate fails instead of emitting a broken workflow

Topology is derived. What to run stays your tasks, expressed as typed capabilities.

Why stay on sbt 2

zipx is built for sbt 2 because that is where the cache and the plugin model are:

  • a machine-wide cache of task results (why one Aggregate job stays cheap on a cold CI runner)

  • optional remote cache over the same protocol Bazel uses for cache only. That is plumbing, not "switch to Bazel"

  • Scala 3 plugins, so zipx's planner is an ordinary tested library

  • common settings: a bare zipxTestTask := zipxTasks.of(testFull) is the plugin default; any module can override

You keep writing sbt. CI stops being a second language for the same modules.

Architecture

How zipx is built. Skip unless you are contributing or curious.

  • zipx-workflow: GitHub Actions AST + deterministic YAML printer

  • zipx-core: pure planner (ModuleGraphWorkflow)

  • sbt-zipx: AutoPlugin; the only layer that touches sbt.*

The plugin owns topology. The build owns what to run (capabilities).

Typed secrets

zipx never stores secret values. You name GitHub secrets in Scala; they render to Actions expressions:

env = Map(
  "PGP_PASSPHRASE"    -> secret"PGP_PASSPHRASE",
  "AWS_REGION"        -> EnvValue.plain("us-west-2"),
  "DEPLOY_ROLE"       -> EnvValue.env("DEPLOY_ROLE"),
)
Render
  .renderMapping(
    ListMap(
      "PGP_PASSPHRASE" -> EnvValue.secret("PGP_PASSPHRASE").render,
      "AWS_REGION"     -> EnvValue.plain("us-west-2").render,
      "DEPLOY_ROLE"    -> EnvValue.env("DEPLOY_ROLE").render,
    )
  )
  .yaml
PGP_PASSPHRASE: ${{ secrets.PGP_PASSPHRASE }}
AWS_REGION: us-west-2
DEPLOY_ROLE: ${{ env.DEPLOY_ROLE }}