Pin feeds

Skip until you pin something that is not a Maven library and not a GitHub Action: a CDN URL plus a checksum, a tarball tag, a file you vendor into the repo.

GitHub Actions live in the catalog as Action vals (zipxActionUpdate). Library and plugin versions live as Lib / Plugin. Pins live in that same catalog as Pin vals. A pin feed is only lookup and policy (Ignore / Report / Update), plus optional materialize for extra files.

zipx owns topology, OSV, and the catalog rewrite. The feed looks up the next version and checksum.

Why a feed

A repo that vendors JS bytes has no package.json. Dependabot never opens a PR when lib-a gets a CVE. A third one-off bot would repeat the catalog split badly. A feed is the same split: zipx schedules, gates, and rewrites Pin(...) in project/ZipxVersions.scala; the feed knows how to talk to jsDelivr / npm / tags.

Who owns what

zipxcatalogyour feed

Inventory is the catalog. Outdated is the feed's lookup plus a VersionStrategy (npm or exact). Advisory is a PURL on the Pin and OSV zipx queries. Apply is zipx rewriting the Pin(...) constructor (version, sha256, and purl together). materialize is only for extra files, such as vendored JS bytes.

What runs where

Pin OSV on a PR folds into the builtin advisories job (zipxAdvisoryCheck), so zipx does not emit two advisory jobs. Cron cannot live on ci.yml or it would also run test and publish on that schedule. Snapshot submit is contents: write and must not run on a PR (that pollutes the Security tab).

Catalog Pin vals

Write Pin next to Lib / Plugin. Keep the canonical constructor so apply can rewrite it:

// project/ZipxVersions.scala
val preact = Pin("cdn", "preact", "10.26.4", sha256 = "sha256-abc", purl = "pkg:npm/preact@10.26.4")

MyVersions.settings collects every Pin val into zipxPins. A Pin whose feed name is not in zipxPinFeeds fails generate.

Register a feed, alert-only

Conservative defaults: outdated = Ignore, advisory = Report, submitSnapshot = false. Do not auto-bump; do fail a PR that pins a known CVE. No inventory list. No apply callback.

zipxPinFeeds += PinFeed(
  name = PinFeedName("cdn"),
  classify = VersionStrategy.npm,
  lookup = pin => lookupLatest(pin),   // Right(Some(PinCandidate(version, sha256, purl)))
)
List(
  s"outdated=${fakeFeed.outdated}",
  s"advisory=${fakeFeed.advisory}",
  s"submitSnapshot=${fakeFeed.submitSnapshot}",
).mkString("\n")
outdated=Ignore
advisory=Report
submitSnapshot=false

PR gate

When zipxPinFeeds is non-empty, some feed has advisory != Ignore, and zipxPinPrGate != Off, pin OSV runs inside zipxAdvisoryCheck (the advisories job). Same-name replace still works if you add Capability.pinCheck yourself. Default is parallel: advisories does not needs test. To fail-closed even when only test is a required check:

zipxCapabilities += Capability.test.copy(needsCapabilities = List(Capability.AdvisoriesName))

zipxVerify := ZipxVerify.Strict.copy(advisories = VerifyOpt.Skip("reason")) skips the whole job, including pin OSV.

DocsRender.jobs("advisories", "test")(
  Capability.once(Capability.AdvisoriesName, SbtCommand.unsafeTask("zipxAdvisoryCheck")),
  Capability.test,
)
advisories:
  name: advisories
  runs-on: ubuntu-latest
  if: "!startsWith(github.ref, 'refs/tags/') && github.event_name != 'workflow_dispatch'"
  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: advisories
        node-version: ""
        sbt-disk-cache: "false"
        local-cache: "true"
        cache-epoch: "0.1.0-ci"
    - name: advisories
      run: sbt 'zipxAdvisoryCheck'
test:
  name: test
  runs-on: ubuntu-latest
  if: "!startsWith(github.ref, 'refs/tags/') && github.event_name != 'workflow_dispatch'"
  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
        node-version: ""
        sbt-disk-cache: "false"
        local-cache: "true"
        cache-epoch: "0.1.0-ci"
    - name: test
      run: sbt 'test'

PinPrGate

Introduced fetches ZIPX_PIN_BASE_SHA and diffs inventory from a worktree at that SHA. Off is the interim "we know, ship anyway" without deleting feeds.

When OSV does not know the pin

nonepkg:...empty vulnsHTTP or parse erroradvisory at or above min-severity

Empty vulns is no known advisory, not "safe." Private packages must stay green. An all-private feed (every pin purl = None) is a successful no-op. Unreachable OSV fails the job: a check that could not run must not go green. Public-ecosystem PURLs (pkg:npm/..., pkg:maven/...) are the ones the gate is for.

Scheduled outdated / Update

.github/workflows/zipx-pin-check.yml is scheduled plus workflow_dispatch (default Sunday 00:00 UTC). sbt zipxPinCheck runs lookup + OSV. When some feed uses Update, the companion is contents: write, pull-requests: write, and issues: write, checks out with GITHUB_TOKEN (or a GitHub App installation token when ZIPX_APP_ID / ZIPX_APP_PRIVATE_KEY are set; same secrets as the version-updates companion), rewrites Pin(...) in the catalog, runs optional materialize, and gh pr creates zipx/pin-updates-$GITHUB_RUN_ID (labeled clean). App secrets skip the Approve workflows to run banner on that PR; unset secrets keep github-actions[bot] and GitHub holds CI. That PR does not rewrite .github/workflows/ (GITHUB_TOKEN has no workflows permission; do not grant the App that either). Alert-only stays contents: read and never opens a PR. The pin-check capability never applies.

