diff --git a/README.md b/README.md index b298cf7..38ed5bb 100644 --- a/README.md +++ b/README.md @@ -1094,12 +1094,28 @@ the machine. Here it is a disposable guest. Same trade, different box, opposite answer — which is why these are two commands and not one with a `--forge` flag. - `--version ` — `forgejo-runner` release (default: latest at install - time). The published `.sha256` is **verified before the binary is - installed**; a mismatch refuses. + time). **Convergent, and it replaces an existing binary** — including + downward, because a pin is an instruction rather than a floor. Without a + pin, a binary already on the box is left alone: chasing "latest" on every + converge would make a plain re-run an unrequested upgrade. + The published `.sha256` is **verified before the binary is installed**, and + a missing or unreadable checksum **refuses** just as a mismatch does — this + binary runs as root under a systemd unit, so the gate does not fail open. + + > Unlike `rig runner install`, presence alone is not enough to skip the + > download here. That command can skip because `actions/runner` + > **self-updates**; `forgejo-runner` does not, so nothing else would ever + > move the version — and a ci-box's template preinstalls the binary at mint, + > which would leave `--version` doing nothing on the exact path this command + > is for. - `--name ` — runner name (default: this host's hostname) - `--labels ` — replaces the default map: `ubuntu-latest:docker://ghcr.io/catthehacker/ubuntu:act-22.04,docker:docker://node:22-bookworm`, - so `runs-on: ubuntu-latest` works in a workflow written for GitHub + so `runs-on: ubuntu-latest` works in a workflow written for GitHub. + **Applied at registration only.** Forgejo owns a runner's labels from the + moment it registers, so passing `--labels` to a re-run cannot change them — + rig says so rather than letting the request evaporate, and changing labels + means `remove` then `install` again - `--user ` — service user (default: `ci` when it exists, else `forgejo-runner`) diff --git a/commands/forgejo-runner-install.sh b/commands/forgejo-runner-install.sh index 723baa9..f6ce3d6 100755 --- a/commands/forgejo-runner-install.sh +++ b/commands/forgejo-runner-install.sh @@ -79,6 +79,12 @@ INSTANCE="" VERSION="" RUNNER_NAME="$(hostname)" LABELS="$DEFAULT_LABELS" +# Whether --labels was ASKED FOR, distinct from what it resolved to. A rerun +# cannot apply labels (Forgejo owns them from registration time), and the +# difference between "operator requested a change" and "operator passed +# nothing" is what separates a warning worth printing from noise on every +# converge. +LABELS_EXPLICIT=0 RUNNER_USER="" while [ $# -gt 0 ]; do case "$1" in @@ -93,7 +99,7 @@ while [ $# -gt 0 ]; do RUNNER_NAME="$2"; shift 2 ;; --labels) [ $# -ge 2 ] || die "--labels needs a value" 2 - LABELS="$2"; shift 2 ;; + LABELS="$2"; LABELS_EXPLICIT=1; shift 2 ;; --user) [ $# -ge 2 ] || die "--user needs a value" 2 RUNNER_USER="$2"; shift 2 ;; @@ -207,9 +213,52 @@ fi # Forgejo publishes BARE BINARIES (not a tarball) with a .sha256 beside each # one. Taking that checksum is nearly free and makes the install auditable — # the same instinct as `coolify install`'s mandatory version pin. +# +# "Already present" is NOT enough to skip here, and this is where the GitHub +# sibling's shape must not be copied. Its skip is justified by "self-update +# owns upgrades" — actions/runner updates itself, and GitHub refuses jobs from +# stale runners, so freezing it would be pointless. **forgejo-runner does not +# self-update.** Nothing else ever moves the version, so a bare presence check +# would mean the binary a box first happened to get is the binary it keeps +# forever. +# +# That lands hardest on the path this command is FOR: a ci-box's template +# install.sh preinstalls /usr/local/bin/forgejo-runner at mint, so the +# executable always exists before an operator ever runs this — and --version, +# documented as the deterministic-pin lever, would silently do nothing on +# every ci-box in the fleet. +# +# So: converge toward --version when it is given, exactly as this command +# converges toward --instance. A pin is not a trust boundary the way an +# instance is (that one refuses), it is an instruction — including downward, +# which is what a pin is for. Absent a pin, an existing binary is left alone: +# chasing "latest" on every converge would make a re-run an unrequested +# upgrade, and convergence must not be a moving target. + +# runner_version_of / runner_download_decision live in the lib, so the rule can +# be driven by test/cli.sh without root — see there for the full reasoning. +PRESENT_VER="" +HAVE_BIN=no if [ -x "$BIN" ]; then - log "forgejo-runner binary already present at ${BIN}; skipping download" -else + HAVE_BIN=yes + PRESENT_VER="$(runner_version_of "$BIN")" +fi +case "$(runner_download_decision "$HAVE_BIN" "$PRESENT_VER" "$VERSION")" in + skip) + NEED_DOWNLOAD=0 + if [ -n "$VERSION" ]; then + log "forgejo-runner ${VERSION} already installed; skipping download" + else + log "forgejo-runner ${PRESENT_VER:-(version unreadable)} already present at ${BIN}; skipping download (pass --version to converge to a specific release)" + fi ;; + converge) + NEED_DOWNLOAD=1 + log "converging ${BIN}: ${PRESENT_VER:-unreadable} -> ${VERSION} (--version)" ;; + *) + NEED_DOWNLOAD=1 ;; +esac + +if [ "$NEED_DOWNLOAD" -eq 1 ]; then case "$(uname -m)" in x86_64) ARCH="amd64" ;; aarch64) ARCH="arm64" ;; @@ -237,27 +286,54 @@ else log "downloading forgejo-runner ${VERSION} (${ARCH})" curl -fsSL "$URL" -o "$WORKDIR/forgejo-runner" \ || die "could not download ${URL}" - if curl -fsSL "${URL}.sha256" -o "$WORKDIR/forgejo-runner.sha256" 2>/dev/null; then - # The published .sha256 names the asset, not our temp path. Compare the - # digest itself rather than rewriting the file into sha256sum -c's format: - # one comparison, no parsing of a file we did not write. - WANT="$(tr -d '\r' < "$WORKDIR/forgejo-runner.sha256" | awk '{print $1}' | head -n1)" - GOT="$(sha256sum "$WORKDIR/forgejo-runner" | awk '{print $1}')" - [ -n "$WANT" ] || die "the published checksum for ${ASSET} is unreadable — refusing to install an unverified binary" - [ "$WANT" = "$GOT" ] \ - || die "checksum mismatch for ${ASSET}: published ${WANT}, downloaded ${GOT} — refusing to install" - log "checksum verified (${GOT})" - else - # A warning, not a refusal: rig should not become unable to install because - # upstream changed its asset layout. But it must be LOUD — an operator who - # needs a verified install has to know this one was not. - warn "no published .sha256 for ${ASSET} — installing WITHOUT checksum verification" - fi - install -m 0755 -o root -g root "$WORKDIR/forgejo-runner" "$BIN" + # THE CHECKSUM IS A GATE, NOT A COURTESY — an unfetchable one refuses. + # + # This previously warned and installed anyway, reasoning that rig should not + # become unable to install if upstream changed its asset layout. That reasons + # about the wrong failure. The binary lands as root and is executed by a + # systemd unit, and the two ways the checksum can go missing are: + # + # - upstream moved the assets — in which case the BINARY url moved too, and + # the download above would already have died. A layout change does not + # present as "binary yes, checksum no". + # - something is interfering with the fetch — which is precisely the case + # the checksum exists to catch. + # + # So the asymmetry is itself the signal: same origin, same release tag, one + # answers and one does not. Failing open there hands an unverified root + # install to anyone who can block a single URL. There is deliberately no + # bypass flag: if upstream really does change layout, that is a rig PR + # editing the URL above, not an operator improvising past a security gate. + curl -fsSL "${URL}.sha256" -o "$WORKDIR/forgejo-runner.sha256" 2>/dev/null \ + || die "no published .sha256 for ${ASSET} at ${URL}.sha256 — refusing to install an unverified binary that runs as root. The binary itself downloaded, so this is not an upstream layout change; check what is intercepting the fetch." + # The published .sha256 names the asset, not our temp path. Compare the + # digest itself rather than rewriting the file into sha256sum -c's format: + # one comparison, no parsing of a file we did not write. + WANT="$(tr -d '\r' < "$WORKDIR/forgejo-runner.sha256" | awk '{print $1}' | head -n1)" + GOT="$(sha256sum "$WORKDIR/forgejo-runner" | awk '{print $1}')" + [ -n "$WANT" ] || die "the published checksum for ${ASSET} is unreadable — refusing to install an unverified binary" + [ "$WANT" = "$GOT" ] \ + || die "checksum mismatch for ${ASSET}: published ${WANT}, downloaded ${GOT} — refusing to install" + log "checksum verified (${GOT})" + + # Staged beside the target and RENAMED into place, never written over. + # Replacing a running executable in place fails with ETXTBSY, and this path + # now runs on boxes where the daemon is live (a --version converge). A + # rename is atomic and leaves the running process on the old inode until the + # restart below picks up the new one. + install -m 0755 -o root -g root "$WORKDIR/forgejo-runner" "$BIN.rig-new" + mv -f "$BIN.rig-new" "$BIN" log "installed ${BIN}" fi INSTALLED_VER="$("$BIN" --version 2>/dev/null | head -n1)" [ -n "$INSTALLED_VER" ] || die "${BIN} does not answer --version — the download landed but cannot run" +# The converge actually took — asserted, not assumed. A pin that silently did +# not land is exactly the failure --version exists to make impossible. +if [ -n "$VERSION" ]; then + EFFECTIVE_VER="$(runner_version_of "$BIN")" + [ "$EFFECTIVE_VER" = "$VERSION" ] \ + || die "asked for forgejo-runner ${VERSION} but ${BIN} reports ${EFFECTIVE_VER:-nothing} after install" +fi # --- register ---------------------------------------------------------------- # UPSTREAM MARKS `register` DEPRECATED (measured on v12.13.2: both `register` @@ -285,6 +361,17 @@ INSTALLED_VER="$("$BIN" --version 2>/dev/null | head -n1)" install -d -m 0755 -o "$RUNNER_USER" -g "$RUNNER_GROUP" "$RUNNER_DIR" if [ -e "$RUNNER_DIR/.runner" ]; then log "already registered; skipping registration" + # 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 + 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." + fi + fi else log "registering runner ${RUNNER_NAME} against ${INSTANCE}" (cd "$RUNNER_DIR" && runuser -u "$RUNNER_USER" -- env HOME="$USER_HOME" \ @@ -294,17 +381,21 @@ else || die "registration failed — check the token is a RUNNER registration token from ${INSTANCE} and has not been used already" [ -e "$RUNNER_DIR/.runner" ] \ || die "register reported success but wrote no ${RUNNER_DIR}/.runner" + # INSIDE the registration branch, where runner-install.sh keeps its copy and + # for the same reason: this file records what rig ACTUALLY registered with, + # so `status` has something to read back. Writing it unconditionally — as an + # earlier draft did — makes a plain re-run stamp this invocation's labels + # over a registration that used different ones, and `status` then reports + # confidently wrong labels while Forgejo still holds the originals. A + # metadata file that can disagree with the thing it describes is worse than + # no metadata file. + printf '%s\n' "$LABELS" > "$RUNNER_DIR/.rig-labels" + chown "$RUNNER_USER:$RUNNER_GROUP" "$RUNNER_DIR/.rig-labels" fi # EVERY run, registration or not: .runner holds the runner's own long-lived # token, and a mode that drifted leaks it silently. See the lib. forgejo_runner_secure "$RUNNER_DIR" "$RUNNER_USER" "$RUNNER_GROUP" -# rig records the labels beside the registration for `status` to read back. -# Box-local metadata, never a credential — the same note runner-install.sh -# writes, for the same reason. -printf '%s\n' "$LABELS" > "$RUNNER_DIR/.rig-labels" -chown "$RUNNER_USER:$RUNNER_GROUP" "$RUNNER_DIR/.rig-labels" - # --- service ------------------------------------------------------------- # Written by rig rather than shipped by upstream: forgejo-runner has no # svc.sh, so there is no vendor unit to defer to (the GitHub sibling defers to diff --git a/commands/lib/forgejo-runner-config.sh b/commands/lib/forgejo-runner-config.sh index 6d8b95f..b2f7ea7 100644 --- a/commands/lib/forgejo-runner-config.sh +++ b/commands/lib/forgejo-runner-config.sh @@ -55,6 +55,51 @@ forgejo_runner_secure() { chown "$user:$group" "$dir/.runner" } +# runner_version_of — the bare version number ("12.13.2") the binary +# reports, empty when it cannot answer. `forgejo-runner --version` prints +# "forgejo-runner version v12.13.2"; the leading v is stripped so this compares +# against a --version argument, which has its own v stripped at parse. +runner_version_of() { + "$1" --version 2>/dev/null | head -n1 \ + | sed -nE 's/.*[Vv]ersion[[:space:]]+v?([0-9][0-9A-Za-z.+-]*).*/\1/p' +} + +# runner_download_decision +# -> "install" | "skip" | "converge" +# +# A PURE function, and pure on purpose: this is the decision review !110 caught +# being wrong, and it was wrong in a way no grep could see. Lifting it out of +# the root-only install path is what makes "a pre-existing binary plus +# --version" a real test rather than a string match. +# +# The rule, and why it is not the GitHub sibling's: +# +# no binary -> install. Nothing to reason about. +# binary, no --version -> skip. Chasing "latest" on every converge would make +# a plain re-run an unrequested upgrade, and a +# convergent verb must not be a moving target. +# binary, pin matches -> skip. +# binary, pin differs -> CONVERGE, including downward. A pin is an +# instruction, not a floor. +# +# runner-install.sh skips on mere presence because actions/runner SELF-UPDATES, +# so its version moves regardless and freezing it would only make GitHub refuse +# the runner's jobs. forgejo-runner does not self-update: nothing else ever +# moves this version, and a ci-box's template preinstalls the binary at mint — +# so mere-presence here would leave --version dead on the one path this whole +# command exists to serve. +# +# An unreadable present version (empty) with a pin asked for falls to +# "converge", which is the right direction: a binary that cannot say what it is +# should be replaced by one that can. +runner_download_decision() { + local have="$1" present="$2" want="$3" + [ "$have" = yes ] || { printf 'install\n'; return 0; } + [ -n "$want" ] || { printf 'skip\n'; return 0; } + [ "$present" = "$want" ] && { printf 'skip\n'; return 0; } + printf 'converge\n' +} + # assert_runner_instance # # Returns 0 when the box has no runner, or has one already registered to diff --git a/docs/plans/2026-07-27-forgejo-ci-box.md b/docs/plans/2026-07-27-forgejo-ci-box.md index ba13092..e28e770 100644 --- a/docs/plans/2026-07-27-forgejo-ci-box.md +++ b/docs/plans/2026-07-27-forgejo-ci-box.md @@ -215,6 +215,37 @@ registered to re-uses the binary and skips registration; a **different** instance refuses and names both. Same trust-boundary reasoning as `assert_runner_repo`, asked about the axis Forgejo actually has. +### Three corrections from review !110 + +The first draft carried three defects that review caught, all of the same +family — a stated contract that the code did not actually keep: + +1. **`--version` was swallowed on the primary path.** The download block + skipped on mere presence, copying `rig runner install`'s shape without its + justification: `actions/runner` self-updates, `forgejo-runner` does not. And + a ci-box's template *preinstalls* the binary at mint, so the flag documented + as the deterministic-pin lever could never fire on a ci-box. The decision is + now `runner_download_decision` in the lib — a pure function, so the rule is + driven by tests instead of asserted by grep — and the binary is renamed into + place rather than written over, since the converge path now runs while the + daemon is live (in-place would be `ETXTBSY`). + +2. **`.rig-labels` outlived the registration it described.** The write had + escaped the registration branch, where `runner-install.sh` correctly keeps + its copy. A plain re-run stamped that invocation's labels over a + registration made with different ones, and `status` then reported + confidently wrong labels while Forgejo held the originals. It is scoped + again, and an *explicit* `--labels` on a re-run now warns that Forgejo owns + labels from registration time. + +3. **The checksum gate failed open.** A missing `.sha256` warned and installed + anyway — contradicting both the README and this file's own comment about + unverified root downloads. The original reasoning (do not let an upstream + layout change break installs) reasons about the wrong failure: a layout + change breaks the *binary* URL too, so "binary yes, checksum no" is the + shape of an interfered fetch, which is what the checksum is for. Both paths + refuse now, with no bypass flag. + ### `.runner` holds a credential here GitHub's `.runner` names a repo. Forgejo's holds `address`, `name`, `labels` diff --git a/docs/templates/ci-box/install.sh b/docs/templates/ci-box/install.sh index d6351a7..1efa397 100755 --- a/docs/templates/ci-box/install.sh +++ b/docs/templates/ci-box/install.sh @@ -58,21 +58,29 @@ curl -fsSL "$URL" -o "$WORKDIR/forgejo-runner" \ # Forgejo publishes a .sha256 beside each binary. Verifying it costs one # request and makes the install auditable; this file executes as root inside # every future mint, so an unverified download is the last thing it should do. -if curl -fsSL "${URL}.sha256" -o "$WORKDIR/forgejo-runner.sha256" 2>/dev/null; then - WANT="$(tr -d '\r' < "$WORKDIR/forgejo-runner.sha256" | awk '{print $1}' | head -n1)" - GOT="$(sha256sum "$WORKDIR/forgejo-runner" | awk '{print $1}')" - if [ -z "$WANT" ]; then - echo "ci-box install: the published checksum for ${ASSET} is unreadable — refusing to install an unverified binary" >&2 - exit 1 - fi - if [ "$WANT" != "$GOT" ]; then - echo "ci-box install: checksum mismatch for ${ASSET}: published ${WANT}, downloaded ${GOT} — refusing to install" >&2 - exit 1 - fi - echo "ci-box install: checksum verified (${GOT})" -else - echo "ci-box install: WARNING: no published .sha256 for ${ASSET} — installing WITHOUT checksum verification" >&2 +# An unfetchable checksum REFUSES — it does not warn and continue. The comment +# above says an unverified root download is the last thing this file should do, +# and an earlier draft then did exactly that whenever the .sha256 404'd. The +# binary downloading while its checksum does not is not an upstream layout +# change (that would break both URLs); it is the shape of an interfered fetch, +# which is the case the checksum exists to catch. Failing open here would hand +# an unverified root install, inside every future mint, to anyone who can block +# one URL. +if ! curl -fsSL "${URL}.sha256" -o "$WORKDIR/forgejo-runner.sha256" 2>/dev/null; then + echo "ci-box install: no published .sha256 for ${ASSET} — refusing to install an unverified binary that runs as root. The binary itself downloaded, so this is not an upstream layout change; check what is intercepting the fetch." >&2 + exit 1 fi +WANT="$(tr -d '\r' < "$WORKDIR/forgejo-runner.sha256" | awk '{print $1}' | head -n1)" +GOT="$(sha256sum "$WORKDIR/forgejo-runner" | awk '{print $1}')" +if [ -z "$WANT" ]; then + echo "ci-box install: the published checksum for ${ASSET} is unreadable — refusing to install an unverified binary" >&2 + exit 1 +fi +if [ "$WANT" != "$GOT" ]; then + echo "ci-box install: checksum mismatch for ${ASSET}: published ${WANT}, downloaded ${GOT} — refusing to install" >&2 + exit 1 +fi +echo "ci-box install: checksum verified (${GOT})" install -m 0755 -o root -g root "$WORKDIR/forgejo-runner" "$BIN" echo "ci-box install: installed ${BIN}" diff --git a/test/cli.sh b/test/cli.sh index 1072647..3a87488 100644 --- a/test/cli.sh +++ b/test/cli.sh @@ -3266,13 +3266,146 @@ check "forgejo-runner: status warns on a drifted mode" 0 "FORGEJO_RUNNER_FILE_MO grep -o "FORGEJO_RUNNER_FILE_MODE" "$ROOT/commands/forgejo-runner-status.sh" rm -rf "$FRW" -# The download must be verified. This binary is installed as root and executed -# by a systemd unit; an unverified fetch is the one step here that silently -# turns a network compromise into root on the box. -check "forgejo-runner: install verifies the published checksum" 0 "checksum mismatch" \ - grep -o "checksum mismatch" "$FR" -check "ci-box: its install.sh verifies the published checksum too" 0 "checksum mismatch" \ - grep -o "checksum mismatch" "$ROOT/docs/templates/ci-box/install.sh" +# --- the checksum gate, DRIVEN not grepped (review !110) -------------------- +# This binary is installed as root and executed by a systemd unit, so the +# verification is the one step here that silently turns a network compromise +# into root on the box. A grep for the mismatch string proved only that a +# string existed — it could not have caught the fail-open branch that used to +# sit beside it. So drive the real script against a stub curl, the +# test/release.sh idiom, and assert on what it DOES. +CIBOX="$ROOT/docs/templates/ci-box/install.sh" +CBW="$(mktemp -d)"; CBSTUB="$CBW/stub"; mkdir -p "$CBSTUB" +cat > "$CBSTUB/curl" <<'CBCURL' +#!/usr/bin/env bash +# Scripted curl — never the network. +# CB_REDIRECT what -w %{url_effective} answers (the latest-release probe) +# CB_PAYLOAD file copied to -o for the BINARY url +# CB_SUM text written to -o for the .sha256 url; unset => that url 404s +set -u +out="" url="" probe=0 +while [ $# -gt 0 ]; do + case "$1" in + -o) out="$2"; shift 2 ;; + -w) probe=1; shift 2 ;; + -*) shift ;; + *) url="$1"; shift ;; + esac +done +if [ "$probe" -eq 1 ]; then printf '%s' "${CB_REDIRECT:-}"; exit 0; fi +case "$url" in + *.sha256) + [ -n "${CB_SUM:-}" ] || exit 22 + # A fetch that SUCCEEDS but yields nothing usable is its own case, distinct + # from a 404 — a truncated proxy response looks exactly like this. + if [ "$CB_SUM" = EMPTY ]; then : > "${out:?}"; exit 0; fi + printf '%s asset\n' "$CB_SUM" > "${out:?}"; exit 0 ;; + *) + cp "${CB_PAYLOAD:?}" "${out:?}"; exit 0 ;; +esac +CBCURL +chmod +x "$CBSTUB/curl" +printf 'not-really-a-binary\n' > "$CBW/payload" +CB_GOOD="$(sha256sum "$CBW/payload" | awk '{print $1}')" +# install(1) must not touch the real /usr/local/bin, and the script runs as a +# non-root user here — stub it to a writable target so the test reaches the +# checksum logic rather than dying on permissions. +cat > "$CBSTUB/install" < "$VSTUB/fr"; chmod +x "$VSTUB/fr" +printf '#!/bin/sh\necho "garbage"\n' > "$VSTUB/bad"; chmod +x "$VSTUB/bad" +check "forgejo-runner: the version reader strips the leading v" 0 "12.13.2" vparse "$VSTUB/fr" +check "forgejo-runner: an unreadable version yields empty, not garbage" 0 "" vparse "$VSTUB/bad" +rm -rf "$VSTUB" + +# The decision itself, driven — this is the case review !110 caught, and a +# grep could not have caught it. Each row is a real lifecycle situation. +dec() { bash -c ". '$FRLIB'; runner_download_decision \"\$1\" \"\$2\" \"\$3\"" _ "$@"; } +check "version: nothing installed -> install" 0 "install" dec no "" "" +check "version: nothing installed, pin asked -> install" 0 "install" dec no "" 12.13.2 +check "version: binary present, no pin -> skip (no surprise upgrade)" 0 "skip" dec yes 12.13.2 "" +check "version: binary present, pin MATCHES -> skip" 0 "skip" dec yes 12.13.2 12.13.2 +# THE ci-box CASE: the template preinstalled a binary at mint, and the operator +# then pins. Mere presence used to swallow this entirely. +check "version: binary present, pin DIFFERS -> converge" 0 "converge" dec yes 12.13.2 12.13.0 +check "version: a pin may converge DOWNWARD (a pin is not a floor)" 0 "converge" dec yes 12.13.2 9.0.0 +check "version: an unreadable present version + pin -> converge" 0 "converge" dec yes "" 12.13.2 +# ...and the command must actually consult it rather than re-deciding inline. +check "forgejo-runner: install routes the decision through the shared rule" 0 "runner_download_decision" \ + grep -o "runner_download_decision" "$FR" +# shellcheck disable=SC2016 # the '${BIN}' is the LITERAL text being grepped for +check "forgejo-runner: the pin is asserted to have LANDED, not assumed" 0 "reports" \ + grep -o 'but ${BIN} reports' "$FR" +# Replacing a live executable in place is ETXTBSY; the converge path now runs +# on boxes where the daemon is up, so it must rename into place. +# shellcheck disable=SC2016 # the '$BIN' is the LITERAL text being grepped for +check "forgejo-runner: the binary is renamed into place, never written over" 0 "mv -f" \ + grep -o 'mv -f "\$BIN.rig-new" "\$BIN"' "$FR" + +# --- .rig-labels must not outlive the registration it describes (review !110) +# A plain re-run used to stamp this invocation's labels over a registration +# made with different ones — status then reported confidently wrong labels +# while Forgejo still held the originals. +labels_write_is_scoped() { + # The write must sit INSIDE the else-branch that actually registers, the way + # runner-install.sh keeps its copy. + awk '/^else$/,/^fi$/' "$FR" | grep -q 'rig-labels' +} +check "forgejo-runner: .rig-labels is written only where registration happens" 0 "" \ + labels_write_is_scoped +check "forgejo-runner: an explicit --labels on a rerun warns it was not applied" 0 "was not applied" \ + grep -o -- "--labels was not applied" "$FR" +check "forgejo-runner: that warning is gated on --labels being EXPLICIT" 0 "LABELS_EXPLICIT" \ + grep -o "LABELS_EXPLICIT" "$FR" +# The GitHub sibling is the precedent this restores — pin that it still scopes +# its own write, so the two cannot drift apart again. +gh_labels_write_is_scoped() { + awk '/^else$/,/^fi$/' "$ROOT/commands/runner-install.sh" | grep -q 'rig-labels' +} +check "rig runner: the sibling still scopes ITS .rig-labels write too" 0 "" \ + gh_labels_write_is_scoped # undo must refuse under a live Forgejo runner for a STRONGER reason than the # GitHub one: Forgejo has no deregistration endpoint, so the ghost it strands