Skip to content

Prepare first npm prerelease - #11

Merged
raghubetina merged 1 commit into
mainfrom
codex/npm-prerelease-release-prep
Jul 31, 2026
Merged

Prepare first npm prerelease#11
raghubetina merged 1 commit into
mainfrom
codex/npm-prerelease-release-prep

Conversation

@raghubetina

@raghubetina raghubetina commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Summary

  • prepare firstdraft@0.1.0-alpha.1 as a public prerelease under the next dist-tag with npm provenance
  • add a tag-triggered workflow that verifies a protected tag against an exact first-parent main state before publication
  • require an approved npm environment and document the one-time bootstrap-token to trusted-publishing transition
  • document prerelease installation, support, approval, verification, and recovery boundaries
  • lock the public registry, repository, provenance, dist-tag, and privileged source-check invariants in package tests

Release boundary

This pull request does not publish a package, create or push a release tag, create an npm token, or change GitHub repository protections. Publication remains a separate manual action after merge.

Before the first v* tag, an administrator must protect main and release tags, configure the reviewed npm environment and fail-closed variable, and add the one-day bootstrap token as an environment secret. The environment reviewer must verify the exact tag, commit, workflow, and completed unprivileged checks before approval.

The first successful publication must be followed by npm organization access and trusted-publisher configuration, then a cleanup pull request that removes NODE_AUTH_TOKEN entirely before another tag.

Verification

PATH="/Users/sandbox2/.asdf/shims:$PATH" npm ci --ignore-scripts
PATH="/Users/sandbox2/.asdf/shims:$PATH" npm audit
PATH="/Users/sandbox2/.asdf/shims:$PATH" npm run check
  • Node 24.18.0 with npm 11.16.0
  • 113 tests passed
  • type checking, ESLint, Prettier, package allowlist, and packed-package smoke passed
  • 0 audit vulnerabilities
PATH="/Users/sandbox2/.asdf/shims:$PATH" ASDF_NODEJS_VERSION=22.0.0 npm test
PATH="/Users/sandbox2/.asdf/shims:$PATH" ASDF_NODEJS_VERSION=22.0.0 npm run pack:check
PATH="/Users/sandbox2/.asdf/shims:$PATH" ASDF_NODEJS_VERSION=22.0.0 npm run pack:smoke
  • 113 tests passed on the minimum supported Node 22.0.0
  • package allowlist and packed-package smoke passed
actionlint .github/workflows/ci.yml .github/workflows/publish.yml
npm pack --dry-run --json --ignore-scripts
  • both workflows passed Actionlint 1.7.12
  • dry-run package contained the exact 15-file allowlist with no bundled dependencies
  • positive and negative first-parent release-source checks passed
  • a verify-only workflow mutation was rejected by the synchronization invariant
  • the pinned npm CLI exposes the documented npm trust github and npm trust list commands

@raghubetina

Copy link
Copy Markdown
Contributor Author

Technical review

This is the highest-consequence change in the series, since it ends with bytes on a public registry under a name nobody has claimed yet. I went through the workflow line by line.

The hardening holds up

Control Verified
top-level permissions: {} default deny, per-job grants only
id-token: write on publish alone, where provenance needs it
actions pinned both checkout and setup-node by SHA with version comments
persist-credentials: false on both checkouts
--ignore-scripts on npm ci, npm pack, and npm publish
approval gate environment: npm plus a fail-closed NPM_RELEASE_ENABLED
concurrency npm-publish, cancel-in-progress: false
token scope NODE_AUTH_TOKEN on the publish step, not the job
tag and version coupling test "$GITHUB_REF_NAME" = "v$package_version"

Re-running the verification inside the publish job is the design decision that matters most. Environment approval sits between the two jobs and can take hours, so a check that ran only in verify would be a check that ran before the window in which things could change. Re-fetching the tag from origin and re-deriving the first-parent list closes that.

The reasoning in the inline comment is right too:

Keep this inline: no tagged repository script runs with the bootstrap secret available.

