diff --git a/Dockerfile b/Dockerfile index f3bc288..063a81c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -62,13 +62,17 @@ RUN curl --proto '=https' --tlsv1.2 -sSf https://just.systems/install.sh | bash # ============================================================================ # Install Pkl (Apple's configuration language - used by canvas) # ============================================================================ +# Version matches what bender-driver/dti-fsic-driver/vehicle-message-definitions +# actually pin in their "Install PKL CLI" workflow step (verified against those +# repos' ci.yml, not assumed) -- see the useradd block below for why this alone +# does not fix those workflows' install step. RUN ARCH="${TARGETARCH:-$(dpkg --print-architecture)}" && \ case "$ARCH" in \ amd64) PKL_ARCH=amd64 ;; \ arm64) PKL_ARCH=aarch64 ;; \ *) echo "Unsupported architecture: $ARCH" >&2; exit 1 ;; \ esac && \ - curl -fL -o /usr/local/bin/pkl "https://github.com/apple/pkl/releases/download/0.30.1/pkl-linux-${PKL_ARCH}" && \ + curl -fL -o /usr/local/bin/pkl "https://github.com/apple/pkl/releases/download/0.31.1/pkl-linux-${PKL_ARCH}" && \ chmod +x /usr/local/bin/pkl # ============================================================================ @@ -197,10 +201,12 @@ RUN ARCH="${TARGETARCH:-$(dpkg --print-architecture)}" && \ rm -rf /tmp/sccache.tar.gz "/tmp/${SCCACHE_PKG}" && \ sccache --version -# Copy entrypoint script and the post-job sweep hook +# Copy entrypoint script, the post-job sweep hook, and the pre-job gitconfig +# reset hook COPY entrypoint.sh /entrypoint.sh COPY job-completed-hook.sh /usr/local/bin/job-completed-hook.sh -RUN chmod +x /entrypoint.sh /usr/local/bin/job-completed-hook.sh +COPY job-started-hook.sh /usr/local/bin/job-started-hook.sh +RUN chmod +x /entrypoint.sh /usr/local/bin/job-completed-hook.sh /usr/local/bin/job-started-hook.sh # Create a non-root user and copy tools RUN useradd -m runner && \ @@ -226,7 +232,30 @@ RUN useradd -m runner && \ # Add source export-esp.sh to runner's bashrc echo 'source $HOME/export-esp.sh 2>/dev/null || true' >> /home/runner/.bashrc && \ # Fix ownership - chown -R runner:runner /home/runner + chown -R runner:runner /home/runner && \ + # Several consuming repos' workflows self-install a version-pinned tool by + # curling a binary straight into /usr/local/bin and chmod +x-ing it -- e.g. + # bender-driver/dti-fsic-driver/vehicle-message-definitions all run: + # curl -L -o /usr/local/bin/pkl https://.../pkl- && chmod +x ... + # On a GitHub-hosted runner this succeeds because the job owns the whole VM. + # Here it hits EACCES: /usr/local/bin is root:root 0755 from the apt/curl + # installs above, and `curl -o` truncates the EXISTING pkl binary in place + # (an open() with O_TRUNC), which needs write on that file's inode, not just + # search/exec on the directory. A PATH-based redirect (e.g. exporting a + # writable $RUNNER_TEMP/bin) cannot fix this: the destination is a literal + # absolute path in those workflows, not something resolved via PATH, and + # editing every consuming repo's workflow is exactly the per-repo workaround + # this fleet's image is meant to avoid. So the directory itself has to + # become writable by the user that actually runs jobs. + # + # chown rather than chmod a+w to match this file's own idiom (chown -R + # runner:runner appears twice above) instead of leaving a world-writable + # system directory. /usr/local/bin holds nothing but the tools this image + # installs (just, pkl, uv/maturin, sccache, bun -- no apt package puts + # anything here), so handing it to runner does not touch anything owned by + # another principal, and the runner user already executes arbitrary job + # code with far broader access than this. + chown -R runner:runner /usr/local/bin # Environment variables for runner user ENV RUSTUP_HOME=/home/runner/.rustup \ diff --git a/README.md b/README.md index 318003d..d5ad604 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,9 @@ This runner includes all tools required for the firmware CI pipeline: ### Build Tools - **just** - Command runner used by the firmware project -- **Pkl** (v0.29.1) - Apple's configuration language (used by canvas) +- **Pkl** (v0.31.1) - Apple's configuration language (used by canvas); `/usr/local/bin` + is writable by the `runner` user so consuming workflows can self-install a + different pinned version without hitting `EACCES` - **maturin** - Build Python wheels from Rust code ### Python diff --git a/entrypoint.sh b/entrypoint.sh index 8d0efe1..9aef3cb 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -328,8 +328,19 @@ fi export ACTIONS_RUNNER_HOOK_JOB_COMPLETED=/usr/local/bin/job-completed-hook.sh export SWEEP_MAX_GB="${SWEEP_MAX_GB:-4}" +# Reset $HOME/.gitconfig before every job. Canvas-consuming repos' shared setup +# snippet writes a git `insteadOf` rewrite with `git config --global set` (then +# `--add`), which is safe on an ephemeral GitHub-hosted runner but accumulates +# in this container's persistent $HOME/.gitconfig job after job until a later +# `set` call hits an already multi-valued key and fails outright. See +# job-started-hook.sh for why this is a job-STARTED hook rather than only +# living in job-completed-hook.sh: it must run regardless of whether the +# previous job finished, was cancelled, or was killed. +export ACTIONS_RUNNER_HOOK_JOB_STARTED=/usr/local/bin/job-started-hook.sh + echo "Cargo: CARGO_INCREMENTAL=${CARGO_INCREMENTAL} CARGO_PROFILE_DEV_DEBUG=${CARGO_PROFILE_DEV_DEBUG} RUSTC_WRAPPER=${RUSTC_WRAPPER:-}" echo "Sweep: target/ budget ${SWEEP_MAX_GB} GB per replica, enforced after each job" +echo "Gitconfig: reset to a clean baseline before each job (ACTIONS_RUNNER_HOOK_JOB_STARTED)" echo "Starting runner..." gosu runner ./run.sh & diff --git a/job-started-hook.sh b/job-started-hook.sh new file mode 100644 index 0000000..41bacef --- /dev/null +++ b/job-started-hook.sh @@ -0,0 +1,48 @@ +#!/bin/bash +# Runs before EVERY job on this replica, via ACTIONS_RUNNER_HOOK_JOB_STARTED. +# +# Why this exists +# ---------------- +# A shared setup snippet used across canvas-consuming repos (bender-driver, +# dti-fsic-driver, vehicle-message-definitions, and others being migrated onto +# this fleet) configures a git `insteadOf` rewrite with +# `git config --global set` (then `--add` for a second value under the same +# key). On an ephemeral GitHub-hosted runner this is harmless: the VM, and +# $HOME/.gitconfig with it, is destroyed the moment the job ends. +# +# On this fleet the container -- and therefore the runner user's +# $HOME/.gitconfig -- outlives any one job, so values written by `--add` +# accumulate under the same key across every job a replica has ever run. A +# later job's plain `set` call then collides with an already multi-valued key +# and the whole step fails with: +# error: cannot overwrite multiple values with a single value +# This is invisible in any single job and only shows up after a replica has +# served enough canvas-consuming jobs to pile up a second value -- which is +# exactly what surfaced once bender-driver/dti-fsic-driver/ +# vehicle-message-definitions started sharing this fleet with firmware/hbf. +# +# Why a job-STARTED hook, and not (only) job-completed-hook.sh +# -------------------------------------------------------------- +# job-completed-hook.sh (see its own header) only runs after a job finishes +# normally. A cancelled, timed-out, or forcibly-killed job skips it entirely, +# and that job's accumulated $HOME/.gitconfig survives into the next one -- +# exactly the collision this hook exists to prevent. A job-STARTED hook runs +# before every job regardless of how the PREVIOUS job ended, so it is the only +# placement that actually closes the gap rather than narrowing it. +# +# What "clean baseline" means here +# --------------------------------- +# Nothing in this image's build or entrypoint.sh ever writes to +# $HOME/.gitconfig for the runner user -- verified by grep, not assumed -- so +# the baseline a freshly created container starts with is simply the file's +# absence. This hook reproduces exactly that, every time, rather than trying +# to selectively undo just the insteadOf rewrite (which would have to know +# every key any consuming repo's setup snippet might someday add). +set -uo pipefail + +rm -f "${HOME:-/home/runner}/.gitconfig" + +# Never fail the job. Like job-completed-hook.sh, this runs adjacent to work +# that must not be put at risk by a cleanup step -- a non-zero exit here would +# fail the job it is meant to protect. +exit 0