Action pins

Skip this page at first. zipx already pins third-party GitHub Actions to full commit SHAs (not floating @v4 tags) in ci.yml and in composites, so a moved tag cannot change what CI runs. Version labels appear as trailing comments:

- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

Jar defaults ship in the plugin. Come back when you want to bump those Actions without waiting for a zipx release: add Action vals to project/ZipxVersions.scala and run zipxActionUpdate. There is no pin YAML to edit, and a github-actions Dependabot ecosystem is not needed.

Resolve order

When generating a workflow, zipx picks pins in this order:

yesnoyesno
  1. Leftover pin YAML: if .github/zipx/action-pins.yml is still on disk, generate fails and prints the Action(...) rows to paste. Dual source is not allowed.

  2. zipxActions: only when set away from ActionPins.Defaults (one-off / escape hatch in build.sbt)

  3. Catalog overlay: ActionPins.Defaults (jar) plus every Action val in ZipxVersions. A row whose name is a field prefix (actions/checkout) updates that field. Any other name is extra, keyed by owner/repo.

No Action vals still means jar defaults. Upgrade zipx to take new default SHAs. This repository lists every zipx-emitted Action so zipxActionUpdate can bump them before a release.

Catalog rows

Action is a catalog type next to Lib / Plugin / Pin. A git commit SHA is not a content checksum: do not reuse Pin for Actions.

val checkout = Action("actions/checkout", "v7.0.1", sha = "3d3c42e5aac5ba805825da76410c181273ba90b1")
  • name is owner/repo or owner/repo/path (the uses: prefix).

  • version is the label stamped as # vX.Y.Z.

  • sha is a full 40-hex commit SHA at construction. Combined name@sha must be a valid ActionRef.

Apply rewrites the canonical constructor so version and sha move together. zipxActionUpdate looks up GitHub releases (tags if there are none), peels the tag to a commit SHA, and queries OSV (pkg:github/owner/repo@version). Never write a floating @v4 into the catalog or into ci.yml uses:. The version-updates companion is the one exception: checkout is a major tag (actions/checkout@v7) because uses: cannot be an expression and GITHUB_TOKEN cannot push workflow SHA edits. Java and sbt pins still move in zipx-sbt-setup. See Dependency updates.

sbt zipxActionUpdate             # list, then prompt
sbt "zipxActionUpdate yes"       # rewrite constructors
sbt "zipxActionUpdate dry-run"

After apply: reload, then sbt zipxCatalogGenerate (composites, plugins.sbt, zipx-ci.env). Use sbt zipxWorkflowGenerate when ci.yml itself must move (checkout SHA, job graph).

If there are no Action rows, the command prints constructors to paste (jar Defaults compared to GitHub). yes with no rows refuses.