The workflow that executes comes from the tagged commit either way, so this cannot defend against a hostile workflow. What it does defend against is a hostile repository script running in the job that holds the token, and it keeps every check visible in the file the environment reviewer is reading before approving. The residual risk lands on tag protection and human approval, which is where it belongs and where the body puts it.

Finding: nothing enforces that publish stays at least as strict as verify

I extracted both blocks and compared them:

only in verify job : []
only in publish job: ['test "$NPM_RELEASE_ENABLED" = "true"']

Correct today. publish is a strict superset, which is the right direction, since it is the job holding the credential.

The relationship is maintained by hand across two copies of about fifteen lines. Adding a check to verify and forgetting publish produces exactly the wrong asymmetry, and nothing fails when it happens. It would look like a tightening.

test/package.test.js is already where this repository locks release invariants, including a test asserting no install lifecycle exists. Reading publish.yml and asserting that every line of the verify block appears in the publish block fits that pattern and is a few lines.

Two things I checked because they are easy to get wrong

--ignore-scripts on publish can ship an unbuilt package. Not here: there is no prepare, prepack, or prepublishOnly, files is ["bin","src"], and there are no runtime dependencies at all. Nothing needs to run before packing, so the flag is free protection rather than a silent breakage.

Publishing with --tag next leaves no latest. For a package's first version that means npm install firstdraft has nothing to resolve. The README says so plainly:

There is intentionally no stable latest release yet. Pin an exact prerelease version instead of next when a repeatable installation matters.

The name is also still free, which is worth confirming before a release plan depends on it:

$ curl -s -o /dev/null -w "%{http_code}" https://registry.npmjs.org/firstdraft
404

On the bootstrap token

Marking the credential with # Remove this credential after the first publish enables trusted publishing and requiring a cleanup pull request before the next tag is the right handling for a step that cannot be avoided. A one-day token limits the window even if the cleanup slips.

Zero runtime dependencies deserves a mention as well. It makes npm audit in the release path a meaningful gate rather than a lottery on someone else's transitive tree.

One finding

Assert the superset relationship between the two verification blocks.

@raghubetina

Copy link
Copy Markdown
Contributor Author

Lesson: npm install runs other people's code

Every npm install and npm ci can execute arbitrary shell commands from every package in your dependency tree, before you have run a single line of your own program.

The mechanism is lifecycle scripts. A package declares them in its own package.json:

{
  "scripts": {
    "postinstall": "node ./setup.js"
  }
}

When that package is installed, npm runs the script. Not when you import it. When you install it. As your user, in your directory, with your environment variables, including whatever secrets your CI job has in scope.

This is the delivery mechanism behind most npm supply-chain incidents. An attacker does not need you to call their function. They need you to install their package, or more commonly, to install a package that depends on a package that depends on theirs.

The defence is a flag

npm ci --ignore-scripts
npm publish --ignore-scripts

That is it. Scripts do not run, and for most projects nothing else changes.

This PR uses it on every npm invocation in its release workflow, which is the right place to be strict: that job holds a registry token, and a postinstall from any transitive dependency would run alongside it.

Before turning it on everywhere, check whether anything you depend on genuinely needs a build step. Packages with native code use install scripts to compile. If you use --ignore-scripts globally, those arrive unbuilt and fail at runtime, which is confusing precisely because installation succeeded.

The usual approach is --ignore-scripts by default with a short allowlist. npm supports this directly:

{
  "onlyBuiltDependencies": ["better-sqlite3"]
}

pnpm has had this for a while and npm has been moving the same way. The principle is the same either way: installing should not execute code, except for the specific packages you decided may.

Your own package should not need scripts either

The flip side is being a good dependency. This PR's package tests assert that the published package declares no installation lifecycle at all:

