If GitHub Actions were fully compromised tomorrow, would your publishing pipeline survive? That's the threat model behind nuqs's staged publishing implementation. With over 3 million weekly downloads, the project needed a defense that didn't rely on trusting the CI/CD platform itself.
This guide walks you through implementing npm staged publishing with reproducible builds. You'll place 2FA outside your automation layer and verify every release before it goes live.
Why Staged Publishing Matters
Supply chain attacks targeting npm packages often compromise the publishing workflow, inject malware during a legitimate release, and exploit trust relationships to reach downstream consumers. The TanStack incident showed that even GitHub Actions cache poisoning can deliver malicious packages through otherwise clean workflows.
Traditional defenses like OIDC trusted publishing and workflow permissions scoping assume your CI/CD platform is trustworthy. But if the platform itself is breached, those controls are ineffective. You need an external checkpoint that an attacker can't bypass through automation alone.
npm staged publishing creates that checkpoint. Packages land in a staging area where they wait for manual 2FA approval. An attacker with a stolen publishing token gets stuck at the 2FA gate, and you receive an email notification about the staged package. No timeout evicts staged packages, so you control the review cadence.
What You Need Before Starting
Access Requirements:
- npm account with 2FA enabled (authenticator app, not SMS)
- npm publishing token with provenance scope
- Repository with OIDC trusted publishing configured (
id-token: writepermission) - Docker installed locally for reproducible build verification
Current State Assumptions:
- You're publishing from GitHub Actions (or similar CI)
- Your package has a stable build process (deterministic file ordering, consistent timestamps)
- You control when releases cut (manual trigger or protected branch merge)
Before You Begin:
- Audit your existing workflows with
zizmorandactionlint - Pin all action dependencies to SHA-1 commits (use
pinactto automate this) - Enable "Require SHA-1 pinning" in repository settings
- Document your current version bumping logic (you'll need to replicate it)
Step-by-Step Implementation
Phase 1: Draft Workflow (Automated Staging)
Create .github/workflows/release-draft.yml:
name: Draft Release
on:
workflow_dispatch:
permissions:
contents: write
id-token: write
jobs:
draft:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@<sha1>
with:
fetch-depth: 0 # need full history for version computation
- name: Compute next version
id: version
run: |
# Walk commit tree, parse conventional commits
# Output: NEW_VERSION=x.y.z
node scripts/compute-version.js
- name: Apply version to package.json
run: |
npm version ${{ steps.version.outputs.NEW_VERSION }} --no-git-tag-version
- name: Stage publish with provenance
run: |
npm publish --provenance --access public --tag staged
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Create draft release
run: |
gh release create v${{ steps.version.outputs.NEW_VERSION }} \
--draft \
--title "v${{ steps.version.outputs.NEW_VERSION }}" \
--notes-file CHANGELOG.md
env:
GH_TOKEN: ${{ github.token }}
Critical Configuration: Your npm token must have provenance scope. Generate it at npmjs.com/settings/tokens with "Automation" type and "Publish" permission.
Phase 2: Local Verification Script
Create scripts/verify-staged.js:
#!/usr/bin/env node
const { execSync } = require('child_process');
const crypto = require('crypto');
const fs = require('fs');
const [pkg, version, expectedIntegrity, expectedShasum] = process.argv.slice(2);
// Build in Docker to match GitHub Actions environment
execSync(`docker run --rm -v $(pwd):/workspace -w /workspace node:24.11.0 sh -c "npm ci && npm pack"`, { stdio: 'inherit' });
const tarball = fs.readFileSync(`${pkg}-${version}.tgz`);
// Compute integrity (SHA-512 base64)
const integrity = 'sha512-' + crypto.createHash('sha512').update(tarball).digest('base64');
// Compute legacy shasum (SHA-1 hex)
const shasum = crypto.createHash('sha1').update(tarball).digest('hex');
console.log(`Expected integrity: ${expectedIntegrity}`);
console.log(`Computed integrity: ${integrity}`);
console.log(`Expected shasum: ${expectedShasum}`);
console.log(`Computed shasum: ${shasum}`);
if (integrity !== expectedIntegrity || shasum !== expectedShasum) {
console.error('FAIL: Hashes do not match');
process.exit(1);
}
console.log('PASS: Tarball reproduces exactly');
Usage After Draft Workflow Completes:
- Check your email for the npm staging notification.
- Note the
integrityandshasumvalues from the notification. - Run:
node scripts/verify-staged.js your-package 1.2.3 <integrity> <shasum>
If hashes match, the staged package contains exactly what your repository would produce. If they don't, download the staged tarball with npm stage download <uid> (requires authenticated CLI) and diff the contents.
Phase 3: Finalization Workflow
Create .github/workflows/release-finalize.yml:
name: Finalize Release
on:
release:
types: [published]
permissions:
contents: read
issues: write
pull-requests: write
jobs:
finalize:
runs-on: ubuntu-latest
steps:
- name: Verify package is live
run: |
VERSION=${GITHUB_REF#refs/tags/v}
sleep 30 # npm propagation delay
LIVE=$(npm view ${{ github.repository }} version)
if [ "$LIVE" != "$VERSION" ]; then
echo "Package not live on registry - did you approve the staged package?"
exit 1
fi
- uses: actions/checkout@<sha1>
with:
fetch-depth: 0
- name: Comment on shipped issues and PRs
run: |
# Parse commit graph, extract issue/PR references
# Post "Released in vX.Y.Z" comments
node scripts/notify-shipped.js
env:
GH_TOKEN: ${{ github.token }}
Manual Steps Between Draft and Finalize:
- Run local verification script (see Phase 2).
- Log into npmjs.com.
- Navigate to your package's staging area.
- Complete 2FA challenge.
- Click "Approve" to publish to registry.
- Return to GitHub, publish the draft release (creates Git tag, triggers finalize workflow).
Validation - How to Verify It Works
After First Staged Publish:
- Confirm email notification arrived with staging details.
- Verify staged package appears in npm web UI under your package's "Staging" tab.
- Run local verification script, confirm hashes match.
- Check that package is NOT yet queryable via
npm view <package>@<version>.
After Approval:
- Verify package appears in
npm view <package> versionsoutput. - Confirm GitHub release was published with correct tag.
- Check that finalize workflow ran and posted PR/issue comments.
- Test installation:
npm install <package>@<version>in a clean directory.
Reproducibility Check for Any Past Release:
# Query registry for published metadata
INTEGRITY=$(npm view [email protected] dist.integrity)
SHASUM=$(npm view [email protected] dist.shasum)
# Reproduce from Git tag
git checkout v1.2.3
node scripts/verify-staged.js your-package 1.2.3 $INTEGRITY $SHASUM
If this passes for historical releases, your build is deterministic.
Maintenance and Ongoing Tasks
Per Release (Manual):
- Trigger draft workflow via Actions UI.
- Wait for email notification (usually under 2 minutes).
- Run verification script with values from email.
- Approve staged package on npmjs.com (2FA required).
- Publish GitHub draft release.
Monthly:
- Review
zizmorandactionlintfor new workflow vulnerabilities. - Update SHA-1 pins for action dependencies (use
pinact). - Audit npm token scopes (rotate if overprivileged).
When Build Becomes Non-Reproducible:
- Check for new dependencies with timestamp-sensitive output.
- Review changes to bundler configuration (esbuild, rollup, etc.).
- Verify Docker base image matches GitHub Actions runner version.
- Consider adding
.npmignoreto exclude non-deterministic files.
If Staged Package Verification Fails:
- Do NOT approve the staged package.
- Run
npm stage reject <uid>to drop it. - Download staged tarball:
npm stage download <uid>. - Extract and diff against local build:
tar -tzf <tarball>. - Investigate discrepancies (common: different Node/npm versions, file ordering, embedded timestamps).
- Fix root cause, re-trigger draft workflow.
Incident Response: If you receive a staging notification you didn't trigger, immediately reject the staged package and rotate your npm token. Review GitHub Actions logs for the unauthorized workflow run and audit repository access permissions.





