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
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=falsePR 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
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)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
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.