Required repo/org setting (only needed for Update): Allow GitHub Actions to create and approve pull requests. Do not loosen Fork pull request workflows to skip the banner.

PinCheckWorkflow.render(ActionPins.Defaults, "21", "ubuntu-latest", hasUpdate = true).yaml
# Generated by zipx. Do not edit. Run 'sbt zipxWorkflowGenerate' to regenerate.
name: zipx pin check
"on":
  schedule:
    - cron: "0 0 * * 0"
  workflow_dispatch: null
permissions:
  contents: write
  pull-requests: write
  issues: write
jobs:
  pin-check:
    name: Check pin feeds
    runs-on: ubuntu-latest
    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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          token: ${{ steps.zipx-app-token.outputs.token || secrets.GITHUB_TOKEN }}
          persist-credentials: "true"
      - name: Setup JDK
        uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
        with:
          distribution: temurin
          java-version: "21"
      - uses: sbt/setup-sbt@c7d2d6258b4bd0d3ec5129e6b3453199d3c79729 # v1.5.8
      - name: Check pin feeds
        run: sbt zipxPinCheck
      - name: Open update PR
        run: |
          if [ -z "$(git status --porcelain)" ]; then
            echo "No pin 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/pin-updates-${GITHUB_RUN_ID}"
          git add --all -- . ":!.github/workflows"
          if [ -z "$(git diff --cached --name-only)" ]; then
            echo "No pin updates to commit."
            exit 0
          fi
          git commit -m "ci: apply zipx pin feed 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 pin feed Update policy.
          EOF
          gh pr create --title "ci: zipx pin feed updates" --body-file /tmp/zipx-pr-body.md --head "zipx/pin-updates-${GITHUB_RUN_ID}" --label clean || true

What the Update PR looks like

The PR always touches project/ZipxVersions.scala. Version, sha256, and purl move together. Lib / Plugin rows stay put. A vendored file appears only if the feed's materialize wrote one.

An sbt plugin that ships the feed still sees this catalog hunk (plus that extra file). It does not also bump its own addSbtPlugin line. That split is Extending Versions.

DocDiff.stack(catalogPinPrDiff, vendorPinPrDiff)
project/ZipxVersions.scala
diff --git a/project/ZipxVersions.scala b/project/ZipxVersions.scala
--- a/project/ZipxVersions.scala
+++ b/project/ZipxVersions.scala
@@ object MyVersions extends ZipxVersions
val preact = Pin(
"cdn",
"preact",
- "10.26.4",
+ "10.26.5",
- sha256 = "abc",
+ sha256 = "def",
- purl = "pkg:npm/preact@10.26.4",
+ purl = "pkg:npm/preact@10.26.5",
)
val zio = Lib("dev.zio", "zio", "2.1.26")
vendor/preact.min.js
diff --git a/vendor/preact.min.js b/vendor/preact.min.js
--- a/vendor/preact.min.js
+++ b/vendor/preact.min.js
@@ materialize wrote this file
-/*! preact 10.26.4 */
+/*! preact 10.26.5 */
(function(){ /* vendored bytes */ })();

Local update with approval

The usual path is local, then you open the PR. Alert-only is the default (outdated = Ignore), so the scheduled job will not rewrite pins for you. See Dependency updates for the same loop next to catalog bumps.

sbt zipxPinUpdate           # list, then prompt Apply N pin update(s)? [y/N]
sbt "zipxPinUpdate yes"     # rewrite Pin constructors, then materialize
sbt "zipxPinUpdate dry-run" # list only

zipxPinUpdate always looks up latest, even when the feed is alert-only. yes applies every listed bump. With no terminal, a bare command lists and stops. After apply, commit and open a pull request yourself (unless the feed is Update and CI already opened zipx/pin-updates-$GITHUB_RUN_ID).

PinEngine
  .outdated(List(fakeFeed), List(fakePin))
  .map(PinEngine.formatBumps)
  .yaml
- cdn lib-a: 1.2.3 -> 1.2.4 (Patch)

Snapshot submit

Opt in per feed (submitSnapshot = true). Emitted only then, as .github/workflows/zipx-pin-snapshot.yml on push to zipxPushBranches. Dependabot security-update auto-PRs stay off for submitted deps: those PRs would bump a version and leave a sha256 stale.

Snapshot never runs on a PR.

PinSnapshotWorkflow.render(ActionPins.Defaults, "21", "ubuntu-latest", List("main")).yaml
# Generated by zipx. Do not edit. Run 'sbt zipxWorkflowGenerate' to regenerate.
name: zipx pin snapshot
"on":
  push:
    branches:
      - main
permissions:
  contents: write
jobs:
  pin-snapshot:
    name: Submit pin snapshot
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      - name: Setup JDK
        uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
        with:
          distribution: temurin
          java-version: "21"
      - uses: sbt/setup-sbt@c7d2d6258b4bd0d3ec5129e6b3453199d3c79729 # v1.5.8
      - name: Submit dependency snapshot
        run: sbt zipxPinSubmit

The versions catalog

project/ZipxVersions.scalalocal

Lib / Plugin / Pin / Action vals share one catalog file. zipxDepUpdate rewrites Maven constructors. zipxPinUpdate rewrites Pin constructors. zipxActionUpdate rewrites Action constructors. A pin feed is only for pins that have no Maven coordinate: a CDN URL plus a checksum, a tarball tag, a file you vendor into the repo.