Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 26 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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: |
Expand Down Expand Up @@ -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)"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Two things about this step, neither blocking, both for a follow up:

  1. This installs a lot more than node-gyp. The cd puts us inside @npmcli/run-script, which has its own package.json, so npm installs that package's full devDependency tree as well. The new Windows logs show added 880 packages for this step, including tap, eslint bits and a pile of deprecated stuff. It is gated now, which is the important part, but it is a much bigger surface than the step name suggests. npm install --no-save node-gyp@9.4.0 or installing node-gyp somewhere else and linking it would shrink it to node-gyp and its deps only.

  2. Your note about this being inert in slow-tests is probably right. npm install -g npm@11.19.0 runs after this step and puts a different npm first on PATH, so the node-gyp we patch into $PROGRAMFILES/nodejs/node_modules/npm is not the npm that runs the tests. I have not verified it either. If it is inert we should either drop the step from slow-tests or move Install npm before it so the patch lands in the npm that actually runs.


- name: Linux setup
if: runner.os == 'Linux'
Expand All @@ -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: |
Expand Down
4 changes: 2 additions & 2 deletions knip.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
29 changes: 27 additions & 2 deletions packages/api/core/spec/slow/install-dependencies.slow.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
() => {
Expand All @@ -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',
]);
Expand All @@ -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 });
});
},
);
15 changes: 13 additions & 2 deletions packages/template/base/src/BaseTemplate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',

@MarshallOfSound MarshallOfSound Aug 25, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This does not do what the comment says. pnpm ignores devEngines.packageManager when it is a string. The spec form is an object. Tested with pnpm 11.21.0:

  • "packageManager": "pnpm@11.20.0" plus "devEngines": {"packageManager": "pnpm@11.21.0"}: pnpm downloaded 11.20.0 and ran it. No warning.
  • Same with "devEngines": {"packageManager": {"name": "pnpm", "version": "11.21.0"}}: pnpm warned that packageManager will be ignored and ran 11.21.0.

In CI the tests run create-electron-app --package-manager=pnpm. resolvePackageManager turns that into latest. corepack use pnpm@latest writes whatever is latest on npmjs that day, 11.24.0 today, already ahead of this pin and the ci.yml pin. Corepack resolves that straight from registry.npmjs.org, it reads COREPACK_NPM_REGISTRY but not npm_config_registry, so Verdaccio is bypassed. The pnpm on PATH then downloads that version and hands over to it. I checked, the age gate does not apply to that download. So the pnpm that actually runs the template installs in CI is unpinned, ungated, and changes daily. Neither the CI pin nor this line controls it.

This predates the PR, but do not claim a floor we do not enforce. Simplest real fix is test only: have template-tests.ts pass --package-manager=pnpm@<version on PATH> so corepack pins what CI installed. The object form here would also work but it is user facing (pnpm warns whenever packageManager differs) so that is a separate PR.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Comment fixed: it now documents the field as a record of the pnpm version CI installs rather than a floor, and says outright that pnpm ignores the string form and that Corepack's packageManager field takes precedence regardless. Real fix left as a follow-up, per your note.

};

// Ensures all transitive dependencies for `electron-winstaller` are
Expand Down
103 changes: 96 additions & 7 deletions packages/utils/test-utils/src/template-tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 });

Expand Down
Loading