fix(ci): the check job installs the tools its image does not ship #147

Closed
claude-bot-andresmgsl wants to merge 2 commits from build/144-ubuntu-latest-tooling into main
4 changed files with 194 additions and 14 deletions

View file

@ -15,6 +15,27 @@ jobs:
# a skip (a guard that can quietly stop guarding is the failure # a skip (a guard that can quietly stop guarding is the failure
# shape these checks exist to refuse). # shape these checks exist to refuse).
fetch-depth: 0 fetch-depth: 0
# GitHub's `ubuntu-latest` ships shellcheck. The image this forge maps
# that label to does not, and no cheap image does: measured 2026-08-01,
# catthehacker's slim `act-22.04` and `runner-22.04` both lack it, and
# the parity image that carries it is 54.5 GB extracted — more than a
# box-class ci tenant has (#144, the numbers live beside DEFAULT_LABELS
# in commands/forgejo-runner-install.sh). So the job equips itself
# rather than the label promising a tool surface it cannot deliver.
#
# The `command -v` short-circuit is what keeps either forge from paying
# for the other: on GitHub the tool is already there and no apt runs.
# Keep the sudo — it is a no-op on the Forgejo path (act jobs run as
# uid 0, and that image has no `runner` account at all) and load-bearing
# on the GitHub path, where the job runs as `runner` with passwordless
# sudo. It reads like dead weight and is not: it is what keeps this
# green on GitHub the day that image stops preinstalling shellcheck.
- name: shellcheck — preinstalled on GitHub, installed here
run: |
command -v shellcheck >/dev/null || {
sudo apt-get update && sudo apt-get install -y shellcheck
}
shellcheck --version
- name: shellcheck - name: shellcheck
# -x follows the `source=SCRIPTDIR/...` directives into commands/lib/. # -x follows the `source=SCRIPTDIR/...` directives into commands/lib/.
# globstar so a script in a new subdirectory is linted without anyone # globstar so a script in a new subdirectory is linted without anyone

7
changelog.d/144.md Normal file
View file

@ -0,0 +1,7 @@
### Fixed
- CI's `check` job installs `shellcheck` where the image lacks it, so a runner on rig's defaults runs rig's own workflows (#144)
### Added
- `ubuntu-latest-full` in the default runner label map — GitHub's full tool surface, pulled only when a job asks for it (#144)

View file

