related with: open-circle/valibot#1474
Motivation
GitHub Actions referenced by mutable tags (e.g. actions/checkout@v6) are vulnerable to supply-chain attacks: anyone with write access to the action's repository can force-push the tag to point at malicious code, and consumers will silently pull it on the next workflow run.
Why this matters specifically for a widely-used OSS library
This is not just a generic best practice — the threat model has sharper edges for an OSS library like formisch:
- Asymmetric blast radius. A successful compromise of formisch's release pipeline propagates to every downstream consumer on npm. The asymmetry between "one compromised CI run" and "thousands of poisoned installs" is fundamentally different from internal/proprietary projects. formisch ships six separate framework packages (
@formisch/preact, qwik, react, solid, svelte, vue) from the same publish workflow, so a single compromise fans out to all of them at once.
- The publish workflow is a high-value target.
.github/workflows/publish.yml runs with id-token: write for trusted publishing to npm. Any action invoked from that workflow (including actions/checkout) is in the trust boundary of the release artifact.
- Public attack surface. The workflow files, action versions, and dependency graph are all public. An attacker can enumerate "which popular OSS libraries trust which actions" at zero reconnaissance cost.
- Recent incidents have targeted exactly this pattern. The Shai-Hulud npm worm propagated by stealing maintainer publish tokens through GitHub Actions and re-publishing poisoned versions of the compromised maintainer's packages — the precise threat that an
id-token: write workflow on a popular OSS package faces. Other recent OSS-targeted incidents (axios, Mini Shai-Hulud, TanStack Router) reinforce that being widely-used and well-maintained does not, by itself, provide protection.
- GitHub and OpenSSF recommend this for OSS maintainers. Both publish this as standard guidance for OSS projects specifically because of the points above. The
actions/checkout README itself recommends pinning to a full length commit SHA.
CI is part of the same threat model: a single tag rewrite on a popular action can compromise every workflow that references it, and for an OSS library that means compromising every downstream user.
OpenSSF Scorecard's Pinned-Dependencies check recommends pinning all GitHub Actions — first-party and third-party alike — to full-length commit SHAs.
Current state
| Action |
Current pin |
Locations |
actions/checkout |
@v6 |
ci.yml (×26), publish.yml (×6) |
actions/setup-node |
@v4 |
.github/actions/environment/action.yml |
pnpm/action-setup |
@v4 |
.github/actions/environment/action.yml |
No Dependabot or Renovate configuration is present, so action updates currently rely on manual review.
Proposal
-
Pin every external action to a full-length commit SHA, keeping the human-readable version in a trailing comment:
- uses: actions/checkout@<40-char-sha> # v6.x.x
-
Add .github/dependabot.yml enabling the github-actions ecosystem with a weekly schedule and grouped updates, so SHAs stay current via a single weekly PR rather than going stale.
-
Upgrade pnpm from v10 to v11, which ships supply-chain hardening as default behavior (minimumReleaseAge: 1 day, blockExoticSubdeps, strictDepBuilds) — see the discussion below. A stricter 7-day minimumReleaseAge override is prepared but left commented out in pnpm-workspace.yaml so that adopting it is a one-line decision; the operational impact of switching it on is documented in this issue.
A draft PR will follow this issue.
How the SHAs in the PR were resolved
Each action's current major-version tag was resolved to a full-length commit SHA using gh api. Reviewers can reproduce / verify the values by running:
# Resolve a major-version tag to its commit SHA
gh api repos/actions/checkout/commits/v6 --jq '.sha'
gh api repos/actions/setup-node/commits/v4 --jq '.sha'
gh api repos/pnpm/action-setup/commits/v4 --jq '.sha'
# Optional: confirm which exact release the floating tag currently points at
gh api repos/pnpm/action-setup/tags --paginate \
--jq '.[] | select(.name | startswith("v4")) | {name, sha: .commit.sha}'
The SHAs proposed in the PR map to: actions/checkout v6.0.2, actions/setup-node v4.4.0, pnpm/action-setup v4.3.0 (the v4 floating tag intentionally points at v4.3.0 rather than v4.4.0, because v4.4.0 introduced a Node.js 24 breaking change that was reverted on the floating tag).
Release cooldown — included in this proposal
SHA pinning protects against tag rewrite attacks, but not against a malicious version published as a legitimate release (as seen with the Shai-Hulud worm, where compromised maintainer tokens pushed poisoned versions through the normal release channel). In most of these incidents the community detects and yanks the bad release within days, so delaying adoption of brand-new versions acts as a complementary defense layer.
npm side — bump pnpm from v10 to v11 (covered by this PR)
pnpm v10 added minimumReleaseAge as opt-in but ships no supply-chain protections enabled by default; v11 turns three supply-chain protections on by default. Bumping to v11 is therefore the lowest-friction way to get layered defense against npm-side attacks.
What each v11 default does and why it matters
1. minimumReleaseAge — default 1440 (1 day)
Newly published packages are not resolved during pnpm install until they have been on the npm registry for at least this many minutes.
- What it stops: Compromised-credential attacks (e.g. Shai-Hulud) typically publish a malicious version, get detected and yanked by the community within hours-to-days, but in the meantime they spread because CI/local installs pull immediately. A non-zero release age guarantees a detection window before any consumer accepts the new version.
- What it does not stop: A long-lived compromise (where the bad version stays on the registry past the cooldown window). Cooldown is a buffer, not a guarantee.
- Trade-off: Legitimate hotfix releases are also delayed by the cooldown period. In practice 1–7 days is reasonable for an OSS library; formisch does not have a hard SLA requiring sub-day patch adoption.
The PR ships with the default 1 day active, and adds a commented-out minimumReleaseAge: 10080 (7 days) in pnpm-workspace.yaml ready to be enabled with a single uncomment. 7 days matches the community consensus on how long is usually sufficient for npm-worm-class incidents to be detected and yanked, but turning it on has concrete operational consequences listed below; the call on when to flip it is left to the maintainer.
First-party exclusion: valibot
valibot is a direct dependency in every formisch package (packages/core, packages/methods, all six frameworks/*, website, and every playgrounds/*). It is also maintained by the same author as formisch (Fabian Hiller), so we treat it as a first-party dependency that does not need to sit out the cooldown window to be vetted by the broader community first.
The PR therefore adds an unconditional exemption in pnpm-workspace.yaml:
minimumReleaseAgeExclude:
- valibot
This applies to both the default 1-day cooldown and any future 7-day override — valibot is always installable at its latest release. No other package gets a standing exemption; if a future fresh release of some other package needs to land inside the window, prefer the per-incident options described below over expanding this list.
Operational impact if you uncomment the 7-day override
The lockfile in main always contains some dependencies that were published recently. Once the 7-day window is active, pnpm install refuses to resolve any lockfile entry whose publish date falls inside that window — which is the whole point of the protection, and also why the cost of enabling it is non-zero.
Implications:
-
For this PR's merge timing. If the 7-day override is enabled at merge time, any lockfile entry that has been published within the last 7 days fails the check. The simplest path is to merge with the override left commented out (current state) and flip it on in a follow-up after the lockfile naturally ages, or to wait until the lockfile entries age past the 7-day cutoff before merging.
-
For ongoing development after the override is on. Whenever a fresh upstream release lands inside the workspace, the next pnpm install will start failing for affected lockfile entries. There is no single "right" response — the choice depends on why the fresh version is in the lockfile:
- Wait it out. Easiest. Re-run
pnpm install after the cooldown expires (≤ 7 days). Best when the bump isn't time-sensitive.
- Pin to an older version. Edit
package.json / lockfile to use a previous release of the offending package. Best when the new version isn't actually needed (e.g. it landed via a transitive ^ range).
- Temporarily exclude. Add the package to
minimumReleaseAgeExclude in pnpm-workspace.yaml. Best when the new version is needed for a fix and you've manually reviewed it. Remove the exclusion once the package ages past the window.
All three options are valid, with decreasing safety: (1) preserves the buffer in full, (2) preserves it for everything except the deliberately-older pin, (3) creates a per-package hole that should be temporary.
These trade-offs are precisely why the override is not enabled by default in the PR — adopting a 7-day buffer is a meaningful change to the contributor workflow, not just a config tweak.
2. blockExoticSubdeps — default true
Refuses to install registry packages whose sub-dependencies are declared as git URLs, http(s) tarballs, or local file paths.
- What it stops: A common supply-chain trick is for an attacker who compromises an npm package to publish a new version that pulls additional code from a non-registry source (a git repo they control, a tarball they host). Those sources bypass npm's audit/yank machinery entirely. Blocking exotic sub-deps removes that escape hatch.
- What it does not stop: Compromise of a "normal" npm-registry dependency. It only closes one specific class of evasion.
- Trade-off: Legitimate but unconventional packages that depend on a git fork or a local link will fail to install. None of formisch's current dependency graph relies on exotic sub-deps (the only patched dependency
@qwik.dev/router@2.0.0-beta.9 is patched via pnpm's first-class patchedDependencies mechanism, which is unaffected by this flag), so this default is a free win.
3. strictDepBuilds — default true
Every package install/postinstall script is denied by default; only packages explicitly listed in allowBuilds are permitted to execute scripts.
- What it stops: install/postinstall scripts are the most common code-execution primitive in npm supply-chain attacks. By default-denying them, the attack surface shrinks from "every transitive dep that has a postinstall" to "only the audited allowlist."
- What it does not stop: Code that runs when an already-installed dependency is required / imported at runtime. This guards installation, not execution at use.
- Trade-off: Packages that genuinely need to run a postinstall (sharp, node-gyp-based deps, native bindings) need an explicit
allowBuilds: { name: true } entry, and pnpm install will fail until they are added. This is intentional — every entry is a review checkpoint.
This PR adds the necessary allowlist. The set was discovered by running pnpm install on v11 and reviewing every package reported in ERR_PNPM_IGNORED_BUILDS:
allowBuilds:
'@parcel/watcher': true # native file watcher used transitively by dev servers / Tailwind v4
deasync: true # C++ addon used transitively by the Vercel CLI
esbuild: true # selects per-OS native binary; required for Vite / Qwik builds
sharp: true # website only — fetches/builds native image-processing binaries
vercel: true # website deploy CLI (`pnpm -C website deploy`)
Each entry was approved on the basis of: (a) what its postinstall actually does (downloads / selects / builds a known native binary, not arbitrary code), and (b) whether the package itself is needed for dev, build, or deploy. CI environments that never run vercel deploy could set vercel: false (which also lets deasync be dropped, since the latter is only pulled in by the Vercel CLI), but the default in this PR keeps the lockfile installable for every workflow used today.
If a future pnpm install reports additional unapproved build scripts, review each one individually and add it to allowBuilds rather than disabling the feature. Disabling strictDepBuilds would remove the entire benefit of v11's default; the friction of adding entries is the point.
Operational notes for the bump
packageManager field updated in root package.json (pnpm@10.24.0 → pnpm@11.3.0). Contributors using a Corepack-aware toolchain will automatically install the matching version; otherwise they will need to bump pnpm locally.
- Lockfile.
pnpm-lock.yaml is still v9 format (no migration), but the first pnpm install on v11 may produce a small diff (metadata fields, etc.). This is expected and should be committed along with the PR.
.npmrc rule change. In v11, .npmrc is restricted to auth and registry settings; all other pnpm configuration must live in pnpm-workspace.yaml. The existing .npmrc only contains shamefully-hoist, strict-peer-dependencies, and engine-strict, which need to be moved to pnpm-workspace.yaml (or kept in .npmrc if v11 still accepts them — verify on first install).
- Node.js requirement. pnpm v11 requires Node.js ≥ 22. The repo already uses Node.js 24 in CI (
actions/setup-node with node-version: 24), so this is already satisfied.
GitHub Actions side — Dependabot cooldown (deferred to maintainer)
Dependabot also supports a cooldown: block to delay PRs after a new release. This is not enabled in the PR because the optimal duration is a maintainer judgement call. Example configuration if you want to adopt it later:
cooldown:
default-days: 7
semver-major-days: 14
semver-minor-days: 7
semver-patch-days: 3
References
PoC
A draft PR will follow this issue with the changes proposed above.
related with: open-circle/valibot#1474
Motivation
GitHub Actions referenced by mutable tags (e.g.
actions/checkout@v6) are vulnerable to supply-chain attacks: anyone with write access to the action's repository can force-push the tag to point at malicious code, and consumers will silently pull it on the next workflow run.Why this matters specifically for a widely-used OSS library
This is not just a generic best practice — the threat model has sharper edges for an OSS library like formisch:
@formisch/preact,qwik,react,solid,svelte,vue) from the same publish workflow, so a single compromise fans out to all of them at once..github/workflows/publish.ymlruns withid-token: writefor trusted publishing to npm. Any action invoked from that workflow (includingactions/checkout) is in the trust boundary of the release artifact.id-token: writeworkflow on a popular OSS package faces. Other recent OSS-targeted incidents (axios, Mini Shai-Hulud, TanStack Router) reinforce that being widely-used and well-maintained does not, by itself, provide protection.actions/checkoutREADME itself recommends pinning to a full length commit SHA.CI is part of the same threat model: a single tag rewrite on a popular action can compromise every workflow that references it, and for an OSS library that means compromising every downstream user.
OpenSSF Scorecard's Pinned-Dependencies check recommends pinning all GitHub Actions — first-party and third-party alike — to full-length commit SHAs.
Current state
actions/checkout@v6ci.yml(×26),publish.yml(×6)actions/setup-node@v4.github/actions/environment/action.ymlpnpm/action-setup@v4.github/actions/environment/action.ymlNo Dependabot or Renovate configuration is present, so action updates currently rely on manual review.
Proposal
Pin every external action to a full-length commit SHA, keeping the human-readable version in a trailing comment:
Add
.github/dependabot.ymlenabling thegithub-actionsecosystem with a weekly schedule and grouped updates, so SHAs stay current via a single weekly PR rather than going stale.Upgrade pnpm from v10 to v11, which ships supply-chain hardening as default behavior (
minimumReleaseAge: 1 day,blockExoticSubdeps,strictDepBuilds) — see the discussion below. A stricter 7-dayminimumReleaseAgeoverride is prepared but left commented out inpnpm-workspace.yamlso that adopting it is a one-line decision; the operational impact of switching it on is documented in this issue.A draft PR will follow this issue.
How the SHAs in the PR were resolved
Each action's current major-version tag was resolved to a full-length commit SHA using
gh api. Reviewers can reproduce / verify the values by running:The SHAs proposed in the PR map to:
actions/checkoutv6.0.2,actions/setup-nodev4.4.0,pnpm/action-setupv4.3.0 (thev4floating tag intentionally points at v4.3.0 rather than v4.4.0, because v4.4.0 introduced a Node.js 24 breaking change that was reverted on the floating tag).Release cooldown — included in this proposal
SHA pinning protects against tag rewrite attacks, but not against a malicious version published as a legitimate release (as seen with the Shai-Hulud worm, where compromised maintainer tokens pushed poisoned versions through the normal release channel). In most of these incidents the community detects and yanks the bad release within days, so delaying adoption of brand-new versions acts as a complementary defense layer.
npm side — bump pnpm from v10 to v11 (covered by this PR)
pnpm v10 added
minimumReleaseAgeas opt-in but ships no supply-chain protections enabled by default; v11 turns three supply-chain protections on by default. Bumping to v11 is therefore the lowest-friction way to get layered defense against npm-side attacks.What each v11 default does and why it matters
1.
minimumReleaseAge— default1440(1 day)The PR ships with the default 1 day active, and adds a commented-out
minimumReleaseAge: 10080(7 days) inpnpm-workspace.yamlready to be enabled with a single uncomment. 7 days matches the community consensus on how long is usually sufficient for npm-worm-class incidents to be detected and yanked, but turning it on has concrete operational consequences listed below; the call on when to flip it is left to the maintainer.First-party exclusion:
valibotvalibotis a direct dependency in every formisch package (packages/core,packages/methods, all sixframeworks/*,website, and everyplaygrounds/*). It is also maintained by the same author as formisch (Fabian Hiller), so we treat it as a first-party dependency that does not need to sit out the cooldown window to be vetted by the broader community first.The PR therefore adds an unconditional exemption in
pnpm-workspace.yaml:This applies to both the default 1-day cooldown and any future 7-day override —
valibotis always installable at its latest release. No other package gets a standing exemption; if a future fresh release of some other package needs to land inside the window, prefer the per-incident options described below over expanding this list.Operational impact if you uncomment the 7-day override
The lockfile in
mainalways contains some dependencies that were published recently. Once the 7-day window is active,pnpm installrefuses to resolve any lockfile entry whose publish date falls inside that window — which is the whole point of the protection, and also why the cost of enabling it is non-zero.Implications:
For this PR's merge timing. If the 7-day override is enabled at merge time, any lockfile entry that has been published within the last 7 days fails the check. The simplest path is to merge with the override left commented out (current state) and flip it on in a follow-up after the lockfile naturally ages, or to wait until the lockfile entries age past the 7-day cutoff before merging.
For ongoing development after the override is on. Whenever a fresh upstream release lands inside the workspace, the next
pnpm installwill start failing for affected lockfile entries. There is no single "right" response — the choice depends on why the fresh version is in the lockfile:pnpm installafter the cooldown expires (≤ 7 days). Best when the bump isn't time-sensitive.package.json/ lockfile to use a previous release of the offending package. Best when the new version isn't actually needed (e.g. it landed via a transitive^range).minimumReleaseAgeExcludeinpnpm-workspace.yaml. Best when the new version is needed for a fix and you've manually reviewed it. Remove the exclusion once the package ages past the window.All three options are valid, with decreasing safety: (1) preserves the buffer in full, (2) preserves it for everything except the deliberately-older pin, (3) creates a per-package hole that should be temporary.
These trade-offs are precisely why the override is not enabled by default in the PR — adopting a 7-day buffer is a meaningful change to the contributor workflow, not just a config tweak.
2.
blockExoticSubdeps— defaulttrue@qwik.dev/router@2.0.0-beta.9is patched via pnpm's first-classpatchedDependenciesmechanism, which is unaffected by this flag), so this default is a free win.3.
strictDepBuilds— defaulttrueallowBuilds: { name: true }entry, andpnpm installwill fail until they are added. This is intentional — every entry is a review checkpoint.This PR adds the necessary allowlist. The set was discovered by running
pnpm installon v11 and reviewing every package reported inERR_PNPM_IGNORED_BUILDS:Each entry was approved on the basis of: (a) what its postinstall actually does (downloads / selects / builds a known native binary, not arbitrary code), and (b) whether the package itself is needed for dev, build, or deploy. CI environments that never run
vercel deploycould setvercel: false(which also letsdeasyncbe dropped, since the latter is only pulled in by the Vercel CLI), but the default in this PR keeps the lockfile installable for every workflow used today.If a future
pnpm installreports additional unapproved build scripts, review each one individually and add it toallowBuildsrather than disabling the feature. DisablingstrictDepBuildswould remove the entire benefit of v11's default; the friction of adding entries is the point.Operational notes for the bump
packageManagerfield updated in rootpackage.json(pnpm@10.24.0→pnpm@11.3.0). Contributors using a Corepack-aware toolchain will automatically install the matching version; otherwise they will need to bump pnpm locally.pnpm-lock.yamlis still v9 format (no migration), but the firstpnpm installon v11 may produce a small diff (metadata fields, etc.). This is expected and should be committed along with the PR..npmrcrule change. In v11,.npmrcis restricted to auth and registry settings; all other pnpm configuration must live inpnpm-workspace.yaml. The existing.npmrconly containsshamefully-hoist,strict-peer-dependencies, andengine-strict, which need to be moved topnpm-workspace.yaml(or kept in.npmrcif v11 still accepts them — verify on first install).actions/setup-nodewithnode-version: 24), so this is already satisfied.GitHub Actions side — Dependabot
cooldown(deferred to maintainer)Dependabot also supports a
cooldown:block to delay PRs after a new release. This is not enabled in the PR because the optimal duration is a maintainer judgement call. Example configuration if you want to adopt it later:References
cooldownminimumReleaseAgesettingPoC
A draft PR will follow this issue with the changes proposed above.