Dependency updates
Libraries, plugins, and GitHub Actions go stale. zipx replaces Scala Steward and a github-actions Dependabot
ecosystem: typed constructors in project/ZipxVersions.scala, a scheduled companion that rewrites those constructors
and opens a PR.
ZipxVersions is required. zipxCheckDeps fails undeclared GAVs. Leftover zipx-scala-steward.yml fails generate
(zipxLeftoverSteward). Do not install a search-replace bot; it cannot move version with sha256 / purl / git SHA.
Four catalog kinds:
GitHub Actions.
Action("owner/repo", "vX.Y.Z", sha = "…").zipxActionUpdatetalks to the GitHub API, peels a tag to a 40-hex SHA, and queries OSV. See Action pins.Scala libraries and sbt plugins.
Lib/Pluginvals.zipxDepUpdate. See Versions.Pins that are not Maven and not Actions.
Pinvals. A pin feed is lookup and policy only.zipxPinUpdate, or CI openszipx/pin-updates-$GITHUB_RUN_IDif you opt a feed intoUpdate. See Pin feeds.
Scheduled PR
Default zipxVersionUpdates := true writes .github/workflows/zipx-version-updates.yml (schedule plus
workflow_dispatch). The default schedule is Sunday 00:00 UTC; set zipxVersionUpdatesSchedule to change it
(Cron.daily, Cron.weekly, Cron.raw). The job installs cs via zipx-sbt-setup (coursier: true), runs
cs launch --ttl Inf --repository m2Local --repository ivy2Local --repository central rocks.earlyeffect:zipx-cli_3:$ZIPX_CLI_VERSION -- catalog update --yes --verify-load
(a release writes ZIPX_CLI_VERSION to project/zipx-ci.env; in-dev dogfood exports it from zipxVersionUpdatesPreSteps). --repository ivy2Local is how zipx dogfood resolves a just-publishLocal'd -SNAPSHOT CLI (sbt 2's publishLocal writes the Ivy local repository, which default cs does not search). Empty local repositories on a consumer runner are a no-op. Then
zipxPinUpdate yes, zipxAgentEnvGenerate, zipxSbtEnvGenerate, zipxCatalogGenerate, and zipxSnapshotAdvance, and opens a PR as github-actions[bot] unless App secrets are set (below). The branch is
zipx/version-updates-$GITHUB_RUN_ID so a second dispatch cannot overwrite an open PR. The PR is labeled clean,
so Verify runs cleanFull (same label as a one-off human PR). That PR is every ZipxVersions row kind: Lib / Plugin /
Action / Pin constructors, plus plugins.sbt and composites when those moved.
The companion never writes repo-root .github/workflows/. GITHUB_TOKEN cannot push those files: GitHub App
tokens need a workflows git permission that permissions: cannot grant (the same reject Scala Steward hits). The job
parameterizes instead of rewriting YAML:
JDK and runner come from
project/zipx-ci.envat runtime.Java and sbt Action pins live in
zipx-sbt-setup(the bot can push.github/actions/).Checkout is a major tag (
actions/checkout@v7).uses:cannot be an expression, so a SHA pin would force a workflow rewrite. Rootci.ymlstays SHA-pinned.git addexcludes repo-root.github/workflowsonly. Nested trees such asexamples/monorepo/.github/workflows/are staged.
zipxVersionUpdatesPreSteps (default empty) runs after setup and before zipx-cli apply. zipx dogfoods this to
publishLocal the whole in-dev graph (not cli/publishLocal alone: cs launch still needs zipx-core and
zipx-syntax at the same version) and export ZIPX_CLI_VERSION on GITHUB_ENV, so Sunday cs launch can resolve
that version without baking an in-dev version into committed zipx-ci.env.
zipxVersionUpdatesExtraSteps (default empty) runs after zipxCatalogGenerate and before the PR opens. Any zipx repo
can set it. The usual case is an sbt plugin whose nested example (or scripted fixture) must see the in-dev plugin:
publishLocal, then zipxWorkflowGenerate in that tree. Nested .github/workflows/ is not repo-root, so
GITHUB_TOKEN can commit that ci.yml and its composites.
zipxVersionUpdatesExtraSteps := Seq(
Step.run(publish).named("Publish plugin locally"),
Step.run(generate).named("Generate example workflow").in("examples/foo"),
)
zipx dogfoods this for examples/monorepo (ExampleCheck.companionSteps in project/). You do not regenerate that
example by hand on every Action pin bump.
Generate the companion YAML once from a clone (human zipxWorkflowGenerate); the bot then leaves the companion file
alone. A checkout SHA bump in the catalog can still make root zipxWorkflowCheck fail. The PR body names this run's
branch (zipx/version-updates-$GITHUB_RUN_ID) and the exact commands to regenerate repo-root workflows onto it:
git fetch origin zipx/version-updates-<run-id>
git checkout zipx/version-updates-<run-id>
sbt zipxWorkflowGenerate
git add .github/workflows
git commit -m "ci: regenerate workflows"
git push origin zipx/version-updates-<run-id>
Do not commit those root workflow files to main. Composites under .github/actions/ (and nested example YAML) are
already in the bot commit.
zipxVersionUpdates := false deletes the companion.
zipxSnapshotAdvance looks snapshot pins up in the zipxReleaseWorkflow registry, so a private one (GitHub Packages,
a ZipxMaven host) puts its credential secrets in the job env, for example
GH_PACKAGES_TOKEN: ${{ secrets.GH_PACKAGES_TOKEN }}. Central and file: registries are read anonymously, and
their publish secrets stay out of the job. zipxEnv is not copied in: the job's GITHUB_TOKEN is the one that pushes
the PR branch.
Required repo/org setting: Allow GitHub Actions to create and approve pull requests. No PAT.
GITHUB_TOKEN still opens the PR by default. Since GitHub's 11 June 2026 change, that pull_request CI waits for a
write-access Approve workflows to run. Set org (or repo) secrets ZIPX_APP_ID and ZIPX_APP_PRIVATE_KEY to mint
an installation token before checkout: the PR author is the App, a write collaborator, and CI starts on its own. Both
secrets or neither; exactly one fails the job. Grant the App contents + pull-requests + issues, not workflows. Do
not loosen Fork pull request workflows to skip the banner: that would also auto-run stranger forks.
VersionUpdatesWorkflow.render(ActionPins.Defaults).yaml# Generated by zipx. Do not edit. Run 'sbt zipxWorkflowGenerate' to regenerate.
name: zipx version updates
"on":
schedule:
- cron: "0 0 * * 0"
workflow_dispatch: null
permissions:
contents: write
pull-requests: write
issues: write
jobs:
version-updates:
name: Catalog version updates
runs-on: ubuntu-latest
env:
COURSIER_TTL: "0s"
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- name: Detect GitHub App credentials
id: zipx-app
run: |
set -euo pipefail
if [ -n "$ZIPX_APP_ID" ] && [ -n "$ZIPX_APP_PRIVATE_KEY" ]; then
echo "present=true" >> "$GITHUB_OUTPUT"
elif [ -n "$ZIPX_APP_ID" ] || [ -n "$ZIPX_APP_PRIVATE_KEY" ]; then
echo "zipx: ZIPX_APP_ID and ZIPX_APP_PRIVATE_KEY must both be set, or neither."
exit 1
else
echo "present=false" >> "$GITHUB_OUTPUT"
fi
env:
ZIPX_APP_ID: ${{ secrets.ZIPX_APP_ID }}
ZIPX_APP_PRIVATE_KEY: ${{ secrets.ZIPX_APP_PRIVATE_KEY }}
- name: Mint GitHub App token
id: zipx-app-token
if: steps.zipx-app.outputs.present == 'true'
uses: actions/create-github-app-token@v3
with:
app-id: ${{ secrets.ZIPX_APP_ID }}
private-key: ${{ secrets.ZIPX_APP_PRIVATE_KEY }}
- name: Export GitHub App token
if: steps.zipx-app.outputs.present == 'true'
run: |
set -euo pipefail
echo "GITHUB_TOKEN=$APP_TOKEN" >> "$GITHUB_ENV"
echo "GH_TOKEN=$APP_TOKEN" >> "$GITHUB_ENV"
env:
APP_TOKEN: ${{ steps.zipx-app-token.outputs.token }}
- uses: actions/checkout@v7
with:
token: ${{ steps.zipx-app-token.outputs.token || secrets.GITHUB_TOKEN }}
persist-credentials: "true"
- name: Load zipx CI params
id: zipx-ci
run: |
set -euo pipefail
. project/zipx-ci.env
echo "java-version=$ZIPX_JAVA_VERSION" >> "$GITHUB_OUTPUT"
echo "runner-os=$ZIPX_RUNNER_OS" >> "$GITHUB_OUTPUT"
- name: zipx sbt setup
uses: ./.github/actions/zipx-sbt-setup
with:
java-version: ${{ steps.zipx-ci.outputs.java-version }}
runner-os: ${{ steps.zipx-ci.outputs.runner-os }}
cache-key-suffix: version-updates
node-version: ""
sbt-disk-cache: "false"
cache-mode: restore
coursier: "true"
- name: Apply catalog updates
run: |
set -euo pipefail
. project/zipx-ci.env
if [ -z "${ZIPX_CLI_VERSION:-}" ]; then
echo "zipx: ZIPX_CLI_VERSION is unset. A release writes it to project/zipx-ci.env; dogfood sets it from zipxVersionUpdatesPreSteps."
exit 1
fi
cs launch --ttl Inf --repository m2Local --repository ivy2Local --repository central "rocks.earlyeffect:zipx-cli_3:$ZIPX_CLI_VERSION" -- catalog update --yes --verify-load
sbt "zipxPinUpdate yes"
sbt zipxAgentEnvGenerate
sbt zipxSbtEnvGenerate
sbt zipxCatalogGenerate
sbt zipxSnapshotAdvance
- name: Open update PR
run: |
if [ -z "$(git status --porcelain)" ]; then
echo "No catalog updates to commit."
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git checkout -B "zipx/version-updates-${GITHUB_RUN_ID}"
git add --all -- . ":!.github/workflows"
if [ -z "$(git diff --cached --name-only)" ]; then
echo "No catalog updates to commit."
exit 0
fi
git commit -m "ci: zipx version updates"
git push -u origin HEAD
gh label create clean --force --description "zipx: Verify runs cleanFull" || true
cat > /tmp/zipx-pr-body.md <<EOF
Applied zipx-cli catalog update and zipxPinUpdate.
## Repo-root workflow YAML is not in this PR
The bot cannot push repo-root .github/workflows/ (GITHUB_TOKEN has no workflows permission). Composites under .github/actions/ are already in this commit. Nested example YAML (examples/monorepo/.github/, including that tree's ci.yml) is also in this commit when the companion regenerated it.
If zipxWorkflowCheck fails on repo-root ci.yml (typical after an Action pin bump, especially checkout), regenerate workflows on this PR branch and push. Do not commit those files to main.
From a clone of this repo:
git fetch origin zipx/version-updates-${GITHUB_RUN_ID}
git checkout zipx/version-updates-${GITHUB_RUN_ID}
sbt zipxWorkflowGenerate
git add .github/workflows
git commit -m "ci: regenerate workflows"
git push origin zipx/version-updates-${GITHUB_RUN_ID}
The PR branch is zipx/version-updates-${GITHUB_RUN_ID}.
EOF
gh pr create --title "ci: zipx version updates" --body-file /tmp/zipx-pr-body.md --head "zipx/version-updates-${GITHUB_RUN_ID}" --label clean
{
val extra = List(
Step
.run(Script.strict(Exec("sbt", Word.squote("publishLocal"))))
.named("Publish plugin locally")
.build,
Step
.run(Script.strict(Exec("sbt", Word.squote("zipxWorkflowGenerate"))))
.named("Generate example workflow")
.in("examples/foo")
.build,
)
VersionUpdatesWorkflow.render(ActionPins.Defaults, extraSteps = extra).yaml
}# Generated by zipx. Do not edit. Run 'sbt zipxWorkflowGenerate' to regenerate.
name: zipx version updates
"on":
schedule:
- cron: "0 0 * * 0"
workflow_dispatch: null
permissions:
contents: write
pull-requests: write
issues: write
jobs:
version-updates:
name: Catalog version updates
runs-on: ubuntu-latest
env:
COURSIER_TTL: "0s"
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- name: Detect GitHub App credentials
id: zipx-app
run: |
set -euo pipefail
if [ -n "$ZIPX_APP_ID" ] && [ -n "$ZIPX_APP_PRIVATE_KEY" ]; then
echo "present=true" >> "$GITHUB_OUTPUT"
elif [ -n "$ZIPX_APP_ID" ] || [ -n "$ZIPX_APP_PRIVATE_KEY" ]; then
echo "zipx: ZIPX_APP_ID and ZIPX_APP_PRIVATE_KEY must both be set, or neither."
exit 1
else
echo "present=false" >> "$GITHUB_OUTPUT"
fi
env:
ZIPX_APP_ID: ${{ secrets.ZIPX_APP_ID }}
ZIPX_APP_PRIVATE_KEY: ${{ secrets.ZIPX_APP_PRIVATE_KEY }}
- name: Mint GitHub App token
id: zipx-app-token
if: steps.zipx-app.outputs.present == 'true'
uses: actions/create-github-app-token@v3
with:
app-id: ${{ secrets.ZIPX_APP_ID }}
private-key: ${{ secrets.ZIPX_APP_PRIVATE_KEY }}
- name: Export GitHub App token
if: steps.zipx-app.outputs.present == 'true'
run: |
set -euo pipefail
echo "GITHUB_TOKEN=$APP_TOKEN" >> "$GITHUB_ENV"
echo "GH_TOKEN=$APP_TOKEN" >> "$GITHUB_ENV"
env:
APP_TOKEN: ${{ steps.zipx-app-token.outputs.token }}
- uses: actions/checkout@v7
with:
token: ${{ steps.zipx-app-token.outputs.token || secrets.GITHUB_TOKEN }}
persist-credentials: "true"
- name: Load zipx CI params
id: zipx-ci
run: |
set -euo pipefail
. project/zipx-ci.env
echo "java-version=$ZIPX_JAVA_VERSION" >> "$GITHUB_OUTPUT"
echo "runner-os=$ZIPX_RUNNER_OS" >> "$GITHUB_OUTPUT"
- name: zipx sbt setup
uses: ./.github/actions/zipx-sbt-setup
with:
java-version: ${{ steps.zipx-ci.outputs.java-version }}
runner-os: ${{ steps.zipx-ci.outputs.runner-os }}
cache-key-suffix: version-updates
node-version: ""
sbt-disk-cache: "false"
cache-mode: restore
coursier: "true"
- name: Apply catalog updates
run: |
set -euo pipefail
. project/zipx-ci.env
if [ -z "${ZIPX_CLI_VERSION:-}" ]; then
echo "zipx: ZIPX_CLI_VERSION is unset. A release writes it to project/zipx-ci.env; dogfood sets it from zipxVersionUpdatesPreSteps."
exit 1
fi
cs launch --ttl Inf --repository m2Local --repository ivy2Local --repository central "rocks.earlyeffect:zipx-cli_3:$ZIPX_CLI_VERSION" -- catalog update --yes --verify-load
sbt "zipxPinUpdate yes"
sbt zipxAgentEnvGenerate
sbt zipxSbtEnvGenerate
sbt zipxCatalogGenerate
sbt zipxSnapshotAdvance
- name: Publish plugin locally
run: |
set -euo pipefail
sbt 'publishLocal'
- name: Generate example workflow
run: |
set -euo pipefail
sbt 'zipxWorkflowGenerate'
working-directory: examples/foo
- name: Open update PR
run: |
if [ -z "$(git status --porcelain)" ]; then
echo "No catalog updates to commit."
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git checkout -B "zipx/version-updates-${GITHUB_RUN_ID}"
git add --all -- . ":!.github/workflows"
if [ -z "$(git diff --cached --name-only)" ]; then
echo "No catalog updates to commit."
exit 0
fi
git commit -m "ci: zipx version updates"
git push -u origin HEAD
gh label create clean --force --description "zipx: Verify runs cleanFull" || true
cat > /tmp/zipx-pr-body.md <<EOF
Applied zipx-cli catalog update and zipxPinUpdate.
## Repo-root workflow YAML is not in this PR
The bot cannot push repo-root .github/workflows/ (GITHUB_TOKEN has no workflows permission). Composites under .github/actions/ are already in this commit. Nested example YAML (examples/monorepo/.github/, including that tree's ci.yml) is also in this commit when the companion regenerated it.
If zipxWorkflowCheck fails on repo-root ci.yml (typical after an Action pin bump, especially checkout), regenerate workflows on this PR branch and push. Do not commit those files to main.
From a clone of this repo:
git fetch origin zipx/version-updates-${GITHUB_RUN_ID}
git checkout zipx/version-updates-${GITHUB_RUN_ID}
sbt zipxWorkflowGenerate
git add .github/workflows
git commit -m "ci: regenerate workflows"
git push origin zipx/version-updates-${GITHUB_RUN_ID}
The PR branch is zipx/version-updates-${GITHUB_RUN_ID}.
EOF
gh pr create --title "ci: zipx version updates" --body-file /tmp/zipx-pr-body.md --head "zipx/version-updates-${GITHUB_RUN_ID}" --label clean
What a row moves to
An update reads each row off the repository under every name sbt could publish it as:
zio_3,zio_sjs1_3, andzio_native0.5_3for a%%row;the bare artifact for a
.javarow;the full Scala version, such as
_3.9.0, for a.fullrow;_sbt2_3for a plugin.
A row is published for the platforms that list its current version, and it moves only to a version every one of them
has. A Scala.js-only row such as ascent-js is read off ascent-js_sjs1_3. A row on the JVM and Scala.js never moves
to a version only one of them has.
A .mod family shares one version literal, so it moves on that literal, to the newest version every member has. When a
member could go further alone, the update says so: give it its own Lib row to take that version. The scheduled job
and zipxDepUpdate read rows the same way, from the catalog source or the loaded build.
{
val js = Lib("rocks.earlyeffect", "ascent-js", "0.7.1")
val css = js.mod("ascent-css")
val repo = Map(
"ascent-js_sjs1_3" -> List("0.7.1", "0.9.0", "0.10.0"),
"ascent-css_3" -> List("0.7.1", "0.9.0"),
"ascent-css_sjs1_3" -> List("0.7.1", "0.9.0"),
)
val lookup: ReleaseLookup = (_, artifact) => Right(repo.get(artifact))
ZipxCatalog.outdated(List(js, css), CatalogCrossing.of("3.9.0", "2.1.0-RC1"), lookup).map { updates =>
(ZipxCatalog.formatBumps(updates.bumps) :: updates.held.map(_.message)).mkString("\n")
}
}Right(- Lib rocks.earlyeffect % ascent-js: 0.7.1 -> 0.9.0 (Minor)
ascent-js could move to 0.10.0 alone; its family moves together to 0.9.0. Give it its own Lib row to take 0.10.0.)Local apply
The same rewrite, without waiting for the schedule. You do not need to know CI YAML.
List what is stale.
zipxDepUpdatefor Maven,zipxActionUpdatefor Actions,zipxPinUpdatefor pin feeds.dry-runlists only.Say yes, or type
yat the prompt.yesapplies every listed bump. A bare command with no terminal lists and stops; passyesfrom a script. Empty Action rows:yesis a no-op (the scheduled job stays green).Reload if the catalog file changed.
project/ZipxVersions.scalais part of the build definition.Regenerate catalog outputs if a plugin, sbt, Scala, or Action version moved.
sbt zipxCatalogGeneratewritesplugins.sbt, composites, andproject/zipx-ci.env. Usesbt zipxWorkflowGeneratewhenci.ymlitself must change (checkout major, job graph). The scheduled job runszipxCatalogGenerateonly.
sbt zipxDepUpdate # list catalog bumps, then prompt
sbt "zipxDepUpdate yes" # apply all listed catalog bumps
sbt "zipxDepUpdate dry-run"
sbt zipxActionUpdate
sbt "zipxActionUpdate yes"
sbt "zipxActionUpdate dry-run"
sbt zipxPinUpdate # list pin-feed bumps, then prompt
sbt "zipxPinUpdate yes"
sbt "zipxPinUpdate dry-run"
Catalog apply rewrites constructors in the catalog file only: Lib("g", "a", "from") / Plugin(...) for Maven,
Action("owner/repo", "from", sha = …) so version and git SHA stay together, and
Pin("feed", "id", "from", sha256 = …, purl = …) so version, checksum, and PURL stay together.
Lookup skips pre-releases by default (zipxPreRelease := PreRelease.Skip). A stable 2.0.18 does not become
2.1.0-alpha1. Set zipxPreRelease := PreRelease.Include to list alphas. GitHub Action lookup already ignores
prerelease releases.
A pinned snapshot is promoted the same way: Lib("rocks.earlyeffect", "zipx-core", "0.15.0-SNAPSHOT") becomes the
latest release once one at 0.15.0 or later exists, and stays pinned while the newest release is older. See
Snapshots and releases.
When update says the catalog is behind
The catalog wins over what a library asked for (Versions), except when a library needs a newer revision than a row
states. Then update fails. The message names the row, the library, what it needs, and the command:
update ends with | Run, then reload |
|---|---|
Update the catalog: sbt zipxDepUpdate, or pin 2.1.26 | sbt "zipxDepUpdate yes", or set that one row |
That line is released: sbt 'zipxPinRelease heddle' | that command |
Move the pin to that line: sbt 'zipxSnapshotAdvance heddle 0.10.0' | that command |
commits of 0.3.0 with no order. Pin one in the catalog. | add a Lib row at one of the two commits |
The scheduled job applies the first kind. It never moves a commit pin to another line: that is a decision about what to integrate, so advance takes the line as an argument.
Typed cron
Schedules use a typed [[zipx.workflow.Cron]] AST (not raw strings):
Cron.weekly(DayOfWeek.Sunday) // 0 0 * * 0
Cron.weekly(DayOfWeek.Monday, hour = 6) // 0 6 * * 1
Cron.daily(hour = 3, minute = 15) // 15 3 * * *
Cron.hourly(minute = 45) // 45 * * * *
Cron.raw("0 */6 * * *") // escape hatch
Cron / DayOfWeek are re-exported from the plugin autoImport. The version-updates companion default is
Cron.weekly(DayOfWeek.Sunday); set zipxVersionUpdatesSchedule to change it. Pin-feed companions use the same
default.
List(
s"weekly: ${Cron.weekly(DayOfWeek.Monday, hour = 6, minute = 30).render}",
s"daily: ${Cron.daily(hour = 3).render}",
s"raw: ${Cron.raw("0 */6 * * *").render}",
).mkString("\n")weekly: 30 6 * * 1
daily: 0 3 * * *
raw: 0 */6 * * *zipxDepCleanup
Sunday bumps every selected Lib. That is wrong when another selected row already pulled the GAV (saferis docs
selecting zio-json while the theme still pulls 0.10.0). zipxDepCleanup is the doctor: per sbt project, after
update, it prints catalog rows to drop from library() and siblings that could use .fromGraph. It does not rewrite
ZipxVersions.scala. zipxWorkflowCheck does not require a clean report. Opt in to fail with zipxDepCleanupFail := true.
{
val json = SelectedLib("zioJson", "dev.zio", "zio-json", "1.0.0", "compile")
val theme = SelectedLib("specularTheme", "rocks.earlyeffect", "specular-theme", "0.1.0", "test")
val edge = CallerEdge(
"dev.zio",
"zio-json_3",
"0.10.0",
"rocks.earlyeffect",
"specular-theme_3",
"test",
)
DepCleanup.analyze("docs", List(json, theme), List(edge)).render
}zipxDepCleanup (docs):
already on the graph; drop from libraryDependencies:
zioJson (dev.zio:zio-json, selected 1.0.0; also from specular-theme_3)