@ -27,11 +27,65 @@ die() { printf 'rig-forgejo-runner: ERROR: %s\n' "$1" >&2; exit "${2:-1}"; }
# editing to migrate; catthehacker's image is the act/Forgejo ecosystem's # editing to migrate; catthehacker's image is the act/Forgejo ecosystem's
# stand-in for GitHub's runner image. `docker` is the lean second option. # stand-in for GitHub's runner image. `docker` is the lean second option.
# #
# Both are `docker://` — jobs run in CONTAINERS on the box's own dockerd, not # Every entry is `docker://` — jobs run in CONTAINERS on the box's own dockerd,
# on the box itself. No docker-in-docker: the guide this came from stacks a # not on the box itself. No docker-in-docker: the guide this came from stacks a
# privileged dind sidecar with a plaintext tcp://…:2375 daemon to isolate jobs # privileged dind sidecar with a plaintext tcp://…:2375 daemon to isolate jobs
# from a shared CI server, and inside a box that boundary is already paid for. # from a shared CI server, and inside a box that boundary is already paid for.
DEFAULT_LABELS='ubuntu-latest:docker://ghcr.io/catthehacker/ubuntu:act-22.04,docker:docker://node:22-bookworm' #
# `ubuntu-latest` is the SLIM act image and does not carry GitHub's tool
# surface. That is a deliberate choice, ruled on 2026-08-01 (#144) after rig's
# own `ci / check` failed 9 times on `shellcheck: command not found`. The
# measurement, so nobody re-litigates it from the tag names:
#
# image on the wire extracted shellcheck
# act-22.04 (this one) 0.55 GB 2.2 GB no
# runner-22.04 0.55 GB 2.2 GB no <- not a middle
# full-22.04 18.67 GB 54.52 GB yes
#
# There is no cheap parity image: `runner-22.04` sounds like one and is the
# same slim class, tool for tool. Parity is 54.52 GB or nothing, and a
# box-class ci tenant does not have it — so mapping `ubuntu-latest` to
# `full-22.04` would be a default that cannot land on the host class it is
# for. `apt-get install -y shellcheck` takes 7s and yields the SAME
# ShellCheck 0.8.0 that `full-22.04` ships, so parity buys no newer tool
# either.
#
# The rule that follows, and the one to state when a workflow surprises
# someone: on this forge a workflow must not assume tools from the image.
# `ubuntu-latest` means "a Linux container that runs GitHub-shaped
# workflows", not "GitHub's runner image" — rig's own .github/workflows/ci.yml
# installs what it uses, and that is the pattern to copy.
#
# `ubuntu-latest-full` is the escape hatch for anyone who does want the whole
# GitHub tool surface. A label mapping pulls NOTHING until a job matches it,
# so shipping it by default costs a box that never says `runs-on:
# ubuntu-latest-full` exactly nothing — and it must ship by default, because
# Forgejo freezes labels at registration and a label absent at that moment
# cannot be added later without re-registering the runner. Budget ~120 GB of
# disk on any box that intends to use it.
DEFAULT_LABELS='ubuntu-latest:docker://ghcr.io/catthehacker/ubuntu:act-22.04,ubuntu-latest-full:docker://ghcr.io/catthehacker/ubuntu:full-22.04,docker:docker://node:22-bookworm'
# Every label map rig has ever shipped as its DEFAULT, oldest first. Append the
# outgoing string here whenever DEFAULT_LABELS changes; never edit or remove a
# row, because the whole point is to recognise a runner registered long ago.
#
# This exists so a plain converge can tell "registered under an older rig" from
# "the operator chose these labels" — the second must stay silent, and nothing
# on disk distinguishes them except the string itself.
SUPERSEDED_DEFAULTS=(
# pre-#144: no ubuntu-latest-full escape hatch
'ubuntu-latest:docker://ghcr.io/catthehacker/ubuntu:act-22.04,docker:docker://node:22-bookworm'
)
# labels_are_a_superseded_default <recorded> — true when the recorded map is one
# rig itself used to ship. Exact match only: a near-miss is an operator's map.
labels_are_a_superseded_default() {
local recorded="$1" past
for past in "${SUPERSEDED_DEFAULTS[@]}"; do
[ "$recorded" = "$past" ] && return 0
done
return 1
}
# fetch_and_verify_sha256 <asset-url> <file> <sumfile> <label> # fetch_and_verify_sha256 <asset-url> <file> <sumfile> <label>
# #
@ -410,12 +464,28 @@ if [ -e "$RUNNER_DIR/.runner" ]; then
# Registration was skipped, so the labels on the instance are the ones it was # Registration was skipped, so the labels on the instance are the ones it was
# registered with — NOT whatever this invocation was passed. Say so when the # registered with — NOT whatever this invocation was passed. Say so when the
# operator explicitly asked for different ones, rather than letting the # operator explicitly asked for different ones, rather than letting the
# request evaporate. Only when EXPLICIT: comparing the default against a # request evaporate.
# runner registered with custom labels would warn on every plain converge. if [ -r "$RUNNER_DIR/.rig-labels" ]; then
if [ "$LABELS_EXPLICIT" -eq 1 ] && [ -r "$RUNNER_DIR/.rig-labels" ]; then
RECORDED="$(cat "$RUNNER_DIR/.rig-labels")" RECORDED="$(cat "$RUNNER_DIR/.rig-labels")"
if [ "$RECORDED" != "$LABELS" ]; then if [ "$LABELS_EXPLICIT" -eq 1 ]; then
warn "--labels was not applied: this runner is already registered, and Forgejo owns its labels from registration time. It still has: ${RECORDED}. Labels are what 'runs-on' matches, so changing them means re-registering: 'rig forgejo-runner remove' then install again with the labels you want." if [ "$RECORDED" != "$LABELS" ]; then
warn "--labels was not applied: this runner is already registered, and Forgejo owns its labels from registration time. It still has: ${RECORDED}. Labels are what 'runs-on' matches, so changing them means re-registering: 'rig forgejo-runner remove' then install again with the labels you want."
fi
elif labels_are_a_superseded_default "$RECORDED"; then
# A plain converge on a runner registered before the defaults moved (the
# #144 mapping change is the first). Silence here is what let a runner
# keep serving a stale map while every re-run reported success.
#
# It warns on a SUPERSEDED DEFAULT rather than on "anything that is not
# the current default", which would fire on every converge of a runner
# the operator deliberately gave --labels — noise, not drift, and the
# reason the old code only spoke when EXPLICIT. Matching known past
# defaults exactly is what tells those two apart.
#
# Deliberately not an error and deliberately not alarming about CI: a
# runner on the old default still runs jobs, and under #144's ruling it
# still runs them GREEN. Re-registering buys the new LABELS, nothing else.
warn "this runner registered with an older rig default label map, and Forgejo owns labels from registration time — it still has: ${RECORDED}. Nothing is broken: jobs keep running, and 'runs-on: ubuntu-latest' still works. Re-register only if you want the labels added since: 'rig forgejo-runner remove' then install again. Current default: ${DEFAULT_LABELS}"
fi fi
fi fi
else else

