Skip to content

Proposal: Pin GitHub Actions to commit SHAs for supply-chain security #1475

@ysknsid25

Description

@ysknsid25

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 valibot:

  • Asymmetric blast radius. A successful compromise of valibot's release pipeline propagates to every downstream consumer on npm and JSR. The asymmetry between "one compromised CI run" and "thousands of poisoned installs" is fundamentally different from internal/proprietary projects.
  • The publish workflow is a high-value target. .github/workflows/publish.yml runs with id-token: write for trusted publishing to npm and JSR. 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 (×13), publish.yml (×5)
actions/setup-node @v6 .github/actions/environment/action.yml
pnpm/action-setup @v4 .github/actions/environment/action.yml
denoland/setup-deno @v2 .github/actions/environment/action.yml

No Dependabot or Renovate configuration is present, so action updates currently rely on manual review.

Proposal

  1. 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
  2. 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.

  3. Upgrade pnpm from v9 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/v6    --jq '.sha'
gh api repos/pnpm/action-setup/commits/v4     --jq '.sha'
gh api repos/denoland/setup-deno/commits/v2   --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 v6.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), denoland/setup-deno v2.0.4.

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 v9 to v11 (covered by this PR)

pnpm v9 has no native supply-chain protections; v10 added minimumReleaseAge as opt-in; 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; valibot 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.

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.

Concrete example with the current pnpm-lock.yaml (as of 2026-05-25):

[ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION] 9 lockfile entries failed verification:
  @shikijs/core@4.1.0           published 2026-05-19 (6 days ago)
  @shikijs/engine-javascript@4.1.0   …
  @shikijs/engine-oniguruma@4.1.0    …
  @shikijs/langs@4.1.0          …
  @shikijs/primitive@4.1.0      …
  @shikijs/rehype@4.1.0         …
  @shikijs/themes@4.1.0         …
  @shikijs/types@4.1.0          …
  shiki@4.1.0                   …

Implications:

  • For this PR's merge timing. If the 7-day override is enabled at merge time, the shiki@4.1.0 family in the current lockfile fails the check. The simplest path is to merge on or after 2026-05-26, the day shiki@4.1.0 ages past the 7-day cutoff. Alternatively, merge with the override left commented out (current state) and flip it on in a follow-up after the lockfile naturally ages.

  • 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:

    1. Wait it out. Easiest. Re-run pnpm install after the cooldown expires (≤ 7 days). Best when the bump isn't time-sensitive.
    2. 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).
    3. 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 valibot's current dependency graph relies on exotic sub-deps, 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 minimum necessary allowlist:

allowBuilds:
  sharp: true  # website only — fetches/builds native image-processing binaries

If pnpm install after the upgrade 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 added to root package.json (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. That is where the new settings are placed in this PR — no .npmrc is needed for cooldown.
  • 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

I've created a #1474, and I'd like to discuss it based on that.

Metadata

Metadata

Assignees

Labels

githubGitHub related changespriorityThis has prioritytoolingTooling for devs

Type

No type
No fields configured for issues without a type.

Projects

No projects

Relationships

None yet

Development

No branches or pull requests

Issue actions