Fix Node 24 action bootstrap fallback (#556)

* Fix Node 24 action bootstrap fallback

Resolve the published CLI launcher through PATH so runners missing a sibling Node 24 npx can still start, and make post cleanup recognize prefixed leaping comments.

* Bump Pullfrog action package version

Ensure the Node 24 bootstrap and post-cleanup fixes publish to npm and move the v0 action tag.

* Walk PATH for corepack and npx in action bootstrap

ensureActionDependencies and runPackageCli now resolve corepack/npx through
PATH the same way as the npx-via-PATH fix, so Node 24 runner pools missing
either sibling can still bootstrap. Also adds a Zod-mirror settings helper
for the preview-556 repo and documents the per-PR settings workflow.

* log when corepack PATH fallback is used
This commit is contained in:
Colin McDonnell
2026-05-01 15:59:46 +00:00
committed by pullfrog[bot]
parent f662b1a0c8
commit 55c95e6f50
4 changed files with 116 additions and 18 deletions
+6
View File
@@ -14,6 +14,12 @@ import { execute, tool } from "./shared.ts";
*/
export const LEAPING_INTO_ACTION_PREFIX = "Leaping into action";
export function isLeapingIntoActionCommentBody(body: string): boolean {
const content = stripExistingFooter(body).trimStart();
const firstLine = content.split(/\r?\n/, 1)[0]?.trimEnd() ?? "";
return new RegExp(`(^|\\s)${LEAPING_INTO_ACTION_PREFIX}(\\.\\.\\.)?$`).test(firstLine);
}
function buildCommentFooter(ctx: ToolContext, customParts?: string[]): string {
const runId = ctx.runId;
return buildPullfrogFooter({
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "pullfrog",
"version": "0.0.201",
"version": "0.0.202",
"type": "module",
"bin": {
"pullfrog": "dist/cli.mjs",
+107 -15
View File
@@ -1,5 +1,5 @@
import { execFileSync } from "node:child_process";
import { existsSync } from "node:fs";
import { accessSync, constants, existsSync } from "node:fs";
import { delimiter, dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import actionPackageJson from "./package.json" with { type: "json" };
@@ -20,6 +20,48 @@ interface RuntimeContext {
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;
}
}
function resolveExecutable(params: { command: string; env: NodeJS.ProcessEnv }): string | null {
const pathValue = params.env.PATH ?? "";
const pathEntries = pathValue.split(delimiter).filter(Boolean);
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)) {
return candidate;
}
}
}
return null;
}
function createRuntimeContext(): RuntimeContext {
const actionRoot = dirname(fileURLToPath(import.meta.url));
const nodeBinDir = dirname(process.execPath);
@@ -38,28 +80,77 @@ function createRuntimeContext(): RuntimeContext {
};
}
function runNpx(context: RuntimeContext, packageSpec: string, cliArgs: string[]): void {
const npxPath =
process.platform === "win32"
? join(context.nodeBinDir, "npx.cmd")
: join(context.nodeBinDir, "npx");
execFileSync(npxPath, ["--yes", packageSpec, ...cliArgs], {
cwd: process.env.GITHUB_WORKSPACE || context.actionRoot,
function runCommand(params: { context: RuntimeContext; command: string; args: string[] }): void {
execFileSync(params.command, params.args, {
cwd: process.env.GITHUB_WORKSPACE || params.context.actionRoot,
stdio: "inherit",
env: context.env,
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 {
const npxPath = resolveExecutable({ command: "npx", env: context.env });
if (npxPath) {
runCommand({ context, command: npxPath, args: ["--yes", packageSpec, ...cliArgs] });
return;
}
const corepackPath = resolveExecutable({ command: "corepack", env: context.env });
if (corepackPath) {
console.warn("» npx not found, using corepack pnpm dlx");
runCommand({ context, command: corepackPath, args: ["pnpm", "dlx", packageSpec, ...cliArgs] });
return;
}
throw new Error(
`could not find 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 =
process.platform === "win32"
? join(context.nodeBinDir, "corepack.cmd")
: join(context.nodeBinDir, "corepack");
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",
@@ -87,7 +178,7 @@ function runPullfrogCliInner(context: RuntimeContext, cliArgs: string[]): void {
return;
}
runNpx(context, FALLBACK_PACKAGE_SPEC, cliArgs);
runPackageCli(context, FALLBACK_PACKAGE_SPEC, cliArgs);
}
export function runPullfrogCli(params: RunPullfrogCliParams): void {
@@ -96,7 +187,8 @@ export function runPullfrogCli(params: RunPullfrogCliParams): void {
if (params.swallowErrors) {
try {
runPullfrogCliInner(context, params.cliArgs);
} catch {
} catch (error) {
console.warn(`» pullfrog cleanup bootstrap failed: ${getErrorMessage(error)}`);
// best-effort cleanup
}
return;
+2 -2
View File
@@ -1,4 +1,4 @@
import { LEAPING_INTO_ACTION_PREFIX } from "../mcp/comment.ts";
import { isLeapingIntoActionCommentBody } from "../mcp/comment.ts";
import { getApiUrl } from "./apiUrl.ts";
import { buildPullfrogFooter } from "./buildPullfrogFooter.ts";
import { log } from "./cli.ts";
@@ -68,7 +68,7 @@ async function validateStuckProgressComment(ctx: PostCleanupContext): Promise<nu
const body = commentResult.data.body ?? "";
if (body.startsWith(LEAPING_INTO_ACTION_PREFIX)) {
if (isLeapingIntoActionCommentBody(body)) {
log.info(`[post] comment ${commentId} is stuck on "Leaping into action"`);
return commentId;
}