Independent versions

A repo whose artifacts always ship together is one ShipGroup over every published module, as zipx itself is. A tag-driven lockstep build (sbt-dynver-ci, Aggregate ZipxCentral.release, Gate.OnReleaseTag) still generates, but the rows below are the model the org is moving to.

Use Ship / ShipGroup rows when a monorepo publishes several libraries on different cadences. Presence of any such val is the feature flag. A row holds the next release number, every build compiles <row>-ci, and a release is a deliberate run of zipx-release.yml. The human writes the number in a PR; CI suggests a MiMa-informed edit as a sticky comment and fails closed when a changed row is still at a released number, or below the MiMa floor. Merges release nothing.

noyes

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.

row still released, or undersizedok
  1. Push a PR that changes sources. You may not have edited a Ship yet.

  2. modver-suggest posts a sticky comment with the MiMa-informed constructors (best-effort on forks).

  3. modver-check reads each changed row's last release from the registry's maven-metadata.xml. A row still at that number must move; a moved row must clear the MiMa floor measured against that release's jar. Over-bump is fine. An unreadable registry or jar fails the check; it never reads as a first release. When the release registry is GitHub Packages, modver-check and modver-suggest also get packages: read and GITHUB_TOKEN: a permissions block drops every scope it does not name, and the metadata request sends that token. Packages answers 401 to either omission.

  4. You write the number (sbt zipxModverBump, or one row by hand) and push. The bump does not run MiMa. modver-check does, against the opened number.

  5. Before merging, prove the change downstream without a release: commit, sbt zipxSnapshotPublish local here, and pin <row>-<sha>-SNAPSHOT there (or sbt zipxSnapshotPublish for another machine's CI). See Snapshots and releases.

  6. Merge. The default branch still compiles <row>-ci and publishes that commit's <row>-<sha>-SNAPSHOT. Later PRs in the same cycle pass without another bump unless MiMa says their change is bigger than the row already declares.

  7. Release when ready: a GitHub Release tagged client/v0.3.1, or Run workflow on zipx release. See Snapshots and releases for the picture: status, advance, release plan, pin-release, then the release.

  8. The release job opens a pull request that runs sbt zipxModverBump. That commit is not the tagged SHA. Until the number moves, a snapshot of the released line is shadowed and zipxDriftGate (default Fail) stops the row's compile and fails the snapshot publish. The check reads the registry zipxReleaseWorkflow names.

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.

ShipGroup libs 1.4.2Ship client 0.3.0
// 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)
ModuleRowPublishes
modelsShipGroup libsyes
coreLibShipGroup libsyes
clientShip clientyes
servicenoneno (publishArtifact := false)
root aggregatornoneno (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:none

Catalog rows

Drop the repo-wide version := "…". A member's version is a pure function of its row: <row>-ci in every build, on a laptop and in CI alike, and the catalog number only inside a zipxRelease session. Aggregators and unpublished apps keep sbt's default version.

WhereNumberWhy
Catalog constructorrelease number only (1.4.2, never 1.4.2-SNAPSHOT)the human writes the next release
Any build: PR, merge, a snapshot publish<row>-ci (1.4.2-ci)the same from commit to commit, so caches hold
sbt zipxSnapshotPublish of a clean commit<row>-<sha>-SNAPSHOT in the repositorythe pin another build resolves, the same coordinate on every registry
A zipxRelease sessioncatalog number, for every row memberits POMs name in-repo dependencies at release numbers

A cache needs a version that does not change between commits. <row>-ci is that version. A per-commit version, such as dynver's hash, changes jar names on the classpath and busts the digest. The published snapshot is a different string, the commit id, and the test job does not compile it. publishLocal of <row>-ci is not how another build tries the change: that command is sbt zipxSnapshotPublish local, and its revision is the sha or the dirty id.

zipxDepUpdate / catalog update rewrite Lib / Plugin / Action only. They never touch Ship / ShipGroup. Bump outbound rows yourself:

sbt zipxModverBump                  # every released row, patch
sbt "zipxModverBump minor"          # every released row, minor
sbt "zipxModverBump client"         # that row, patch, released or not
sbt "zipxModverBump libs minor"
sbt "zipxModverBump client major"

No arguments, or a kind alone, never bumps a row the release registry does not already have: that would skip a planned release. Naming the row does. A ship whose name is patch is that ship, not the kind. modver-check still owns MiMa.

{
  val ids                                                          = Set("client", "libs")
  def show(tokens: List[String], known: Set[String] = ids): String =
    ShipBumpRequest.parse(tokens, known).fold(_.message, _.toString)
  List(
    show(Nil),
    show(List("minor")),
    show(List("client")),
    show(List("client", "major")),
    show(List("patch"), Set("patch", "libs")),
  ).mkString("\n")
}
Released(Patch)
Released(Minor)
One(client,Patch)
One(client,Major)
One(patch,Patch)
catalogBumpDiff
project/ZipxVersions.scala
diff --git a/project/ZipxVersions.scala b/project/ZipxVersions.scala
--- a/project/ZipxVersions.scala
+++ b/project/ZipxVersions.scala
@@ object MyVersions extends ZipxVersions
val libs = ShipGroup("libs", "1.4.2")("models", "coreLib")
- val client = Ship("client", "0.3.0")
+ val client = Ship("client", "0.3.1")

Affected is not the bump set

Affected answers "which Verify jobs can we skip." Modver answers "which rows must move, and how far." Reusing affectedModules for the second question is how a check fails open.

SetInputsRuleFailure
Verify (Affected)graph, filesreverse-dep of owners; .sbt / project/ => all; diff fail => ["all"]fail open
Bumpgraph, ships, files, registryowners ∩ publishes, no reverse-dep, no build-file explosion, group lift, then MiMa against the last release, then propagatefail closed

What a release carries is a third question with its own rule (the requested rows plus their unreleased in-repo upstream rows); see Snapshots and releases.

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 set

Library vs image

Libraries release from zipx-release.yml. Docker and deploy stay in ci.yml on a human v* tag.

WhatSignalWhere
Library coordinatesa GitHub Release tagged <row>/v<n>, or a dispatch (ships, default all)zipx-release.yml (zipxReleaseWorkflow)
Docker image / deploya human v* tagci.yml (ZipxAws.dockerPublishAll, deploy)

With rows, ci.yml has no library publish job, and generate refuses one. A library-only release does not push an image.

zipxReleaseWorkflow := Some(ZipxCentral.releases)
zipxCapabilities += Capability.testLayers
zipxCapabilities += ZipxAws.dockerPublishAll(Registry.destinations) // still OnReleaseTag
DocsRender.job("docker")(Capability.docker)(using graph)
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"
        cache-mode: restore
        cache-epoch: "0.1.0-SNAPSHOT"
    - name: docker
      run: sbt 'service/Docker/publish'

Propagate

Propagation answers one question: when a row moves, which of the rows built on it must move too? Default is MatchBump. A published row whose in-repo upstream takes a bump must take at least the same kind, so modver-check fails the PR until it does. Built-ins walk published reverse-deps across rows after MiMa kinds exist. Intra-group dependsOn (models → coreLib inside libs) is not a propagate edge: a group already moves as one.

The reason is what a release carries. A row's POM names its in-repo dependencies at the numbers they had when it released. Say libs breaks and releases 1.5.0 while client stays at its released 0.3.0: client 0.3.0 on the registry still names libs 1.4.2. A consumer that takes the new libs and any client now resolves a mix sbt rejects (libs 1.5.0 is selected over 1.4.2), and nothing in this repo fails, because in-repo builds always use the current libs. Only a consumer finds out, one release later. A consumer on zipx is not refused, because its catalog states libs (Versions), but its client 0.3.0 then runs against a libs it was never built with. MatchBump makes the PR that breaks libs also move client, so one release carries both and every released POM agrees.

ShipGroup libsShip clientpropagate edge · MatchBump moves client
zipxModverPropagate := ModverPropagate.MatchBump        // default: at least the triggering kind
zipxModverPropagate := ModverPropagate.PatchPublished   // patch published reverse-deps
zipxModverPropagate := ModverPropagate.Never            // opt out: only the rows the diff touches
zipxModverPropagate := ModverPropagate.custom { (kinds, graph, ships) => kinds }
PolicyWhat a dirty libs does to clientReleased POMs afterwards
MatchBump (default)at least the libs kind (a binary break floors client at major too)agree
PatchPublishedpatch, if client publishesagree, but a patch may carry a break
Nevernothingclient keeps naming the old libs

Propagation acts when a PR moves the upstream row. A repo whose rows are already out of step (released under Never, say) catches up with one PR that moves each stale dependent itself; after that, MatchBump keeps them in step.

{
  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=Minor

Cache epoch

With rows, zipxCacheEpoch defaults to CacheEpoch.ShipCatalog: LocalDir keys off sorted row identity and number (baked at generate, same path as Fixed).

One row bump rolls the repo-wide LocalDir namespace in the same PR that moves the <row>-ci strings. A release rolls nothing: the release run restores the cache and never saves. Remote cacheVersion stays JDK/OS only; a bump already changes that module's version, so only that module's remote entries miss. Full guide: Caching.

{
  val hash = Modver.epochHash(ships)
  val yaml = DocsRender.job("test")(Capability.test)(using
    graph,
    config.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"
        cache-mode: save
        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

These run at zipxWorkflowGenerate / zipxWorkflowCheck, not at sbt load.

WhenError
zipxCapabilities has a publishShip rows release from zipx-release.yml (zipxReleaseWorkflow), so ci.yml has no publish job
zipxReleaseWorkflow is Noneset zipxReleaseWorkflow (ZipxCentral.releases, ZipxGitHubPackages.releases, or ZipxMaven.releases)
A publishing module has no rowpublished module '…' is not in a Ship or ShipGroup
The same root is in two rowsEach publishes=true module must be in exactly one row
ShipGroup with empty membershas no members
A member that does not publishdoes not publish
sbt-dynver-ci still loadedcannot share version with sbt-dynver-ci

A row version that is not major.minor.patch, such as Ship("client", "0.3.0-SNAPSHOT"), never reaches generate: the catalog does not compile (a release number is major.minor.patch).

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"unpublished: ${show(ships :+ Ship("service", "1.0.0"))}",
    s"uncovered: ${show(List(libsRow))}",
  ).mkString("\n")
}
ok: ok
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

  1. Add Ship / ShipGroup vals holding each row's next release. Every publishes = true matrix root belongs in exactly one row.

  2. Remove repo-wide version :=. Remove sbt-dynver-ci if it was a catalog plugin.

  3. Remove Capability.publish / publishLayers / ZipxCentral.release from zipxCapabilities, and set zipxReleaseWorkflow := Some(ZipxCentral.releases) (or a ReleaseWorkflow for your registry).

  4. sbt zipxWorkflowGenerate, commit ci.yml, zipx-release.yml, and composites, open a PR.

  5. Create the zipx-release GitHub Environment. Release with a GitHub Release or Run workflow.

Human still writes the next number. Settings: Settings (zipxShips, zipxReleaseWorkflow, zipxDriftGate, zipxModverPropagate, zipxModverBump, zipxModverCheck, zipxModverSuggest).