{
      val src =
        """val checkout = Action("actions/checkout", "v7.0.1", sha = "3d3c42e5aac5ba805825da76410c181273ba90b1")
""".stripMargin
      val action = Action("actions/checkout", "v7.0.1", sha = "3d3c42e5aac5ba805825da76410c181273ba90b1")
      val bump   = ActionBump(action, BumpKind.Minor, "v8.0.0", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
      ZipxCatalog.applyActionBumps(src, List(bump))
    }
Right(val checkout = Action("actions/checkout", "v8.0.0", sha = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
)

Pins for actions zipx does not emit

Typed fields are the actions zipx itself writes. An action you reach through extraSteps, a custom capability, or a published pack has no field. Those are Action vals whose name is not a field prefix; overlay puts them in extra, keyed by owner/repo. Packs look up by prefix (aws-actions/configure-aws-credentials), not a YAML key.

val awsCredentials = Action(
  "aws-actions/configure-aws-credentials",
  "v6.0.0",
  sha = "b47578312673ae6fa5b5096b330d9fbac3d13d67",
)

In Scala, zipxActions := ActionPins.Defaults.copy(...) / .withExtra is the one-off hatch. Prefer a catalog row.

A typed field and an extra pin are not interchangeable:

typed fieldextra (non-field Action name)
Foran action zipx emitsan action your steps or a pack emit
Keyclosed set of prefixesowner/repo
Ref must be pinnedyesyes
Ref must name the right actionyes, via the field's known prefixno prefix check beyond the name you wrote

checkout as evil/malware@<sha> is refused because that field may only name actions/checkout.

{
  val aws = Action(
    "aws-actions/configure-aws-credentials",
    "v6.0.0",
    sha = "b47578312673ae6fa5b5096b330d9fbac3d13d67",
  )
  ActionPins.overlay(ActionPins.Defaults, List(aws))
}
Right(ActionPins(actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1,actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961,sbt/setup-sbt@c7d2d6258b4bd0d3ec5129e6b3453199d3c79729,actions/setup-node@820762786026740c76f36085b0efc47a31fe5020,actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9,actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a,actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c,HashMap(setupJava -> v5.7.0, setupNode -> v7.0.0, extra.coursier/setup-action -> v3.0.2, downloadArtifact -> v8.0.1, extra.aws-actions/amazon-ecr-login -> v2.1.7, setupSbt -> v1.5.8, uploadArtifact -> v7.0.1, checkout -> v7.0.1, extra.aws-actions/configure-aws-credentials -> v6.0.0, cache -> v6.1.0),Map(aws-actions/amazon-ecr-login -> aws-actions/amazon-ecr-login@03f1aad4c6c7ffd436567f42f9384779290529bd, aws-actions/configure-aws-credentials -> aws-actions/configure-aws-credentials@b47578312673ae6fa5b5096b330d9fbac3d13d67, coursier/setup-action -> coursier/setup-action@9b7939bf01fd1185ce2babe16135168361bf2c62)))

`setupNode`: a typed field zipx emits only on request

setupNode is a typed field, like checkout, because zipx writes the step itself. Unlike the others it appears in a workflow only when a capability asks for a Node version:

zipxCapabilities += Capability.testGraph.withNodeVersion(NodeVersion("22"))

Off by default, and for a Scala.js build that is usually right: sbt-scalajs downloads its own Node for jsEnv, so a plain .jsPlatform test suite needs nothing here. Ask for it when the version matters, which is narrower than "the build has JS in it": a jsEnv requiring a specific Node, or a step running npm ci for a bundler.

The version is a NodeVersion newtype, so every form setup-node accepts (22, 22.11.0, latest, lts/jod, lts/*) is checked while your build.sbt compiles, and a value that would break the YAML is a compile error rather than a workflow GitHub rejects.

Per-capability rather than build-wide, which is the difference from zipxJavaVersion: a Node toolchain belongs to one test suite, so asking for it must not put a Node tool on every publish job in the build. The version is an input on zipx-sbt-setup (after checkout), so a jsEnv and an npm ci both see it before any cache restore or extraSteps.

DocsRender.job("test-schema")(Capability.testGraph.withNodeVersion(NodeVersion("22")))
test-schema:
  name: test schema
  runs-on: ubuntu-latest
  needs:
    - affected
  if: (!startsWith(github.ref, 'refs/tags/') && github.event_name != 'workflow_dispatch') && (!cancelled() && (contains(fromJson(needs.affected.outputs.modules), 'schema') || contains(fromJson(needs.affected.outputs.modules), 'all')))
  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-schema
        node-version: "22"
        sbt-disk-cache: "false"
        local-cache: "true"
        cache-epoch: "0.1.0-ci"
    - name: test
      run: sbt 'schema/test'

Without withNodeVersion the same job passes an empty node-version input (the composite skips setup-node), so adopting this changes only the capability that asked:

DocsRender.job("test-schema")(Capability.testGraph)
test-schema:
  name: test schema
  runs-on: ubuntu-latest
  needs:
    - affected
  if: (!startsWith(github.ref, 'refs/tags/') && github.event_name != 'workflow_dispatch') && (!cancelled() && (contains(fromJson(needs.affected.outputs.modules), 'schema') || contains(fromJson(needs.affected.outputs.modules), 'all')))
  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-schema
        node-version: ""
        sbt-disk-cache: "false"
        local-cache: "true"
        cache-epoch: "0.1.0-ci"
    - name: test
      run: sbt 'schema/test'

Leftover pin YAML

Nothing under .github/zipx/ is an editable source. If a committed .github/zipx/action-pins.yml is still present (old consumer or this repo before the catalog), generate fails with the Action(...) vals to paste. No silent dual source.

zipx: .github/zipx/action-pins.yml is leftover input. Action pins live in project/ZipxVersions.scala. Delete …
  val checkout = Action("actions/checkout", "v7.0.1", sha = "…")
Then sbt reload and sbt zipxWorkflowGenerate.
ZipxCatalog.leftoverPinFileError(ActionPinFile.DefaultPath, ActionPins.Defaults)
zipx: .github/zipx/action-pins.yml is leftover input. Action pins live in project/ZipxVersions.scala. Delete .github/zipx/action-pins.yml and paste:

  val checkout = Action("actions/checkout", "v7.0.1", sha = "3d3c42e5aac5ba805825da76410c181273ba90b1")
  val setupJava = Action("actions/setup-java", "v5.7.0", sha = "b6effb05e454b25005698d916606bdc6ffcbf961")
  val setupSbt = Action("sbt/setup-sbt", "v1.5.8", sha = "c7d2d6258b4bd0d3ec5129e6b3453199d3c79729")
  val setupNode = Action("actions/setup-node", "v7.0.0", sha = "820762786026740c76f36085b0efc47a31fe5020")
  val cache = Action("actions/cache", "v6.1.0", sha = "55cc8345863c7cc4c66a329aec7e433d2d1c52a9")
  val uploadArtifact = Action("actions/upload-artifact", "v7.0.1", sha = "043fb46d1a93c77aae656e7c1c64a875d1fc6a0a")
  val downloadArtifact = Action("actions/download-artifact", "v8.0.1", sha = "3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c")

Then sbt reload and sbt zipxWorkflowGenerate.

Friction ladder

GoalWhat to do
Stay on zipx release defaultsNo Action vals; upgrade sbt-zipx when we bump pins
Track actionsAction vals in ZipxVersions; scheduled zipx-version-updates.yml (or sbt "zipxActionUpdate yes"; reload; generate)
One-off exotic pinzipxActions := ActionPins.Defaults.copy(...) in build.sbt

build.sbt escape hatch

Prefer catalog rows for ongoing SHA tracking. Use zipxActions only for temporary or exotic overrides:

zipxActions := ActionPins.Defaults.copy(
  setupSbt = ActionRef("sbt/setup-sbt@d059c39de700f4cc5cb64f9f56577315e44a984e"),
)

The ActionRef(...) wrapper is not ceremony: it is validated while your build.sbt compiles, so ActionRef("sbt/setup-sbt") is a compile error naming the missing @ref rather than an unpinned action in ci.yml. For a ref your build computes rather than writes out, ActionRef.make(...) returns an Either instead.

An explicit zipxActions that differs from ActionPins.Defaults wins over catalog rows.

How jar defaults stay honest

In the zipx repository, Action vals in project/ZipxVersions.scala are the editable source. At compile time, resourceGenerators renders zipx/action-pins.yml into the zipx-core jar from those rows (overlay onto bootstrap so a missing field still has a pin). That YAML lives in the jar / target/, not as something you commit and edit. ActionPins.Defaults loads the classpath resource at runtime.

Release dogfood: the scheduled companion applies zipxActionUpdate yes and zipxCatalogGenerate. Locally: sbt zipxActionUpdate yesreloadzipxCatalogGenerate (and zipxWorkflowGenerate if ci.yml must move) → compile/publish. A zipx release is how consumers on jar defaults move.

A github-actions Dependabot ecosystem is leftover, not the ladder.

Settings and tasks

Setting / taskRole
zipxActionRowscollected Action vals (from MyVersions.settings)
zipxActionsPathlegacy path we refuse when the file is still on disk
zipxActionsexplicit ActionPins override (escape hatch)
zipxActionUpdateGitHub releases + SHA peel + OSV; rewrite constructors after yes
zipxCatalogGeneratewrite composites, plugins.sbt, zipx-ci.env (not workflow YAML)
zipxWorkflowGenerate / zipxWorkflowCheckwrite / verify ci.yml

Pinned actions today: actions/checkout, actions/setup-java, sbt/setup-sbt, actions/setup-node, actions/cache, actions/upload-artifact, actions/download-artifact, plus ZipxAws extras aws-actions/configure-aws-credentials and aws-actions/amazon-ecr-login. Anything else your steps use is an extra Action val.

Pins that are not GitHub Actions (CDN + sha256, tarball tags, vendor files) are Pin feeds, a different machine. See Pin feeds and Dependency updates.