Extending Versions

Skip unless you are writing an sbt plugin that sits on zipx (a company catalog, CDN or vendor pins; sbt-splice is one). Consumers stay on Versions: they extend ZipxVersions, drop MyVersions.settings, and write Lib / Plugin / Pin / Action vals. Outbound Ship / ShipGroup rows are Independent versions.

Two jobs. They are not the same hook.

  1. Emit your plugin line into generated project/plugins.sbt from the version on the classpath (zipxSelfPlugins / ZipxSelf.emit). Consumers should not duplicate that GAV in ZipxVersions.

  2. Optionally put your libraries, pins, and Actions in the consumer catalog (Lib / Pin / Action vals on a subtype, or AsCoords / AsPins / AsActions on a bundle). That is the row story below. A plugin that ships a PinFeed (lookup plus optional materialize) does not ship the inventory: the consumer repo owns which version is pinned.

Emit your plugin line

zipxEmitSelf / zipxPluginVersion are sbt-zipx only (dogfood, scripted). Every other plugin that sits on zipx appends zipxSelfPlugins. A Plugin val on ZipxVersions is for consumer-owned plugins that do not emit themselves (scalafmt, native-packager).

Group and artifact are always written out. zipx never scans the session. Missing Implementation-Version is a zipx: error naming that GAV. Duplicate group+artifact in the self list fails generate. ZipxSelf.emit does not read zipx's -Dplugin.version.

object SpliceZipxPlugin extends AutoPlugin:
  override def requires = ZipxPlugin
  override def buildSettings = Seq(
    zipxSelfPlugins += ZipxSelf.emit("rocks.earlyeffect", "sbt-splice", getClass)
  )

Generate writes zipx first, then your line, then the consumer's catalog plugins:

{
  val zipx     = Plugin("rocks.earlyeffect", "sbt-zipx", "0.5.1")
  val splice   = Plugin("rocks.earlyeffect", "sbt-splice", "1.0.0")
  val scalafmt = Plugin("org.scalameta", "sbt-scalafmt", "2.6.2")
  ZipxCatalog.renderPlugins(List(scalafmt), self = List(zipx, splice))
}
// Generated by zipx. Do not edit. Run 'sbt zipxWorkflowGenerate' to regenerate.
addSbtPlugin("rocks.earlyeffect" % "sbt-zipx" % "0.5.1")
addSbtPlugin("rocks.earlyeffect" % "sbt-splice" % "1.0.0")
addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.6.2")

Your libraries in that same catalog

Collection is a typeclass, AsCoords / AsPins / AsActions / AsShips. Lib and Plugin share AsCoords (A <: ZipxCoord). Pin has AsPins. Action has AsActions. Ship / ShipGroup have AsShips. You add a given for your own type, or you put Lib / Plugin / Pin / Action / Ship vals on a ZipxVersions subtype. There is no second coords list for the consumer to keep in sync. Do not put your plugin GAV on that subtype if you already emit it; that catalog line is dropped.

Two paths

Path 1: Lib / Plugin / Pin / Action vals on your subtype. Inherited fields are collected. The consumer writes object MyVersions extends SpliceVersions and your vals are rows.

trait SpliceVersions extends ZipxVersions:
  val spliceRuntime = Lib("rocks.earlyeffect", "splice-core", "1.0.0")
  val preact        = Pin("cdn", "preact", "10.26.4", sha256 = "sha256-abc", purl = "pkg:npm/preact@10.26.4")
  // extra settings: MyVersions.settings ++ spliceCdnFeed in build.sbt

The consumer still owns those Pin vals (or copies them). You ship the feed, not a second inventory list.

Path 2: a bundle type with given AsCoords[YourType]. Put the given on the companion so the catalog file does not import extra machinery. Collection summons it the same way it summons Lib. CDN bundles use given AsPins the same way; Action bundles use given AsActions.

final case class SpliceLibs(runtime: Lib)
object SpliceLibs:
  given AsCoords[SpliceLibs] with
    def coords(s: SpliceLibs) = Seq(s.runtime)

trait SpliceVersions extends ZipxVersions:
  val splice = SpliceLibs(
    Lib("rocks.earlyeffect", "splice-core", "1.0.0")
  )

Do not add given AsCoords[List[Lib]]. That would collect every helper list on the consumer object. Own a named type.

Keep Lib("g", "a", "from") / Plugin("g", "a", "from") / Pin("feed", "id", "from", …) / Action("owner/repo", "from", sha = …) constructors in project/ZipxVersions.scala. zipxDepUpdate, zipxPinUpdate, and zipxActionUpdate rewrite those literals; they do not know about SpliceLibs("1.0.0"). Maven, pin, and self-emit hunks are below.

What collection sees

coordsOf / pinsOf walk vals on the concrete object (this.type). A def is skipped. SbtVersion / ScalaVersion have no given. A parent-trait Lib val and a bundle val both become rows:

Sample.coords.map(c => s"${c.group}:${c.artifact}").sorted.mkString("\n")
com.acme:acme-core
com.acme:from-trait
com.acme:sbt-acme

zipxCheckDeps compares libraryDependencies to the flattened Lib rows. A GAV that came out of your bundle is a catalog hit, same as a bare val zio = Lib(...).

