Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 33 additions & 4 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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

# ============================================================================
Expand Down Expand Up @@ -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 && \
Expand All @@ -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-<version> && 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 \
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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:-<none>}"
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 &
Expand Down
48 changes: 48 additions & 0 deletions job-started-hook.sh
Original file line number Diff line number Diff line change
@@ -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