docker testing rewrite: bake the image, drop the allowlist, kill the quoting (#750)
* docker testing rewrite: bake the image, drop the allowlist, kill the quoting - new `pnpm gha <script>` wrapper. one entry point for running any node script in the GHA-like container; replaces the runtime apt-get + useradd + chown ceremony in `action/utils/docker.ts`. - `action/Dockerfile` bakes ubuntu:24.04 + node 24 + gh + jq + sudo + testuser at uid 1000. `action/docker-entrypoint.sh` remaps to the host uid/gid and `exec`s the requested command — no `bash -c` nesting, no `escapeForDoubleQuotes`. - env passthrough: full `process.env` (+ `.env` via dotenv) flows through `--env-file`, multi-line values via `-e` fallback. drops `EnvFilterMode` / `testEnvAllowList`. - image rebuild is content-hash gated on Dockerfile + entrypoint; volume is versioned by hash so a stale `node_modules` cache from an old image can't poison a new one. - `action/play.ts` slimmed to a CLI; `run()` extracted to `action/utils/runFixture.ts`. drops the `--local` / `PLAY_LOCAL` dual mode in favor of explicit `play:local` / `runtest:local` scripts. - `action/test/run.ts` no longer self-relaunches into docker — that's `gha`'s job now. - `action/test/coverage.ts` `ALWAYS_RUN_ALL` updated to track the new files. - `wiki/docker.md` rewritten (243 → 105 lines). `wiki/action-tests.md`, `wiki/billing.md`, `wiki/adversarial.md`, `README.md`, `AGENTS.md` all updated to drop `--local` / `PLAY_LOCAL` references. verified end-to-end: `pnpm play` runs the default fixture against pullfrog/scratch, exit 0; `sudo unshare --pid` still works inside the container; `pnpm runtest` boots through the wrapper. * gha: address review feedback + 3 related issues found locally review-flagged: - bare `pnpm gha --build` now builds the image and exits 0 (was printing help and exiting 1 — docs claimed it was a valid standalone) - `initVolumeOwnership` skipped when the named volume already exists; saves the ~240ms `docker run … chown` on every warm invocation - `GIT_SSH_COMMAND` gate widened to any `id_*` private key (was hard- coded to `id_rsa`, leaving ed25519-only linux contributors with the default ssh config). dropped `-i` so ssh picks whichever key exists - new `action/.dockerignore` — partial mitigation noted: BuildKit (default since docker 23) only sends files referenced by the Dockerfile (~42B in practice), so the perf concern is mostly hypothetical. file is still worth keeping for `DOCKER_BUILDKIT=0` fallback and as documented intent for future `COPY . .` additions related issues found while validating locally: - `parseArgs` now stops flag-parsing at the first positional (or literal `--`); `pnpm gha test/run.ts --build` previously intercepted `--build` as a gha flag instead of forwarding to `test/run.ts` - new `pnpm gha --clean` command prunes orphan `pullfrog-gha:*` images and `pullfrog-gha-node-modules-*` volumes whose hash doesn't match the current Dockerfile (each Dockerfile/entrypoint edit creates a fresh hash and orphans the prior pair, ~600MB + ~200MB each — without a cleaner they accumulate silently) - `--shell` without a TTY now fails fast with an actionable message before docker is invoked, instead of producing the confusing `the input device is not a TTY` from docker run wiki updated: documents `--clean`, the parseArgs passthrough rule, and a new "Reclaiming disk" section. * gha: fidelity, flexibility, and signal-safety improvements investigated local fidelity vs the real GHA ubuntu-24.04 runner and addressed the gaps that have actually bitten contributors or could. fidelity (image now matches GHA closer): - bake build-essential, wget, xz-utils, file alongside the existing toolset. gh, jq, git, python3, sudo, ssh, build-essential, wget, xz, file, unzip, curl all present. native module builds (node-gyp, any package missing arm64 prebuilts) now work; common agent shell calls don't hit ENOENT - `host.docker.internal:host-gateway` flag wires the host into the container's DNS on linux (macOS Docker Desktop bakes it in). lets scripts that hit a local dev server use `API_URL=http://host.docker. internal:3100` and work identically on both platforms - `--init` makes tini PID 1, fixing signal forwarding during the pre-exec warmup window (Ctrl-C was previously taking up to 10s to tear down because bash-as-PID-1 swallowed the signal) - pnpm version is correctly pinned via the workspace's `packageManager` field — corepack resolves it at install time; verified via the new `--doctor` command flexibility (new affordances): - `pnpm gha --doctor` runs an inside-the-container fidelity audit: os + arch + node/pnpm/python versions, version snapshots of every baked tool, env vars (CI, HOME, TMPDIR), uid/gid, and the host.docker.internal resolution. useful for "works in CI fails locally" or vice versa - `pnpm gha --build --no-cache` busts the docker layer cache when an apt mirror, base image, or external download has changed upstream - entrypoint's `pnpm install` warmup is now wrapped in a `flock` on a file in the shared node_modules volume — concurrent `pnpm gha` invocations (e.g. play in one terminal, runtest in another) serialize their install instead of racing docs: - new "Gaps (known)" section in wiki/docker.md explicitly calling out the things this system can't do yet, including the missing `uses: ./action` semantics gap that `.github/workflows/action-gha-e2e-adhoc.yml` currently fills via GHA only (designing a local `pnpm gha-action <fixture>` is on the roadmap), service containers, parallel-run sharing, and arch differences (arm64 vs amd64) * docs: audit + corrections after testing fronts self-audit pass for stale references and incomplete pointers: - wiki/browser.md: `Docker (node:24)` → `pnpm gha container (ubuntu:24.04)`. the substance was right (chrome not preinstalled) but the base image reference was stale. - wiki/docker.md: the "Permission errors" troubleshooting line claimed the node_modules volume is chowned on every run; now correctly says "owned by the host uid on first creation; warm runs skip the chown" to match the actual behavior after the initVolumeOwnership fix. - wiki/action-tests.md: `API_URL` env-var doc now mentions BOTH paths (`localhost:` from play:local, `host.docker.internal:` from inside the container). Proxy/router recipe now shows both invocations side-by-side instead of saying "must use play:local". - wiki/billing.md: same dual-recipe update for the loop-including-the- action proxy walkthrough. - gha.ts header: expanded the usage block to include --clean / --doctor / --no-cache / --shell-TTY, added the host.docker.internal note, and pointed at wiki/docker.md for design rationale. self-document check: a future agent landing on this code can answer "how do I run a fixture / debug in shell / add a tool / diagnose fidelity / reach a local dev server" purely from gha.ts header + wiki/docker.md without spelunking through the entrypoint or git history.
This commit is contained in:
committed by
pullfrog[bot]
parent
8e1acfba99
commit
76879b27ec
@@ -0,0 +1,31 @@
|
|||||||
|
# the Dockerfile only `COPY`s docker-entrypoint.sh, so most of this is
|
||||||
|
# defense-in-depth — modern docker BuildKit (default since docker 23)
|
||||||
|
# already prunes unreferenced files from the build context. but:
|
||||||
|
# - documents intent for future maintainers who add `COPY . .`
|
||||||
|
# - resurfaces the bytes-saved win if someone disables BuildKit
|
||||||
|
# (DOCKER_BUILDKIT=0) or adopts a builder that doesn't prune
|
||||||
|
# - keeps `docker build` snappy even on cold builders that DO send
|
||||||
|
# everything
|
||||||
|
|
||||||
|
# pnpm-managed workspace deps — large and never needed at build time
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# secrets — must never enter an image, even by accident
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# build outputs
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# editor / VCS noise
|
||||||
|
.DS_Store
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
# tests + fixtures we don't need at build time
|
||||||
|
coverage/
|
||||||
|
test/
|
||||||
|
.scripts/
|
||||||
+78
@@ -0,0 +1,78 @@
|
|||||||
|
# pullfrog GHA-like test container.
|
||||||
|
#
|
||||||
|
# baked once at image build time, used by `pnpm gha`. all runtime cost
|
||||||
|
# (apt-get, useradd, sudoers wiring) is paid here so each `gha` invocation
|
||||||
|
# is a single `docker run` with no in-container setup.
|
||||||
|
#
|
||||||
|
# rebuild is content-hash gated by gha.ts (Dockerfile + docker-entrypoint.sh).
|
||||||
|
# bump anything in this file or the entrypoint and the next `pnpm gha` rebuilds.
|
||||||
|
|
||||||
|
FROM ubuntu:24.04
|
||||||
|
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
|
# core toolset matching what GHA `ubuntu-24.04` runners ship: gh, jq, git,
|
||||||
|
# python3, ssh client, plus the compression + build-essential surface that
|
||||||
|
# `pnpm install` / `node-gyp` / agent shell calls regularly need. keeps
|
||||||
|
# test-time invocations of these tools honest (no "works on the runner,
|
||||||
|
# breaks in the local container").
|
||||||
|
RUN apt-get update -qq \
|
||||||
|
&& apt-get install -qq -y --no-install-recommends \
|
||||||
|
build-essential \
|
||||||
|
ca-certificates \
|
||||||
|
curl \
|
||||||
|
file \
|
||||||
|
git \
|
||||||
|
gnupg \
|
||||||
|
jq \
|
||||||
|
openssh-client \
|
||||||
|
python3 \
|
||||||
|
sudo \
|
||||||
|
unzip \
|
||||||
|
wget \
|
||||||
|
xz-utils \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# node 24 from nodesource + corepack (provides pnpm without a global install).
|
||||||
|
RUN curl -fsSL https://deb.nodesource.com/setup_24.x | bash - \
|
||||||
|
&& apt-get install -y nodejs \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
|
&& corepack enable
|
||||||
|
|
||||||
|
# gh cli (matches GHA pre-installed tooling).
|
||||||
|
RUN mkdir -p /etc/apt/keyrings \
|
||||||
|
&& curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
|
||||||
|
| gpg --dearmor -o /etc/apt/keyrings/githubcli-archive-keyring.gpg \
|
||||||
|
&& chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \
|
||||||
|
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
|
||||||
|
> /etc/apt/sources.list.d/github-cli.list \
|
||||||
|
&& apt-get update -qq \
|
||||||
|
&& apt-get install -qq -y gh \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# ubuntu:24.04 ships a default `ubuntu` user at uid 1000 — remove it so we
|
||||||
|
# can place `testuser` at 1000 (the typical macOS dev uid). the entrypoint
|
||||||
|
# remaps to the host uid/gid at runtime if they differ.
|
||||||
|
RUN userdel -r ubuntu 2>/dev/null || true \
|
||||||
|
&& groupadd -g 1000 testuser \
|
||||||
|
&& useradd -u 1000 -g 1000 -m -s /bin/bash testuser \
|
||||||
|
&& echo "testuser ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/testuser \
|
||||||
|
&& chmod 0440 /etc/sudoers.d/testuser
|
||||||
|
|
||||||
|
# layout matching the bind mount + named volume targets in gha.ts.
|
||||||
|
RUN mkdir -p /app/action /app/action/node_modules /tmp/home/.config /tmp/home/.cache \
|
||||||
|
&& chown -R testuser:testuser /app /tmp/home
|
||||||
|
|
||||||
|
# CI=true is critical: `shell.ts` PID-namespace sandbox keys off it. baking
|
||||||
|
# it ensures security tests can't pass vacuously because someone forgot the
|
||||||
|
# flag.
|
||||||
|
ENV HOME=/tmp/home \
|
||||||
|
TMPDIR=/tmp \
|
||||||
|
CI=true \
|
||||||
|
COREPACK_ENABLE_DOWNLOAD_PROMPT=0
|
||||||
|
|
||||||
|
COPY docker-entrypoint.sh /entrypoint.sh
|
||||||
|
RUN chmod +x /entrypoint.sh
|
||||||
|
|
||||||
|
WORKDIR /app/action
|
||||||
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
Executable
+63
@@ -0,0 +1,63 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# entrypoint for the pullfrog GHA-like container (see Dockerfile).
|
||||||
|
#
|
||||||
|
# - remaps `testuser` to the host uid/gid so bind-mounted files keep correct
|
||||||
|
# ownership after writes inside the container
|
||||||
|
# - on linux hosts, copies host ssh keys into testuser's $HOME (darwin hosts
|
||||||
|
# forward the ssh-agent socket instead, no copy needed)
|
||||||
|
# - installs action workspace deps (volume-cached, ~1.5s warm)
|
||||||
|
# - exec's the requested command as testuser; argv is preserved (no nested
|
||||||
|
# `bash -c`, no shell quoting hazards)
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
HOST_UID="${HOST_UID:-1000}"
|
||||||
|
HOST_GID="${HOST_GID:-1000}"
|
||||||
|
|
||||||
|
if [ "$HOST_UID" != "1000" ] || [ "$HOST_GID" != "1000" ]; then
|
||||||
|
groupmod -g "$HOST_GID" testuser 2>/dev/null || true
|
||||||
|
usermod -u "$HOST_UID" -g "$HOST_GID" testuser 2>/dev/null || true
|
||||||
|
# chown top-level dirs only — recursive chown would fail on `:ro` bind
|
||||||
|
# mounts (e.g. macOS known_hosts mounted directly into /tmp/home/.ssh).
|
||||||
|
chown "$HOST_UID:$HOST_GID" /tmp/home /tmp/home/.config /tmp/home/.cache 2>/dev/null || true
|
||||||
|
chown "$HOST_UID:$HOST_GID" /app /app/action /app/action/node_modules 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# linux hosts: copy host ssh keys into testuser's $HOME (we own this dir,
|
||||||
|
# safe to chown). darwin hosts forward the ssh-agent socket instead and
|
||||||
|
# bind-mount known_hosts read-only — nothing to do here.
|
||||||
|
if [ -d /tmp/.ssh-host ]; then
|
||||||
|
mkdir -p /tmp/home/.ssh
|
||||||
|
cp /tmp/.ssh-host/id_* /tmp/home/.ssh/ 2>/dev/null || true
|
||||||
|
chmod 600 /tmp/home/.ssh/id_* 2>/dev/null || true
|
||||||
|
ssh-keyscan -t ed25519,rsa github.com >> /tmp/home/.ssh/known_hosts 2>/dev/null || true
|
||||||
|
chmod 644 /tmp/home/.ssh/known_hosts 2>/dev/null || true
|
||||||
|
chown -R "$HOST_UID:$HOST_GID" /tmp/home/.ssh 2>/dev/null || true
|
||||||
|
# set GIT_SSH_COMMAND if any private key got copied. don't pin a
|
||||||
|
# specific key with -i — let ssh pick whatever's in /tmp/home/.ssh
|
||||||
|
# (covers id_rsa, id_ed25519, id_ecdsa, etc.).
|
||||||
|
if ls /tmp/home/.ssh/id_* 2>/dev/null | grep -qv '\.pub$'; then
|
||||||
|
export GIT_SSH_COMMAND="ssh -o UserKnownHostsFile=/tmp/home/.ssh/known_hosts -o StrictHostKeyChecking=no"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# warm the volume-cached node_modules. frozen-lockfile + ignore-scripts keeps
|
||||||
|
# this idempotent and fast (~1.5s when nothing changed).
|
||||||
|
#
|
||||||
|
# the lockfile lives IN the shared node_modules volume so concurrent
|
||||||
|
# `pnpm gha` invocations (e.g. `pnpm play` in one terminal and
|
||||||
|
# `pnpm runtest` in another) serialize their install instead of racing.
|
||||||
|
# `flock -w 120` waits up to 2min before giving up — well under any
|
||||||
|
# real-world install time but short enough to surface true deadlocks.
|
||||||
|
mkdir -p /app/action/node_modules
|
||||||
|
flock -w 120 /app/action/node_modules/.gha-install.lock \
|
||||||
|
sudo -u testuser -E env HOME=/tmp/home \
|
||||||
|
corepack pnpm install --frozen-lockfile --ignore-scripts >/dev/null
|
||||||
|
|
||||||
|
# `--shell` drops into an interactive bash for debugging the container.
|
||||||
|
if [ "${1:-}" = "--shell" ]; then
|
||||||
|
exec sudo -u testuser -E env HOME=/tmp/home bash
|
||||||
|
fi
|
||||||
|
|
||||||
|
# exec the command as testuser, preserving env. argv passes through unchanged
|
||||||
|
# — no `bash -c` nesting, no quoting required by callers.
|
||||||
|
exec sudo -u testuser -E env HOME=/tmp/home "$@"
|
||||||
@@ -0,0 +1,494 @@
|
|||||||
|
// 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). everything else passes through.
|
||||||
|
const HOST_ONLY_VARS = new Set([
|
||||||
|
"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",
|
||||||
|
]);
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
+5
-2
@@ -16,8 +16,11 @@
|
|||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"build": "node esbuild.config.js && tsc -p tsconfig.exports.json",
|
"build": "node esbuild.config.js && tsc -p tsconfig.exports.json",
|
||||||
"check:entrypoints": "node scripts/check-entrypoint-imports.ts",
|
"check:entrypoints": "node scripts/check-entrypoint-imports.ts",
|
||||||
"play": "node play.ts",
|
"gha": "node gha.ts",
|
||||||
"runtest": "node test/run.ts",
|
"play": "node gha.ts play.ts",
|
||||||
|
"play:local": "node play.ts",
|
||||||
|
"runtest": "node gha.ts test/run.ts",
|
||||||
|
"runtest:local": "node test/run.ts",
|
||||||
"scratch": "node scratch.ts",
|
"scratch": "node scratch.ts",
|
||||||
"upDeps": "pnpm up --latest",
|
"upDeps": "pnpm up --latest",
|
||||||
"lock": "pnpm install --no-frozen-lockfile",
|
"lock": "pnpm install --no-frozen-lockfile",
|
||||||
|
|||||||
@@ -1,22 +1,22 @@
|
|||||||
import { execSync } from "node:child_process";
|
// thin CLI for ad-hoc fixture runs against the Pullfrog action.
|
||||||
import { mkdtemp } from "node:fs/promises";
|
// for the GHA-like containerized version, run `pnpm gha play.ts […]`.
|
||||||
import { devNull, tmpdir } from "node:os";
|
|
||||||
import { dirname, join, resolve } from "node:path";
|
import { dirname, join, resolve } from "node:path";
|
||||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||||
import arg from "arg";
|
import arg from "arg";
|
||||||
import { config } from "dotenv";
|
import { config } from "dotenv";
|
||||||
import type { AgentResult } from "./agents/shared.ts";
|
import type { Inputs } from "./main.ts";
|
||||||
import { type Inputs, main } from "./main.ts";
|
|
||||||
import { defineFixture } from "./test/utils.ts";
|
import { defineFixture } from "./test/utils.ts";
|
||||||
import { log } from "./utils/cli.ts";
|
import { log } from "./utils/cli.ts";
|
||||||
import { runInDocker } from "./utils/docker.ts";
|
import { run } from "./utils/runFixture.ts";
|
||||||
import { ensureGitHubToken } from "./utils/github.ts";
|
|
||||||
import { isInsideDocker } from "./utils/globals.ts";
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
import { setupTestRepo } from "./utils/setup.ts";
|
|
||||||
|
config();
|
||||||
|
config({ path: join(__dirname, "..", ".env") });
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* default play fixture for ad-hoc testing.
|
* default fixture for ad-hoc `pnpm play` runs. change this freely without
|
||||||
* change this freely without affecting any tests.
|
* affecting any tests — it's only consumed by this script's no-arg path.
|
||||||
*/
|
*/
|
||||||
export const playFixture = defineFixture(
|
export const playFixture = defineFixture(
|
||||||
{
|
{
|
||||||
@@ -25,85 +25,6 @@ export const playFixture = defineFixture(
|
|||||||
{ localOnly: true }
|
{ localOnly: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
|
||||||
const __dirname = dirname(__filename);
|
|
||||||
|
|
||||||
// load action's .env file in case it exists for local dev
|
|
||||||
config();
|
|
||||||
// also load .env from repo root (for monorepo structure)
|
|
||||||
config({ path: join(__dirname, "..", ".env") });
|
|
||||||
|
|
||||||
export async function run(inputsOrPrompt: Inputs | string): Promise<AgentResult> {
|
|
||||||
await ensureGitHubToken();
|
|
||||||
|
|
||||||
// play.ts is a CI-emulator — isolate it from the developer's user- and
|
|
||||||
// system-scope gitconfig so checks like `validatePushDestination` see the
|
|
||||||
// raw stored remote URL instead of values mutated by `url.*.insteadOf`
|
|
||||||
// rewrites (a common SSH-auth convenience on dev boxes). CI runners have
|
|
||||||
// empty gitconfigs so this is a no-op there; locally it makes `pnpm play`
|
|
||||||
// and real runs produce identical git state. `os.devNull` canonicalizes
|
|
||||||
// the null device across Unix (`/dev/null`) and Windows (`\\.\nul`).
|
|
||||||
process.env.GIT_CONFIG_GLOBAL = devNull;
|
|
||||||
process.env.GIT_CONFIG_SYSTEM = devNull;
|
|
||||||
|
|
||||||
// create unique temp directory path in OS temp location for parallel execution
|
|
||||||
// use a parent dir from mkdtemp, then clone into a 'repo' subdirectory
|
|
||||||
const tempParent = await mkdtemp(join(tmpdir(), "pullfrog-play-"));
|
|
||||||
const tempDir = join(tempParent, "repo");
|
|
||||||
const originalCwd = process.cwd();
|
|
||||||
|
|
||||||
try {
|
|
||||||
setupTestRepo({ tempDir });
|
|
||||||
process.chdir(tempDir);
|
|
||||||
|
|
||||||
// run repo setup commands if provided (for pre-planting test state like symlinks).
|
|
||||||
// this runs AFTER clone but BEFORE the agent, simulating pre-existing repo content.
|
|
||||||
if (process.env.PULLFROG_TEST_REPO_SETUP) {
|
|
||||||
log.info("» running repo setup commands...");
|
|
||||||
execSync(process.env.PULLFROG_TEST_REPO_SETUP, { cwd: tempDir, stdio: "pipe" });
|
|
||||||
}
|
|
||||||
|
|
||||||
// set GITHUB_WORKSPACE to tempDir so main() doesn't try to chdir to the CI checkout path
|
|
||||||
process.env.GITHUB_WORKSPACE = tempDir;
|
|
||||||
|
|
||||||
// allow passing full Inputs object or just a prompt string
|
|
||||||
const inputs: Inputs =
|
|
||||||
typeof inputsOrPrompt === "string" ? { prompt: inputsOrPrompt } : inputsOrPrompt;
|
|
||||||
|
|
||||||
// set INPUT_* env vars for @actions/core.getInput()
|
|
||||||
for (const [key, value] of Object.entries(inputs)) {
|
|
||||||
if (value !== undefined && value !== null) {
|
|
||||||
process.env[`INPUT_${key.toUpperCase()}`] = String(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const result: AgentResult = await main();
|
|
||||||
|
|
||||||
process.chdir(originalCwd);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
log.success("Action completed successfully");
|
|
||||||
return { success: true, output: result.output || undefined, error: undefined };
|
|
||||||
} else {
|
|
||||||
log.error(`Action failed: ${result.error || "Unknown error"}`);
|
|
||||||
return { success: false, error: result.error || undefined, output: undefined };
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
const errorMessage = (err as Error).message;
|
|
||||||
log.error(`Error: ${errorMessage}`);
|
|
||||||
return { success: false, error: errorMessage, output: undefined };
|
|
||||||
} finally {
|
|
||||||
// cleanup temp directory - use sudo rm because sandbox isolation may create
|
|
||||||
// files with different ownership that rmSync can't delete
|
|
||||||
process.chdir(originalCwd);
|
|
||||||
try {
|
|
||||||
execSync(`sudo rm -rf "${tempParent}"`, { stdio: "ignore" });
|
|
||||||
} catch {
|
|
||||||
// ignore - cleanup failure is not critical
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const isDirectExecution = process.argv[1]
|
const isDirectExecution = process.argv[1]
|
||||||
? import.meta.url === pathToFileURL(resolve(process.argv[1])).href
|
? import.meta.url === pathToFileURL(resolve(process.argv[1])).href
|
||||||
: false;
|
: false;
|
||||||
@@ -112,71 +33,40 @@ if (isDirectExecution) {
|
|||||||
const args = arg({
|
const args = arg({
|
||||||
"--help": Boolean,
|
"--help": Boolean,
|
||||||
"--raw": String,
|
"--raw": String,
|
||||||
"--local": Boolean,
|
|
||||||
"-h": "--help",
|
"-h": "--help",
|
||||||
"-l": "--local",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (args["--help"]) {
|
if (args["--help"]) {
|
||||||
log.info(`
|
log.info(`
|
||||||
Usage: node play.ts [options]
|
Usage: node play.ts [--raw <input>]
|
||||||
|
|
||||||
Test the Pullfrog action with the inline playFixture.
|
Run the Pullfrog action against an inline fixture. Host-side, in-process.
|
||||||
|
For a GHA-like Linux container, use \`pnpm gha play.ts […]\` instead.
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
--raw [input] Use raw string as prompt, or JSON object as full fixture
|
--raw <input> raw string used as the prompt, or JSON object as full fixture
|
||||||
--local, -l Run locally (default: runs in Docker)
|
-h, --help show this message
|
||||||
-h, --help Show this help message
|
|
||||||
|
|
||||||
Environment:
|
|
||||||
PLAY_LOCAL=1 Same as --local
|
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
node play.ts # Run inline playFixture
|
node play.ts
|
||||||
node play.ts --raw "Hello world" # Use raw string as prompt
|
node play.ts --raw "Hello world"
|
||||||
node play.ts --raw '{"prompt":"Hello","timeout":"5s"}' # Use JSON fixture
|
node play.ts --raw '{"prompt":"Hi","timeout":"5s"}'
|
||||||
`);
|
`);
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// default: run in Docker (unless --local, PLAY_LOCAL=1, or already inside Docker)
|
|
||||||
const useLocal = args["--local"] || process.env.PLAY_LOCAL === "1" || isInsideDocker;
|
|
||||||
|
|
||||||
if (!useLocal) {
|
|
||||||
const passArgs = process.argv
|
|
||||||
.slice(2)
|
|
||||||
.map((a) => `'${a.replace(/'/g, "'\\''")}'`)
|
|
||||||
.join(" ");
|
|
||||||
const nodeCmd = `node play.ts ${passArgs}`;
|
|
||||||
|
|
||||||
const volumeName = "pullfrog-action-node-modules";
|
|
||||||
|
|
||||||
const result = runInDocker({
|
|
||||||
actionDir: __dirname,
|
|
||||||
args: process.argv.slice(2),
|
|
||||||
nodeCmd,
|
|
||||||
volumeName,
|
|
||||||
envFilterMode: "passthrough",
|
|
||||||
onStart: () => log.info("» running in Docker container..."),
|
|
||||||
});
|
|
||||||
|
|
||||||
process.exit(result.status ?? 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (args["--raw"]) {
|
if (args["--raw"]) {
|
||||||
const raw = args["--raw"];
|
const raw = args["--raw"];
|
||||||
// try to parse as JSON, otherwise treat as prompt string
|
|
||||||
let input: Inputs | string = raw;
|
let input: Inputs | string = raw;
|
||||||
try {
|
try {
|
||||||
input = JSON.parse(raw) as Inputs;
|
input = JSON.parse(raw) as Inputs;
|
||||||
} catch {
|
} catch {
|
||||||
// not valid JSON, use as prompt string
|
// not valid JSON — treat as a literal prompt string.
|
||||||
}
|
}
|
||||||
const result = await run(input);
|
const result = await run(input);
|
||||||
process.exit(result.success ? 0 : 1);
|
process.exit(result.success ? 0 : 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// no args - use inline playFixture
|
|
||||||
const result = await run(playFixture);
|
const result = await run(playFixture);
|
||||||
process.exit(result.success ? 0 : 1);
|
process.exit(result.success ? 0 : 1);
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-1
@@ -38,8 +38,12 @@ export const ALWAYS_RUN_ALL: string[] = [
|
|||||||
"action/utils/setup.ts",
|
"action/utils/setup.ts",
|
||||||
"action/utils/lifecycle.ts",
|
"action/utils/lifecycle.ts",
|
||||||
"action/utils/install.ts",
|
"action/utils/install.ts",
|
||||||
"action/utils/docker.ts",
|
"action/utils/runFixture.ts",
|
||||||
"action/utils/globals.ts",
|
"action/utils/globals.ts",
|
||||||
|
// GHA-like container plumbing (changes invalidate every test's environment)
|
||||||
|
"action/Dockerfile",
|
||||||
|
"action/docker-entrypoint.sh",
|
||||||
|
"action/gha.ts",
|
||||||
// MCP orchestrator (every test runs through it)
|
// MCP orchestrator (every test runs through it)
|
||||||
"action/mcp/server.ts",
|
"action/mcp/server.ts",
|
||||||
"action/mcp/shared.ts",
|
"action/mcp/shared.ts",
|
||||||
|
|||||||
+2
-33
@@ -2,9 +2,6 @@ import { existsSync, readdirSync } from "node:fs";
|
|||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { config } from "dotenv";
|
import { config } from "dotenv";
|
||||||
import { runInDocker } from "../utils/docker.ts";
|
|
||||||
import { ensureGitHubToken } from "../utils/github.ts";
|
|
||||||
import { isInsideDocker } from "../utils/globals.ts";
|
|
||||||
import { killTrackedChildren, setSignalHandler } from "../utils/subprocess.ts";
|
import { killTrackedChildren, setSignalHandler } from "../utils/subprocess.ts";
|
||||||
import {
|
import {
|
||||||
type AgentResult,
|
type AgentResult,
|
||||||
@@ -37,17 +34,16 @@ import {
|
|||||||
* - "agnostic": runs with opencode only, excluded when filtering by agent
|
* - "agnostic": runs with opencode only, excluded when filtering by agent
|
||||||
* - "adhoc": excluded from default runs, must be explicitly requested
|
* - "adhoc": excluded from default runs, must be explicitly requested
|
||||||
*
|
*
|
||||||
* by default, runs in a Docker container for isolation.
|
* runs in-process. for the GHA-like Linux container, invoke via
|
||||||
|
* `pnpm gha test/run.ts […]` (the `runtest` package script does this).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
export const actionDir = join(__dirname, "..");
|
export const actionDir = join(__dirname, "..");
|
||||||
|
|
||||||
// load .env files
|
|
||||||
config({ path: join(actionDir, ".env") });
|
config({ path: join(actionDir, ".env") });
|
||||||
config({ path: join(actionDir, "..", ".env") });
|
config({ path: join(actionDir, "..", ".env") });
|
||||||
|
|
||||||
const nodeModulesVolume = "pullfrog-action-test-node-modules";
|
|
||||||
const mcpPortBase = 49000;
|
const mcpPortBase = 49000;
|
||||||
let nextMcpPort = mcpPortBase;
|
let nextMcpPort = mcpPortBase;
|
||||||
|
|
||||||
@@ -57,25 +53,6 @@ function allocateMcpPort(): number {
|
|||||||
return port;
|
return port;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildNodeCmd(args: string[]): string {
|
|
||||||
const passArgs = args.map((arg) => `'${arg.replace(/'/g, "'\\''")}'`).join(" ");
|
|
||||||
return `node test/run.ts ${passArgs}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// run the test runner inside docker
|
|
||||||
function runTestsInDocker(args: string[]): never {
|
|
||||||
const result = runInDocker({
|
|
||||||
actionDir,
|
|
||||||
args,
|
|
||||||
nodeCmd: buildNodeCmd(args),
|
|
||||||
volumeName: nodeModulesVolume,
|
|
||||||
envFilterMode: "allowlist",
|
|
||||||
onStart: () => console.log("» running tests in docker container...\n"),
|
|
||||||
});
|
|
||||||
|
|
||||||
process.exit(result.status ?? 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
type TestInfo = {
|
type TestInfo = {
|
||||||
name: string;
|
name: string;
|
||||||
config: TestRunnerOptions;
|
config: TestRunnerOptions;
|
||||||
@@ -393,14 +370,6 @@ async function runTestForAgent(ctx: RunContext): Promise<ValidationResult> {
|
|||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
|
|
||||||
// run in Docker unless already inside
|
|
||||||
if (!isInsideDocker) {
|
|
||||||
// acquire token for docker if needed
|
|
||||||
await ensureGitHubToken();
|
|
||||||
runTestsInDocker(args);
|
|
||||||
}
|
|
||||||
|
|
||||||
// load all tests
|
|
||||||
const allTests = await loadAllTests();
|
const allTests = await loadAllTests();
|
||||||
const parsed = parseArgs(args, allTests);
|
const parsed = parseArgs(args, allTests);
|
||||||
|
|
||||||
|
|||||||
-286
@@ -1,286 +0,0 @@
|
|||||||
/**
|
|
||||||
* shared docker utilities for running commands in containers.
|
|
||||||
* used by both play.ts (dev) and test/run.ts (CI).
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { type SpawnSyncReturns, spawnSync } from "node:child_process";
|
|
||||||
import { existsSync } from "node:fs";
|
|
||||||
import { platform } from "node:os";
|
|
||||||
import { join } from "node:path";
|
|
||||||
|
|
||||||
export type DockerRunContext = {
|
|
||||||
actionDir: string;
|
|
||||||
args: string[];
|
|
||||||
platformName: NodeJS.Platform;
|
|
||||||
home: string | undefined;
|
|
||||||
env: NodeJS.ProcessEnv;
|
|
||||||
uid: number;
|
|
||||||
gid: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SshSetup = {
|
|
||||||
sshFlags: string[];
|
|
||||||
sshSetupCmd: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type DockerRunArgsContext = {
|
|
||||||
ctx: DockerRunContext;
|
|
||||||
envFlags: string[];
|
|
||||||
nodeCmd: string;
|
|
||||||
sshSetup: SshSetup;
|
|
||||||
volumeName: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type VolumeInitContext = {
|
|
||||||
actionDir: string;
|
|
||||||
volumeName: string;
|
|
||||||
uid: number;
|
|
||||||
gid: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function buildDockerRunContext(ctx: {
|
|
||||||
actionDir: string;
|
|
||||||
args: string[];
|
|
||||||
}): DockerRunContext {
|
|
||||||
return {
|
|
||||||
actionDir: ctx.actionDir,
|
|
||||||
args: ctx.args,
|
|
||||||
platformName: platform(),
|
|
||||||
home: process.env.HOME,
|
|
||||||
env: process.env,
|
|
||||||
uid: process.getuid?.() ?? 1000,
|
|
||||||
gid: process.getgid?.() ?? 1000,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function assertDockerSupported(ctx: DockerRunContext): void {
|
|
||||||
if (ctx.platformName === "win32") {
|
|
||||||
throw new Error("docker mode is not supported on native windows. use wsl2.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildDarwinSshSetup(ctx: DockerRunContext): SshSetup {
|
|
||||||
const sshFlags: string[] = [];
|
|
||||||
const sshSetupCmd = "";
|
|
||||||
if (ctx.home) {
|
|
||||||
const knownHostsPath = join(ctx.home, ".ssh", "known_hosts");
|
|
||||||
if (existsSync(knownHostsPath)) {
|
|
||||||
sshFlags.push("-v", `${knownHostsPath}:/root/.ssh/known_hosts:ro`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sshFlags.push(
|
|
||||||
"-v",
|
|
||||||
"/run/host-services/ssh-auth.sock:/run/host-services/ssh-auth.sock",
|
|
||||||
"-e",
|
|
||||||
"SSH_AUTH_SOCK=/run/host-services/ssh-auth.sock"
|
|
||||||
);
|
|
||||||
return { sshFlags, sshSetupCmd };
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildLinuxSshSetup(ctx: DockerRunContext): SshSetup {
|
|
||||||
const sshFlags: string[] = [];
|
|
||||||
let sshSetupCmd = "";
|
|
||||||
if (ctx.home) {
|
|
||||||
const sshDir = join(ctx.home, ".ssh");
|
|
||||||
if (existsSync(sshDir)) {
|
|
||||||
sshFlags.push("-v", `${sshDir}:/tmp/.ssh-host:ro`);
|
|
||||||
sshSetupCmd =
|
|
||||||
"mkdir -p /tmp/home/.ssh && cp /tmp/.ssh-host/id_* /tmp/home/.ssh/ 2>/dev/null; chmod 600 /tmp/home/.ssh/id_* 2>/dev/null; " +
|
|
||||||
"ssh-keyscan -t ed25519,rsa github.com >> /tmp/home/.ssh/known_hosts 2>/dev/null; chmod 644 /tmp/home/.ssh/known_hosts; " +
|
|
||||||
"export GIT_SSH_COMMAND='ssh -i /tmp/home/.ssh/id_rsa -o UserKnownHostsFile=/tmp/home/.ssh/known_hosts -o StrictHostKeyChecking=no'; ";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { sshFlags, sshSetupCmd };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildSshSetup(ctx: DockerRunContext): SshSetup {
|
|
||||||
if (ctx.platformName === "darwin") {
|
|
||||||
return buildDarwinSshSetup(ctx);
|
|
||||||
}
|
|
||||||
return buildLinuxSshSetup(ctx);
|
|
||||||
}
|
|
||||||
|
|
||||||
// allowlist of env vars to pass through to the container for `pnpm runtest`.
|
|
||||||
// NOTE: `pnpm play` uses "passthrough" mode and passes ALL env vars.
|
|
||||||
// if your env var isn't working with `pnpm runtest`, add it here!
|
|
||||||
// see wiki/adversarial.md for documentation.
|
|
||||||
const testEnvAllowList = new Set([
|
|
||||||
"CI",
|
|
||||||
"GITHUB_ACTIONS",
|
|
||||||
"PULLFROG_DISABLE_SECURITY_INSTRUCTIONS", // disables security messaging for pentest
|
|
||||||
"GITHUB_TOKEN",
|
|
||||||
"GH_TOKEN",
|
|
||||||
"GITHUB_REPOSITORY",
|
|
||||||
"GITHUB_APP_ID",
|
|
||||||
"GITHUB_PRIVATE_KEY",
|
|
||||||
"OPENAI_API_KEY",
|
|
||||||
"ANTHROPIC_API_KEY",
|
|
||||||
"CLAUDE_CODE_OAUTH_TOKEN",
|
|
||||||
"GEMINI_API_KEY",
|
|
||||||
"GOOGLE_GENERATIVE_AI_API_KEY",
|
|
||||||
"XAI_API_KEY",
|
|
||||||
"DEEPSEEK_API_KEY",
|
|
||||||
"OPENROUTER_API_KEY",
|
|
||||||
"MOONSHOT_API_KEY",
|
|
||||||
"OPENCODE_API_KEY",
|
|
||||||
"PULLFROG_MODEL",
|
|
||||||
"LOG_LEVEL",
|
|
||||||
"DEBUG",
|
|
||||||
"NODE_ENV",
|
|
||||||
"PLAY_LOCAL",
|
|
||||||
"HOME",
|
|
||||||
"USER",
|
|
||||||
"SSH_AUTH_SOCK",
|
|
||||||
"ACTIONS_ID_TOKEN_REQUEST_URL",
|
|
||||||
"ACTIONS_ID_TOKEN_REQUEST_TOKEN",
|
|
||||||
"GITHUB_API_URL",
|
|
||||||
"GITHUB_SERVER_URL",
|
|
||||||
"GITHUB_GRAPHQL_URL",
|
|
||||||
"GITHUB_OUTPUT",
|
|
||||||
]);
|
|
||||||
|
|
||||||
export type EnvFilterMode = "allowlist" | "passthrough";
|
|
||||||
|
|
||||||
export function buildEnvFlags(ctx: DockerRunContext, mode: EnvFilterMode): string[] {
|
|
||||||
const envFlags: string[] = [];
|
|
||||||
const entries = Object.entries(ctx.env);
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
|
||||||
const key = entry[0];
|
|
||||||
const value = entry[1];
|
|
||||||
|
|
||||||
if (value === undefined) continue;
|
|
||||||
|
|
||||||
if (mode === "passthrough" || testEnvAllowList.has(key)) {
|
|
||||||
envFlags.push("-e", `${key}=${value}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return envFlags;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function initializeNodeModulesVolume(ctx: VolumeInitContext): void {
|
|
||||||
spawnSync(
|
|
||||||
"docker",
|
|
||||||
[
|
|
||||||
"run",
|
|
||||||
"--rm",
|
|
||||||
"-v",
|
|
||||||
`${ctx.volumeName}:/app/action/node_modules`,
|
|
||||||
"node:24",
|
|
||||||
"chown",
|
|
||||||
"-R",
|
|
||||||
`${ctx.uid}:${ctx.gid}`,
|
|
||||||
"/app/action/node_modules",
|
|
||||||
],
|
|
||||||
{ stdio: "ignore", cwd: ctx.actionDir }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* escape a string for embedding in a double-quoted shell context.
|
|
||||||
* handles: backslash, double quote, dollar sign, backtick.
|
|
||||||
*/
|
|
||||||
function escapeForDoubleQuotes(str: string): string {
|
|
||||||
return str.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\$/g, "\\$").replace(/`/g, "\\`");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildDockerRunArgs(config: DockerRunArgsContext): string[] {
|
|
||||||
const args: string[] = [
|
|
||||||
"run",
|
|
||||||
"--rm",
|
|
||||||
"-t",
|
|
||||||
"--privileged", // needed for PID namespace isolation (unshare --pid)
|
|
||||||
"-v",
|
|
||||||
`${config.ctx.actionDir}:/app/action:cached`,
|
|
||||||
"-v",
|
|
||||||
`${config.volumeName}:/app/action/node_modules`,
|
|
||||||
"-w",
|
|
||||||
"/app/action",
|
|
||||||
];
|
|
||||||
args.push(...config.envFlags);
|
|
||||||
args.push(...config.sshSetup.sshFlags);
|
|
||||||
|
|
||||||
// escape nodeCmd for embedding in su -c "..." context
|
|
||||||
const escapedNodeCmd = escapeForDoubleQuotes(config.nodeCmd);
|
|
||||||
|
|
||||||
// run as root initially, setup sudo for a test user, then run tests as that user
|
|
||||||
// this simulates GHA environment where sudo is available
|
|
||||||
const setupCmd = [
|
|
||||||
// install sudo (node:24 is Debian-based) - check if already installed first
|
|
||||||
`which sudo > /dev/null 2>&1 || (apt-get update -qq && apt-get install -qq -y sudo > /dev/null 2>&1)`,
|
|
||||||
// remove any existing user/group with the same uid/gid (e.g. node:24 has "node" at 1000:1000)
|
|
||||||
`existing_user=$(getent passwd ${config.ctx.uid} | cut -d: -f1) && [ -n "$existing_user" ] && [ "$existing_user" != "testuser" ] && userdel "$existing_user" 2>/dev/null || true`,
|
|
||||||
`existing_group=$(getent group ${config.ctx.gid} | cut -d: -f1) && [ -n "$existing_group" ] && [ "$existing_group" != "testuser" ] && groupdel "$existing_group" 2>/dev/null || true`,
|
|
||||||
// create user matching host uid/gid for file permissions
|
|
||||||
`id testuser > /dev/null 2>&1 || (groupadd -g ${config.ctx.gid} testuser 2>/dev/null || true; useradd -u ${config.ctx.uid} -g ${config.ctx.gid} -m -s /bin/bash testuser 2>/dev/null || true)`,
|
|
||||||
// configure passwordless sudo (like GHA runners) - check if already configured
|
|
||||||
`grep -q "testuser ALL" /etc/sudoers 2>/dev/null || echo "testuser ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers`,
|
|
||||||
// setup directories
|
|
||||||
`mkdir -p /tmp/home/.config /tmp/home/.cache`,
|
|
||||||
`chown -R ${config.ctx.uid}:${config.ctx.gid} /tmp/home /app/action/node_modules`,
|
|
||||||
// install deps as user
|
|
||||||
`su testuser -c "corepack pnpm install --frozen-lockfile --ignore-scripts"`,
|
|
||||||
// run test as user - nodeCmd is escaped for double-quote context
|
|
||||||
`su testuser -c "${escapedNodeCmd}"`,
|
|
||||||
].join(" && ");
|
|
||||||
args.push(
|
|
||||||
"-e",
|
|
||||||
"COREPACK_ENABLE_DOWNLOAD_PROMPT=0",
|
|
||||||
"-e",
|
|
||||||
"HOME=/tmp/home",
|
|
||||||
"-e",
|
|
||||||
"TMPDIR=/tmp",
|
|
||||||
// always set CI=true in docker to enable sandbox - this is critical for security tests
|
|
||||||
// without this, PID namespace isolation is skipped and tests may pass vacuously
|
|
||||||
"-e",
|
|
||||||
"CI=true",
|
|
||||||
"node:24",
|
|
||||||
"bash",
|
|
||||||
"-c",
|
|
||||||
`${config.sshSetup.sshSetupCmd}${setupCmd}`
|
|
||||||
);
|
|
||||||
return args;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type RunInDockerOptions = {
|
|
||||||
actionDir: string;
|
|
||||||
args: string[];
|
|
||||||
nodeCmd: string;
|
|
||||||
volumeName: string;
|
|
||||||
envFilterMode: EnvFilterMode;
|
|
||||||
onStart?: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function runInDocker(options: RunInDockerOptions): SpawnSyncReturns<Buffer> {
|
|
||||||
const ctx = buildDockerRunContext({
|
|
||||||
actionDir: options.actionDir,
|
|
||||||
args: options.args,
|
|
||||||
});
|
|
||||||
assertDockerSupported(ctx);
|
|
||||||
|
|
||||||
const sshSetup = buildSshSetup(ctx);
|
|
||||||
const envFlags = buildEnvFlags(ctx, options.envFilterMode);
|
|
||||||
|
|
||||||
initializeNodeModulesVolume({
|
|
||||||
actionDir: ctx.actionDir,
|
|
||||||
volumeName: options.volumeName,
|
|
||||||
uid: ctx.uid,
|
|
||||||
gid: ctx.gid,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (options.onStart) {
|
|
||||||
options.onStart();
|
|
||||||
}
|
|
||||||
|
|
||||||
return spawnSync(
|
|
||||||
"docker",
|
|
||||||
buildDockerRunArgs({
|
|
||||||
ctx,
|
|
||||||
envFlags,
|
|
||||||
nodeCmd: options.nodeCmd,
|
|
||||||
sshSetup,
|
|
||||||
volumeName: options.volumeName,
|
|
||||||
}),
|
|
||||||
{ stdio: "inherit", cwd: ctx.actionDir }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
// in-process fixture runner used by `play.ts` (and any future host-side
|
||||||
|
// runner). does NOT know about Docker — that's `gha.ts`'s job. when run
|
||||||
|
// inside the GHA container, this is what executes after the entrypoint.
|
||||||
|
import { execSync } from "node:child_process";
|
||||||
|
import { mkdtemp } from "node:fs/promises";
|
||||||
|
import { devNull, tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import type { AgentResult } from "../agents/shared.ts";
|
||||||
|
import { type Inputs, main } from "../main.ts";
|
||||||
|
import { log } from "./cli.ts";
|
||||||
|
import { ensureGitHubToken } from "./github.ts";
|
||||||
|
import { setupTestRepo } from "./setup.ts";
|
||||||
|
|
||||||
|
export async function run(inputsOrPrompt: Inputs | string): Promise<AgentResult> {
|
||||||
|
await ensureGitHubToken();
|
||||||
|
|
||||||
|
// play.ts is a CI-emulator — isolate it from the developer's user- and
|
||||||
|
// system-scope gitconfig so checks like `validatePushDestination` see the
|
||||||
|
// raw stored remote URL instead of values mutated by `url.*.insteadOf`
|
||||||
|
// rewrites (a common SSH-auth convenience on dev boxes). CI runners have
|
||||||
|
// empty gitconfigs so this is a no-op there; locally it makes `pnpm play`
|
||||||
|
// and real runs produce identical git state. `os.devNull` canonicalizes
|
||||||
|
// the null device across Unix (`/dev/null`) and Windows (`\\.\nul`).
|
||||||
|
process.env.GIT_CONFIG_GLOBAL = devNull;
|
||||||
|
process.env.GIT_CONFIG_SYSTEM = devNull;
|
||||||
|
|
||||||
|
const tempParent = await mkdtemp(join(tmpdir(), "pullfrog-play-"));
|
||||||
|
const tempDir = join(tempParent, "repo");
|
||||||
|
const originalCwd = process.cwd();
|
||||||
|
|
||||||
|
try {
|
||||||
|
setupTestRepo({ tempDir });
|
||||||
|
process.chdir(tempDir);
|
||||||
|
|
||||||
|
// optional pre-agent setup (e.g. seed symlinks for adversarial fixtures).
|
||||||
|
if (process.env.PULLFROG_TEST_REPO_SETUP) {
|
||||||
|
log.info("» running repo setup commands...");
|
||||||
|
execSync(process.env.PULLFROG_TEST_REPO_SETUP, { cwd: tempDir, stdio: "pipe" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// tell main() to use the cloned tempDir instead of the GHA workspace path.
|
||||||
|
process.env.GITHUB_WORKSPACE = tempDir;
|
||||||
|
|
||||||
|
const inputs: Inputs =
|
||||||
|
typeof inputsOrPrompt === "string" ? { prompt: inputsOrPrompt } : inputsOrPrompt;
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(inputs)) {
|
||||||
|
if (value !== undefined && value !== null) {
|
||||||
|
process.env[`INPUT_${key.toUpperCase()}`] = String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: AgentResult = await main();
|
||||||
|
process.chdir(originalCwd);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
log.success("Action completed successfully");
|
||||||
|
return { success: true, output: result.output || undefined, error: undefined };
|
||||||
|
}
|
||||||
|
log.error(`Action failed: ${result.error || "Unknown error"}`);
|
||||||
|
return { success: false, error: result.error || undefined, output: undefined };
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = (err as Error).message;
|
||||||
|
log.error(`Error: ${errorMessage}`);
|
||||||
|
return { success: false, error: errorMessage, output: undefined };
|
||||||
|
} finally {
|
||||||
|
process.chdir(originalCwd);
|
||||||
|
// sandbox isolation may create files with non-host ownership; rmSync
|
||||||
|
// can't always delete those, so escalate.
|
||||||
|
try {
|
||||||
|
execSync(`sudo rm -rf "${tempParent}"`, { stdio: "ignore" });
|
||||||
|
} catch {
|
||||||
|
// best-effort cleanup.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user