Skip to content

Add OIDC trusted publishing + staged npm releases via GitHub Actions#11

Merged
patocallaghan merged 3 commits into
masterfrom
patoc/oidc-staged-publishing
Jun 10, 2026
Merged

Add OIDC trusted publishing + staged npm releases via GitHub Actions#11
patocallaghan merged 3 commits into
masterfrom
patoc/oidc-staged-publishing

Conversation

@patocallaghan

Copy link
Copy Markdown
Member

Why?

passport-intercom is published to npm by hand today, which depends on a
long-lived npm token. This moves publishing onto short-lived, per-run OIDC
credentials and adds a human approval step before any release goes live —
removing the need for a stored npm token entirely.

How?

Adds a release-triggered GitHub Actions workflow that:

  • Authenticates to npm via OIDC trusted publishing — no NPM_TOKEN secret.
  • Uses npm's staged publishing (npm stage publish), so every release is
    queued and a maintainer must approve it with 2FA before it goes live.
  • Routes prereleases to the next dist-tag and stable versions to latest.
  • Verifies the release tag matches package.json (tolerant of a leading v)
    and refuses releases not reachable from the default branch.
  • Pins the CI Node version via .nvmrc (needed for the npm CLI version that
    supports staged publishing).

Before the first release can publish, a stage-only Trusted Publisher for this
package must be registered on npm (configured against this repo + workflow).

Generated with Claude Code

@socket-security

socket-security Bot commented Jun 10, 2026

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addedgithub/​actions/​checkout@​de0fac2e4500dabe0009e67214ff5f5447ce83dd99100100100100

View full report

@patocallaghan patocallaghan marked this pull request as ready for review June 10, 2026 11:45
@anubhav-intercom

Copy link
Copy Markdown

Workflow review — one correctness issue in the verify job

Looks good overall — SHA-pinned actions, id-token: write only on stage-publish, all github.event.* routed through env: (no script-injection surface), no NPM_TOKEN, and skipping npm ci/build is correct here since the package ships committed lib/ sources with no build/prepare script and no lockfile. One real issue plus minor notes.

🟠 The branch-ancestry guard can false-negative legitimate releases

In "Refuse releases not on the default branch", both the actions/checkout (default fetch-depth: 1) and the git fetch origin "$DEFAULT_BRANCH" --depth=1 are shallow, so the tagged commit and the master tip share no common history in the local clone. git merge-base --is-ancestor then returns non-zero on a legitimate release whenever master has advanced past the tagged commit — it passes only when the tag commit is the branch tip at fetch time. It fails closed (refuses a publish; never allows a bad one), so it's a release-availability bug, not a safety one.

Heads-up: fetch-depth: 0 on the checkout alone won't fix it — the step's own --depth=1 fetch re-shallows master's tip.

Suggested fix: on the verify checkout set fetch-depth: 0 and drop the manual git fetch line (the full checkout populates origin/$DEFAULT_BRANCH); or, minimally, just drop --depth=1 from the existing fetch:

# minimal change:
git fetch origin "$DEFAULT_BRANCH"        # no --depth=1
git merge-base --is-ancestor "$GITHUB_SHA" "origin/$DEFAULT_BRANCH" \
  || { echo "release $RELEASE_TAG not reachable from $DEFAULT_BRANCH — refusing"; exit 1; }

One portability note: persist-credentials: false + the manual git fetch origin works here because this repo is public (anonymous fetch is allowed). The same pattern fails on an internal/private repo, where the manual fetch would 403 and refuse every release — worth keeping in mind if this workflow gets copied to a private repo.

Smaller notes (non-blocking)

  • CI green ≠ published. npm stage publish only stages; live requires npm stage approve <id> + 2FA.
  • No concurrency: on the workflow — overlapping releases could race.
  • ${RELEASE_TAG#v} won't normalize SemVer build-metadata (+build) — latent only; current tags are clean.
  • npm install -g npm@11.15.0 fetches npm at publish time — minor supply-chain surface, just flagging.
  • Public repo → OIDC auto-generates provenance (correct as-is, no --provenance flag needed).

@patocallaghan

Copy link
Copy Markdown
Member Author

@anubhav-intercom thanks. let me incorporate your guidance

The "refuse releases not on the default branch" check used a depth-1
checkout plus a depth-1 `git fetch`, so the tag commit and the default
branch tip shared no history locally — `git merge-base --is-ancestor`
could only pass when the tag was exactly the branch tip, failing closed
on any legitimate release once the branch advanced. Use `fetch-depth: 0`
and drop the re-shallowing manual fetch; this also removes the
public-repo-only dependency on an anonymous `git fetch` (works on
private repos too, since checkout fetches before stripping credentials).

Add a top-level `concurrency:` group so overlapping releases serialize
instead of racing for a dist-tag in the staging queue;
`cancel-in-progress: false` to queue rather than kill an in-flight
`npm stage publish`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@patocallaghan

Copy link
Copy Markdown
Member Author

@anubhav-intercom thanks! I incorporated the concurrency and branch ancestry feedback 🙇‍♂️

Match the cli workflow: cap stage-publish so a hung publish fails fast
instead of running to the 6h default.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@patocallaghan patocallaghan merged commit 16abb1c into master Jun 10, 2026
3 checks passed
@patocallaghan patocallaghan deleted the patoc/oidc-staged-publishing branch June 10, 2026 16:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants