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:

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.env at 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. Root ci.yml stays SHA-pinned.

  • git add excludes repo-root .github/workflows only. Nested trees such as examples/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.

  1. List what is stale. zipxDepUpdate for Maven, zipxActionUpdate for Actions, zipxPinUpdate for pin feeds. dry-run lists only.

  2. Say yes, or type y at the prompt. yes applies every listed bump. A bare command with no terminal lists and stops; pass yes from a script. Empty Action rows: yes is a no-op (the scheduled job stays green).

  3. Reload if the catalog file changed. project/ZipxVersions.scala is part of the build definition.

  4. Regenerate catalog outputs if a plugin, sbt, Scala, or Action version moved. sbt zipxCatalogGenerate writes plugins.sbt, composites, and project/zipx-ci.env. Use sbt zipxWorkflowGenerate when ci.yml itself must change (checkout major, job graph). The scheduled job runs zipxCatalogGenerate only.

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)