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 m2Local is how zipx dogfood resolves a just-publishLocal'd 0.0.0-ci CLI (sbt 2 writes Maven local; default cs does not search it). Empty m2Local on a consumer runner is a no-op. Then
zipxPinUpdate yes and zipxCatalogGenerate, 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 dynver) and export ZIPX_CLI_VERSION on GITHUB_ENV, so Sunday cs launch can resolve that
version without baking dynver 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.
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:
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"
local-cache: "true"
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 zipxCatalogGenerate
- 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 || true
{
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:
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"
local-cache: "true"
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 zipxCatalogGenerate
- 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 || true
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.
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)