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
Edit
build.sbtthe way you already do (add a module, changedependsOn).sbt zipxWorkflowGeneratewrites.github/workflows/ci.ymland.github/actions/zipx-*.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 calluses: ./.github/actions/…. You do not copy bootstrap steps into every job.MatrixCollapse.Autofolds 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
| Approach | When you add a module you… |
|---|---|
| Hand-written Actions YAML | Edit the workflow (and often a second config) |
| A second build graph (Bazel BUILD, …) | Edit that graph (and usually CI too) |
| zipx | Edit 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.
| Surface | What you get |
|---|---|
| Execution modes | Aggregate (default): one root test job plus parallel Once gates. Layer / Graph only when you need waves or per-module jobs |
| Matrix collapse | Auto 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) |
| Capabilities | Built-in test / publish / docker / deploy; packs for Central, Packages, docs, AWS |
| Ordering and gates | Publish on a version tag; deploy destinations are never skipped by path |
| Affected | Graph only: skip jobs this PR did not touch |
| Caching | Restore sbt's cache on the runner so test does not start from zero |
| Action pins | Exact Action commits in the generated YAML; catalog rows when you want to bump without a zipx release |
| Pin feeds | Pins that are not Maven and not Actions; see Pin feeds |
| Versions | Lib / Plugin / Pin / Action vals on a ZipxVersions object; MyVersions.settings; bump locally |
| Extending Versions | For sbt plugins that sit on zipx (splice, a company catalog); skip unless you write one |
| Job conditions | Optional extra if: (fork, label, …). Skip until you need one |
| Validation | Generate 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 (
ModuleGraph→Workflow)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,
)
)
.yamlPGP_PASSPHRASE: ${{ secrets.PGP_PASSPHRASE }}
AWS_REGION: us-west-2
DEPLOY_ROLE: ${{ env.DEPLOY_ROLE }}