ddbc610569
* review prompt: friendly green callouts + per-section severity emojis - Replace `[!NOTE]` informational tier and the no-callout minor-suggestions tier with friendly green blockquotes (`> ✅` / `> 💡`). The two loud tiers (`[!CAUTION]` / `[!IMPORTANT]`) keep their GitHub admonitions. - Add a per-`##`-section severity-emoji rule (🚨/⚠️/💡/ℹ️) for cross-cutting review concerns that don't anchor to a line and would otherwise be buried in summary content. - Drop the `<br/>` between summary sections — heading + blank line carries enough visual spacing. - Skip the post-run learnings-reflection turn for `IncrementalReview`. It's the lowest-novelty mode (delta review against existing PR with prior summary already loaded) and almost never produces durable learnings — reflection there costs ~$0.50-0.80/run for nothing. - Surface real error info on `agent-browser` skill install failures (exit code + stdout + stderr + spawn error). The skills CLI uses a TUI that prints errors to stdout, so the prior stderr-only logging silently swallowed every failure. * review prompt: per-bullet severity emoji + bullets-only sections Section headings are plain again (no leading severity emoji). Severity moves to individual bullets so a section that mixes a 🚨 and a 💡 isn't mislabeled by either. Section bodies are now bullets only — paragraph prose under a heading is harder to scan and tends to bury the actionable point. Bullets can carry indented continuation content (sub-bullets, code fences, blockquotes) by indenting two spaces under the parent. * review prompt: cap section length + identifier discipline Bound each summary section to at most 4 bullets at most 2 lines each, and explicitly call out identifier-heavy prose as an anti-pattern. The reader is often a manager or non-author; identifier-dense paragraphs ('foo calls bar.fetch which dispatches to baz via qux...') are unreadable for them. Default to plain-language behavior descriptions, name an identifier only when it's the subject of an actionable concern or a public surface a reader would recognize, target 2-3 backtick tokens per bullet. Move the deep-explanation pattern from open blockquote to a default- collapsed details/summary so depth doesn't dominate the visible body. * review prompt: hard cap on bullet identifier density + worked rewrite example Soft 'aim for 2-3 tokens' guidance was ignored — first big-PR e2e showed 12 of 19 actionable bullets exceeded the target (avg 4.8 tokens, several over 8). Promote to a hard cap of 3 backticked tokens per bullet and pair with a concrete bad/good rewrite the agent can pattern- match against. Also tighten the per-bullet length cap from ~240 to ~200 chars and explicitly call it 'hard cap, not target'. * review prompt: tighten bullet length cap to 160 chars, dramatize the worked example V2 e2e test: token discipline improved (4.8 -> 3.3 avg, 12/19 -> 6/14 violations) but length got worse (235 -> 286 chars, 13/14 over the 200 cap). The agent compensated for fewer identifiers with more prose. Two changes: (1) tighten the cap from ~200 chars to 160 chars / 1 visual line and call out wrap-to-multiple-lines as the failure mode; (2) rewrite the worked example so the good version is genuinely half the length of the bad one, not just lower token count. The example was the thing the agent pattern-matches against; making the good version ~130 chars vs the bad version's ~290 chars sets the right shape. * review prompt: drop fixed bullet-count cap, keep length + identifier caps Per user feedback — section length should be governed by content, not an arbitrary count. Soft guidance ('past ~6, ask whether to split') is fine; the hard '≤ 4 bullets per section' rule was the wrong shape. Length cap (160c) and identifier cap (3 backtick tokens) stay; those target the actual scanability problem. * review prompt: drop ## subsystem sections, flat 'Issues found' list Per-section structure forced every concern into a subsystem frame and made the body read like a series of mini-essays. Replace with two parts: (1) TL;DR + Key changes as the dispassionate overview, (2) flat '### Issues found' list ordered by severity, intermixed across files and subsystems. Per-bullet rules (≤160c, ≤3 backtick tokens, severity emoji prefix, optional indented continuation) carry over unchanged. * review prompt: full v6 structure — preamble + cross-cutting H3s + nitpicks Replaces the flat 'Issues found' bullet list with the iterated v6 shape: - Preamble is a bolded inline 'Reviewed changes' lead-in plus bullets plus a collapsed 'Review metadata' block (mode/files/commits/refs/ reviewed commits list/prior pullfrog review/staleness note). - Each cross-cutting concern gets a '### emoji Title' section. The visible problem write-up is human-friendly and DESCRIBES THE PROBLEM ONLY — no asks, no suggested fixes, no 'the right thing to do is'. - Each section carries a collapsed 'Technical details' block wrapped in a 4-backtick markdown fence (so it can hold its own 3-tick code fences cleanly, agent-readable, one-click copyable). Standard four inner sections: Affected sites, Required outcome, optional Suggested approach, optional Open questions for the human. - '### ℹ️ Nitpicks' at the bottom for body-only nits that don't inline; simple bullets, no technical-details collapse. - Anti-paragraph-wall rule: never two successive plain paragraphs in visible '### ' sections; alternate prose with structure. - Inline-vs-body discipline: anything that anchors to a single line goes inline, body is for cross-cutting only. - Drops legacy '### Key changes', '### Issues found', '<b>TL;DR</b>', and the '<sub>Summary</sub>' line. * model effort: bump Gemini + GPT to high effort; drop Gemini Pro→Flash subagent E2E review eval against a substantive billing-module diff surfaced two related quality gaps: 1. Gemini Pro at thinkingLevel=medium (#663's CI-timeout fix) reviewed the diff only, took the 0-lens path, and missed a catastrophic camelCase/snake_case service-vs-schema mismatch. Bumping back to high — review work is exactly the wrong shape for the medium/high tradeoff #663 was optimizing for; the per-turn TTFT cost is worth paying when reasoning IS the value. 2. GPT had no reasoningEffort override, defaulting to upstream medium. Same diff, similar shallow result vs Claude. Adding reasoningEffort: high for the curated direct-OpenAI slugs, mirroring the Gemini pattern (Anthropic separately uses --effort high via the Claude Code CLI flag in claude.ts). 3. Gemini Pro's subagentModel was 'gemini-flash' — but Google has no in-between tier between Pro and Flash, and Flash is a meaningful capability cliff for review work. Dropping the override so subagents inherit Pro. Cost stays reasonable since Gemini Pro is already the cheapest of the flagship trio. Other providers unchanged: Anthropic opus→sonnet and OpenAI gpt→gpt-5.4 remain (each is a one-tier drop to a still-capable sibling). * model effort: revert orchestrator override, set explicit high on reviewfrog subagent Reshape the effort design after eval: - Drop the explicit Gemini and GPT model-level overrides — orchestrators now run at upstream defaults (Gemini high, GPT-5.x medium). Gemini's upstream IS high, so this is a no-op there; GPT goes back to upstream medium for orchestrator-level routing work. - Add explicit 'high' on the reviewfrog subagent via agent.options. OpenCode merge order is base ← model.options ← agent.options ← variant per session/llm.ts:141, so the subagent always runs at high regardless of which orchestrator dispatched it. Both thinkingConfig.thinkingLevel (Gemini) and reasoningEffort (GPT) keys included; irrelevant keys are ignored per provider. - Bump providers-live timeouts (12min job / 10min step, from 8/6) to budget for Gemini's TTFT variance at high effort. #663's 4min timeout was sized for the medium-effort override that's now removed. * model effort: restore Gemini explicit high override (no-override path breaks) Bare 'rely on upstream default' for Gemini failed in e2e — removing the model-level provider config produced 'Function call is missing a thought_signature' API errors on every gemini-pro run. Even though upstream opencode's options() returns the same thinkingLevel: high we were explicitly setting, opencode's resolution path differs subtly between the two cases. v2's explicit override worked; v3's removal broke. Reproducible across two consecutive runs. Restoring the explicit Gemini override (back to v2 design). GPT orchestrator stays UN-overridden — at upstream default (medium) — since removing that override didn't trigger the same failure pattern and the reviewfrog subagent agent.options high override compensates for the extra depth GPT loses at medium. * diag: remove reviewfrog agent.options to isolate Gemini thought_signature failure v3 (no Gemini orch override) failed with thought_signature error. v4 (restored Gemini orch override at v2-equivalent) ALSO failed, even though the orchestrator config matches v2. The variable between v2 (working) and v4 (failing) is the new reviewfrog agent.options block. Removing it to confirm — if Gemini works again, the agent.options addition is the culprit and we need a different shape for it. * opencode-ai: bump 1.1.56 → 1.15.0 + clean up gemini effort config opencode-ai@1.1.56 was published 2026-02-10 (3 months old). The Google API tightened thought_signature validation 24-48h ago (per https://discuss.ai.google.dev/t/gemini-thought-signature-patch/122555), and the bug class hits opencode's session→prompt serializer for MCP tool-call parts (anomalyco/opencode#4832, #8321). Latest stable bumps us through ~3 months of fixes; needed for Gemini-direct to stop dying with 'thought_signature is missing' on every multi-turn run. Companion cleanup: the gemini provider override in opencode.ts had 30-line block of comments, four unused constants, and a 6-line Object.fromEntries map for two entries. Replaced with one source-of- truth helper that loops modelAliases, filters provider==='google', strips the 'google/' prefix, and returns the override map. Adding any future Google alias to the registry now flows through automatically. Test added: action/agents/opencode.test.ts asserts the helper covers every direct-Google alias, strips the prefix correctly, and pins every entry to thinkingLevel high — catches drift in helper logic without hardcoding the API ids the test would have to update in lockstep with the registry. * fix(workflow): tolerate listJobsForWorkflowRun 404 in resolveRun PR #750 (docker testing rewrite) replaced the per-call env allowlist with full process.env passthrough into the test container. That now leaks GITHUB_RUN_ID + GITHUB_JOB into runs whose MCP token is scoped to a DIFFERENT repo (e.g. providers-live smoke runs the action against pullfrog/test-repo with pullfrog/app's run ID). The unconditional listJobsForWorkflowRun call 404s and crashes the entire run, breaking every providers-live job on main since #750 landed. jobId is purely cosmetic (deep-links 'View workflow run' footer to a specific job vs the run-level URL). Wrapping the API call in try/catch so a 404 logs a debug message and falls through to undefined jobId is the right fix — the failure mode is exactly what graceful degradation is for, and the alternative (filter the env vars at the docker boundary) re-introduces the kind of allowlist #750 was getting rid of. * opencode-ai: pin 1.14.51 instead of 1.15.0 (effect refactor breaks JSON output) opencode 1.15.0 (May 15) ships a major architectural refactor onto @effect — the run command boots an in-process server via @opencode-ai/sdk/v2 and the JSON event emission path through that SDK client doesn't surface on stdout the way our parser expects (CI run on 1.15.0 produced 0 stdout events but the agent still completed). Local invocation also hangs at the in-process server boot. The Gemini thought_signature fixes (the original reason for bumping) landed earlier in the 1.14.x line, so 1.14.51 (May 14) gets us the upstream fix without the Effect rewrite. Defer the 1.15.x bump until we're ready to rewire our parser/spawn around the new SDK. * opencode-ai: revert to 1.1.56; gha: filter outer-CI workflow-run vars at the docker boundary Two related changes for the docker testing harness's ergonomics: 1. Revert opencode-ai 1.14.51 → 1.1.56. The 1.14+ line ships an Effect refactor (the SDK-v2 client + in-process server architecture) that our --format json parser doesn't speak — even the 1.14.51 release, pre-dating the 1.15.0 Effect rename, produced 0 stdout events on our skill-invoke smoke. There's no clean pre-Effect version that ships the Gemini thought_signature fix; that fix needs a separate workstream once we're ready to rewire the parser onto SDK v2. 2. Filter outer-CI workflow-run identifiers (GITHUB_RUN_ID, GITHUB_JOB, GITHUB_WORKFLOW, GITHUB_ACTION, GITHUB_REF, GITHUB_SHA, etc.) from gha.ts's --env-file passthrough. PR #750's full-process.env design leaks pullfrog/app's CI run identifiers into runs that act against a different repo (e.g. pullfrog/test-repo); any code path inside the action that uses them as keys (most notably resolveRun's listJobsForWorkflowRun lookup) 404s. Filtering them here means the action sees undefined and skips the lookup, complementing the defensive try/catch in resolveRun (commit addc76d4). GITHUB_REPOSITORY and GITHUB_TOKEN are NOT filtered — those are genuinely needed. Companion to addc76d4 (resolveRun 404 tolerance). The two together make this class of bug 'either fix would have caught it' rather than 'silently breaks the entire test matrix'. * fix(deps): sync pnpm-lock.yaml with opencode-ai 1.1.56 manifest revert Forgot to refresh the lockfile after reverting the manifest in 02c6d8c1. CI's frozen-lockfile install was failing with 'lockfile: 1.14.51, manifest: 1.1.56' mismatch.
517 lines
16 KiB
TypeScript
517 lines
16 KiB
TypeScript
// run any node script inside the pullfrog GHA-like container.
|
|
//
|
|
// usage:
|
|
// pnpm gha <script> [args…] # run script in container
|
|
// pnpm gha --shell # interactive bash (requires TTY)
|
|
// pnpm gha --build [--no-cache] # force-rebuild image
|
|
// pnpm gha --clean # prune orphan images/volumes
|
|
// pnpm gha --doctor # versions of every baked tool
|
|
//
|
|
// the container is a baked ubuntu:24.04 image (see Dockerfile) with the
|
|
// same toolset as GHA `ubuntu-24.04` runners. host env passes through
|
|
// verbatim — no allowlist. multi-line values (RSA keys) handled via -e
|
|
// fallback; everything else flows through `--env-file` for cleanliness.
|
|
//
|
|
// host services are reachable at `host.docker.internal:<port>` (works on
|
|
// both linux and macOS — see --add-host below).
|
|
//
|
|
// rebuild is content-hash gated on Dockerfile + docker-entrypoint.sh.
|
|
//
|
|
// design rationale + gaps: wiki/docker.md.
|
|
import { spawnSync } from "node:child_process";
|
|
import { createHash } from "node:crypto";
|
|
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
import { platform, tmpdir } from "node:os";
|
|
import { dirname, join, resolve } from "node:path";
|
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
import { config } from "dotenv";
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const actionDir = __dirname;
|
|
const repoRoot = join(actionDir, "..");
|
|
|
|
config({ path: join(actionDir, ".env") });
|
|
config({ path: join(repoRoot, ".env") });
|
|
|
|
// host env vars that would actively conflict with the container's own
|
|
// configuration (paths, identity, shell, and outer-CI workflow-run identifiers
|
|
// that don't apply to whatever repo the harness is acting against). everything
|
|
// else passes through.
|
|
const HOST_ONLY_VARS = new Set([
|
|
// paths / identity / shell — would clobber the container's testuser setup
|
|
"PATH",
|
|
"HOME",
|
|
"USER",
|
|
"LOGNAME",
|
|
"SHELL",
|
|
"PWD",
|
|
"OLDPWD",
|
|
"TMPDIR",
|
|
"TMP",
|
|
"TEMP",
|
|
"DOCKER_HOST",
|
|
"DOCKER_CONFIG",
|
|
"_",
|
|
"SHLVL",
|
|
"PS1",
|
|
"PS2",
|
|
"TERM_PROGRAM",
|
|
"TERM_PROGRAM_VERSION",
|
|
"TERM_SESSION_ID",
|
|
"__CF_USER_TEXT_ENCODING",
|
|
"XPC_SERVICE_NAME",
|
|
"XPC_FLAGS",
|
|
"Apple_PubSub_Socket_Render",
|
|
"COMMAND_MODE",
|
|
"COLORTERM",
|
|
"ITERM_PROFILE",
|
|
"ITERM_SESSION_ID",
|
|
// outer-CI workflow-run identifiers — when `pnpm runtest smoke` runs inside
|
|
// pullfrog/app's CI, these refer to pullfrog/app's run, NOT the test repo
|
|
// the harness is acting against (e.g. pullfrog/test-repo). Anything inside
|
|
// the action that uses them as keys to look up state on the test repo (most
|
|
// notably `resolveRun()`'s `actions.listJobsForWorkflowRun(...)` call) will
|
|
// 404. Filtering them here means the action sees them as undefined and
|
|
// skips the lookup, instead of misdirecting it. `GITHUB_REPOSITORY` and
|
|
// `GITHUB_TOKEN` are NOT filtered — those are genuinely needed inside.
|
|
"GITHUB_RUN_ID",
|
|
"GITHUB_RUN_NUMBER",
|
|
"GITHUB_RUN_ATTEMPT",
|
|
"GITHUB_JOB",
|
|
"GITHUB_WORKFLOW",
|
|
"GITHUB_ACTION",
|
|
"GITHUB_REF",
|
|
"GITHUB_SHA",
|
|
"GITHUB_HEAD_REF",
|
|
"GITHUB_BASE_REF",
|
|
"GITHUB_TRIGGERING_ACTOR",
|
|
]);
|
|
|
|
type Args = {
|
|
forceBuild: boolean;
|
|
noCache: boolean;
|
|
shell: boolean;
|
|
clean: boolean;
|
|
doctor: boolean;
|
|
passthrough: string[];
|
|
};
|
|
|
|
/**
|
|
* parses gha-level flags up to (but not including) the first positional
|
|
* argument. anything after the first positional, or after a literal `--`,
|
|
* passes through verbatim to the inner script. this prevents
|
|
* `pnpm gha test/run.ts --build` from intercepting `--build` as a
|
|
* gha flag.
|
|
*/
|
|
function parseArgs(argv: string[]): Args {
|
|
const out: Args = {
|
|
forceBuild: false,
|
|
noCache: false,
|
|
shell: false,
|
|
clean: false,
|
|
doctor: false,
|
|
passthrough: [],
|
|
};
|
|
let i = 0;
|
|
while (i < argv.length) {
|
|
const a = argv[i];
|
|
if (a === "--") {
|
|
out.passthrough.push(...argv.slice(i + 1));
|
|
return out;
|
|
}
|
|
if (a === "--build") out.forceBuild = true;
|
|
else if (a === "--no-cache") {
|
|
out.forceBuild = true;
|
|
out.noCache = true;
|
|
} else if (a === "--shell") out.shell = true;
|
|
else if (a === "--clean") out.clean = true;
|
|
else if (a === "--doctor") out.doctor = true;
|
|
else if (a === "--help" || a === "-h") {
|
|
showHelp();
|
|
process.exit(0);
|
|
} else {
|
|
// first positional — script name and everything after passes through.
|
|
out.passthrough.push(...argv.slice(i));
|
|
return out;
|
|
}
|
|
i++;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function showHelp(): void {
|
|
process.stdout.write(`Usage: pnpm gha <script> [args…]
|
|
pnpm gha --shell
|
|
pnpm gha --build [--no-cache]
|
|
pnpm gha --clean
|
|
pnpm gha --doctor
|
|
|
|
Run a node script inside the pullfrog GHA-like container. Mirrors the
|
|
GitHub Actions ubuntu-24.04 runner toolset (gh, jq, python3, sudo, +
|
|
build-essential / wget / xz / file). Host env passes through verbatim.
|
|
The host is reachable from inside the container at host.docker.internal
|
|
(useful for scripts that hit your local dev server).
|
|
|
|
Options:
|
|
--build rebuild the current image (otherwise rebuilt automatically
|
|
when Dockerfile or docker-entrypoint.sh content changes).
|
|
on its own, builds and exits.
|
|
--no-cache pair with --build to also bust docker's layer cache;
|
|
useful when an apt mirror or base image changed.
|
|
--shell drop into an interactive bash inside the container.
|
|
requires a TTY.
|
|
--clean prune orphaned pullfrog-gha:* images and node_modules
|
|
volumes whose hash doesn't match the current Dockerfile.
|
|
--doctor print version info for tools inside the container (node,
|
|
pnpm, gh, jq, git, python3, ssh, …). useful for diagnosing
|
|
"works in CI fails locally" or vice versa.
|
|
-h, --help show this message.
|
|
|
|
Pass-through:
|
|
Anything after the first positional argument (or after a literal \`--\`)
|
|
goes to the inner script verbatim. so \`pnpm gha test/run.ts --build\`
|
|
passes \`--build\` to test/run.ts, not to gha.
|
|
|
|
Examples:
|
|
pnpm gha play.ts
|
|
pnpm gha play.ts --raw '{"prompt":"hi"}'
|
|
pnpm gha test/run.ts smoke
|
|
pnpm gha --shell
|
|
pnpm gha --build # build image, then exit
|
|
pnpm gha --build --no-cache # rebuild from scratch
|
|
pnpm gha --clean # reclaim disk from old image hashes
|
|
pnpm gha --doctor # fidelity audit
|
|
`);
|
|
}
|
|
|
|
function ensureDocker(): void {
|
|
if (platform() === "win32") {
|
|
fail("pnpm gha is not supported on native windows. use wsl2.");
|
|
}
|
|
const probe = spawnSync("docker", ["info"], { stdio: "ignore" });
|
|
if (probe.status !== 0) {
|
|
fail("docker is not running. start docker desktop and retry.");
|
|
}
|
|
}
|
|
|
|
function fail(msg: string): never {
|
|
process.stderr.write(`error: ${msg}\n`);
|
|
process.exit(1);
|
|
}
|
|
|
|
type ImageRef = { tag: string; volumeName: string };
|
|
|
|
function imageRefFor(ctx: { dockerfile: string; entrypoint: string }): ImageRef {
|
|
const hash = createHash("sha256")
|
|
.update(readFileSync(ctx.dockerfile))
|
|
.update(readFileSync(ctx.entrypoint))
|
|
.digest("hex")
|
|
.slice(0, 12);
|
|
return {
|
|
tag: `pullfrog-gha:${hash}`,
|
|
// version the volume by image hash so a stale node_modules cache from
|
|
// an old image (e.g. different node major) can't poison a new image.
|
|
volumeName: `pullfrog-gha-node-modules-${hash}`,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* remove pullfrog-gha:* images and pullfrog-gha-node-modules-* volumes
|
|
* whose hash doesn't match the current Dockerfile + entrypoint. each
|
|
* Dockerfile/entrypoint edit creates a fresh hash and orphans the prior
|
|
* pair; without periodic cleanup these accumulate (~600MB image + ~200MB
|
|
* node_modules each).
|
|
*/
|
|
function cleanOrphans(currentRef: ImageRef): void {
|
|
const imgList = spawnSync("docker", ["image", "ls", "--format", "{{.Repository}}:{{.Tag}}"], {
|
|
encoding: "utf8",
|
|
});
|
|
const images = (imgList.stdout ?? "")
|
|
.split("\n")
|
|
.filter((s) => s.startsWith("pullfrog-gha:") && s !== currentRef.tag);
|
|
if (images.length > 0) {
|
|
process.stderr.write(`» removing ${images.length} orphan image(s): ${images.join(", ")}\n`);
|
|
spawnSync("docker", ["image", "rm", "-f", ...images], { stdio: "inherit" });
|
|
}
|
|
const volList = spawnSync("docker", ["volume", "ls", "-q"], { encoding: "utf8" });
|
|
const volumes = (volList.stdout ?? "")
|
|
.split("\n")
|
|
.filter((s) => s.startsWith("pullfrog-gha-node-modules-") && s !== currentRef.volumeName);
|
|
if (volumes.length > 0) {
|
|
process.stderr.write(`» removing ${volumes.length} orphan volume(s): ${volumes.join(", ")}\n`);
|
|
spawnSync("docker", ["volume", "rm", ...volumes], { stdio: "inherit" });
|
|
}
|
|
if (images.length === 0 && volumes.length === 0) {
|
|
process.stderr.write("» no orphans to clean (all matching current image hash)\n");
|
|
}
|
|
}
|
|
|
|
function buildImageIfNeeded(ctx: {
|
|
ref: ImageRef;
|
|
force: boolean;
|
|
noCache: boolean;
|
|
dockerfile: string;
|
|
}): void {
|
|
if (!ctx.force) {
|
|
const inspect = spawnSync("docker", ["image", "inspect", ctx.ref.tag], { stdio: "ignore" });
|
|
if (inspect.status === 0) return;
|
|
}
|
|
process.stderr.write(
|
|
`» building ${ctx.ref.tag}${ctx.noCache ? " (--no-cache)" : ""} (one-time, ~30-60s)…\n`
|
|
);
|
|
const buildArgs = ["build", "-t", ctx.ref.tag, "-f", ctx.dockerfile];
|
|
if (ctx.noCache) buildArgs.push("--no-cache");
|
|
buildArgs.push(actionDir);
|
|
const build = spawnSync("docker", buildArgs, { stdio: "inherit" });
|
|
if (build.status !== 0) {
|
|
fail("image build failed");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* print versions of every tool we expect to be available, so contributors
|
|
* can sanity-check fidelity with the GHA `ubuntu-24.04` runner when a test
|
|
* passes locally but fails in CI (or vice versa).
|
|
*/
|
|
function runDoctor(ref: ImageRef): void {
|
|
// multi-line bash script; spawnSync passes the whole thing as one argv
|
|
// entry so there's no nested-shell quoting to worry about, and `do` is
|
|
// not followed by a stray semicolon.
|
|
const script = `set +e
|
|
echo '--- container ---'
|
|
grep -E '^(NAME|VERSION)=' /etc/os-release
|
|
echo "arch=$(uname -m)"
|
|
|
|
echo
|
|
echo '--- runtimes ---'
|
|
echo "node $(node --version)"
|
|
if cd /app/action 2>/dev/null; then
|
|
echo "pnpm $(corepack pnpm --version) (corepack-resolved from packageManager)"
|
|
else
|
|
echo "pnpm $(pnpm --version) (system fallback — /app/action not mounted?)"
|
|
fi
|
|
python3 --version
|
|
|
|
echo
|
|
echo '--- tools ---'
|
|
for t in gh jq git ssh curl wget tar gzip xz unzip file make gcc g++ sudo unshare awk sed grep find xargs; do
|
|
if ! command -v "$t" >/dev/null 2>&1; then
|
|
printf ' %-10s MISSING\\n' "$t"
|
|
continue
|
|
fi
|
|
case "$t" in
|
|
ssh|unzip) v=$("$t" -V 2>&1 | head -1) ;;
|
|
*) v=$("$t" --version 2>&1 | head -1) ;;
|
|
esac
|
|
printf ' %-10s %s\\n' "$t" "$v"
|
|
done
|
|
|
|
echo
|
|
echo '--- env ---'
|
|
echo "CI=$CI HOME=$HOME TMPDIR=$TMPDIR"
|
|
echo "doctor runs as: $(whoami) (uid=$(id -u) gid=$(id -g))"
|
|
echo "tests run as: testuser (uid remapped to host uid at entrypoint)"
|
|
echo "host.docker.internal -> $(getent hosts host.docker.internal | awk '{print $1}' || echo UNRESOLVED)"
|
|
`;
|
|
const result = spawnSync(
|
|
"docker",
|
|
[
|
|
"run",
|
|
"--rm",
|
|
"-v",
|
|
`${actionDir}:/app/action:cached`,
|
|
"--add-host=host.docker.internal:host-gateway",
|
|
"--entrypoint",
|
|
"/bin/bash",
|
|
ref.tag,
|
|
"-c",
|
|
script,
|
|
],
|
|
{ stdio: "inherit" }
|
|
);
|
|
process.exit(result.status ?? 1);
|
|
}
|
|
|
|
function volumeExists(name: string): boolean {
|
|
return spawnSync("docker", ["volume", "inspect", name], { stdio: "ignore" }).status === 0;
|
|
}
|
|
|
|
function initVolumeOwnership(ctx: { ref: ImageRef; uid: number; gid: number }): void {
|
|
// a fresh named volume is owned by root; chown once on creation. on warm
|
|
// runs the volume already has the right ownership and `docker run … chown`
|
|
// is sub-second pure overhead — skip it.
|
|
if (volumeExists(ctx.ref.volumeName)) return;
|
|
spawnSync(
|
|
"docker",
|
|
[
|
|
"run",
|
|
"--rm",
|
|
"--entrypoint",
|
|
"chown",
|
|
"-v",
|
|
`${ctx.ref.volumeName}:/app/action/node_modules`,
|
|
ctx.ref.tag,
|
|
"-R",
|
|
`${ctx.uid}:${ctx.gid}`,
|
|
"/app/action/node_modules",
|
|
],
|
|
{ stdio: "ignore" }
|
|
);
|
|
}
|
|
|
|
type EnvParts = { envFile: string; multiLineFlags: string[] };
|
|
|
|
function buildEnvParts(env: NodeJS.ProcessEnv): EnvParts {
|
|
const dir = join(tmpdir(), "pullfrog-gha");
|
|
mkdirSync(dir, { recursive: true });
|
|
const envFile = join(dir, `env-${process.pid}-${Date.now()}.list`);
|
|
const lines: string[] = [];
|
|
const multiLineFlags: string[] = [];
|
|
for (const key of Object.keys(env)) {
|
|
if (HOST_ONLY_VARS.has(key)) continue;
|
|
const value = env[key];
|
|
if (value === undefined) continue;
|
|
// docker --env-file is line-oriented and does not support multi-line
|
|
// values. fall back to -e for those (RSA keys, multi-line PEMs, etc.).
|
|
if (value.includes("\n") || value.includes("\r")) {
|
|
multiLineFlags.push("-e", `${key}=${value}`);
|
|
} else {
|
|
lines.push(`${key}=${value}`);
|
|
}
|
|
}
|
|
writeFileSync(envFile, `${lines.join("\n")}\n`, { mode: 0o600 });
|
|
return { envFile, multiLineFlags };
|
|
}
|
|
|
|
function buildSshFlags(home: string | undefined): string[] {
|
|
const flags: string[] = [];
|
|
if (!home) return flags;
|
|
if (platform() === "darwin") {
|
|
const knownHosts = join(home, ".ssh", "known_hosts");
|
|
if (existsSync(knownHosts)) {
|
|
flags.push("-v", `${knownHosts}:/tmp/home/.ssh/known_hosts:ro`);
|
|
}
|
|
flags.push(
|
|
"-v",
|
|
"/run/host-services/ssh-auth.sock:/run/host-services/ssh-auth.sock",
|
|
"-e",
|
|
"SSH_AUTH_SOCK=/run/host-services/ssh-auth.sock"
|
|
);
|
|
} else {
|
|
const sshDir = join(home, ".ssh");
|
|
if (existsSync(sshDir)) {
|
|
flags.push("-v", `${sshDir}:/tmp/.ssh-host:ro`);
|
|
}
|
|
}
|
|
return flags;
|
|
}
|
|
|
|
function main(): void {
|
|
const args = parseArgs(process.argv.slice(2));
|
|
ensureDocker();
|
|
|
|
const dockerfile = join(actionDir, "Dockerfile");
|
|
const entrypoint = join(actionDir, "docker-entrypoint.sh");
|
|
const ref = imageRefFor({ dockerfile, entrypoint });
|
|
|
|
if (args.clean) {
|
|
cleanOrphans(ref);
|
|
if (!args.shell && !args.doctor && args.passthrough.length === 0 && !args.forceBuild) {
|
|
process.exit(0);
|
|
}
|
|
}
|
|
|
|
buildImageIfNeeded({ ref, force: args.forceBuild, noCache: args.noCache, dockerfile });
|
|
|
|
if (args.doctor) {
|
|
runDoctor(ref);
|
|
// runDoctor exits; unreachable.
|
|
}
|
|
|
|
// standalone `--build`: image's done, nothing to run.
|
|
if (!args.shell && args.passthrough.length === 0) {
|
|
if (!args.forceBuild) {
|
|
showHelp();
|
|
process.exit(1);
|
|
}
|
|
process.exit(0);
|
|
}
|
|
|
|
// node sets isTTY to `true` for a terminal stdin, `undefined` otherwise
|
|
// (never `false`). check truthiness, not equality.
|
|
if (args.shell && !process.stdin.isTTY) {
|
|
fail("--shell needs a TTY (stdin is not a terminal). run from an interactive shell.");
|
|
}
|
|
|
|
const uid = process.getuid?.() ?? 1000;
|
|
const gid = process.getgid?.() ?? 1000;
|
|
initVolumeOwnership({ ref, uid, gid });
|
|
|
|
const envParts = buildEnvParts(process.env);
|
|
const sshFlags = buildSshFlags(process.env.HOME);
|
|
|
|
const runArgs: string[] = [
|
|
"run",
|
|
"--rm",
|
|
// `--init` uses tini as PID 1, which forwards signals (SIGINT/SIGTERM)
|
|
// to our entrypoint and reaps zombies. Without it, bash-as-PID-1
|
|
// swallows Ctrl-C during the pre-exec warmup phase.
|
|
"--init",
|
|
args.shell ? "-it" : "-t",
|
|
"--privileged",
|
|
// make the host reachable from inside the container at a stable name
|
|
// (macOS Docker Desktop bakes this in; the flag makes Linux match,
|
|
// matters when scripts hit local dev servers like API_URL=
|
|
// http://host.docker.internal:3100).
|
|
"--add-host=host.docker.internal:host-gateway",
|
|
"-v",
|
|
`${actionDir}:/app/action:cached`,
|
|
"-v",
|
|
`${ref.volumeName}:/app/action/node_modules`,
|
|
"-w",
|
|
"/app/action",
|
|
"--env-file",
|
|
envParts.envFile,
|
|
"-e",
|
|
`HOST_UID=${uid}`,
|
|
"-e",
|
|
`HOST_GID=${gid}`,
|
|
...envParts.multiLineFlags,
|
|
...sshFlags,
|
|
ref.tag,
|
|
];
|
|
|
|
if (args.shell) {
|
|
runArgs.push("--shell");
|
|
} else {
|
|
// resolve script paths relative to actionDir (matches `pnpm -C action`
|
|
// mental model). absolute paths and bare flags pass through unchanged.
|
|
const [script, ...rest] = args.passthrough;
|
|
if (script === undefined) {
|
|
fail("internal: passthrough empty");
|
|
}
|
|
runArgs.push("node", script, ...rest);
|
|
}
|
|
|
|
let exitCode = 1;
|
|
try {
|
|
const result = spawnSync("docker", runArgs, { stdio: "inherit" });
|
|
exitCode = result.status ?? 1;
|
|
} finally {
|
|
try {
|
|
unlinkSync(envParts.envFile);
|
|
} catch {
|
|
// best-effort; tmpdir is GC'd by the OS regardless.
|
|
}
|
|
}
|
|
process.exit(exitCode);
|
|
}
|
|
|
|
const isDirectExecution = process.argv[1]
|
|
? import.meta.url === pathToFileURL(resolve(process.argv[1])).href
|
|
: false;
|
|
|
|
if (isDirectExecution) {
|
|
main();
|
|
}
|