Independent versions
Skip this page if every artifact in the repo ships together on a v* tag. That lockstep path is still the default:
sbt-dynver-ci, Aggregate ZipxCentral.release, Gate.OnReleaseTag. zipx itself stays there.
Use Ship / ShipGroup rows when a monorepo publishes several libraries on different cadences. Presence of any such
val is the feature flag. The human writes the number in the PR. CI suggests a MiMa-informed edit as a sticky comment
and fails closed on a missing or undersized bump. Merge to the default branch is the release signal. Graph publish
uploads only modules whose row moved, and skips a GAV already on the registry.
Inbound catalog rows (Lib / Plugin / Pin / Action) stay on Versions. This page is outbound versions only.
The examples/monorepo dogfoods a ShipGroup plus
an independent Ship.
The everyday loop
A source change and a version change are different commits' jobs. CI never writes ZipxVersions.scala for you.
Push a PR that changes sources. You may not have edited a
Shipyet.modver-suggestposts a sticky comment with the MiMa-informed constructors (best-effort on forks).modver-checkfails the PR until the catalog number is at least that floor. Over-bump is fine. Skipping is not. Bumping less is not.You write the number (
zipxModverBump client, or by hand) and push.Merge to the default branch. That is the release signal. Nothing here creates a
v*tag.The synthetic
modverjob diffs catalog rows againstgithub.event.before(or registry-only onworkflow_dispatch) and writes a compact module-id array. No'all'sentinel.Graph
publish-*jobs run only for ids in that array, skip-tolerant of a skipped ancestor, and skip a GAV already on the registry.
modver-check / modver-suggest self-compile (needsCapabilities = Nil). They do not wait on test topology.
{
val row = ModverReportRow(
identity = "client",
label = "Ship",
from = "0.3.0",
written = "0.3.0",
suggested = "0.3.1",
constructor = """Ship("client", "0.3.1")""",
kind = BumpKind.Patch,
mimaRan = true,
status = BumpStatus.Missing,
)
ModverComment.body(ModverReport(List(row)), Some("""Ship("client", "0.3.1")"""))
}<!-- zipx-modver -->
## zipx module versions
| Identity | From | Suggested | Written | Status |
---|---|---|---|---|
`client` | `0.3.0` | `0.3.1` | `0.3.0` | Missing |
```suggestion
Ship("client", "0.3.1")
```
The monorepo graph
Same shape as examples/monorepo: two libraries
that always share a number, one library on its own cadence, one unpublished app that still builds an image.
// project/ZipxVersions.scala
object MyVersions extends ZipxVersions:
val zio = Lib("dev.zio", "zio", "2.1.26")
val libs = ShipGroup("libs", "1.4.2")("models", "coreLib")
val client = Ship("client", "0.3.0")
def libraries = library(zio)
| Module | Row | Publishes |
|---|---|---|
models | ShipGroup libs | yes |
coreLib | ShipGroup libs | yes |
client | Ship client | yes |
service | none | no (publishArtifact := false) |
| root aggregator | none | no (publish / skip) |
Ship("client", "0.3.0") is one sbt project, including every projectMatrix platform row of that root.
ShipGroup("libs", "1.4.2")("models", "coreLib") is several projects that always share one number and one release. A
group of one is legal and pointless (it is just Ship). Empty members are refused at generate.
Modver.membership(graph, ships) match
case Left(err) => err
case Right(idx) =>
List("models", "coreLib", "client", "service")
.map { id =>
val mid = ModuleId.unsafeMake(id)
idx.rowFor(mid).fold(s"$id:none")(r => s"$id:${r.label}:${r.identity}")
}
.mkString("\n")models:ShipGroup:libs
coreLib:ShipGroup:libs
client:Ship:client
service:noneCatalog rows
Drop the repo-wide version := "…". Members take <row>-ci locally and the catalog number on publish. Aggregators and
unpublished apps keep sbt's default version.
| Where | Number |
|---|---|
| Catalog constructor | release number only (1.4.2, never 1.4.2-ci) |
| Local / PR checkout | <row>-ci (1.4.2-ci) |
| Default-branch push that releases this row | catalog number |
POM / publishLocal sibling revision | catalog number, never -ci |
zipxDepUpdate / catalog update rewrite Lib / Plugin / Action only. They never touch Ship / ShipGroup.
Bump outbound rows yourself:
sbt "zipxModverBump client" # patch, default
sbt "zipxModverBump libs minor"
sbt "zipxModverBump client major"
The PR is that constructor hunk:
catalogBumpDiffThree sets: affected, bump, publish
Affected answers "which Verify jobs can we skip." A version manager answers "which coordinates move, to what, and
when is upload legal." Reusing affectedModules for that second question is how you fail-open a publish.
| Set | Inputs | Rule | Failure |
|---|---|---|---|
| Verify (Affected) | graph, files | reverse-dep of owners; .sbt / project/ => all; diff fail => ["all"] | fail open |
| Bump | graph, ships, files | owners ∩ publishes, no reverse-dep, no build-file explosion, group lift, then MiMa, then propagate | fail closed |
| Publish | ships, before SHA, registry | every member of a row whose version or membership changed; job skipped only when every binary is 200 | fail closed |
A dirty models source file reverse-deps into coreLib, client, and service for test. For bump it lifts
only the libs group. client does not have to move. See Affected.
{
def refsOf(files: Option[List[String]]): String =
Modver.liftedBumpSet(graph, index, files) match
case Left(err) => err
case Right(set) =>
set
.map {
case ShipRef.Group(n) => s"group:$n"
case ShipRef.One(id) => s"ship:$id"
}
.toList
.sorted
.mkString(",")
List(
s"models src -> ${refsOf(Some(List("models/src/Main.scala")))}",
s"client src -> ${refsOf(Some(List("client/src/Main.scala")))}",
s"no diff -> ${refsOf(None)}",
).mkString("\n")
}models src -> group:libs
client src -> ship:client
no diff -> could not diff changed files for modver; refusing to guess the bump setLibrary vs image
Library publish moves off tags. Docker and deploy do not.
| What | Signal | Pack |
|---|---|---|
| Library coordinates | merge to main when a Ship / ShipGroup row moved | ZipxModver.publish |
| Docker image / deploy | a human v* tag (docs/docker only, not the library version) | ZipxAws.dockerPublishAll, deploy |
Nothing in independent mode creates v* tags. A library-only release does not push an image. Do not retarget docker to
Gate.OnDefaultPush. Keep docker and deploy in the build so they still teach AWS wiring; the comment is the contract.
zipxCapabilities ++= Seq(
Capability.testLayers,
ZipxModver.publish(),
)
zipxCapabilities += ZipxAws.dockerPublishAll(Registry.destinations) // still OnReleaseTag
DocsRender.jobs("publish-client", "docker")(
ZipxModver.publish(publishCmd),
Capability.docker,
)(using graph, independent)publish-client:
name: publish client
runs-on: ubuntu-latest
needs:
- modver
- publish-coreLib
if: "!cancelled() && (((github.event_name == 'push') && ((github.ref == 'refs/heads/main'))) || (github.event_name == 'workflow_dispatch')) && contains(fromJson(needs.modver.outputs.modules), 'client') && needs.publish-coreLib.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: publish-client
node-version: ""
sbt-disk-cache: "false"
local-cache: "true"
cache-epoch: "0.1.0-ci"
- name: publish
run: sbt '+client/zipxModverPublishSigned'
docker:
name: docker
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: docker
node-version: ""
sbt-disk-cache: "false"
local-cache: "true"
cache-epoch: "0.1.0-ci"
- name: docker
run: sbt 'service/Docker/publish'ZipxModver.publish
Replace builtin Aggregate publish (or Capability.publishLayers) with ZipxModver.publish. Graph,
Gate.OnDefaultPush (push to zipxPushBranches or workflow_dispatch), MatrixCollapse.Off. Default command is
zipxModverPublishSigned. No Central secrets unless you compose them.
zipxCapabilities += ZipxModver.publish()
// Optional: Central sonaRelease once after Graph publish
zipxCapabilities += ZipxCentral.releaseOnce.copy(gate = Gate.OnDefaultPush)
Generate refuses Aggregate / Layer / OnReleaseTag library publish when ships are present. Docker is not that
refusal. Graph if: is contains(fromJson(needs.modver.outputs.modules), '<id>') with no 'all' sentinel. A
skipped publish-coreLib does not skip publish-client. workflow_dispatch runs modver in registry-only mode
(every catalog GAV not on the registry). gh run rerun of the merge SHA is the other recovery path. See Packs and
Job conditions.
Default Graph waits on upstream publish jobs (Ordering.DependencyOrdered). That is the right default when a dependent
must resolve the upstream POM from the registry (Central staging). GH Packages publish compiles dependsOn from the
checkout, so the wait only serializes uploads. Combinators, not .copy:
| Combinator | Jobs | When |
|---|---|---|
ZipxModver.publish() | N Graph, needs upstream | Central / registry completeness |
.withoutUpstreamJobs | N Graph, needs: [modver] only | parallel GH Packages uploads, per-Ship checks |
.withoutUpstreamJobs.withMatrixCollapse(Auto) | 1 matrix job, step if on matrix.module | same, one check. Job if never uses matrix.* (GitHub rejects it). No 'all'. |
.inOneSession | 1 Once job, zipxModverPublishMoved | one sbt JVM over the moved set |
Do not bake Auto into withoutUpstreamJobs. Collapse is withMatrixCollapse. Job-level if: cannot mention matrix.
DocsRender.jobs("modver", "publish-client", "modver-check")(
ZipxModver.publish(publishCmd),
Capability.modverCheck(),
)(using graph, independent)modver:
name: modver
runs-on: ubuntu-latest
if: ((github.event_name == 'push') && ((github.ref == 'refs/heads/main'))) || (github.event_name == 'workflow_dispatch')
permissions:
contents: read
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: modver
node-version: ""
sbt-disk-cache: "false"
local-cache: "false"
cache-epoch: "0.1.0-ci"
- name: Compute version-moved modules
id: compute
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
sbt -batch --error zipxModverPublishModules
modules=$(cat target/zipx-modver-modules.json)
elif [ "${{ github.event_name }}" = "push" ]; then
before="${{ github.event.before }}"
if [ -z "$before" ] || [ "$before" = "0000000000000000000000000000000000000000" ]; then
echo "zipx: github.event.before is missing or all-zero; refusing to guess the publish set"
exit 1
else
sbt -batch --error "zipxModverPublishModules $before"
modules=$(cat target/zipx-modver-modules.json)
fi
else
echo "zipx: github.event.before is missing or all-zero; refusing to guess the publish set"
exit 1
fi
echo "modules=$modules" >> "$GITHUB_OUTPUT"
publish-client:
name: publish client
runs-on: ubuntu-latest
needs:
- modver
- publish-coreLib
if: "!cancelled() && (((github.event_name == 'push') && ((github.ref == 'refs/heads/main'))) || (github.event_name == 'workflow_dispatch')) && contains(fromJson(needs.modver.outputs.modules), 'client') && needs.publish-coreLib.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: publish-client
node-version: ""
sbt-disk-cache: "false"
local-cache: "true"
cache-epoch: "0.1.0-ci"
- name: publish
run: sbt '+client/zipxModverPublishSigned'
modver-check:
name: modver-check
runs-on: ubuntu-latest
if: (!startsWith(github.ref, 'refs/tags/') && github.event_name != 'workflow_dispatch') && (github.event_name == 'pull_request')
permissions:
contents: read
env:
ZIPX_MODVER_BASE_SHA: ${{ github.event.pull_request.base.sha }}
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: modver-check
node-version: ""
sbt-disk-cache: "false"
local-cache: "true"
cache-epoch: "0.1.0-ci"
- name: Fetch PR base SHA
run: git fetch --no-tags origin "$ZIPX_MODVER_BASE_SHA"
- name: modver-check
run: sbt 'zipxModverCheck'{
val yaml = DocsRender.jobs("publish")(
ZipxModver.publish(publishCmd).withoutUpstreamJobs.withMatrixCollapse(MatrixCollapse.Auto)
)(using graph, independent)
s"$yaml"
}publish:
name: publish
runs-on: ubuntu-latest
needs:
- modver
if: "!cancelled() && (((github.event_name == 'push') && ((github.ref == 'refs/heads/main'))) || (github.event_name == 'workflow_dispatch')) && needs.modver.outputs.modules != '[]'"
strategy:
fail-fast: false
matrix:
module:
- models
- coreLib
- client
steps:
- if: contains(fromJson(needs.modver.outputs.modules), matrix.module)
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
fetch-depth: "0"
fetch-tags: "true"
- name: zipx sbt setup
if: contains(fromJson(needs.modver.outputs.modules), matrix.module)
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
if: contains(fromJson(needs.modver.outputs.modules), matrix.module)
run: sbt '+${{ matrix.module }}/zipxModverPublishSigned'DocsRender.job("publish")(ZipxModver.publish(publishCmd).inOneSession)(using graph, independent)publish:
name: publish
runs-on: ubuntu-latest
needs:
- modver
if: ((((github.event_name == 'push') && ((github.ref == 'refs/heads/main'))) || (github.event_name == 'workflow_dispatch'))) && (needs.modver.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: publish
node-version: ""
sbt-disk-cache: "false"
local-cache: "true"
cache-epoch: "0.1.0-ci"
- name: publish
run: sbt 'zipxModverPublishMoved'scala.util
.Try(DocsRender.plan(Capability.publish)(using graph, independent))
.fold(_.getMessage, _ => "planned (no error)")zipx: Ship rows require Graph publish (version-moved). Capability 'publish' is Aggregate. Use ZipxModver.publish, ZipxModver.publish(...).inOneSession, or Capability.publishGraph.copy(gate = Gate.OnDefaultPush).Propagate
Default is Never: only dirty published roots, lifted through groups. Built-ins walk published reverse-deps across
groups after MiMa kinds exist. Intra-group dependsOn (models → coreLib inside libs) is not a propagate edge.
zipxModverPropagate := ModverPropagate.Never // default
zipxModverPropagate := ModverPropagate.PatchPublished // patch published reverse-deps
zipxModverPropagate := ModverPropagate.MatchBump // at least the triggering kind
zipxModverPropagate := ModverPropagate.custom { (kinds, graph, ships) => kinds }
| Policy | What a dirty libs does to client |
|---|---|
Never | nothing |
PatchPublished | patch, if client publishes |
MatchBump | at least the libs kind (a binary break floors client at major too) |
{
val kinds = BumpSet(Map(ShipRef.Group(libsRow.name) -> BumpKind.Minor))
val never = Modver.expand(kinds, graph, index, ModverPropagate.Never)
val patch = Modver.expand(kinds, graph, index, ModverPropagate.PatchPublished)
val matchB = Modver.expand(kinds, graph, index, ModverPropagate.MatchBump)
def show(b: BumpSet): String =
b.asMap.toList
.sortBy(_._1.toString)
.map {
case (ShipRef.Group(n), k) => s"group:$n=$k"
case (ShipRef.One(id), k) => s"ship:$id=$k"
}
.mkString(",")
List(s"Never ${show(never)}", s"PatchPublished ${show(patch)}", s"MatchBump ${show(matchB)}").mkString("\n")
}Never group:libs=Minor
PatchPublished group:libs=Minor,ship:client=Patch
MatchBump group:libs=Minor,ship:client=MinorCache epoch
Independent mode does not force GitTags(). Set zipxCacheEpoch := CacheEpoch.ShipCatalog so LocalDir keys off sorted
Ship identity and version (baked at generate, same path as Fixed).
One row bump rolls the repo-wide LocalDir namespace, the same way a v* tag does under GitTags(). Remote
cacheVersion stays JDK/OS only; a bump already changes that module's version, so only that module's remote entries
miss. Lockstep OSS keeps GitTags(). Full guide: Caching.
zipxCacheEpoch := CacheEpoch.ShipCatalog
{
val hash = Modver.epochHash(ships)
val yaml = DocsRender.job("test")(Capability.test)(using
graph,
independent.copy(cacheEpoch = CacheEpoch.ShipCatalog, shipEpochHash = Some(hash)),
)
s"hash: $hash\n$yaml"
}hash: 8f2e77a00c7c0f58
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: "8f2e77a00c7c0f58"
- name: test
run: sbt 'test'Matrix root
Ship identity is the matrix root. One Ship("core") covers core and coreJS. Ship("coreJS") is refused at
generate with a hint to use the root. Other axes set zipxMatrixRoot or generate fails.
{
val matrix = GraphFixture(
List(
ModuleNode(ModuleId("core"), publishes = true, baseDir = "core"),
ModuleNode(
ModuleId("coreJS"),
publishes = true,
baseDir = "core",
matrixRootOpt = Some(ModuleId("core")),
),
)
)
val covered = Modver.rowForProject("coreJS", List(Ship("core", "1.4.2"))).map(r => r.identity: String)
val bad = Modver.membership(matrix, List(Ship("coreJS", "1.4.2")))
List(
s"root covers JS: ${covered.getOrElse("none")}",
s"platform row: ${bad.fold(identity, _ => "accepted")}",
).mkString("\n")
}root covers JS: core
platform row: Ship("coreJS") names a platform row; use Ship("core", …) for the matrix root.What generate refuses
Membership and dynver checks run at zipxWorkflowGenerate / zipxWorkflowCheck, not at sbt load. Topology checks run
in the planner when ships are present.
| When | Error |
|---|---|
Library publish is Aggregate or Layer | Ship rows require Graph publish |
Library publish is OnReleaseTag | cannot use Gate.OnReleaseTag as the publish gate |
| A publishing module has no row | published module '…' is not in a Ship or ShipGroup |
| The same root is in two rows | Each publishes=true module must be in exactly one row |
ShipGroup with empty members | has no members |
| A member that does not publish | does not publish |
Catalog version already ends in -ci | must be the release number, not a -ci suffix |
sbt-dynver-ci still loaded | cannot share version with sbt-dynver-ci |
Docker Aggregate on a tag is not this table. service in the example is unpublished, so it is not a membership
hole. See Validation.
{
def show(ships: List[PublishedRow]): String =
Modver.membership(graph, ships).fold(identity, _ => "ok")
List(
s"ok: ${show(ships)}",
s"ci suffix: ${show(List(Ship("client", "0.3.0-ci"), libsRow))}",
s"unpublished: ${show(ships :+ Ship("service", "1.0.0"))}",
s"uncovered: ${show(List(libsRow))}",
).mkString("\n")
}ok: ok
ci suffix: Ship("client") version '0.3.0-ci' must be the release number, not a -ci suffix.
unpublished: Ship("service") does not publish. Drop it or set publish / skip := false.
uncovered: published module 'client' is not in a Ship or ShipGroup. Add Ship("client", "…") or a ShipGroup member.Adopt
Add
Ship/ShipGroupvals. Everypublishes = truematrix root belongs in exactly one row.Remove repo-wide
version :=. Removesbt-dynver-ciif it was a catalog plugin.Replace
Capability.publish/publishLayers/ZipxCentral.releasewithZipxModver.publish(). ComposeZipxCentral.releaseOnce.copy(gate = Gate.OnDefaultPush)only if you actually publish to Maven Central.Set
zipxCacheEpoch := CacheEpoch.ShipCatalog(LocalDir). Lockstep OSS keepsGitTags().sbt zipxWorkflowGenerate, commitci.ymland composites, open a PR.
Human still writes the next number. Settings: Settings (zipxShips, zipxModverPropagate, zipxModverBump,
zipxModverCheck, zipxModverSuggest, zipxModverPublishSigned).