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.

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.

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 fails the PR until the catalog number is at least that floor. Over-bump is fine. Skipping is not. Bumping less is not.

  4. You write the number (zipxModverBump client, or by hand) and push.

  5. Merge to the default branch. That is the release signal. Nothing here creates a v* tag.

  6. The synthetic modver job diffs catalog rows against github.event.before (or registry-only on workflow_dispatch) and writes a compact module-id array. No 'all' sentinel.

  7. 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.

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 := "…". Members take <row>-ci locally and the catalog number on publish. Aggregators and unpublished apps keep sbt's default version.

WhereNumber
Catalog constructorrelease 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 rowcatalog number
POM / publishLocal sibling revisioncatalog 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:

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")

Three 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.

SetInputsRuleFailure
Verify (Affected)graph, filesreverse-dep of owners; .sbt / project/ => all; diff fail => ["all"]fail open
Bumpgraph, ships, filesowners ∩ publishes, no reverse-dep, no build-file explosion, group lift, then MiMa, then propagatefail closed
Publishships, before SHA, registryevery member of a row whose version or membership changed; job skipped only when every binary is 200fail 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 set

Library vs image

Library publish moves off tags. Docker and deploy do not.

WhatSignalPack
Library coordinatesmerge to main when a Ship / ShipGroup row movedZipxModver.publish
Docker image / deploya 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.

skip-tolerantskip-tolerant
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:

CombinatorJobsWhen
ZipxModver.publish()N Graph, needs upstreamCentral / registry completeness
.withoutUpstreamJobsN Graph, needs: [modver] onlyparallel GH Packages uploads, per-Ship checks
.withoutUpstreamJobs.withMatrixCollapse(Auto)1 matrix job, step if on matrix.modulesame, one check. Job if never uses matrix.* (GitHub rejects it). No 'all'.
.inOneSession1 Once job, zipxModverPublishMovedone 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.

ShipGroup libsShip clientpropagate edge · Never ignores it
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 }
PolicyWhat a dirty libs does to client
Nevernothing
PatchPublishedpatch, if client publishes
MatchBumpat 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=Minor

Cache 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.

WhenError
Library publish is Aggregate or LayerShip rows require Graph publish
Library publish is OnReleaseTagcannot use Gate.OnReleaseTag as the publish gate
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
Catalog version already ends in -cimust be the release number, not a -ci suffix
sbt-dynver-ci still loadedcannot 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

  1. Add Ship / ShipGroup vals. 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. Replace Capability.publish / publishLayers / ZipxCentral.release with ZipxModver.publish(). Compose ZipxCentral.releaseOnce.copy(gate = Gate.OnDefaultPush) only if you actually publish to Maven Central.

  4. Set zipxCacheEpoch := CacheEpoch.ShipCatalog (LocalDir). Lockstep OSS keeps GitTags().

  5. sbt zipxWorkflowGenerate, commit ci.yml and composites, open a PR.

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