runCli.ts
Rama main · réplica de lectura del repositorio original.
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 is our runtime, not a customer-vetted dep: every action ref, a
// SHA pin included, tracks `^<version>` (wiki/action-bootstrap.md), 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";
// `prefer-offline` trusts a cached packument forever, so it would pin the
// bootstrap to the newest version that packument lists, or ETARGET an exact one
env.npm_config_prefer_offline = "false";
const currentPath = process.env.PATH ?? "";
env.PATH = currentPath ? `${nodeBinDir}${delimiter}${currentPath}` : nodeBinDir;
// data, not logic: a version-manager npx shim runs the CLI on its own node, and the
// CLI re-runs itself under this one (commands/gha.ts, #1565)
env.PULLFROG_NODE = process.execPath;
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;
}
// npm lists a new version before its CDN serves it, and the CDN then caches that
// 404 for five minutes; every run in that window died on 0.1.93. the 330s total
// outlasts a 404 this run's own first attempt cached. see wiki/action-bootstrap.md.
const PREFETCH_RETRY_SECONDS = [30, 60, 90, 150];
function isNotYetServed(error: unknown): boolean {
if (!(error instanceof Error) || !("stderr" in error)) return false;
const stderr = String(error.stderr);
return (stderr.includes("E404") && stderr.includes(".tgz")) || stderr.includes("ETARGET");
}
/**
* Download the package into npm's cache, retrying only a release npm lists but
* does not serve yet, so the real run installs from the cache. Any other failure
* is left for the real run to reproduce with its output visible.
*/
function prefetchPackage(params: {
context: RuntimeContext;
npxPath: string;
packageSpec: string;
}): void {
for (let attempt = 0; ; attempt++) {
try {
execFileSync(
params.npxPath,
["--yes", "--package", params.packageSpec, "--", "node", "-e", "0"],
{
cwd: mkdtempSync(join(tmpdir(), "pullfrog-bootstrap-")),
stdio: ["ignore", "ignore", "pipe"],
env: params.context.env,
}
);
return;
} catch (error) {
const delay = PREFETCH_RETRY_SECONDS[attempt];
if (delay === undefined || !isNotYetServed(error)) return;
console.warn(`» npm does not serve ${params.packageSpec} yet; retrying in ${delay}s`);
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, delay * 1000);
}
}
}
function compareReleases(a: string, b: string): number {
const left = a.split(".").map(Number);
const right = b.split(".").map(Number);
return left[0] - right[0] || left[1] - right[1] || left[2] - right[2];
}
/** `version` is a stable release satisfying `^<base>`: same major (same minor below 1.0), not older */
function satisfiesCaret(version: string, base: string): boolean {
if (!/^\d+\.\d+\.\d+$/.test(version) || compareReleases(version, base) < 0) return false;
const [major, minor] = version.split(".");
const [baseMajor, baseMinor] = base.split(".");
return major === baseMajor && (baseMajor !== "0" || minor === baseMinor);
}
/**
* npx before npm 11.2 reuses any cached install that satisfies a range, so on a
* self-hosted runner's persistent `~/.npm/_npx` the range pins the first version
* it ever installed. An exact version gets a fresh npx dir per release. Picks
* the way npm does: `latest` if it satisfies the range, else the highest match.
*/
async function resolvePackageSpec(): Promise<string> {
try {
const response = await fetch(`${NPM_REGISTRY}/pullfrog`, {
headers: { accept: "application/vnd.npm.install-v1+json" },
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) throw new Error(`registry answered ${response.status}`);
const packument: { "dist-tags": { latest: string }; versions: Record<string, unknown> } =
JSON.parse(await response.text());
const matches = Object.keys(packument.versions)
.filter((version) => satisfiesCaret(version, actionPackageJson.version))
.sort((a, b) => compareReleases(b, a));
const latest = packument["dist-tags"].latest;
const picked = matches.includes(latest) ? latest : matches[0];
if (!picked) throw new Error("no published version matches");
return `pullfrog@${picked}`;
} catch (error) {
console.warn(
`» could not resolve ${FALLBACK_PACKAGE_SPEC} (${getErrorMessage(error)}); using the range`
);
return FALLBACK_PACKAGE_SPEC;
}
}
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}`);
prefetchPackage({ context, npxPath, packageSpec });
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,
});
}
async function runPullfrogCliInner(context: RuntimeContext, cliArgs: string[]): Promise<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, await resolvePackageSpec(), 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 async function runPullfrogCli(params: RunPullfrogCliParams): Promise<void> {
const context = createRuntimeContext();
if (params.swallowErrors) {
try {
await runPullfrogCliInner(context, params.cliArgs);
} catch (error) {
console.warn(`» pullfrog cleanup bootstrap failed: ${getErrorMessage(error)}`);
// best-effort cleanup
}
return;
}
try {
await runPullfrogCliInner(context, params.cliArgs);
} catch (error) {
propagateChildExit(error);
}
}