View file

@ -3349,12 +3349,25 @@ check "forgejo-runner: --version refuses a path, not a release number" 2 "releas
"$FR" --instance https://f.example.com --version ../../etc/passwd "$FR" --instance https://f.example.com --version ../../etc/passwd
check "forgejo-runner: --version refuses a non-numeric pin" 2 "release number like" \ check "forgejo-runner: --version refuses a non-numeric pin" 2 "release number like" \
"$FR" --instance https://f.example.com --version latest "$FR" --instance https://f.example.com --version latest
# Reaching the root check is the proof a good pin got THROUGH validation: this # Reaching the gate BELOW validation is the proof a good pin got THROUGH it.
# runs as a normal user in CI, so "must run as root" is the next gate down. # Which gate that is depends on who runs the suite, and both readings are
check "forgejo-runner: a plain release number passes validation" 1 "must run as root" \ # equally good evidence: a normal user hits "must run as root", while root
"$FR" --instance https://f.example.com --version 12.13.2 # itself gets further, to the token prompt. Asserting only the first made these
check "forgejo-runner: a leading v is stripped before that check" 1 "must run as root" \ # two checks depend on the account the suite happened to run under — #136's
"$FR" --instance https://f.example.com --version v12.13.2 # class again, surfaced by the Forgejo runner, which runs job containers as
# uid 0 where GitHub's runner is the unprivileged `runner` user. Either way the
# exit is 1 and validation is behind us, which is the whole claim (#144).
past_version_validation() {
local out rc
out="$("$FR" --instance https://f.example.com --version "$1" 2>&1)"; rc=$?
[ "$rc" -eq 1 ] || { printf 'exit %s, wanted 1\n' "$rc"; return 1; }
printf '%s' "$out" | grep -qE 'must run as root|FORGEJO_RUNNER_TOKEN is unset' \
|| { printf 'neither gate below validation was reached:\n%s\n' "$out"; return 1; }
}
check "forgejo-runner: a plain release number passes validation" 0 "" \
past_version_validation 12.13.2
check "forgejo-runner: a leading v is stripped before that check" 0 "" \
past_version_validation v12.13.2
# A schemeless host and a repo URL are the two ways an operator mis-states the # A schemeless host and a repo URL are the two ways an operator mis-states the
# instance, and only one of them would fail loudly on its own — a repo URL # instance, and only one of them would fail loudly on its own — a repo URL
# registers somewhere subtly wrong instead. Both refuse by name. # registers somewhere subtly wrong instead. Both refuse by name.
@ -3413,6 +3426,75 @@ check "forgejo-runner: …and the install creates it, owned by the runner user"
check "forgejo-runner: ProtectHome stays read-only (the cache is not an excuse to widen it)" 0 "" \ check "forgejo-runner: ProtectHome stays read-only (the cache is not an excuse to widen it)" 0 "" \
grep -qF 'ProtectHome=read-only' "$FRI" grep -qF 'ProtectHome=read-only' "$FRI"
# #144: rig's own `ci / check` failed 9 times on `shellcheck: command not found`
# because `ubuntu-latest` maps to the SLIM act image. The ruling kept the slim
# image and made workflows equip themselves, so what has to stay pinned is the
# mapping AND the reason — the next reader who sees "ubuntu-latest → slim" with
# no explanation re-opens the same question and re-measures the same images.
check "forgejo-runner: ubuntu-latest maps to the slim act image" 0 "" \
grep -qF 'ubuntu-latest:docker://ghcr.io/catthehacker/ubuntu:act-22.04' "$FRI"
# The escape hatch must ship BY DEFAULT or it cannot be had at all: Forgejo
# freezes labels at registration, so a label absent then needs a re-register.
check "forgejo-runner: …and ubuntu-latest-full ships beside it as the escape hatch" 0 "" \
grep -qF 'ubuntu-latest-full:docker://ghcr.io/catthehacker/ubuntu:full-22.04' "$FRI"
# The measured cost is the whole argument for the slim default. Pinned so a
# tidy-up cannot delete the evidence and leave the choice looking arbitrary.
check "forgejo-runner: the extracted-size measurement is recorded beside the map" 0 "" \
grep -qF '54.52 GB' "$FRI"
check "forgejo-runner: …including that runner-22.04 is not a cheaper middle" 0 "" \
grep -qF 'runner-22.04' "$FRI"
# The rule a consumer needs, in the file that sets the expectation.
check "forgejo-runner: the map states workflows must not assume image tools" 0 "" \
grep -qF 'must not assume tools from the image' "$FRI"
# The stale-default warning fires on a SUPERSEDED default, never on labels the
# operator chose — that distinction is the whole reason the old code stayed
# silent on plain converges, and losing it turns the warning into noise.
check "forgejo-runner: past defaults are remembered so drift can be recognised" 0 "" \
grep -qF 'SUPERSEDED_DEFAULTS=(' "$FRI"
check "forgejo-runner: the pre-#144 default is one of them" 0 "" \
grep -qF "'ubuntu-latest:docker://ghcr.io/catthehacker/ubuntu:act-22.04,docker:docker://node:22-bookworm'" "$FRI"
# It must not read as "your CI is broken": under #144's ruling a runner on the
# old map still runs jobs, and still runs them green.
check "forgejo-runner: the stale-default warning says nothing is broken" 0 "" \
grep -qF 'Nothing is broken' "$FRI"
# …and the recogniser is DRIVEN, not grepped — a list of past defaults is only
# worth having if the match is exact. Extraction pattern is test/drill.sh's:
# the shipped bytes are awk-extracted, with a guard on the boundary.
SD_FNS="$(mktemp -d)/superseded.sh"
awk '/^SUPERSEDED_DEFAULTS=\(/,/^\)/' "$FRI" > "$SD_FNS"
awk '/^labels_are_a_superseded_default\(\) \{/,/^\}/' "$FRI" >> "$SD_FNS"
check "extraction guards the awk: SUPERSEDED_DEFAULTS landed" 0 "SUPERSEDED_DEFAULTS=(" \
grep -F 'SUPERSEDED_DEFAULTS=(' "$SD_FNS"
check "extraction guards the awk: the recogniser landed" 0 "labels_are_a_superseded_default() {" \
grep -F 'labels_are_a_superseded_default() {' "$SD_FNS"
# shellcheck source=/dev/null
. "$SD_FNS"
PRE_144='ubuntu-latest:docker://ghcr.io/catthehacker/ubuntu:act-22.04,docker:docker://node:22-bookworm'
CURRENT='ubuntu-latest:docker://ghcr.io/catthehacker/ubuntu:act-22.04,ubuntu-latest-full:docker://ghcr.io/catthehacker/ubuntu:full-22.04,docker:docker://node:22-bookworm'
check "superseded: a runner on the pre-#144 default is recognised" 0 "" \
labels_are_a_superseded_default "$PRE_144"
# The current default is not drift — warning on it would fire on every converge
# of every up-to-date runner.
check "superseded: the CURRENT default is not drift" 1 "" \
labels_are_a_superseded_default "$CURRENT"
# The whole reason this is a list of exact strings rather than "≠ the default":
# a map the operator chose must stay silent forever.
check "superseded: an operator's own --labels map is never called drift" 1 "" \
labels_are_a_superseded_default 'drill:docker://node:22-bookworm'
check "superseded: a near-miss of a past default is not a match either" 1 "" \
labels_are_a_superseded_default "${PRE_144} "
# rig's own ci.yml is the worked example of that rule: it installs shellcheck
# only when the image lacks it. Both halves are pinned — drop the `command -v`
# and every GitHub run pays an apt round-trip; drop the sudo and the GitHub
# path breaks the day that image stops preinstalling the tool.
CIYML="$ROOT/.github/workflows/ci.yml"
check "ci.yml: check installs shellcheck when the image does not ship it" 0 "" \
grep -qF 'sudo apt-get install -y shellcheck' "$CIYML"
check "ci.yml: …only when absent, so GitHub pays nothing for it" 0 "" \
grep -qF 'command -v shellcheck >/dev/null ||' "$CIYML"
check "forgejo-runner: status --help exits 0" 0 "usage:" "$ROOT/commands/forgejo-runner-status.sh" --help check "forgejo-runner: status --help exits 0" 0 "usage:" "$ROOT/commands/forgejo-runner-status.sh" --help
check "forgejo-runner: remove --help exits 0" 0 "usage:" "$ROOT/commands/forgejo-runner-remove.sh" --help check "forgejo-runner: remove --help exits 0" 0 "usage:" "$ROOT/commands/forgejo-runner-remove.sh" --help