fix: use npm co-located with the action node binary (#239)

* fix: use npm co-located with the action node binary

* fix: resolve npm by absolute path; guard against unset PATH

Follow-up to 5a9e198. Two refinements to the GHE self-hosted runner fix:

- Spawn npm via `path.join(dirname(process.execPath), 'npm[.cmd]')`
  instead of relying on PATH lookup. This matches the original PR
  description and is robust against PATH-shadowed npm installations.
- Avoid `"<dir>:undefined"` leaking into PATH when `process.env.PATH`
  is unset (rare, but possible in stripped environments).

PATH still has the node directory prepended so npm's
`#!/usr/bin/env node` shebang can resolve node on Linux/macOS.

* fix: revert npm to PATH lookup; runner externals lacks npm

Revert 42e75a1's switch to absolute-path npm resolution. The premise
that npm is co-located with the action's node binary is false on
GitHub-hosted runners: `process.execPath` points into
`runner/externals/node24/bin/`, which contains node only — not npm.
The absolute-path spawn produced ENOENT on Linux/macOS and
"not recognized" on Windows.

Go back to spawning `'npm'` and relying on PATH lookup, which works
on standard runners (npm is on PATH from the runner image) and on
the GHE self-hosted setup that motivated the original fix. Keep the
node-directory prepend so npm's `#!/usr/bin/env node` shebang
resolves, and keep the unset-PATH guard.

---------

Co-authored-by: Zoltan Kochan <z@kochan.io>
This commit is contained in:
Ben Quarmby
2026-04-30 13:28:41 -07:00
committed by GitHub
parent 903f9c1a6e
commit cb9c4fdd70
2 changed files with 46 additions and 35 deletions
+33 -33
View File
File diff suppressed because one or more lines are too long
+13 -2
View File
@@ -29,7 +29,17 @@ export async function runSelfInstaller(inputs: Inputs): Promise<number> {
await writeFile(path.join(dest, 'package.json'), packageJson) await writeFile(path.join(dest, 'package.json'), packageJson)
await writeFile(path.join(dest, 'package-lock.json'), JSON.stringify(lockfile)) await writeFile(path.join(dest, 'package-lock.json'), JSON.stringify(lockfile))
const npmExitCode = await runCommand('npm', ['ci'], { cwd: dest }) // Prepend the action's node directory to PATH so npm's
// `#!/usr/bin/env node` shebang resolves on runners (e.g. GHE
// self-hosted) where node isn't already on PATH. npm itself is
// resolved via PATH — on the GitHub Actions runner it is not
// co-located with `process.execPath`.
const nodeDir = path.dirname(process.execPath)
// On Windows, the PATH key casing varies; search case-insensitively.
const pathKey = Object.keys(process.env).find(k => k.toUpperCase() === 'PATH') ?? 'PATH'
const currentPath = process.env[pathKey]
const npmEnv = { ...process.env, [pathKey]: currentPath ? nodeDir + path.delimiter + currentPath : nodeDir }
const npmExitCode = await runCommand('npm', ['ci'], { cwd: dest, env: npmEnv })
if (npmExitCode !== 0) { if (npmExitCode !== 0) {
return npmExitCode return npmExitCode
} }
@@ -155,10 +165,11 @@ function getSystemNodeVersion(): Promise<{ major: number; minor: number }> {
}) })
} }
function runCommand(cmd: string, args: string[], opts: { cwd: string }): Promise<number> { function runCommand(cmd: string, args: string[], opts: { cwd: string; env?: Record<string, string | undefined> }): Promise<number> {
return new Promise<number>((resolve, reject) => { return new Promise<number>((resolve, reject) => {
const cp = spawn(cmd, args, { const cp = spawn(cmd, args, {
cwd: opts.cwd, cwd: opts.cwd,
env: opts.env,
stdio: ['pipe', 'inherit', 'inherit'], stdio: ['pipe', 'inherit', 'inherit'],
shell: process.platform === 'win32', shell: process.platform === 'win32',
}) })