{
  val extra = ZipxCatalog.extraLibs(
    List(DeclaredGav("com.acme", "acme-core", "1.2.3")),
    Sample.coords,
  )
  if extra.isEmpty then "(none)" else extra.map(_.render).mkString("\n")
}
(none)

settings stays on the plugin

coords / pins / actions live on the core trait so a process that is not the target sbt can compile the catalog file. settings is an inline extension on the plugin (MyVersions.settings in build.sbt). Extra settings belong next to that call (MyVersions.settings ++ spliceSettings), not as inline override def settings on a subtype.

The consumer still writes one line in build.sbt: MyVersions.settings.

Classpath and package

The consumer compiles project/ZipxVersions.scala with your plugin and sbt-zipx on the meta classpath. Put the trait, the bundle, and the given in a package they can import (import splice.*, plus import zipx.* for ZipxVersions / AsCoords / AsPins / AsActions / Pin / Action). The given on the bundle companion is found without a further import.

Do not name a package sbt. On sbt 2 that shadows _root_.sbt and the plugin will not compile. zipx keeps catalog types in package zipx for the same reason.

Apply still rewrites constructors

Flattening into zipxVersions / zipxPins is what check and lookup see. Apply is still a constructor rewrite in the catalog source. Nested Lib(...) / Plugin(...) / Pin(...) literals inside a bundle are what move. A bundle that stores only a version string will list as stale and then not rewrite.

A Path 1 Lib / Plugin val on your subtype looks like Versions. Path 2 is the same rewrite, with your type around the constructor. Sibling constructors in that bundle stay put. Pins are the next section.

bundleCatalogPrDiff
project/ZipxVersions.scala
diff --git a/project/ZipxVersions.scala b/project/ZipxVersions.scala
--- a/project/ZipxVersions.scala
+++ b/project/ZipxVersions.scala
@@ object MyVersions extends AcmeVersions
val acme = AcmeBundle(
- Lib("com.acme", "acme-core", "1.2.3"),
+ Lib("com.acme", "acme-core", "1.2.4"),
Plugin("com.acme", "sbt-acme", "1.2.3"),
)

Custom pins

You ship the PinFeed (lookup, policy, optional materialize). The consumer writes the Pin vals, on your ZipxVersions subtype or inside an AsPins bundle. Apply still rewrites those Pin(...) constructors: version, sha256, and purl together. Maven rows stay put. Your own addSbtPlugin line stays put.

A given AsPins bundle is the same rewrite: the Pin(...) literal inside the case class, not CdnPins("1.2.4").

If materialize writes a vendored file, that second path is in the same PR. zipx does not regex that file; your feed wrote it. Policy and local zipxPinUpdate are Pin feeds.

DocDiff.stack(customPinCatalogDiff, customPinVendorDiff)
project/ZipxVersions.scala
diff --git a/project/ZipxVersions.scala b/project/ZipxVersions.scala
--- a/project/ZipxVersions.scala
+++ b/project/ZipxVersions.scala
@@ object MyVersions extends CdnVersions
val widget = Pin(
"cdn",
"widget",
- "1.2.3",
+ "1.2.4",
- sha256 = "abc",
+ sha256 = "def",
- purl = "pkg:npm/widget@1.2.3",
+ purl = "pkg:npm/widget@1.2.4",
)
val acme = Lib("com.acme", "acme-core", "1.2.3")
vendor/widget.js
diff --git a/vendor/widget.js b/vendor/widget.js
--- a/vendor/widget.js
+++ b/vendor/widget.js
@@ materialize wrote this file
-/*! widget 1.2.3 */
+/*! widget 1.2.4 */
(function(){ /* vendored bytes */ })();

Your plugin line is a different file

ZipxSelf.emit writes your GAV into generated project/plugins.sbt from the JAR on the classpath. That version is not a Plugin val. zipxDepUpdate will not rewrite it. When you publish sbt-splice 1.1.0, the consumer loads that plugin and generate rewrites the addSbtPlugin line. project/ZipxVersions.scala does not change.

If they also wrote Plugin("rocks.earlyeffect", "sbt-splice", …) on ZipxVersions, generate drops that catalog line and keeps the loaded version. The PR they open is still plugins.sbt:

selfEmitPrDiff
project/plugins.sbt
diff --git a/project/plugins.sbt b/project/plugins.sbt
--- a/project/plugins.sbt
+++ b/project/plugins.sbt
@@ generated plugins.sbt
addSbtPlugin("rocks.earlyeffect" % "sbt-zipx" % "0.5.1")
-addSbtPlugin("rocks.earlyeffect" % "sbt-splice" % "1.0.0")
+addSbtPlugin("rocks.earlyeffect" % "sbt-splice" % "1.1.0")
addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.6.2")

Nested example on the catalog PR

If this plugin repo has a nested consumer example whose generated ci.yml must track Action peels, set zipxVersionUpdatesExtraSteps: publishLocal the in-dev plugin, then zipxWorkflowGenerate in that tree. Nested .github/workflows/ is not repo-root, so the bot can commit it. Root ci.yml still needs a human generate. Full recipe: Dependency updates.