Skip to main content
The state of ai impact assessment
Staged Publishing for npm: A Manual Defense LayerSoftware Supply Chain Security
5 min readFor TPRM Practitioners

Staged Publishing for npm: A Manual Defense Layer

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: write permission)
  • 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 zizmor and actionlint
  • Pin all action dependencies to SHA-1 commits (use pinact to 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:

  1. Check your email for the npm staging notification.
  2. Note the integrity and shasum values from the notification.
  3. 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:

  1. Run local verification script (see Phase 2).
  2. Log into npmjs.com.
  3. Navigate to your package's staging area.
  4. Complete 2FA challenge.
  5. Click "Approve" to publish to registry.
  6. 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> versions output.
  • 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):

  1. Trigger draft workflow via Actions UI.
  2. Wait for email notification (usually under 2 minutes).
  3. Run verification script with values from email.
  4. Approve staged package on npmjs.com (2FA required).
  5. Publish GitHub draft release.

Monthly:

  • Review zizmor and actionlint for 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 .npmignore to 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.

a promotional banner asking how ready are you for PCI DSS 4.0? With a call-to-action to get the checklist now.

You Might Also Like