test("package metadata defines no installation lifecycle", () => {
  for (const script of ["preinstall", "install", "postinstall", ...])

Worth stealing as a habit. If your package needs to run code at install time, ask what it is for. Frequently it is a welcome message, an analytics ping, or a check that could happen lazily on first use instead. All of those are reasons for a security-conscious user to refuse to install you.

How this compares to Bundler

Ruby is not immune, but the shape differs in a way worth knowing.

Bundler has no equivalent of postinstall. A plain Ruby gem cannot run code when you bundle install. What it can do is ship a C extension, and building that extension runs extconf.rb, which is arbitrary Ruby executed on your machine at install time.

So the exposure exists in both ecosystems. What differs is the count. A Rails application has a handful of gems with native extensions and you can name them: nokogiri, pg, bcrypt, sassc. A Node application can easily have hundreds of packages with lifecycle scripts, most of which arrived transitively and none of which you chose.

That difference is really about dependency-tree size, and it is why --ignore-scripts matters more in npm than any equivalent would in Bundler.

What to actually do

In CI, always. npm ci --ignore-scripts in any job that has credentials. The cost is nearly zero and the exposure is real.

Locally, know your build steps. Try --ignore-scripts, see what breaks, allowlist those, and keep the list short enough to read.

Prefer fewer dependencies. The package in this PR has none, which is why running npm audit in its release gate means something. Every dependency you do not add is a lifecycle script you never have to think about.

Read package.json when adding something new. A postinstall in a package that has no obvious reason to need one is worth thirty seconds of curiosity.

Separate repository readiness from the irreversible tag that publishes
the first public alpha.

Pin registry and provenance metadata, verify the exact tagged main
state without credentials, and require environment approval before the
one-time bootstrap token can publish under next.
@raghubetina
raghubetina force-pushed the codex/npm-prerelease-release-prep branch from c590b67 to cd4fb3a Compare July 31, 2026 21:52
@raghubetina

Copy link
Copy Markdown
Contributor Author

Addressed the hosted finding in cd4fb3a.

The two intentionally inline source-check blocks now have explicit markers, and test/package.test.js enforces that:

  • both marked blocks are present and non-empty
  • the privileged block contains exactly one NPM_RELEASE_ENABLED approval gate immediately after set -euo pipefail
  • after removing that publish-only gate, the privileged checks exactly match the unprivileged checks in order

The publish job still executes no tagged repository helper script while the bootstrap secret is available.

Focused verification:

  • 113 tests on Node 24 and Node 22
  • a temporary verify-only mutation was correctly rejected by the new invariant
  • npm audit, typecheck, lint, formatting, package allowlist, packed-install smoke, package dry run, and Actionlint are green
  • Claude Opus 5/high re-review: Approve, no remaining findings

Hosted CI is rerunning on the exact pushed commit.

@raghubetina
raghubetina merged commit 5bd29fe into main Jul 31, 2026
4 checks passed
@raghubetina
raghubetina deleted the codex/npm-prerelease-release-prep branch July 31, 2026 21:53
@raghubetina

Copy link
Copy Markdown
Contributor Author

Follow-up on cd4fb3a: the guard works, and I tried to break it

The finding is addressed, with a better implementation than I proposed. Delimiting the block with # release-source-checks:begin / :end makes it extractable, and privileged publication repeats every release source check compares the two copies.

Suite is green at 113, up from 112:

ℹ pass 113
ℹ fail 0

A test that asserts a relationship between two things can pass because it found neither of them, so I checked that this one actually bites. Removing a single line from the publish job's block:

-          test "$GITHUB_REF_PROTECTED" = "true"
✖ privileged publication repeats every release source check
ℹ pass 112
ℹ fail 1

It fails, on the drift direction that matters, where the job holding the credential ends up with fewer checks than the unprivileged one.

Three details in the test worth noting, since they cover the ways this kind of guard usually fails:

assert.ok(verifyChecks.length > 0, "release source checks must not be empty");

If the markers ever move or a refactor empties the extraction, "every verify check appears in publish" is vacuously true over nothing. That is the same empty-collection trap as the length > 0 in firstdraft#267's CI aggregator, and it is guarded the same way.

The test also asserts that publish follows verify in the file, and that the extracted checks precede the publish command. So a future edit cannot satisfy the containment check while moving the verification after the thing it is meant to gate.

No further findings.

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.

1 participant