forked from pullfrog/pullfrog
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathrunCli.ts
More file actions
319 lines (289 loc) · 12 KB
/
Copy pathrunCli.ts
File metadata and controls
319 lines (289 loc) · 12 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
import { execFileSync } from "node:child_process";
import { accessSync, constants, existsSync, mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { delimiter, dirname, isAbsolute, join, resolve, sep } from "node:path";
import { fileURLToPath } from "node:url";
import actionPackageJson from "./package.json" with { type: "json" };
interface RunPullfrogCliParams {
cliArgs: string[];
swallowErrors?: boolean;
}
interface RuntimeContext {
actionRef: string | undefined;
actionRepository: string | undefined;
actionRoot: string;
nodeBinDir: string;
env: NodeJS.ProcessEnv;
}
const NPM_REGISTRY = "https://registry.npmjs.org";
const FALLBACK_PACKAGE_SPEC = `pullfrog@^${actionPackageJson.version}`;
function getErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function canAccessExecutable(path: string): boolean {
try {
accessSync(path, constants.X_OK);
return true;
} catch {
if (process.platform !== "win32") {
return false;
}
}
try {
accessSync(path, constants.F_OK);
return true;
} catch {
return false;
}
}
// reject PATH entries that an attacker can plausibly write to before pullfrog
// runs. specifically: relative entries (., bin, etc., which resolve against
// cwd), and anything inside the customer's checkout. an attacker who can land
// a malicious `npx` in the repo and prepend `$GITHUB_WORKSPACE/bin` to
// `GITHUB_PATH` from a prior workflow step would otherwise get full code
// execution under our action token.
//
// on Windows the filesystem is case-insensitive but `resolve()` preserves
// input case, so we lowercase both sides before comparing — otherwise an
// attacker can bypass the filter by varying the case of GITHUB_WORKSPACE in
// their injected PATH entry (`d:\a\repo` vs `D:\a\repo`).
function normalizePathForCompare(path: string): string {
return process.platform === "win32" ? resolve(path).toLowerCase() : resolve(path);
}
function isUntrustedPathEntry(entry: string, untrustedRoots: string[]): boolean {
if (!isAbsolute(entry)) return true;
const normalized = normalizePathForCompare(entry);
for (const root of untrustedRoots) {
if (normalized === root) return true;
if (normalized.startsWith(root + sep)) return true;
}
return false;
}
function getUntrustedPathRoots(env: NodeJS.ProcessEnv): string[] {
const roots: string[] = [];
const workspace = env.GITHUB_WORKSPACE;
if (workspace && isAbsolute(workspace)) roots.push(normalizePathForCompare(workspace));
return roots;
}
/**
* Existence + the execute bit is not proof a launcher WORKS. Self-hosted pools
* ship partial `externals/node24` trees whose `npx` is a shim over a
* `lib/cli.js` that isn't there: it passes every filesystem check and then dies
* with a bare `MODULE_NOT_FOUND`. Probing `--version` is what lets
* `resolveExecutable` keep walking PATH to the runner's working copy, and what
* makes the corepack fallback reachable at all. See #1084.
*/
function isExecutableUsable(path: string, env: NodeJS.ProcessEnv): boolean {
try {
// MUST use the same env the real invocation gets. `context.env` prepends
// `nodeBinDir` precisely because the action runtime's `node` may not be on
// the ambient PATH — and a GHA runner's `npx` is a `#!/usr/bin/env node`
// shebang, so probing with the inherited env exits 127 and would demote a
// perfectly good launcher (then throw, when corepack fails the same way).
execFileSync(path, ["--version"], { stdio: "ignore", env });
return true;
} catch {
return false;
}
}
function resolveExecutable(params: {
command: string;
env: NodeJS.ProcessEnv;
/** probe each candidate before accepting it, and keep walking PATH if it fails */
verify?: boolean;
}): string | null {
const pathValue = params.env.PATH ?? "";
const untrustedRoots = getUntrustedPathRoots(params.env);
const pathEntries = pathValue
.split(delimiter)
.filter(Boolean)
.filter((entry) => !isUntrustedPathEntry(entry, untrustedRoots));
const extensions =
process.platform === "win32"
? (params.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)
: [""];
for (const pathEntry of pathEntries) {
for (const extension of extensions) {
const candidate = join(pathEntry, `${params.command}${extension.toLowerCase()}`);
if (!canAccessExecutable(candidate)) continue;
if (params.verify && !isExecutableUsable(candidate, params.env)) {
console.warn(`» ${candidate} is not runnable, continuing to search PATH`);
continue;
}
return candidate;
}
}
return null;
}
function createRuntimeContext(): RuntimeContext {
const actionRoot = dirname(fileURLToPath(import.meta.url));
const nodeBinDir = dirname(process.execPath);
const env: NodeJS.ProcessEnv = { ...process.env };
env.npm_config_registry = NPM_REGISTRY;
env.COREPACK_NPM_REGISTRY = NPM_REGISTRY;
// bypass customer-side release-age gates (npm's `min-release-age`, pnpm's
// `minimumReleaseAge`) so our bootstrap can resolve the latest publish.
// pullfrog's npm version is server-stamped from a SHA-pinned action ref the
// customer already vets at the action layer — not a customer-vetted dep, so
// the gate is the wrong affordance here. env beats .npmrc in both tools.
// npm uses `npm_config_*`; pnpm v11+ requires `pnpm_config_*` (the v10→v11
// migration renamed the prefix). tracked: #713
env.npm_config_min_release_age = "0";
env.pnpm_config_minimum_release_age = "0";
const currentPath = process.env.PATH ?? "";
env.PATH = currentPath ? `${nodeBinDir}${delimiter}${currentPath}` : nodeBinDir;
return {
actionRef: process.env.GITHUB_ACTION_REF,
actionRepository: process.env.GITHUB_ACTION_REPOSITORY,
actionRoot,
nodeBinDir,
env,
};
}
// $GITHUB_WORKSPACE is the customer's repo. running `npx --yes pullfrog@…`
// there makes npm read THEIR `package.json` first, which on npm v11+ enforces
// `devEngines.packageManager` and aborts the bootstrap with EBADDEVENGINES
// before the agent ever boots. our bootstrap doesn't need anything from the
// customer's tree — a freshly-created tmpdir is package.json-free and
// parent-less, so npm walks up to `/` finding nothing. see #837.
//
// `mkdtempSync` (vs raw `tmpdir()`): `$TMPDIR` is overridable from a prior
// `$GITHUB_ENV` step, and a customer-authored or compromised prior step
// could plant `node_modules/pullfrog/` in the resolved tmpdir to hijack
// `npx --yes pullfrog@<version>` resolution. a fresh per-invocation
// subdirectory is mode 0700 and not pre-writable by anything earlier in
// the job.
function runCommand(params: { context: RuntimeContext; command: string; args: string[] }): void {
execFileSync(params.command, params.args, {
cwd: mkdtempSync(join(tmpdir(), "pullfrog-bootstrap-")),
stdio: "inherit",
env: params.context.env,
});
}
// resolve a launcher binary by walking PATH (which already has the action
// runtime's nodeBinDir prepended). some hosted Node 24 runner pools ship
// `node` at `externals/node24/bin/node` without the sibling `npx`/`corepack`,
// so a hardcoded sibling path can't be relied on — fall back to whatever the
// runner image provides on PATH.
function requireExecutable(params: {
context: RuntimeContext;
command: string;
purpose: string;
}): string {
const resolved = resolveExecutable({ command: params.command, env: params.context.env });
if (!resolved) {
throw new Error(
`could not find ${params.command} on PATH (needed to ${params.purpose}); ` +
`runtime PATH was: ${params.context.env.PATH ?? "<empty>"}`
);
}
return resolved;
}
function runPackageCli(context: RuntimeContext, packageSpec: string, cliArgs: string[]): void {
// `verify` here and not in `requireExecutable`: this is the one resolution
// whose failure is unattributable, because a broken launcher dies before any
// of our logging exists. see #1084.
const npxPath = resolveExecutable({ command: "npx", env: context.env, verify: true });
if (npxPath) {
console.log(`» running ${packageSpec} via ${npxPath}`);
runCommand({ context, command: npxPath, args: ["--yes", packageSpec, ...cliArgs] });
return;
}
const corepackPath = resolveExecutable({ command: "corepack", env: context.env, verify: true });
if (corepackPath) {
console.warn("» npx not found, using corepack pnpm dlx");
runCommand({ context, command: corepackPath, args: ["pnpm", "dlx", packageSpec, ...cliArgs] });
return;
}
// `::error::` written directly rather than via @actions/core: this is the
// bootstrap, deliberately dependency-light, and it runs before any of our
// error plumbing is initialized. without it a runner with a broken launcher
// fails with no annotation and no PR comment at all.
console.log(
`::error::Pullfrog could not start: no working npx or corepack on this runner's PATH. ` +
`This usually means a self-hosted runner has an incomplete Node installation. PATH was: ${context.env.PATH ?? "<empty>"}`
);
throw new Error(
`could not find a working npx or corepack on PATH to run ${packageSpec}; ` +
`runtime PATH was: ${context.env.PATH ?? "<empty>"}`
);
}
function ensureActionDependencies(context: RuntimeContext): void {
const nodeModulesPath = join(context.actionRoot, "node_modules");
if (existsSync(nodeModulesPath)) {
return;
}
const corepackPath = requireExecutable({
context,
command: "corepack",
purpose: "install action dependencies via pnpm",
});
const adjacentCorepack = join(
context.nodeBinDir,
process.platform === "win32" ? "corepack.cmd" : "corepack"
);
if (corepackPath !== adjacentCorepack) {
// bad-runner case: GitHub's externals/node24/bin/ is missing the corepack
// sibling, so we resolved via PATH instead. logging this lets us correlate
// bootstrap path to runner pool when validating the fix.
console.warn(
`» nodeBinDir corepack missing (${adjacentCorepack}); using PATH-resolved ${corepackPath}`
);
}
execFileSync(corepackPath, ["pnpm", "install", "--frozen-lockfile", "--ignore-scripts"], {
cwd: context.actionRoot,
stdio: "inherit",
env: context.env,
});
}
function runLocalCli(context: RuntimeContext, cliArgs: string[]): void {
ensureActionDependencies(context);
execFileSync(process.execPath, ["cli.ts", ...cliArgs], {
cwd: context.actionRoot,
stdio: "inherit",
env: context.env,
});
}
function runPullfrogCliInner(context: RuntimeContext, cliArgs: string[]): void {
if (process.env.PULLFROG_FORCE_LOCAL_CLI === "1") {
runLocalCli(context, cliArgs);
return;
}
if (context.actionRef === "main" && context.actionRepository === "pullfrog/pullfrog") {
runLocalCli(context, cliArgs);
return;
}
runPackageCli(context, FALLBACK_PACKAGE_SPEC, cliArgs);
}
// the inner CLI and the bootstrap install run with `stdio: "inherit"`, so on a
// non-zero exit they've already printed their own `##[error]` line. node turns
// that exit into a thrown `Error: Command failed…`; letting it bubble crashes
// the outer bootstrap with a `node:internal/errors` stack trace that buries the
// real failure (#862, #867). propagate the child's exit code silently instead;
// only genuine spawn failures (ENOENT, missing npx, …) still surface.
function propagateChildExit(error: unknown): never {
if (error instanceof Error && "status" in error && typeof error.status === "number") {
process.exit(error.status);
}
if (error instanceof Error && "signal" in error && error.signal != null) {
process.exit(1);
}
throw error;
}
export function runPullfrogCli(params: RunPullfrogCliParams): void {
const context = createRuntimeContext();
if (params.swallowErrors) {
try {
runPullfrogCliInner(context, params.cliArgs);
} catch (error) {
console.warn(`» pullfrog cleanup bootstrap failed: ${getErrorMessage(error)}`);
// best-effort cleanup
}
return;
}
try {
runPullfrogCliInner(context, params.cliArgs);
} catch (error) {
propagateChildExit(error);
}
}