diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..49b6483 --- /dev/null +++ b/.dockerignore @@ -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/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..07f6c8a --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100755 index 0000000..2e97063 --- /dev/null +++ b/docker-entrypoint.sh @@ -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 "$@" diff --git a/gha.ts b/gha.ts new file mode 100644 index 0000000..63e97bf --- /dev/null +++ b/gha.ts @@ -0,0 +1,494 @@ +// run any node script inside the pullfrog GHA-like container. +// +// usage: +// pnpm gha