Skip to content

fix(release): name the crate for neon dist, which pnpm exec cannot default - #882

Merged
tobyhede merged 3 commits into
mainfrom
fix/neon-dist-crate-name
Aug 12, 2026
Merged

fix(release): name the crate for neon dist, which pnpm exec cannot default#882
tobyhede merged 3 commits into
mainfrom
fix/neon-dist-crate-name

Conversation

@tobyhede

Copy link
Copy Markdown
Contributor

The FFI release pipeline merged in #878 could not have produced a single tarball. The first dispatch of ffi-preflight.ymlrun 31555436823, against main — failed all six platform legs at the same step, Place the binding in its platform package:

error: $npm_package_name is not defined

The binding itself built fine on every platform. This is the step after it.

Why

neon dist locates the compiled cdylib in the cargo log by crate name, and defaults that name to basename($npm_package_name)@neon-rs/cli/index.js, ensureDefined(process.env['npm_package_name'], '$npm_package_name'). That variable is set by the package-script runner.

The step has to invoke the binary directly, because that is the only way to pass -o platforms/<platform>/index.node. A bare neon dist writes ./index.node, which is the debug: fallback in load.cts — right for local development, wrong for a package about to be published.

Two reasons nobody caught it earlier, and neither was carelessness:

  • Upstream never met it. protectjs-ffi reached neon dist only through the postcargo-build / postzig-build lifecycle hooks, where the variable exists.

  • The plan's spelling would not have either. It carried npx neon dist -o …; the implementation uses pnpm exec. Measured locally:

    npx       -> npm_package_name=@cipherstash/protect-ffi
    pnpm exec -> npm_package_name=UNDEFINED
    

    Moving to pnpm exec is right on its own terms — npx will fetch a missing binary over the network — but it removed the default's only source, and nothing in the diff said so.

The fix

pnpm exec neon dist -n protect-ffi \
  -o "platforms/${PLATFORM}/index.node" < "${BUILD_LOG}"

-n names the crate (crates/protect-ffi/Cargo.toml), not the npm package.

The guard

scripts/__tests__/neon-dist-crate-name.test.mjs asserts, across every workflow and by discovery rather than a list, that each pnpm exec neon dist passes -n and that its value matches the crate in Cargo.toml.

Both halves earn their place. Dropping the flag fails loudly and identically on all six platforms. Renaming the crate without updating the flag does notneon dist finds no matching artifact and generates nothing, which fails a step later as an empty output rather than an error.

Mutation-tested both ways (flag removed; name changed to protectffi); each fails the guard.

Verification

  • actionlint on all four release workflows — clean, shellcheck included
  • biome check — clean
  • pnpm run test:scripts — 31 files, 424 tests, green
  • Locally, with an empty log so nothing compiles: without -n, usage; with -n protect-ffi, error: No artifacts were generated for crate protect-ffi — the flag is parsed and the crate lookup runs

This cannot be fully verified without another preflight dispatch, which is the next step once this is on a branch CI can reach:

gh workflow run ffi-preflight.yml --ref fix/neon-dist-crate-name -f ref=fix/neon-dist-crate-name

Notes

  • No changeset. Repo tooling, no published surface. The pipeline stays inert until the trusted-publishing cutover (CIP-3719), and lint-no-ffi-changeset.mjs would reject one naming these packages anyway.
  • docs/plans/2026-08-04-protect-ffi-monorepo-absorption.md is updated in both places that describe this step — the neon dist section and the inline workflow source, which still showed npx.
  • Worth recording against CIP-3718: its last acceptance box was "ffi-preflight.yml runs green against a versioned release-PR ref", and it was never ticked because the workflow had never run. That box was load-bearing.

…default

Every platform leg of `_build-ffi-artifacts.yml` failed with
`error: $npm_package_name is not defined`, so the FFI release pipeline
could not have produced a single tarball. Found by the first dispatch of
`ffi-preflight.yml` (run 31555436823) — which is what it is for.

`neon dist` locates the compiled cdylib in the cargo log by crate name,
defaulting it to `basename($npm_package_name)`. That variable comes from
the package-script runner. The step invokes the binary directly, because
that is the only way to pass `-o platforms/<platform>/index.node` and so
avoid the bare-`neon dist` default of `./index.node` — the `debug:`
fallback in `load.cts`, right locally and wrong in a published tarball.

Upstream never met this: its `postcargo-build` / `postzig-build` hooks run
`neon dist` as lifecycle scripts, where the variable exists. Nor would the
plan's spelling have, which was `npx` — npx sets `npm_package_name`,
`pnpm exec` does not. Moving to `pnpm exec` was right on its own terms,
npx will fetch a missing binary over the network, but it removed the
default's only source and nothing said so.

`scripts/__tests__/neon-dist-crate-name.test.mjs` pins both halves across
every workflow, discovered rather than listed: the flag is present, and its
value equals the crate in `crates/protect-ffi/Cargo.toml`. The second is
the quiet one — a renamed crate with a stale `-n` does not error, it
generates nothing.

No changeset: repo tooling, no published surface. The pipeline remains
inert until the trusted-publishing cutover.
@tobyhede
tobyhede requested a review from a team as a code owner August 12, 2026 02:25
@changeset-bot

changeset-bot Bot commented Aug 12, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 0038284

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

…lure

The second defect the pre-flight found, and the one the first was hiding.
With the crate name fixed, both Darwin legs went green and the other four
failed one step later: `<tarball> has no index.node`, on a tarball whose
own pack listing shows index.node present.

`grep -q` exits at the first match. The writer upstream then takes
SIGPIPE, and under `pipefail` — which GitHub sets for every `shell: bash`
step before the block's own `set -euo pipefail` — the pipeline's status
becomes the killed writer's 141. The match succeeded and the pipeline
reports failure.

It presents as a platform problem, which is why it cost a full matrix run
to see. GNU tar writes an entry at a time and hits it; bsdtar buffers a
four-entry listing into one write and does not. Hence macOS green, Linux
and Windows red, on the packaging step, in a job that had just changed.

Three call sites, all in the FFI release path, and they do not fail in the
same direction:

  - `_build-ffi-artifacts.yml` `tar tzf | grep -qx package/index.node`
    fails CLOSED — this run's visible failure.
  - `ffi-preflight.yml`'s glibc check on the two gnu platforms, also
    closed.
  - `ffi-preflight.yml`'s musl check fails OPEN. `if readelf | grep -q`
    with a poisoned status is false, so it reports "no glibc NEEDED entry"
    for precisely the binary it exists to reject — a gnu build shipped
    inside the musl package, landing on an Alpine user at dlopen rather
    than in CI. A dynamic section is much longer than a tarball listing,
    so it would have been reliably wrong. Neither preflight check had ever
    executed.

All three capture first and match against the variable.
`scripts/__tests__/workflow-grep-q-pipelines.test.mjs` rejects the pattern
across every workflow and composite action. `grep -q` against a file or a
here-string is untouched: no writer, no signal.

@cipherstash-bot cipherstash-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Coverage review: found one targeted gap in the new workflow guard test. The workflow change itself has a positive guard for the current invocation, but the scanner is not covered for multiple neon dist invocations inside one run: block.

describe('neon dist through pnpm exec', () => {
const calls = workflowFiles().flatMap((file) =>
runSteps(readWorkflow(file))
.filter((step) => NEON_DIST.test(step.run))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gap: The guard has no negative test for a single workflow step containing multiple pnpm exec neon dist invocations, so one correctly named call can mask a later unnamed call in the same run: block.

it('does not let an unnamed second neon dist call hide behind a named first one', () => {
  const run = [
    `pnpm exec neon dist -n ${crateName()} -o one < cargo.log`,
    'pnpm exec neon dist -o two < cargo.log',
  ].join('\n')

  const names = run
    .split('\n')
    .filter((line) => /pnpm\s+exec\s+neon\s+dist\b/.test(line))
    .map((line) => line.match(/(?:^|\s)(?:-n|--name)\s+(\S+)/)?.[1])

  expect(names).toEqual([crateName(), crateName()])
})

Expected: this fails today with the second entry undefined, then passes after the scanner validates each neon dist invocation rather than only the first named call in a step.

@auxesis auxesis left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this @tobyhede. ❤️

Approved, with one test gap to fix.

Review finding, and the gap was wider than reported. The guard matched a
single non-global regex over the whole `run:` body, so a step with two
`neon dist` calls was judged by whichever `-n` appeared first — and the
flag it found did not have to belong to a `neon dist` at all. `echo -n
protect-ffi` on an earlier line satisfied it, as the new coverage shows.

The scan now splits a body into individual commands (continuations joined
first, so a newline is a real boundary) and holds every invocation to the
crate name separately.

Two tests drive the scanner over shapes no workflow in the tree has: a
second unnamed call behind a correctly named one, and a `-n` belonging to
a neighbouring command. Both fail against the previous body-wide match —
`length 1, expected 2` and `['protect-ffi'], expected [undefined]` — so
they pin the fix rather than restating it.

Reported alongside a suggested test that scanned an inline string with
logic defined in the test body. That version never calls the scanner, so
its result does not move when the scanner changes: it would have failed
before and after. These call the real thing.
@tobyhede
tobyhede merged commit 3956122 into main Aug 12, 2026
11 checks passed
@tobyhede
tobyhede deleted the fix/neon-dist-crate-name branch August 12, 2026 06:20
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.

3 participants