fix(ci): the check job installs the tools its image does not ship #147
4 changed files with 194 additions and 14 deletions
21
.github/workflows/ci.yml
vendored
21
.github/workflows/ci.yml
vendored
|
|
@ -15,6 +15,27 @@ jobs:
|
|||
# a skip (a guard that can quietly stop guarding is the failure
|
||||
# shape these checks exist to refuse).
|
||||
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
|
||||
# -x follows the `source=SCRIPTDIR/...` directives into commands/lib/.
|
||||
# globstar so a script in a new subdirectory is linted without anyone
|
||||
|
|
|
|||
7
changelog.d/144.md
Normal file
7
changelog.d/144.md
Normal 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)
|
||||
|
|
@ -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
|
||||
# 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
|
||||
# on the box itself. No docker-in-docker: the guide this came from stacks a
|
||||
# Every entry is `docker://` — jobs run in CONTAINERS on the box's own dockerd,
|
||||
# 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
|
||||
# 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>
|
||||
#
|
||||
|
|
@ -410,12 +464,28 @@ if [ -e "$RUNNER_DIR/.runner" ]; then
|
|||
# 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
|
||||
# operator explicitly asked for different ones, rather than letting the
|
||||
# request evaporate. Only when EXPLICIT: comparing the default against a
|
||||
# runner registered with custom labels would warn on every plain converge.
|
||||
if [ "$LABELS_EXPLICIT" -eq 1 ] && [ -r "$RUNNER_DIR/.rig-labels" ]; then
|
||||
# request evaporate.
|
||||
if [ -r "$RUNNER_DIR/.rig-labels" ]; then
|
||||
RECORDED="$(cat "$RUNNER_DIR/.rig-labels")"
|
||||
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."
|
||||
if [ "$LABELS_EXPLICIT" -eq 1 ]; then
|
||||
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
|
||||
else
|
||||
|
|
|
|||
94
test/cli.sh
94
test/cli.sh
|
|
@ -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
|
||||
check "forgejo-runner: --version refuses a non-numeric pin" 2 "release number like" \
|
||||
"$FR" --instance https://f.example.com --version latest
|
||||
# Reaching the root check is the proof a good pin got THROUGH validation: this
|
||||
# runs as a normal user in CI, so "must run as root" is the next gate down.
|
||||
check "forgejo-runner: a plain release number passes validation" 1 "must run as root" \
|
||||
"$FR" --instance https://f.example.com --version 12.13.2
|
||||
check "forgejo-runner: a leading v is stripped before that check" 1 "must run as root" \
|
||||
"$FR" --instance https://f.example.com --version v12.13.2
|
||||
# Reaching the gate BELOW validation is the proof a good pin got THROUGH it.
|
||||
# Which gate that is depends on who runs the suite, and both readings are
|
||||
# equally good evidence: a normal user hits "must run as root", while root
|
||||
# itself gets further, to the token prompt. Asserting only the first made these
|
||||
# two checks depend on the account the suite happened to run under — #136's
|
||||
# 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
|
||||
# instance, and only one of them would fail loudly on its own — a repo URL
|
||||
# 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 "" \
|
||||
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: remove --help exits 0" 0 "usage:" "$ROOT/commands/forgejo-runner-remove.sh" --help
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue