Skip to content

test: apply the supply-chain age gate to npm and pnpm in Verdaccio tests - #4347

Draft
erickzhao wants to merge 10 commits into
nextfrom
verdaccio-age-gate-all-package-managers
Draft

test: apply the supply-chain age gate to npm and pnpm in Verdaccio tests#4347
erickzhao wants to merge 10 commits into
nextfrom
verdaccio-age-gate-all-package-managers

Conversation

@erickzhao

Copy link
Copy Markdown
Member

The root .yarnrc.yml sets npmMinimalAgeGate with an exemption list for our own packages. The Verdaccio tests install the freshly published monorepo into app directories under os.tmpdir(), so they never pick that file up, and the harness only ever mirrored the Yarn half of the policy — npm and pnpm installed with no gate at all. Both have since grown the same setting, so this mirrors it for them too, with the age and the exemption list defined once and spelled three ways.

  • npm calls it min-release-age and counts it in days. It landed in 11.19, and older versions warn "unknown config" on every single invocation, so the harness only passes it when the npm on PATH supports it and warns once when it doesn't. CI installs npm 11.19.0 for the slow-tests job — npm 12 requires Node ^22.22.2 || ^24.15.0 || >=26 and .nvmrc pins 22.13, so 12 can't be installed there at all.
  • pnpm calls it minimumReleaseAge and counts it in minutes, but only reads it from pnpm-workspace.yaml or the global config file. Writing a pnpm-workspace.yaml into each generated app would make Forge's own resolvePackageManager misdetect the npm and Yarn cases, so the harness generates a global config under the Verdaccio storage directory and points pnpm at it with XDG_CONFIG_HOME.

The pnpm leg wasn't using the local registry

That generated config also has to set registry. pnpm honors --registry and its config files, but it ignores npm_config_registry from the environment, which is how every other package manager in the harness is pointed at Verdaccio. So the pnpm third of these tests has been resolving @electron-forge/* from the public registry and validating the last published alpha rather than the local build.

Verified by baking a marker into packages/api/cli/dist, publishing, and diffing what got installed: before the fix npm and Yarn got the marker and pnpm didn't, and pnpm's lockfile integrity matched the npmjs tarball. After it, pnpm's integrity matches the locally published one.

Two fixes that fall out of it

  • The pnpm hang. pnpm store prune is replaced by a per-run cacheDir under the storage directory, which startVerdaccio already deletes. The prune was there so a republished version couldn't resolve through stale metadata, but staleness lives in the metadata cache, not in the content-addressed store — and a fully cold store made pnpm hang after installing (its worker pool never shut down) until the test runner timed out. Confirmed by running twice with different markers: the warm store serves the fresh build both times.
  • COREPACK_ROOT leakage. yarn test:verdaccio runs Yarn through Corepack, which exports COREPACK_ROOT to everything below it, including the package manager installing each generated app. pnpm refuses to switch to the version it's asked for when it believes Corepack invoked it, and create-electron-app runs corepack use pnpm@latest, so every pnpm app failed its first install with a version mismatch. The harness now drops the variable.

Testing

yarn test:verdaccio — the real entry point, through Corepack Yarn — passes 64/64 across all 10 files.

Noticed but not changed

exitOnError: false on the "Installing common dependencies" task at packages/external/create-electron-app/src/init.ts:248 means a failed dependency install is swallowed by listr2: the CLI exits 0 and leaves the user with a scaffold that has no dependencies. That's what made the COREPACK_ROOT bug above so hard to see, and real users hitting an install failure get the same silent success. Worth fixing separately, since it changes user-facing CLI behavior.

🤖 Generated with Claude Code

Co-Authored-By: Claude svc-devxp-claude@slack-corp.com

The Verdaccio tests install the freshly published monorepo into throwaway
app directories, so they can never pick up the root `.yarnrc.yml`. Until
now only Yarn's gate was mirrored in the harness; npm and pnpm installed
without one. Both have since grown the same policy, so set it for them
too and keep the shared values in one place:

- npm calls it `min-release-age` (in days) and has supported it since
  11.19, so only pass it when the npm on `PATH` is new enough and say
  once when it isn't. CI pins npm 11.19.0 because npm 12 requires a
  newer Node than `.nvmrc`.
- pnpm calls it `minimumReleaseAge` (in minutes) but reads it from its
  config files only, so generate a global config and point pnpm at it
  with `XDG_CONFIG_HOME`.

That config also has to set `registry`: pnpm honors `--registry` and its
config files but not `npm_config_registry`, so the pnpm half of these
tests was resolving `@electron-forge/*` from the public registry and
validating the last published release instead of the local build.

Two other fixes fall out of this:

- `pnpm store prune` is gone in favour of a per-run `cacheDir` under the
  storage directory. Staleness lives in the metadata cache, not in the
  content-addressed store, and a cold store made pnpm hang after
  installing until the test runner timed out.
- `COREPACK_ROOT` no longer leaks into the spawned tests. `yarn
  test:verdaccio` runs Yarn through Corepack, and pnpm refuses to switch
  to the version `create-electron-app` pins when it thinks Corepack
  invoked it.

Co-Authored-By: Claude <svc-devxp-claude@slack-corp.com>
@github-actions github-actions Bot added the next label Aug 19, 2026
erickzhao and others added 9 commits August 19, 2026 16:44
pnpm shuts its tarball worker pool down once per install, but any worker
call that happens after that lazily creates a new pool that nothing ever
shuts down, and the idle worker thread keeps the event loop alive. An
install whose last download finishes just after pnpm prints `Done in Xs`
therefore writes the lockfile, links everything, reports success and then
never exits (pnpm/pnpm#13617). Installs that fetch nothing are
unaffected, which is why this only shows up in these tests: they always
install into a brand new project.

Every test here spawns its package manager and waits for it to exit, so
the hang costs the whole test rather than just the process, which is what
was timing out the `pnpm` template tests on all three platforms. Put a
stand-in for pnpm at the front of `PATH` for the duration of these tests
that kills it once it has reported that it is done and has had a grace
period to exit on its own.

Co-Authored-By: Claude <svc-devxp-claude@slack-corp.com>
Since pnpm 11, `pnpm run` silently runs an install first whenever it
decides that `node_modules` is out of sync with the lockfile. These tests
run `<package manager> run start` to check that the app
`create-electron-app` just installed can start, so that install replaces
the very thing they are checking: on Windows it rewrote the dependency
tree into one where the generated `forge.config.ts` could no longer
resolve the Forge plugin it imports, and `electron-forge start` failed.
Set `verifyDepsBeforeRun` to `warn` so the check still runs and still
reports whatever it believes is out of sync, without acting on it.

While here, only watch for the exit hang on the commands that install
packages. `pnpm run start` keeps running long after pnpm reports that it
is done, and the last thing the exit shim should do is kill it.

Co-Authored-By: Claude <svc-devxp-claude@slack-corp.com>
pnpm decides whether to link dependencies through a store-wide virtual
store or one inside the project based on whether it believes it is running
in CI, and these tests cannot keep that consistent: they install with the
environment they inherit and then run the app's `start` script with a
minimal one, so pnpm read the same project two different ways and reported
that `node_modules` no longer matched the lockfile. Pin the setting to the
value CI would pick anyway.

Also report the dependency tree the package manager installed when `start`
fails, since a `start` that cannot resolve the app's own configuration says
nothing about the tree it was trying to resolve it from.

Co-Authored-By: Claude <svc-devxp-claude@slack-corp.com>
The shim this removes ran pnpm and killed it 15 seconds after it printed
`Done in`, on the theory that anything past that point was the leaked
worker pool of pnpm/pnpm#13617 keeping a finished process alive. On
Windows it was cutting installs short instead: both TypeScript templates
came out of `create-electron-app` with exactly the base template's
dependencies and none of their own, because the second install reports
that it is done and is then killed while the very work the pnpm bug
defers — copying packages into `node_modules` — is still going. Windows
CI keeps its pnpm store on `C:` and its projects on `D:`, so nothing can
be hardlinked and that tail takes far longer than it does anywhere else.

An install that quietly loses half of a project is a worse failure than
one that hangs, and the hang the shim was written for was in the install
`pnpm run` used to perform behind the tests' back, which
`verifyDepsBeforeRun: warn` already stopped.

Also report what `create-electron-app` printed when `start` fails, since
it runs its steps with listr2's `exitOnError: false` and so exits 0 with
a broken project when an install fails.

Co-Authored-By: Claude <svc-devxp-claude@slack-corp.com>
Restores the watchdog removed in the commit before this one — pnpm really
does finish an install and then hang, and without it the vite-typescript
project times out at 240s — but stops it from leaving a project behind
that cannot be repaired.

Killing pnpm once it reports success is only safe if it has really
stopped working, and it hasn't: the hang comes from a package that
arrived after pnpm stopped expecting one, and that package is still being
written. Worse, pnpm will not put it back, because it decides whether a
project is up to date from the state files it keeps in `node_modules`
rather than from the packages themselves — installing again into a tree
it has already recorded is `Already up to date` even with packages
deleted out of it, `--force` included. That is what Windows was failing
on: both TypeScript projects came out of `create-electron-app` with the
base template's dependencies and none of their own.

So the watchdog now discards those state files after killing pnpm and
installs again, which makes pnpm compare the tree against the store and
write whatever is missing. A repair only has to link packages that are
already in the store, so it is very unlikely to hang in turn; if every
attempt does, the shim now says so and fails instead of reporting a
success it cannot vouch for.

Co-Authored-By: Claude <svc-devxp-claude@slack-corp.com>
Windows keeps producing projects whose `node_modules` has the base
template's dependencies and none of the template's own, and nothing says
why: `create-electron-app` installs through listr2 with
`exitOnError: false` and never prints the errors it collects, so a failed
install leaves the task without a tick and without a word about it, and
the package manager's own output is thrown away with it.

So the pnpm shim now records every pnpm it runs — the command, the
directory, how it ended and everything it printed — and a test whose app
fails to start reports the runs for its own project, along with the
`package.json` they were working from, which is what says whether the
dependencies were recorded and not installed or never recorded at all.

Co-Authored-By: Claude <svc-devxp-claude@slack-corp.com>
… passes on

On Windows the real pnpm is a `.cmd` file, so the shim ran it through
`cmd.exe`, which Node hands the command line to unquoted. `cmd.exe` reads
`^` as its own escape character, so `pnpm add typescript@^6.0.0` reached
pnpm as `typescript@6.0.0` — a version that does not exist — and the
install failed with `ERR_PNPM_NO_MATCHING_VERSION`. Every other
dependency the templates ask for happens to have a release at exactly the
version its range starts from, which is why only the two TypeScript
templates failed, and why the projects they left behind had
`"@electron/fuses": "2.0.0"` where every other platform gets `"^2.1.3"`.

`create-electron-app` runs its steps with listr2's `exitOnError: false`
and never prints the errors it collects, so all of this was silent: the
install failed, the project was left without the template's own
dependencies, and the test only found out when the app could not resolve
its Forge configuration.

Hand the spawning to `cross-spawn`, which quotes and escapes arguments
the way `cmd.exe` needs and is what Forge itself runs package managers
with.

Co-Authored-By: Claude <svc-devxp-claude@slack-corp.com>
The manifest entry was left out of the commit that started importing it,
so installs with a frozen lockfile failed. `^7.0.3` is the range the rest
of the project asks for, which `yarn constraints` requires it to match.

Co-Authored-By: Claude <svc-devxp-claude@slack-corp.com>
`create-electron-app` pins every pnpm app it creates to the pnpm version
this repository runs in CI, and until pnpm 11.18 adding a dependency to a
project that already had some could drop a package that another package
it kept still depends on. The last of the four installs
`create-electron-app` runs left `rimraf` in `node_modules` without the
`glob` it requires, so `forge.config.ts` could no longer load and the
Verdaccio template tests could not start the app they had just created.

It only showed up on Windows, for two reasons: everywhere else the
platform-specific makers' dependencies pull `glob` in through a second
path that keeps it in the tree, and Corepack pins `pnpm@latest` for the
app it creates, which is much newer than this pin. Corepack fails on the
Windows runners, so there the pin is what actually installs.

Co-Authored-By: Claude <svc-devxp-claude@slack-corp.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant