diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3960172ec1..16daa3d5d9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -101,12 +101,18 @@ jobs: name: dist-files path: packages + # `node-gyp` itself is pinned, but its dependencies are resolved fresh on + # every run, so `--before` holds them to the same week-old floor the root + # `.yarnrc.yml` applies to Yarn. It does that through `--before` rather + # than `min-release-age` because this step runs under the npm bundled with + # the Node version in `.nvmrc`, which is older than either age-gate + # setting and would ignore both without failing. - name: Windows setup if: runner.os == 'Windows' shell: bash run: | cd "$PROGRAMFILES/nodejs/node_modules/npm/node_modules/@npmcli/run-script" - npm install node-gyp@9.4.0 + npm install node-gyp@9.4.0 --before "$(date -u -d '7 days ago' +%Y-%m-%d)" - name: Linux setup if: runner.os == 'Linux' @@ -125,7 +131,7 @@ jobs: sudo add-apt-repository -y ppa:alexlarsson/flatpak - name: Install pnpm - run: npm install -g pnpm@11.10.0 + run: npm install -g pnpm@11.21.0 - name: Run fast tests run: | @@ -180,12 +186,18 @@ jobs: name: dist-files path: packages + # `node-gyp` itself is pinned, but its dependencies are resolved fresh on + # every run, so `--before` holds them to the same week-old floor the root + # `.yarnrc.yml` applies to Yarn. It does that through `--before` rather + # than `min-release-age` because this step runs under the npm bundled with + # the Node version in `.nvmrc`, which is older than either age-gate + # setting and would ignore both without failing. - name: Windows setup if: runner.os == 'Windows' shell: bash run: | cd "$PROGRAMFILES/nodejs/node_modules/npm/node_modules/@npmcli/run-script" - npm install node-gyp@9.4.0 + npm install node-gyp@9.4.0 --before "$(date -u -d '7 days ago' +%Y-%m-%d)" - name: Linux setup if: runner.os == 'Linux' @@ -204,7 +216,17 @@ jobs: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 - name: Install pnpm - run: npm install -g pnpm@11.10.0 + run: npm install -g pnpm@11.21.0 + + # The Verdaccio tests age-gate their installs like the root `.yarnrc.yml` + # does, which needs npm 11.17: 11.10 added `min-release-age` + # (npm/cli#8965) and 11.17 added `min-release-age-exclude` + # (npm/cli#9534). Both are newer than the npm bundled with the Node + # version in `.nvmrc`. npm 12 requires Node + # `^22.22.2 || ^24.15.0 || >=26`, so it cannot be installed on that Node + # version at all; bump this pin when `.nvmrc` moves. + - name: Install npm + run: npm install -g npm@11.19.0 - name: Run slow tests run: | diff --git a/knip.json b/knip.json index dbf3844a3a..815bb6b25f 100644 --- a/knip.json +++ b/knip.json @@ -4,8 +4,8 @@ "ignoreExportsUsedInFile": true, "workspaces": { ".": { - "entry": ["tools/*.ts", "tools/verdaccio/*.ts"], - "project": ["tools/**/*.ts"], + "entry": ["tools/*.ts", "tools/verdaccio/*.ts", "tools/verdaccio/*.mjs"], + "project": ["tools/**/*.{ts,mjs}"], "ignoreDependencies": [ "@types/keyv", "electron", diff --git a/package.json b/package.json index 1017ba0eed..a77d80a90d 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "@types/which": "^3.0.4", "@typescript/native": "npm:typescript@^7.0.0", "@yarnpkg/types": "^4.0.1", + "cross-spawn": "^7.0.3", "debug": "^4.3.1", "electron": "^42.3.3", "fork-ts-checker-webpack-plugin": "^7.2.13", diff --git a/packages/api/core/spec/slow/install-dependencies.slow.spec.ts b/packages/api/core/spec/slow/install-dependencies.slow.spec.ts index 61fd6d9958..fc089d58ea 100644 --- a/packages/api/core/spec/slow/install-dependencies.slow.spec.ts +++ b/packages/api/core/spec/slow/install-dependencies.slow.spec.ts @@ -6,9 +6,29 @@ import { installDependencies, PACKAGE_MANAGERS, } from '@electron-forge/core-utils'; -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { pathToFileURL } from 'node:url'; +/** + * How old a release has to be before this test will install it, matching the + * `npmMinimalAgeGate` the root `.yarnrc.yml` holds Yarn to. This test installs + * from the public registry with no lockfile, so without a floor the caret below + * picks up a brand new `2.x` the moment one is published. + * + * It holds it there with npm's `before` config — through the environment, since + * `installDependencies` does not pass flags through — rather than npm's own + * `min-release-age`, because this test also runs under the npm bundled with the + * Node version in `.nvmrc`, which is older than that setting and would ignore it + * without failing. + */ +const MINIMUM_RELEASE_AGE_DAYS = 7; + +function releasedBefore(days: number): string { + const cutoff = new Date(); + cutoff.setUTCDate(cutoff.getUTCDate() - days); + return cutoff.toISOString(); +} + describe.runIf(!(process.platform === 'linux' && process.env.CI))( 'install-dependencies', () => { @@ -21,6 +41,8 @@ describe.runIf(!(process.platform === 'linux' && process.env.CI))( }); it('should install the latest minor version when the dependency has a caret', async () => { + vi.stubEnv('npm_config_before', releasedBefore(MINIMUM_RELEASE_AGE_DAYS)); + await installDependencies(PACKAGE_MANAGERS['npm'], installDir, [ 'debug@^2.0.0', ]); @@ -33,6 +55,9 @@ describe.runIf(!(process.platform === 'linux' && process.env.CI))( expect(packageJSON.version).not.toEqual('2.0.0'); }); - afterAll(async () => fs.rm(installDir, { recursive: true, force: true })); + afterAll(async () => { + vi.unstubAllEnvs(); + await fs.rm(installDir, { recursive: true, force: true }); + }); }, ); diff --git a/packages/template/base/src/BaseTemplate.ts b/packages/template/base/src/BaseTemplate.ts index 4f7647ba5f..44348a46bb 100644 --- a/packages/template/base/src/BaseTemplate.ts +++ b/packages/template/base/src/BaseTemplate.ts @@ -174,9 +174,20 @@ export class BaseTemplate implements ForgeTemplate { const pm = await resolvePackageManager(); if (pm.executable === 'pnpm') { - // Ensures we're using the same `pnpm` version that we use in CI. + // Records the pnpm version this template is known to work with, which is + // the one CI installs. Anything older than 11.18 is known not to: adding a + // dependency to a project that already had some could drop a package that + // another one it kept still depends on, which left `forge.config.ts` + // unable to load on Windows, where the platform-specific makers' + // dependencies are skipped and `rimraf` is the only thing left asking for + // `glob`. + // + // It is a record and not a floor. pnpm ignores `devEngines.packageManager` + // unless it is written in the object form the spec describes, and + // `create-electron-app` then has Corepack write a `packageManager` field, + // which takes precedence over this one either way. packageJSON.devEngines = { - packageManager: 'pnpm@11.10.0', + packageManager: 'pnpm@11.21.0', }; // Ensures all transitive dependencies for `electron-winstaller` are diff --git a/packages/utils/test-utils/src/template-tests.ts b/packages/utils/test-utils/src/template-tests.ts index 3c37d855ef..65398eee1a 100644 --- a/packages/utils/test-utils/src/template-tests.ts +++ b/packages/utils/test-utils/src/template-tests.ts @@ -36,6 +36,59 @@ export type TestForgeTemplateOptions = { const d = debug('electron-forge:testForgeTemplate'); +/** + * Summarizes the layout a package manager installed into a project, which is + * what tells a flat `node_modules` (npm, Yarn, pnpm with `nodeLinker: hoisted`) + * apart from one where every package is a link into a store, and shows which + * packages a failing app could have resolved. + */ +function describeDependencyTree(projectDir: string) { + const nodeModules = path.join(projectDir, 'node_modules'); + + let entries; + try { + entries = fs.readdirSync(nodeModules, { withFileTypes: true }); + } catch (error) { + return `no readable \`node_modules\` in ${projectDir} (${error})`; + } + + const names = entries + .flatMap((entry) => + // Scopes hold the packages we care about rather than being one themselves. + entry.name.startsWith('@') + ? fs + .readdirSync(path.join(nodeModules, entry.name)) + .map((scoped) => `${entry.name}/${scoped}`) + : [entry.name], + ) + .sort(); + + return `${names.length} packages into ${nodeModules}: ${names.join(' ')}`; +} + +/** + * Everything the Verdaccio harness' pnpm shim recorded about the installs into + * one project, which is the only account there is of them: pnpm's own output. + */ +function describePnpmInstalls(projectDir: string) { + const invocationLog = process.env.FORGE_PNPM_INVOCATION_LOG; + if (!invocationLog) return 'no record of the pnpm runs was kept'; + + let records; + try { + records = fs.readFileSync(invocationLog, 'utf8').split(/^(?==== pnpm )/m); + } catch (error) { + return `no readable record of the pnpm runs in ${invocationLog} (${error})`; + } + + const forThisProject = records.filter((record) => + record.includes(`in ${projectDir}`), + ); + return forThisProject.length + ? forThisProject.join('') + : `nothing in ${invocationLog} mentions ${projectDir}`; +} + /** * Runs the local version of `create-electron-app` to create a project based on * a given Forge template using all supported package managers. Because this @@ -105,7 +158,7 @@ export function testForgeTemplate({ throw new Error(`unknown template ${templateName}`); } - await spawn('node', [ + const createOutput = await spawn('node', [ path.resolve( __dirname, '../../../external/create-electron-app/dist/create-electron-app.js', @@ -212,13 +265,21 @@ export function testForgeTemplate({ ].join('\n'), ); - const electronForgeStartOutput = await spawn( - packageManager, - ['run', 'start'], - { + const startApp = () => + spawn(packageManager, ['run', 'start'], { cwd: tmpDir, env: { PATH: process.env.PATH, + /** + * `start` makes the package manager check the lockfile it just + * wrote, and `XDG_CONFIG_HOME` is where the Verdaccio test harness + * puts the config that tells pnpm which registry to use, how old a + * release has to be, which packages are exempt, and to warn rather + * than fail when the check finds a difference. Dropping it would + * leave the check looking at the public registry under a policy + * the install never ran under. + */ + XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME, ...(process.platform === 'linux' && { DISPLAY: process.env.DISPLAY, XAUTHORITY: process.env.XAUTHORITY, @@ -246,8 +307,36 @@ export function testForgeTemplate({ .replace(/\bnpm\/\?/, 'npm/99.99.99'), }), }, - }, - ); + }); + + let electronForgeStartOutput: string; + try { + electronForgeStartOutput = await startApp(); + } catch (error) { + /** + * When `start` fails, it is usually because the package manager + * installed a dependency tree the app cannot resolve its own + * configuration from, and the failure alone doesn't say which tree it + * ended up with. `create-electron-app` runs its steps with listr2's + * `exitOnError: false`, so a failed install leaves a broken project + * behind and still exits 0; its output is the only place that failure + * is reported at all. + */ + console.error( + [ + `[template-tests] ${packageManager} installed ${describeDependencyTree(tmpDir)}`, + `[template-tests] from this package.json:\n${fs.readFileSync(path.join(tmpDir, 'package.json'), 'utf8')}`, + ...(packageManager === 'pnpm' + ? [ + `[template-tests] pnpm was run like this:\n${describePnpmInstalls(tmpDir)}`, + ] + : []), + `[template-tests] create-electron-app said:\n${createOutput}`, + ].join('\n'), + ); + + throw error; + } d({ electronForgeStartOutput }); diff --git a/tools/verdaccio/pnpm-exit-shim.mjs b/tools/verdaccio/pnpm-exit-shim.mjs new file mode 100644 index 0000000000..b6d710940c --- /dev/null +++ b/tools/verdaccio/pnpm-exit-shim.mjs @@ -0,0 +1,331 @@ +/** + * A stand-in for `pnpm` that the Verdaccio test harness puts at the front of + * `PATH` (see `spawn-verdaccio.ts`). It runs the real pnpm and, when an install + * says that it is finished and then never exits, kills it and installs again. + * + * pnpm shuts its tarball worker pool down once per install, but a worker call + * that happens after that lazily creates a new pool which nothing ever shuts + * down, and the idle worker thread keeps the event loop alive forever. So an + * install whose last package arrives just too late writes the lockfile, reports + * success and then hangs. 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 spawns its package manager and waits for it to exit, so + * the hang costs us the whole test rather than just the process. + * https://github.com/pnpm/pnpm/issues/13617 + * + * That late package is also why killing pnpm is not enough on its own: it is + * still being written when pnpm reports success, so killing pnpm leaves it out + * of `node_modules` — and pnpm will not put it back. It decides whether a + * project is up to date from the state files it keeps alongside the packages + * rather than from the packages themselves, so installing again into a tree it + * has already recorded is `Already up to date` even with packages missing from + * it, `--force` included. Discarding those files first makes the next install + * compare the tree against the store and fill in whatever is not there. + * + * Delete this shim and its wiring once the fix has shipped in a pnpm release. + */ + +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; + +/** + * On Windows the real pnpm is a `.cmd` file, which can only be run through + * `cmd.exe`, and Node passes it the command line unquoted: `cmd.exe` then reads + * the `^` in a version range like `typescript@^6.0.0` as its own escape + * character and asks pnpm for `typescript@6.0.0`, a version that need not + * exist. `cross-spawn` quotes and escapes arguments the way `cmd.exe` needs, + * and is what Forge itself runs package managers with. + */ +import spawn from 'cross-spawn'; + +/** + * How long pnpm gets to exit by itself after it says it is done. Long enough + * that we don't cut a slow-but-healthy shutdown short, short enough that a hang + * doesn't eat the test timeout — and cutting one short only costs the install + * that repairs it. + */ +const EXIT_GRACE_PERIOD_MS = 15_000; + +/** + * How many times to install in total. The hang needs a package to arrive after + * pnpm has stopped expecting one, so an install that only has to link what is + * already in the store — which is all a repair has left to do — is very + * unlikely to hit it again. + */ +const MAX_ATTEMPTS = 3; + +/** + * What pnpm prints when it has finished the work it was asked to do. + */ +const DONE_PATTERN = /(^|\n)(Done in |Already up to date)/; + +/** + * The files pnpm reads to decide that a project already has the dependencies it + * is being asked to install, which is what stops it from repairing a tree that + * an interrupted install left incomplete. pnpm writes them itself, so removing + * them costs nothing beyond the check they exist for. + */ +const INSTALL_STATE_FILES = [ + '.modules.yaml', + '.package-map.json', + '.pnpm-workspace-state-v1.json', +]; + +/** + * The commands that install packages, and therefore the only ones that can hang + * this way. Every other command — `pnpm run start`, which the tests use to + * launch the app they are testing, above all — is left alone entirely: it keeps + * running long after pnpm has said that it is done, and killing it is the last + * thing we want. + */ +const INSTALL_COMMANDS = new Set([ + 'add', + 'dedupe', + 'fetch', + 'i', + 'import', + 'install', + 'link', + 'prune', + 'remove', + 'rm', + 'un', + 'uninstall', + 'unlink', + 'up', + 'update', +]); + +/** + * How much of a chunk of output has to be kept around to match `DONE_PATTERN` + * against the next one: anything at least as long as the strings it looks for. + */ +const TAIL_LENGTH = 32; + +const pnpmArgs = process.argv.slice(2); + +/** + * These tests only ever run pnpm as `pnpm [flags]`, so the first + * argument that isn't a flag is the command. + */ +const watchForHang = INSTALL_COMMANDS.has( + pnpmArgs.find((arg) => !arg.startsWith('-')), +); + +/** + * The directory the launcher that runs this file lives in, which the launcher + * itself tells us about: it is somewhere else entirely, so this file cannot + * work it out on its own. + */ +const LAUNCHER_DIR = process.env.FORGE_PNPM_EXIT_SHIM_DIR; +if (!LAUNCHER_DIR) { + throw new Error( + 'FORGE_PNPM_EXIT_SHIM_DIR must point at the directory this shim is installed in', + ); +} + +/** + * The names a real pnpm can have on `PATH`, in the order the OS would pick + * between them. + */ +const PNPM_FILENAMES = + process.platform === 'win32' + ? ['pnpm.exe', 'pnpm.cmd', 'pnpm.bat'] + : ['pnpm']; + +// Windows spells this `Path`, and `process.env` only hides the difference until +// it gets copied. +const pathKey = + Object.keys(process.env).find((key) => key.toUpperCase() === 'PATH') ?? + 'PATH'; + +/** + * `PATH` without the directory this shim is installed in, which is both where + * we look for the pnpm we are standing in for and what we hand to it: leaving + * ourselves in would let anything that resolves `pnpm` through `PATH` — this + * shim included — end up back here. + */ +const realPath = (process.env[pathKey] ?? '') + .split(path.delimiter) + .filter((dir) => dir !== '' && !isLauncherDir(dir)); + +function isLauncherDir(dir) { + const [candidate, launcher] = [path.resolve(dir), path.resolve(LAUNCHER_DIR)]; + return process.platform === 'win32' + ? candidate.toLowerCase() === launcher.toLowerCase() + : candidate === launcher; +} + +const realPnpm = realPath + .flatMap((dir) => PNPM_FILENAMES.map((filename) => path.join(dir, filename))) + .find((candidate) => fs.existsSync(candidate)); +if (!realPnpm) { + throw new Error(`pnpm not found on PATH (${realPath.join(path.delimiter)})`); +} + +function report(message) { + process.stderr.write(`\n[verdaccio harness] ${message}\n`); +} + +/** + * Where to write a record of every pnpm run. `create-electron-app` installs + * through listr2 with `exitOnError: false` and does not forward what the + * package manager printed, so a failing test otherwise has nothing to say about + * the installs that produced the project it is failing on. + */ +const INVOCATION_LOG = process.env.FORGE_PNPM_INVOCATION_LOG; + +function recordInvocation(attempt, output, outcome) { + if (!INVOCATION_LOG) return; + + fs.appendFileSync( + INVOCATION_LOG, + [ + `=== pnpm ${pnpmArgs.join(' ')}`, + ` in ${process.cwd()}${attempt > 1 ? ` (attempt ${attempt})` : ''}`, + ` ${outcome}`, + output, + '', + ].join('\n'), + ); +} + +/** + * Runs pnpm once. Resolves with how it went: either it exited on its own, and + * with what status, or it reported that it was done and had to be killed. + */ +function runPnpm(attempt) { + return new Promise((resolve, reject) => { + const pnpm = spawn(realPnpm, pnpmArgs, { + // Watching for the hang means reading pnpm's output on the way past. + // Anything we are not watching gets the real thing's streams, untouched. + stdio: watchForHang ? ['inherit', 'pipe', 'pipe'] : 'inherit', + env: { ...process.env, [pathKey]: realPath.join(path.delimiter) }, + // Run pnpm in its own process group so that we can take down the version + // of itself that it hands over to along with it. + detached: process.platform !== 'win32', + }); + + let exitTimer; + let hung = false; + let output = ''; + + function killPnpm() { + if (process.platform === 'win32') { + spawnSync('taskkill', ['/pid', String(pnpm.pid), '/t', '/f'], { + stdio: 'ignore', + }); + } else { + try { + process.kill(-pnpm.pid, 'SIGKILL'); + } catch { + pnpm.kill('SIGKILL'); + } + } + } + + /** + * Pass pnpm's output through untouched — the tests read it — while watching + * for the point where it has nothing left to do. `DONE_PATTERN` can + * straddle two chunks, so each chunk is matched together with the tail of + * the one before it. + */ + function forward(stream, chunk, previousTail) { + stream.write(chunk); + output += chunk; + + const text = `${previousTail}${chunk}`; + if (exitTimer === undefined && DONE_PATTERN.test(text)) { + exitTimer = setTimeout(() => { + report( + `pnpm did not exit ${EXIT_GRACE_PERIOD_MS / 1000}s after reporting that it was done, killing it (https://github.com/pnpm/pnpm/issues/13617)`, + ); + hung = true; + killPnpm(); + }, EXIT_GRACE_PERIOD_MS); + } + + return text.slice(-TAIL_LENGTH); + } + + if (watchForHang) { + let stdoutTail = ''; + pnpm.stdout.on('data', (chunk) => { + stdoutTail = forward(process.stdout, chunk, stdoutTail); + }); + + let stderrTail = ''; + pnpm.stderr.on('data', (chunk) => { + stderrTail = forward(process.stderr, chunk, stderrTail); + }); + } + + pnpm.on('error', (error) => { + clearTimeout(exitTimer); + reject(error); + }); + + // pnpm runs in its own process group, so whoever kills this shim would + // leave it running behind us. + for (const signal of ['SIGINT', 'SIGTERM']) { + process.on(signal, () => { + clearTimeout(exitTimer); + killPnpm(); + process.exitCode = 1; + }); + } + + // Once pnpm is gone its output pipes close and whatever it had already + // written has been flushed. + pnpm.on('close', (code, signal) => { + clearTimeout(exitTimer); + const exitCode = signal ? 1 : (code ?? 0); + recordInvocation( + attempt, + output, + hung + ? 'reported that it was done and then had to be killed' + : `exited with ${exitCode}`, + ); + resolve({ hung, code: exitCode }); + }); + }); +} + +/** + * Makes pnpm stop believing that the project it just installed into is already + * up to date, so that the next install checks what is actually there. pnpm is + * run from the directory it installs into throughout these tests. + */ +async function discardInstallState() { + await Promise.all( + INSTALL_STATE_FILES.map((file) => + fs.promises.rm(path.join(process.cwd(), 'node_modules', file), { + force: true, + }), + ), + ); +} + +for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + const { hung, code } = await runPnpm(attempt); + + if (!hung) { + process.exitCode = code; + break; + } + + if (attempt === MAX_ATTEMPTS) { + report( + `pnpm hung on every one of ${MAX_ATTEMPTS} attempts, so \`node_modules\` may be missing whatever it was writing when it was killed`, + ); + process.exitCode = 1; + break; + } + + await discardInstallState(); + report( + `installing again to replace whatever pnpm was still writing when it was killed (attempt ${attempt + 1} of ${MAX_ATTEMPTS})`, + ); +} diff --git a/tools/verdaccio/spawn-verdaccio.ts b/tools/verdaccio/spawn-verdaccio.ts index d2d1691fff..b206dd91cb 100644 --- a/tools/verdaccio/spawn-verdaccio.ts +++ b/tools/verdaccio/spawn-verdaccio.ts @@ -34,6 +34,27 @@ const VERDACCIO_PORT = 4873; const VERDACCIO_URL = `http://${LOCALHOST}:${VERDACCIO_PORT}`; const STORAGE_PATH = path.resolve(import.meta.dirname, 'storage'); +/** + * We publish the monorepo to Verdaccio seconds before the tests install it, so + * every package manager's minimum age gate (which Yarn enables by default since + * 4.18) quarantines every local package. The tests install those packages into + * app directories created under `os.tmpdir()`, which are outside this repository + * and therefore never pick up the root `.yarnrc.yml`, so we mirror its policy + * for all three package managers here instead of switching the gate off: our own + * packages are exempt, everything else still has to have been on the registry + * for a week. Keep these in sync with `.yarnrc.yml`. + */ +const MINIMUM_RELEASE_AGE_MINUTES = 10080; +const MINIMUM_RELEASE_AGE_DAYS = MINIMUM_RELEASE_AGE_MINUTES / 60 / 24; +const PREAPPROVED_PACKAGES = [ + '@electron/*', + '@electron-forge/*', + '@electron-internal/*', + 'create-electron-app', + 'electron', + 'node-abi', +]; + const d = debug('electron-forge:verdaccio'); let verdaccioProcess: ChildProcess | null = null; @@ -133,16 +154,180 @@ async function publishPackages(): Promise { } } -async function runCommand(args: string[]) { +/** + * Whether npm understands the age gate these tests hold their installs to. npm + * learned about `min-release-age` in 11.10 and about `min-release-age-exclude` + * in 11.17, so 11.17 is the first version that understands the whole policy. + * Older versions install without a gate and warn that the config is unknown on + * every single npm invocation, so we only pass it when it is supported. + * + * CI installs an npm new enough for it (see the `Install npm` step in + * `ci.yml`), and the gate quietly going missing there — a `.nvmrc` bump that + * drops that step, say — is exactly the failure this policy exists to catch, so + * only a local run is allowed to carry on without it. Checked before anything + * is published so that a CI failure costs a couple of seconds. + */ +async function checkNpmAgeGateSupport(): Promise { + const npmVersion = (await spawnPromise('npm', ['--version'])).trim(); + const [npmMajor, npmMinor] = npmVersion.split('.').map(Number); + const supported = npmMajor > 11 || (npmMajor === 11 && npmMinor >= 17); + + if (!supported) { + const message = `npm ${npmVersion} does not support \`min-release-age\` (npm >= 11.17 required)`; + if (process.env.CI) throw new Error(message); + console.warn( + `⚠️ ${message}, so npm installs in these tests are not age-gated`, + ); + } + + return supported; +} + +async function runCommand(args: string[], npmSupportsAgeGate: boolean) { process.env.COREPACK_ENABLE_STRICT = '0'; - console.log('🗑️ Pruning pnpm store before running command'); - await spawnPromise('pnpm', ['store', 'prune']); + + /** + * `yarn test:verdaccio` runs Yarn through Corepack, which sets + * `COREPACK_ROOT` for everything it spawns, and the tests inherit it all the + * way down to the package manager that installs each generated app. pnpm + * refuses to switch to the version it is asked for when it believes Corepack + * invoked it, and `create-electron-app` asks Corepack to pin the latest pnpm + * in every app it creates, so the first install in a pnpm app fails with a + * version mismatch against whatever pnpm happens to be on `PATH`. The tests + * spawn their own package managers and have no business inheriting this + * repository's Corepack context, so we drop the variable here. + */ + const { COREPACK_ROOT: _corepackRoot, ...parentEnv } = process.env; /** * Avoid polluting the global yarn cache. */ const tempYarnGlobal = path.join(STORAGE_PATH, '.yarn-global'); - fs.promises.mkdir(tempYarnGlobal, { recursive: true }); + await fs.promises.mkdir(tempYarnGlobal, { recursive: true }); + + /** + * npm and Yarn take their settings from the environment, but pnpm reads these + * ones from its config files only, so we generate a global config file for it + * and point pnpm at it with `XDG_CONFIG_HOME` (which pnpm honors on every + * platform, including Windows). The tests install into throwaway directories, + * so the alternative would be writing a `pnpm-workspace.yaml` into each of + * them, but that would also make Forge's own `resolvePackageManager` treat + * them as pnpm projects, which we don't want in the npm/Yarn cases. + * + * Note that any other tool the tests run also picks up this config home (on + * Linux, for instance, the test apps write their Electron `userData` there), + * which is harmless because the directory is thrown away on the next run. + */ + const tempXdgConfigHome = path.join(STORAGE_PATH, '.xdg-config-home'); + await fs.promises.mkdir(path.join(tempXdgConfigHome, 'pnpm'), { + recursive: true, + }); + await fs.promises.writeFile( + path.join(tempXdgConfigHome, 'pnpm', 'config.yaml'), + // YAML is a superset of JSON, so this is a valid pnpm config file. + JSON.stringify( + { + /** + * pnpm reads the registry from its config files and from `--registry`, + * but not from `npm_config_registry` in the environment, so it is the + * one package manager that does not pick up `NPM_CONFIG_REGISTRY` + * below. Without this line pnpm quietly resolves `@electron-forge/*` + * from the public registry instead of from Verdaccio, and the tests + * pass against the last published release rather than the local build. + * https://pnpm.io/settings#registry + */ + registry: VERDACCIO_URL, + // https://pnpm.io/settings#minimumreleaseage + minimumReleaseAge: MINIMUM_RELEASE_AGE_MINUTES, + // https://pnpm.io/settings#minimumreleaseageexclude + minimumReleaseAgeExclude: PREAPPROVED_PACKAGES, + /** + * Every run republishes the monorepo under the version that is already + * in the manifests, so pnpm must not resolve those packages through + * metadata it cached during an earlier run: it would then look up the + * integrity hash that version had last time and find the matching + * tarball in its store, which is how the tests would end up running + * against a stale build. `startVerdaccio` deletes `STORAGE_PATH`, so a + * cache directory under it is empty on every run, which forces pnpm to + * ask Verdaccio for the hashes it is serving now. + * + * The store itself is deliberately left alone: it only holds content + * addressed by hash, so once the metadata is gone it cannot serve a + * stale tarball, and keeping it saves every run from downloading all of + * the third-party dependencies again. Emptying it with `pnpm store + * prune` was the previous approach. + * https://pnpm.io/settings#cachedir + */ + cacheDir: path.join(STORAGE_PATH, '.pnpm-cache'), + /** + * Since pnpm 11, `pnpm run` silently runs an install first whenever it + * decides that `node_modules` is out of sync with the lockfile. The + * tests run ` run start` to check that the app + * `create-electron-app` just installed can start, so an install in + * between 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. `warn` keeps the check itself, and its report of whatever it + * believes is out of sync, without acting on it. + * https://pnpm.io/settings#verifydepsbeforerun + */ + verifyDepsBeforeRun: 'warn', + /** + * Whether pnpm links dependencies through a store-wide virtual store or + * one inside the project defaults to whether pnpm believes it is + * running in CI, which these tests cannot keep 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 it to the value CI would pick anyway. + * https://pnpm.io/settings#enableglobalvirtualstore + */ + enableGlobalVirtualStore: false, + }, + null, + 2, + ), + ); + + /** + * pnpm can finish an install and then never exit, which costs us a whole test + * because the tests wait for the package manager they spawned to be done. + * `pnpm-exit-shim.mjs` explains why and works around it; putting it in front + * of the real pnpm on `PATH` covers every pnpm these tests run, whether they + * run it themselves or through `create-electron-app`. + */ + const tempBinDir = path.join(STORAGE_PATH, '.bin'); + await fs.promises.mkdir(tempBinDir, { recursive: true }); + const pnpmExitShim = path.resolve(import.meta.dirname, 'pnpm-exit-shim.mjs'); + if (process.platform === 'win32') { + await fs.promises.writeFile( + path.join(tempBinDir, 'pnpm.cmd'), + [ + '@echo off', + `set "FORGE_PNPM_EXIT_SHIM_DIR=${tempBinDir}"`, + `node "${pnpmExitShim}" %*`, + '', + ].join('\r\n'), + ); + } else { + const pnpmLauncher = path.join(tempBinDir, 'pnpm'); + await fs.promises.writeFile( + pnpmLauncher, + [ + '#!/bin/sh', + `FORGE_PNPM_EXIT_SHIM_DIR="${tempBinDir}"`, + 'export FORGE_PNPM_EXIT_SHIM_DIR', + `exec node "${pnpmExitShim}" "$@"`, + '', + ].join('\n'), + ); + await fs.promises.chmod(pnpmLauncher, 0o755); + } + // Windows spells this `Path`, and spreading `process.env` above lost the + // case-insensitive lookup that hides the difference. + const pathKey = + Object.keys(parentEnv).find((key) => key.toUpperCase() === 'PATH') ?? + 'PATH'; console.log(`🏃 Running: ${args.join(' ')}`); console.log(` Using registry: ${VERDACCIO_URL}`); @@ -151,7 +336,14 @@ async function runCommand(args: string[]) { cwd: FORGE_ROOT_DIR, stdio: 'inherit', env: { - ...process.env, + ...parentEnv, + [pathKey]: [tempBinDir, parentEnv[pathKey]].join(path.delimiter), + // Where the shim records what every pnpm it ran printed, which is what a + // failing test reports when the project it was given cannot start. + FORGE_PNPM_INVOCATION_LOG: path.join( + STORAGE_PATH, + 'pnpm-invocations.log', + ), // https://docs.npmjs.com/cli/v9/using-npm/config#registry // https://pnpm.io/settings#registry NPM_CONFIG_REGISTRY: VERDACCIO_URL, @@ -159,27 +351,24 @@ async function runCommand(args: string[]) { YARN_NPM_REGISTRY_SERVER: VERDACCIO_URL, // https://yarnpkg.com/configuration/yarnrc#unsafeHttpWhitelist YARN_UNSAFE_HTTP_WHITELIST: LOCALHOST, - // We publish the monorepo to Verdaccio seconds before the tests install - // it, so Yarn's minimum age gate (1 day by default since Yarn 4.18) - // quarantines every local package. The tests install those packages into - // app directories created under `os.tmpdir()`, which are outside this - // repository and therefore never pick up the root `.yarnrc.yml`, so we - // mirror its policy here instead of switching the gate off: our own - // packages are exempt, everything else still has to have been on the - // registry for a week. Keep this in sync with `.yarnrc.yml`. + // Yarn's minimum age gate is 1 day by default since Yarn 4.18. // https://yarnpkg.com/configuration/yarnrc#npmMinimalAgeGate - YARN_NPM_MINIMAL_AGE_GATE: '10080', + YARN_NPM_MINIMAL_AGE_GATE: String(MINIMUM_RELEASE_AGE_MINUTES), // Yarn only accepts comma-separated values for array settings passed // through the environment. // https://yarnpkg.com/configuration/yarnrc#npmPreapprovedPackages - YARN_NPM_PREAPPROVED_PACKAGES: [ - '@electron/*', - '@electron-forge/*', - '@electron-internal/*', - 'create-electron-app', - 'electron', - 'node-abi', - ].join(','), + YARN_NPM_PREAPPROVED_PACKAGES: PREAPPROVED_PACKAGES.join(','), + ...(npmSupportsAgeGate && { + // npm calls the same policy `min-release-age` and counts it in days + // instead of minutes. + // https://docs.npmjs.com/cli/v12/using-npm/config#min-release-age + npm_config_min_release_age: String(MINIMUM_RELEASE_AGE_DAYS), + // Like Yarn, npm accepts a comma-separated list for this array setting. + // https://docs.npmjs.com/cli/v12/using-npm/config#min-release-age-exclude + npm_config_min_release_age_exclude: PREAPPROVED_PACKAGES.join(','), + }), + // Where pnpm looks for the global config file generated above. + XDG_CONFIG_HOME: tempXdgConfigHome, // Isolate package manager caches so Verdaccio packages // don't corrupt the global caches. These directories live // under STORAGE_PATH and get cleaned up on next run. @@ -205,6 +394,8 @@ async function main(): Promise { }); try { + const npmSupportsAgeGate = await checkNpmAgeGateSupport(); + await startVerdaccio(); await publishPackages(); @@ -215,7 +406,7 @@ async function main(): Promise { // Keep the process alive await new Promise(() => {}); } else { - await runCommand(args); + await runCommand(args, npmSupportsAgeGate); stopVerdaccio(); process.exit(0); } diff --git a/yarn.lock b/yarn.lock index e8be4ea658..e758d82de3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8447,6 +8447,7 @@ __metadata: "@types/which": "npm:^3.0.4" "@typescript/native": "npm:typescript@^7.0.0" "@yarnpkg/types": "npm:^4.0.1" + cross-spawn: "npm:^7.0.3" debug: "npm:^4.3.1" electron: "npm:^42.3.3" electron-installer-debian: "npm:^3.2.0"