Agent skill
JavaScript supply chain checks
Lock down npm, pnpm, yarn or bun installs: release-age delays, install-script allowlists, frozen CI installs and trusted publishing.
Use this skill
Install it into a project with the Skills CLI, or read the files below and adapt them to your own agent.
npx skills add shan8851/agent-skills --skill javascript-supply-chain-hardeningSKILL.md
View this file on GitHub---
name: javascript-supply-chain-hardening
description: Harden a JavaScript or TypeScript repo against npm supply-chain attacks with one pinned package manager, a committed lockfile, a release-age delay, an allowlist for install scripts, frozen installs in CI and Docker, SHA-pinned CI actions, and trusted publishing for packages. Use when the user says "harden npm installs", "lock down pnpm", "supply chain security for this repo", "what should we do after the latest npm compromise", or is cleaning up mixed package managers before shipping.
---
# JavaScript Supply-Chain Hardening
Reduce the chance that a malicious or compromised npm package runs on a developer machine, in CI or in production, and make installs repeatable.
This is a **first line of defence, not a guarantee**. The aim is to make installs deterministic, delay brand-new releases, stop dependency install scripts running unless reviewed, keep CI and Docker on the same policy, and publish without long-lived tokens.
Package-manager security settings change quickly. Every setting below names the version it needs. **Check the current docs and the installed version before writing config**, and never copy a config shape across major versions. The references record when their facts were last verified.
## When to use
- "Harden npm installs", "lock down pnpm", "supply chain security for this repo".
- After a public npm compromise, to check the repo wouldn't have pulled the bad version.
- Before publishing a package or CLI, or when tidying a repo that mixes npm, yarn, pnpm and bun.
Do not use when:
- the user wants a full security audit or penetration test (this covers dependencies and installs only);
- the repo isn't JavaScript or TypeScript;
- the user only wants one dependency upgraded.
## Rules that matter most
1. **Audit before changing anything.** Read what's there first: package manager, lockfiles, CI, Dockerfiles, scripts.
2. **Ask before migrating package managers.** Hardening the current one is often enough. If the user agrees to migrate, remove the old lockfile and update every script, doc, CI job and Dockerfile in the **same commit**.
3. **Put settings where the current docs say.** For pnpm 11 and later, `.npmrc` holds only registry and auth settings; everything else goes in `pnpm-workspace.yaml`. Settings in the wrong file are silently ignored on older versions.
4. **Pin deliberately, and say how to check "current".** Never hardcode a version from memory. Look it up (`npm view pnpm dist-tags`, the Node.js releases page, each action's releases) and record it.
5. **Keep allowlists short.** A build-script or release-age exception list that includes everything is no policy at all.
6. **Never put real secrets in workflow files, Dockerfiles or committed `.npmrc` files.**
7. **Commit or push only when the user asks.**
## Process
1. **Audit.** Run the checks in `references/audit-commands.md` and classify the repo: app, library, CLI or monorepo; public or private; current package manager(s); CI provider; Dockerfiles; packages that need install scripts (native modules, `esbuild`, `sharp`, Prisma, Playwright and similar).
2. **Decide the package manager with the user.** If one is already used consistently, keep it and harden it. If the repo is mixed or the user wants to switch, recommend one (pnpm is a strong default for its build-script and release-age controls), explain the cost, and **wait for a yes** before migrating.
3. **Pin the package manager.** Set `packageManager` in `package.json` to the current version (`npm view pnpm dist-tags`; for pnpm 11 and later, `devEngines.packageManager` also works). Corepack is not bundled with Node.js 25 and later, so don't rely on `corepack enable` alone: install Corepack explicitly (`npm install -g corepack`), or let pnpm manage its own version from `packageManager`.
4. **Write the install policy** in the right file for the version in use. For pnpm, see `references/pnpm-config.md`. In short:
- **Release-age delay:** `minimumReleaseAge` in minutes (pnpm ≥10.16; defaults to 1440, one day, from pnpm 11). 4320 (3 days) is a sensible repo setting.
- **Install scripts:** pnpm ≥10.26 and all of pnpm 11: `allowBuilds` map (`esbuild: true`). pnpm 10 before 10.26: `onlyBuiltDependencies` list, which pnpm 11 removed. Keep `strictDepBuilds: true` (the default from pnpm 11) so unreviewed scripts fail the install.
- **Exotic sources and trust:** `blockExoticSubdeps: true` (pnpm ≥10.26, default from 11) and `trustPolicy: no-downgrade` (pnpm ≥10.21).
- For npm, yarn or bun, use `references/other-package-managers.md`.
5. **Install and verify.** Run a normal install, review what the build-script prompt or warning asks for, approve only what the repo really needs, then run a frozen install (`pnpm install --frozen-lockfile`) to prove the lockfile is complete.
6. **Bring CI and Docker onto the same policy.** Use `references/ci-and-docker.md`:
- frozen installs only;
- the toolchain version taken from `packageManager`, not a separate hardcoded number;
- Node on a current LTS line (check nodejs.org/en/about/previous-releases);
- GitHub Actions pinned to full commit SHAs, with a version comment, and kept up to date by Dependabot or Renovate;
- the Dockerfile copies `pnpm-workspace.yaml` as well as `package.json`, the lockfile and any non-secret `.npmrc`, before installing;
- a `.dockerignore` so `.env` files and `node_modules` stay out of the build context.
7. **Run the repo's real checks.** List the scripts in `package.json`, then run the aggregate one if it exists (`validate`, `check`, `ci`) or else lint, typecheck, test and build. Fix any that the new policy breaks, often a package that needs its build script approved or an explicit generate step such as `prisma generate`.
8. **Harden publishing** if the repo publishes to npm. Lead with npm trusted publishing (OIDC from CI, no long-lived token, provenance generated automatically). See `references/publishing.md`.
9. **Review the lockfile diff as code.** Look for unexpected new packages, git or tarball sources, new install scripts, and large tree changes from a small edit.
10. **Report** in the format below.
## Output format
```markdown
**Package manager:** [name@version], pinned via [packageManager/devEngines]; [kept / migrated from X with the user's agreement]
**Policy:** [file] — release age [n] [unit]; build scripts allowed for [list]; [other settings]
**CI:** [frozen install, actions pinned to SHAs, Node line]
**Docker:** [changes, or "no Dockerfile"]
**Publishing:** [trusted publishing set up / recommended / not published]
**Checks run:** [commands and results]
**Left for the user:** [e.g. "enable 'Require 2FA and disallow tokens' on npmjs.com", "turn on the org SHA-pinning policy"]
**Re-check:** settings verified against docs on [date]; re-check after the next major package-manager release.
```
## Common failure modes
- **Config in the wrong file:** pnpm 11+ ignores non-auth settings in `.npmrc`, and older versions ignore keys they don't know. Check with `pnpm config get <key>`. pnpm 12 fails on unrecognised `pnpm-workspace.yaml` keys when the project pins a matching version, which catches typos.
- **Units mixed up:** pnpm counts release age in minutes, npm in days, bun in seconds, and yarn takes a duration string such as `"3d"`.
- **Old keys on a new major:** `onlyBuiltDependencies`, `neverBuiltDependencies` and `ignoredBuiltDependencies` were removed in pnpm 11. Use `allowBuilds` (pnpm offers a codemod: `pnpx codemod run pnpm-v10-to-v11`).
- **Docker install ignores the policy:** `pnpm-workspace.yaml` wasn't copied before `pnpm install`, so build-script approvals are missing and the build fails, or the policy silently doesn't apply.
- **Assuming Corepack is there:** it isn't bundled from Node.js 25.
- **Thinking the age delay fixes the existing lockfile.** It only affects new resolution. Versions already locked stay locked.
- **Approving every build script** or adding every package to an exclude list, which recreates the default risk.
- **Tags instead of SHAs for actions:** a tag can be moved if the action's repo is compromised.
- **Pins that never move:** pinning without Dependabot or Renovate (or a written review cadence) leaves the repo on old, vulnerable versions.
- **Silent migration:** switching package manager without asking, or leaving the old lockfile and stale `npm ci` commands behind.
- **Secrets in CI or images:** real tokens in workflow files, or a `.npmrc` with an auth token copied into a Docker layer.
## References
- `references/audit-commands.md`: commands for the initial audit and the lockfile review.
- `references/pnpm-config.md`: annotated `pnpm-workspace.yaml` for pnpm 10 and 11+, with version notes.
- `references/ci-and-docker.md`: GitHub Actions workflow with SHA pins, Dependabot config, Dockerfile and `.dockerignore`.
- `references/other-package-managers.md`: npm, yarn and bun equivalents.
- `references/publishing.md`: npm trusted publishing, provenance and token lockdown.
references/audit-commands.md
View this file on GitHub# Audit and review commands
Last verified: 2026-10-11. Commands are POSIX shell; on Windows run them in Git Bash or WSL, or translate them.
## Initial audit
Run these before changing anything.
```bash
git status --short
node --version
npm --version
pnpm --version 2>/dev/null || echo "no pnpm"
corepack --version 2>/dev/null || echo "no corepack (not bundled from Node.js 25)"
```
Package manager and scripts:
```bash
node -e "const p=require('./package.json'); console.log({packageManager:p.packageManager, devEngines:p.devEngines, scripts:Object.keys(p.scripts||{}).sort()})"
for f in package-lock.json npm-shrinkwrap.json yarn.lock pnpm-lock.yaml bun.lock bun.lockb; do [ -e "$f" ] && echo "lockfile: $f"; done
for f in .npmrc pnpm-workspace.yaml .yarnrc.yml bunfig.toml; do [ -e "$f" ] && echo "config: $f"; done
```
CI, Docker and install commands outside `package.json`:
```bash
find .github/workflows -maxdepth 2 -type f 2>/dev/null
find . -maxdepth 3 -not -path '*/node_modules/*' \( -iname 'Dockerfile*' -o -name 'docker-compose*.yml' -o -name 'compose*.yml' \) 2>/dev/null
grep -rnE '\b(npm (ci|install|i)|npx|yarn( install)?|pnpm (install|i)|bun install)\b' .github Dockerfile* docs README* 2>/dev/null
```
Current versions to pin against (look these up; don't use remembered numbers):
```bash
npm view pnpm dist-tags # current pnpm release lines
npm view npm dist-tags
# Node.js release lines: https://nodejs.org/en/about/previous-releases
```
## Questions to answer from the audit
- App, library, CLI, monorepo or docs site? Public or private? Published to npm?
- Which package manager(s)? Is a mix deliberate and documented?
- Which packages need install scripts? (native modules, `esbuild`, `sharp`, Prisma, Playwright, Electron and similar)
- Which CI jobs and Dockerfiles install dependencies independently of the local setup?
- Which quality scripts exist (`validate`, `check`, `lint`, `typecheck`, `test`, `build`)?
## Lockfile review
A lockfile change is code. After any install:
```bash
git diff --stat -- package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc .github Dockerfile*
git diff -- pnpm-workspace.yaml package.json
```
Look for:
- new direct dependencies nobody asked for;
- a large tree change from a small `package.json` edit;
- git, tarball, `file:` or other non-registry sources;
- new packages with install scripts, or new `allowBuilds` entries;
- very recent versions if no release-age delay was active;
- typosquats (`lodahs`), abandoned packages, or a surprising change of maintainer.
`pnpm audit` / `npm audit` output is triage input, not a to-do list. Some lockfile scanners only read the first YAML document in a pnpm lockfile and report nothing; check that whatever scanner you use reads it all.
references/ci-and-docker.md
View this file on GitHub# CI and Docker
Last verified: 2026-10-11 against GitHub's "Secure use reference" for Actions, the pnpm 11 release notes and the Node.js releases page. Re-check action versions and Node release lines each time you use this.
## GitHub Actions
Rules:
- **Pin every third-party action to a full-length commit SHA**, with the version in a comment. GitHub's docs say this is currently the only way to use an action as an immutable release; a tag can be moved if the action's repo is compromised. Check the SHA comes from the action's own repo, not a fork.
- Let Dependabot or Renovate update the SHA pins (they update the SHA and the comment). Note that Dependabot doesn't raise *security alerts* for SHA-pinned actions, so keep version updates switched on.
- Organisations and repos can enforce SHA pinning with a GitHub Actions policy setting. Suggest it to the user; don't change it yourself.
- Take the pnpm version from `packageManager` in `package.json` rather than repeating it in the workflow.
- Use a current Node.js LTS line (see nodejs.org/en/about/previous-releases). pnpm 11 needs Node.js 22 or later.
- Set `permissions:` to the minimum (`contents: read` for a build job).
- Use dummy values with the right shape for build-time environment variables. Never commit real secrets.
How to find the SHA for a release (replace the owner, repo and tag):
```bash
gh api repos/actions/checkout/git/ref/tags/<tag> --jq .object.sha
# If .object.type is "tag" (an annotated tag), resolve it once more:
gh api repos/actions/checkout/git/tags/<sha-from-above> --jq .object.sha
```
Tools such as `pinact` or Renovate's `helpers:pinGitHubActionDigests` preset can pin a whole workflow at once.
Example workflow. The SHAs are placeholders: look up the current release of each action and fill them in.
```yaml
name: CI
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@<full-commit-sha> # vX.Y.Z
- uses: pnpm/action-setup@<full-commit-sha> # vX.Y.Z — reads the version from packageManager
- uses: actions/setup-node@<full-commit-sha> # vX.Y.Z
with:
node-version: <current LTS major, e.g. from .nvmrc>
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm run validate # or the lint / typecheck / test / build scripts that exist
```
Install pnpm (`pnpm/action-setup`) **before** `actions/setup-node` with `cache: pnpm`, or the cache step can't find pnpm.
Keeping the Node version in `.nvmrc` or `.node-version` and using `node-version-file:` keeps local and CI in step.
## Dependabot
```yaml
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: npm # also covers pnpm and yarn lockfiles
directory: /
schedule:
interval: weekly
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
- package-ecosystem: docker
directory: /
schedule:
interval: weekly
```
Dependabot updates aren't held back by your package manager's release-age setting. Dependabot has its own `cooldown` option for delaying new versions; check GitHub's current Dependabot docs for its syntax before relying on it. Renovate has `minimumReleaseAge`.
## Dockerfile
```Dockerfile
# Pin the base image by digest; find it with `docker buildx imagetools inspect node:<tag>`.
FROM node:<current-LTS>-slim@sha256:<digest>
WORKDIR /app
# Node.js 25+ images don't bundle Corepack. Either install it (pinned) or install the
# exact pnpm version from package.json's packageManager field.
RUN npm install -g corepack@<version> && corepack enable
# Copy everything pnpm reads for policy BEFORE installing. Without pnpm-workspace.yaml,
# allowBuilds / minimumReleaseAge aren't applied and approved build scripts don't run.
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
# Copy .npmrc only if it exists and holds no secrets. For a private registry token,
# use a build secret instead: RUN --mount=type=secret,id=npmrc,target=/root/.npmrc ...
COPY .npmrc ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm run build
```
Notes:
- `COPY` fails if a listed file doesn't exist. Drop lines for files the repo doesn't have.
- In a monorepo, also copy each workspace package's `package.json`, or use `pnpm fetch` (which needs only the lockfile and `pnpm-workspace.yaml`) followed by `pnpm install --offline --frozen-lockfile`.
- Every stage that installs dependencies needs the same files and the same pinned pnpm.
- Never `curl | sh` an installer inside a Dockerfile, and avoid unpinned `npm i -g pnpm`.
## .dockerignore
```gitignore
.git
node_modules
.pnpm-store
.env
.env.*
*.log
.DS_Store
coverage
```
Without it, `.env` files, local caches and Git history can end up in the build context and image layers.
references/other-package-managers.md
View this file on GitHub# npm, yarn and bun equivalents
Last verified: 2026-10-11. These tools are changing fast after the 2025–26 npm compromises. **Check the current docs for the installed version before writing config**, and note that each one measures release age in a different unit.
| | Release-age delay | Unit | Install scripts | Frozen install |
|---|---|---|---|---|
| pnpm | `minimumReleaseAge` | minutes | `allowBuilds` (≥10.26) | `pnpm install --frozen-lockfile` |
| npm | `min-release-age` | days | `allowScripts` + `npm approve-scripts` | `npm ci` |
| yarn 4 | `npmMinimalAgeGate` | duration string, e.g. `"3d"` | `enableScripts` / `dependenciesMeta` | `yarn install --immutable` |
| bun | `install.minimumReleaseAge` | seconds | `trustedDependencies` | `bun install --frozen-lockfile` |
## npm
Check current docs: docs.npmjs.com/cli/v11/using-npm/config (and the v12 equivalent if the project uses npm 12).
- **Release age:** `min-release-age=3` in the project `.npmrc` (days; npm ≥11.10.0, off by default). `min-release-age-exclude` exempts named packages. Known quirk: `npm config get min-release-age` may print `null` even when set, because npm stores it internally as `before`.
- **Install scripts:** npm ≥11.10 has an allowlist in `package.json` (`allowScripts`), managed with `npm approve-scripts` and `npm deny-scripts`. Review what's pending with `npm approve-scripts --allow-scripts-pending`. In npm 11 it only warns. npm 12 blocks dependency `preinstall`/`install`/`postinstall` scripts, and implicit `node-gyp` builds, unless approved. Commit the allowlist.
- **Git and URL dependencies:** `allow-git` and `allow-remote` limit git and remote-tarball dependencies; npm 12 defaults both to `none`.
- **Blunt option (any npm version):** `ignore-scripts=true` blocks all install scripts, including the project's own lifecycle scripts during install. It overrides `allowScripts`, so remove it if you adopt the allowlist.
- **CI:** `npm ci` with a committed `package-lock.json`.
- **Pin:** `"packageManager": "npm@<version>"` or `devEngines`, and pin Node in CI.
## yarn (Berry, v4)
Check current docs: yarnpkg.com/configuration/yarnrc.
- **Release age:** `npmMinimalAgeGate: "3d"` in `.yarnrc.yml` (added in yarn 4.10, per secondary sources). `npmPreapprovedPackages` exempts packages from the package gates; keep it short.
- **Install scripts:** `enableScripts` controls third-party `postinstall` scripts. The current docs give `false` as the default; older versions and older guides differ, so set it explicitly. Allow individual packages with `dependenciesMeta` in `package.json` (`"dependenciesMeta": { "esbuild": { "built": true } }`) and check the docs for the exact shape.
- **Hardened mode:** `enableHardenedMode` checks the lockfile for malicious changes; yarn turns it on automatically for pull requests from public forks on GitHub.
- **CI:** `yarn install --immutable` (yarn enables immutable installs by default on CI). Commit `yarn.lock` and the `.yarn/releases` file if the repo uses `yarnPath`.
- **Pin:** `"packageManager": "yarn@<version>"`.
## bun
Check current docs: bun.com/docs/runtime/bunfig and bun.com/docs/pm/lifecycle.
- **Release age:** in `bunfig.toml`:
```toml
[install]
minimumReleaseAge = 259200 # seconds = 3 days
minimumReleaseAgeExcludes = [] # packages exempt from the delay
```
- **Install scripts:** bun doesn't run dependency lifecycle scripts by default, except for a built-in list of popular npm packages. Setting `trustedDependencies` in `package.json` **replaces** that list: list every package you need (e.g. `["esbuild", "sharp"]`), or `[]` for none. Packages from `git:`, `github:`, `file:` or `link:` must always be listed explicitly. `install.ignoreScripts = true` in `bunfig.toml` turns scripts off entirely.
- **CI:** `bun install --frozen-lockfile` (or `install.frozenLockfile = true`). Commit `bun.lock`.
- **Pin:** the bun version in CI (e.g. via `oven-sh/setup-bun` pinned to a SHA, with an explicit `bun-version`).
## Mixed repos
Mixed package managers are a smell. Keep them only when there's a documented reason. If the user agrees to migrate, change the lockfile, scripts, docs, CI and Dockerfiles in one commit, and delete the old lockfile in that same commit.
references/pnpm-config.md
View this file on GitHub# pnpm install policy
Last verified: 2026-10-11 against pnpm.io/settings, pnpm.io/settings/build, pnpm.io/settings/dependency-resolution, pnpm.io/supply-chain-security and the pnpm 11 and 12 release posts. pnpm changes quickly: check the docs for the version you're pinning before copying anything here.
## Which file
- **pnpm 11 and later:** `.npmrc` is read for registry and auth settings only. Everything else goes in `pnpm-workspace.yaml` (per project) or `~/.config/pnpm/config.yaml` (global). `pnpm-workspace.yaml` works in a single-package repo too; it doesn't need a `packages:` list. pnpm 11 also stopped reading the `pnpm` field in `package.json` and `npm_config_*` environment variables (use `pnpm_config_*`).
- **pnpm 12:** reports unrecognised `pnpm-workspace.yaml` settings; when the project pins a pnpm version that the running pnpm satisfies, the command fails. Useful for catching typos.
- **pnpm 10:** later 10.x releases read most settings from `pnpm-workspace.yaml` as well as `.npmrc` (check the docs for your exact version) (kebab-case there, e.g. `minimum-release-age`). Prefer `pnpm-workspace.yaml` so the upgrade to 11 needs no move.
To migrate a pnpm 10 config to 11, pnpm suggests: `pnpx codemod run pnpm-v10-to-v11`.
## Settings and the versions that need them
| Setting | What it does | Needs | Default |
|---|---|---|---|
| `minimumReleaseAge` | Minutes a version must have been published before pnpm will install it | ≥10.16 | 1440 from pnpm 11; 0 before |
| `minimumReleaseAgeExclude` | Package names or patterns exempt from the delay | ≥10.16 | none |
| `minimumReleaseAgeStrict` | Fail resolution instead of falling back when nothing old enough matches | ≥11.0 | true if `minimumReleaseAge` is set explicitly |
| `allowBuilds` | Map of packages allowed (`true`) or denied (`false`) to run install scripts | ≥10.26 | unlisted = not allowed |
| `onlyBuiltDependencies` | Older allowlist (array) | pnpm 10 only | **removed in 11** |
| `strictDepBuilds` | Fail the install when a dependency has unreviewed build scripts | ≥10.3 | true from pnpm 11 |
| `dangerouslyAllowAllBuilds` | Lets every dependency run scripts. Don't use it | ≥10.9 | false |
| `blockExoticSubdeps` | Only direct dependencies may come from git or tarball URLs | ≥10.26 | true from pnpm 11 |
| `trustPolicy: no-downgrade` | Fail if a package's publishing trust evidence (e.g. provenance) got weaker than earlier releases | ≥10.21 | off |
| `trustPolicyExclude` | Packages or versions exempt from the trust check | ≥10.22 | none |
## Example: pnpm 11 and later
```yaml
# pnpm-workspace.yaml
# Security settings. Verified against pnpm docs on 2026-10-11; re-check on each pnpm major.
minimumReleaseAge: 4320 # minutes = 3 days (pnpm 11 default is 1440)
minimumReleaseAgeExclude: [] # add only packages you must take on release day, with a reason
strictDepBuilds: true # default in pnpm 11; keep it explicit
allowBuilds: # only packages whose install scripts you've reviewed
esbuild: true
# sharp: true
# core-js: false # explicitly deny a package that asks
blockExoticSubdeps: true # default in pnpm 11
trustPolicy: no-downgrade
```
During install, pnpm 11 adds packages with blocked build scripts to `allowBuilds` with a placeholder value for you to set to `true` or `false`. Review each one: does the repo really need that package's script (a native binary, a postinstall download)? If it works without it, set `false`.
## Example: pnpm 10 (before 10.26)
```yaml
# pnpm-workspace.yaml
minimumReleaseAge: 4320 # pnpm ≥10.16
onlyBuiltDependencies: # removed in pnpm 11; migrate to allowBuilds when upgrading
- esbuild
trustPolicy: no-downgrade # pnpm ≥10.21
```
`pnpm approve-builds` lists packages whose scripts were skipped and lets you approve them interactively. Review the config it writes like source code.
## Checking what pnpm sees
```bash
pnpm --version
pnpm config get minimumReleaseAge
pnpm install --frozen-lockfile
```
If a value comes back empty or unexpected, the key is in the wrong file or misspelt, or the version doesn't support it.
## Pinning pnpm itself
- `package.json`: `"packageManager": "pnpm@<exact version>"` (find it with `npm view pnpm dist-tags`). pnpm 11+ also accepts `devEngines.packageManager` with a range, and records the resolved version in the lockfile.
- pnpm 11 can download the pinned version itself (the `pmOnFail` setting; `download` is the default).
- Corepack is not bundled with Node.js 25 and later. If you use it, install it explicitly (`npm install -g corepack`).
- When a new pnpm major ships, the npm `latest` tag may lag behind the new line for a while (pnpm 12 first shipped on a `latest-12` tag). Check `npm view pnpm dist-tags` rather than assuming.
references/publishing.md
View this file on GitHub# Publishing packages safely
Last verified: 2026-10-11 against docs.npmjs.com/trusted-publishers. Check the current page before setting this up; supported providers and minimum versions change.
Hardening the repo doesn't protect people who install your package. Publishing is where a stolen token does the most damage.
## 1. npm trusted publishing (lead with this)
Trusted publishing lets CI publish using short-lived OpenID Connect (OIDC) credentials instead of a long-lived npm token.
- **Supported:** GitHub Actions (GitHub-hosted runners), GitLab CI/CD (GitLab.com shared runners) and CircleCI cloud. Self-hosted runners aren't supported at the time of checking.
- **Needs:** npm CLI 11.5.1 or later and Node.js 22.14.0 or later in the publish job.
- **Set up:** on npmjs.com, open the package's settings and add a trusted publisher (repo owner, repo name, workflow file name, and optionally an environment). The package must already exist on npm, so the very first publish may need doing another way.
- **Workflow permissions (GitHub Actions):** the publish job needs `id-token: write`.
- **Provenance:** generated automatically for public packages published from public repos via GitHub Actions or GitLab; no `--provenance` flag needed. CircleCI doesn't generate it.
Minimal GitHub Actions publish job (SHAs are placeholders; see `ci-and-docker.md`):
```yaml
on:
release:
types: [published]
permissions:
contents: read
id-token: write
jobs:
publish:
runs-on: ubuntu-latest
environment: npm-publish # optional: add required reviewers to this environment
steps:
- uses: actions/checkout@<full-commit-sha> # vX.Y.Z
- uses: pnpm/action-setup@<full-commit-sha> # vX.Y.Z
- uses: actions/setup-node@<full-commit-sha> # vX.Y.Z
with:
node-version: <current LTS major>
registry-url: https://registry.npmjs.org
- run: pnpm install --frozen-lockfile
- run: pnpm run build
- run: npm --version # must be ≥ 11.5.1 for OIDC; upgrade npm in the job if not
- run: npm publish
```
This uses `npm publish` because npm's docs describe OIDC with the npm CLI. If you want `pnpm publish`, check pnpm's current docs for its trusted-publishing support first.
## 2. Lock down tokens
After trusted publishing works:
- On npmjs.com, under the package's **Publishing access**, choose "Require two-factor authentication and disallow tokens". Trusted publishers keep working.
- Revoke old automation and granular tokens that are no longer needed.
- npm also supports a staged-publish option where CI publishes need a maintainer's approval before going public; check the current docs if the user wants that.
These are account settings: tell the user what to change; don't change them yourself.
## 3. Before every release
- `pnpm pack --dry-run` (or `npm pack --dry-run`) and read the file list: no `.env`, test fixtures with real data, or source maps you didn't mean to ship.
- CI green from a clean, frozen install.
- `bin`, `exports` and README install instructions match reality.
- Publish only after lockfile and package-manager changes are understood.
A CI-only hardening change doesn't need a release. If runtime dependencies or install instructions changed, include it in the next patch release.