feat: Forgejo-native CI — a ci-box tenant and a forgejo-runner command family #110

Merged
andres merged 9 commits from build/109-forgejo-ci-box into main 2026-07-28 19:58:37 +00:00
7 changed files with 207 additions and 72 deletions
Showing only changes of commit cf5858bb60 - Show all commits

4
.github/labeler.yml vendored
View file

@ -11,7 +11,9 @@
"scope:runner":
- changed-files:
- any-glob-to-any-file:
["commands/runner-*.sh", "commands/lib/runner-config.sh"]
["commands/runner-*.sh", "commands/lib/runner-config.sh",
"commands/forgejo-runner-*.sh", "commands/lib/forgejo-runner-config.sh",
"docs/templates/ci-box/**"]
"scope:coolify":
- changed-files:
- any-glob-to-any-file: ["commands/coolify-*.sh"]

2
.github/labels.conf vendored
View file

@ -2,7 +2,7 @@ panel=claude-bot-andresmgsl codex-bot-andresmgsl grok-bot-andresmgsl kimi-bot-an
triage-actors=dan-claude-bot
scope:bootstrap|C5DEF5|bootstrap — hardening a pristine server into a node
scope:users|C5DEF5|users-* — class model, apply/status, close-root
scope:runner|C5DEF5|runner-* — GitHub runner lifecycle
scope:runner|C5DEF5|runner-* / forgejo-runner-* — CI runner lifecycle, either forge
scope:coolify|C5DEF5|coolify-* — Coolify and backup install
scope:db|C5DEF5|db.sh — dump/restore
scope:installer|C5DEF5|install.sh — how rig lands on a machine

View file

@ -1174,6 +1174,28 @@ FORGEJO__actions__ENABLED=true
FORGEJO__actions__DEFAULT_ACTIONS_URL=https://code.forgejo.org
```
> **`DEFAULT_ACTIONS_URL` is a single fallback, and rig's own workflows need
> two origins.** It decides where a *bare* `uses: owner/repo@ref` resolves.
> Measured:
>
> | reference | count in `.github/workflows/` | `code.forgejo.org` |
> |---|---|---|
> | `actions/checkout@v4` | 3 | **200** — mirrored |
> | `heavy-duty/ceremony/...@0.3.0` | 8 | **404** — lives on this Forgejo instead |
>
> So with the value above, every ceremony guard (`changelog-armed`,
> `changelog-monotonic`, `changelog-assembled`, `drill-recorded`,
> `runner-isolated`, `docs-sync`, and the release/labels callers) fails to
> resolve the moment rig's CI actually runs on the forge.
>
> Forgejo accepts an absolute URL per step, so one side has to be explicit —
> which one is a **decision this PR does not make**. Either set the default to
> this instance and make the three `actions/*` references absolute
> (`uses: https://code.forgejo.org/actions/checkout@v4`), or keep the value
> above and make the eight ceremony references absolute. Until that lands,
> **rig's CI is not expected to run on Forgejo**`rig forgejo-runner` is for
> running *your* repositories' workflows, and does not depend on this.
> **A registry served from Forgejo needs one more.** `RIG_TEMPLATES_HOST`
> (below) lets the template registry live on any forge, but the mint-time fetch
> is **unauthenticated by contract** — box auto-runs `rig bootstrap <role>-box`

View file

@ -33,6 +33,55 @@ die() { printf 'rig-forgejo-runner: ERROR: %s\n' "$1" >&2; exit "${2:-1}"; }
# 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'
# fetch_and_verify_sha256 <asset-url> <file> <sumfile> <label>
#
# The whole checksum POLICY, in one place: fetch the published .sha256 beside
# an asset and prove the download matches it. Prints the reason on stderr and
# returns 1 on any failure; the caller supplies the refusal in its own voice.
#
# BYTE-IDENTICAL to the copy in docs/templates/ci-box/install.sh, diffed by
# test/cli.sh — the valid_version / templates_archive_urls precedent. The two
# downloaders cannot share a lib: this one sources commands/lib/, and that one
# is a REGISTRY DEFINITION that runs standalone inside a mint from a fetched
# tarball, with rig's tree nowhere in reach. So the pin is the only mechanism
# that keeps one policy from becoming two.
#
# Review !110 is the evidence for why that matters: a fail-open branch lived in
# BOTH copies while a grep for "checksum mismatch" passed against both, because
# the string it looked for sat right beside the branch it could not see. The
# next checksum-policy change must not be able to land in one file only.
#
# AN UNFETCHABLE CHECKSUM REFUSES — it is a gate, not a courtesy. The earlier
# reasoning ("do not let an upstream layout change break installs") reasons
# about the wrong failure: a layout change moves the BINARY url too, so the
# download would already have died. "Binary yes, checksum no" is not what a
# layout change looks like — it is what an interfered fetch looks like, which
# is precisely what a checksum exists to catch. Failing open would hand an
# unverified root install to anyone able to block a single URL. There is
# deliberately no bypass flag: if upstream really does move its assets, that is
# a rig PR editing the URL, not an operator improvising past a security gate.
fetch_and_verify_sha256() {
local url="$1" file="$2" sumfile="$3" label="$4" want got
if ! curl -fsSL "${url}.sha256" -o "$sumfile" 2>/dev/null; then
printf 'no published .sha256 for %s at %s.sha256 — the binary itself downloaded, so this is not an upstream layout change; check what is intercepting the fetch\n' "$label" "$url" >&2
return 1
fi
# 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' < "$sumfile" 2>/dev/null | awk '{print $1}' | head -n1)"
got="$(sha256sum "$file" | awk '{print $1}')"
if [ -z "$want" ]; then
printf 'the published checksum for %s is unreadable — a fetch that succeeds but returns nothing usable is not a verified download\n' "$label" >&2
return 1
fi
if [ "$want" != "$got" ]; then
printf 'checksum mismatch for %s: published %s, downloaded %s\n' "$label" "$want" "$got" >&2
return 1
fi
printf 'checksum verified (%s)\n' "$got"
}
usage() {
cat <<'EOF'
usage: rig forgejo-runner install --instance <url> [options]
@ -286,35 +335,8 @@ if [ "$NEED_DOWNLOAD" -eq 1 ]; then
log "downloading forgejo-runner ${VERSION} (${ARCH})"
curl -fsSL "$URL" -o "$WORKDIR/forgejo-runner" \
|| die "could not download ${URL}"
# 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})"
fetch_and_verify_sha256 "$URL" "$WORKDIR/forgejo-runner" "$WORKDIR/forgejo-runner.sha256" "$ASSET" \
|| die "refusing to install an unverified ${ASSET} — it lands as root and runs under a systemd unit. See the checksum failure above."
# Staged beside the target and RENAMED into place, never written over.
# Replacing a running executable in place fails with ETXTBSY, and this path

View file

@ -67,19 +67,36 @@ fi
UNIT=/etc/systemd/system/forgejo-runner.service
# --- nothing to remove? -----------------------------------------------------
if ! id -u "$RUNNER_USER" >/dev/null 2>&1; then
log "no ${RUNNER_USER} user on this box; nothing to remove"
# The unit is checked INDEPENDENTLY of the user, and that ordering is the whole
# point. A missing user used to exit 0 here before the unit was ever looked at,
# so a deleted account with a leftover forgejo-runner.service reported "nothing
# to remove" and left the unit behind — while the absence-assert at the end,
# which never ran, implied removal had been complete. `bootstrap --undo`'s own
# unit check would still have caught it, but a verb that claims to have removed
# everything must not be the thing that lies about it.
RUNNER_DIR=""
if id -u "$RUNNER_USER" >/dev/null 2>&1; then
USER_HOME="$(getent passwd "$RUNNER_USER" | cut -d: -f6)"
RUNNER_DIR="$USER_HOME/forgejo-runner"
else
log "no ${RUNNER_USER} user on this box"
fi
if [ -z "$RUNNER_DIR" ] && [ ! -e "$UNIT" ]; then
log "no runner user and no unit on this box; nothing to remove"
exit 0
fi
USER_HOME="$(getent passwd "$RUNNER_USER" | cut -d: -f6)"
RUNNER_DIR="$USER_HOME/forgejo-runner"
if [ ! -e "$RUNNER_DIR/.runner" ] && [ ! -e "$UNIT" ]; then
if [ -n "$RUNNER_DIR" ] && [ ! -e "$RUNNER_DIR/.runner" ] && [ ! -e "$UNIT" ]; then
log "no runner registered in ${RUNNER_DIR}; nothing to remove"
exit 0
fi
[ -n "$RUNNER_DIR" ] || warn "the ${RUNNER_USER} user is gone but ${UNIT} is still here — removing the orphaned unit"
INSTANCE="$(forgejo_runner_instance "$RUNNER_DIR")"
RUNNER_NAME="$(forgejo_runner_name "$RUNNER_DIR")"
INSTANCE=""
RUNNER_NAME=""
if [ -n "$RUNNER_DIR" ]; then
INSTANCE="$(forgejo_runner_instance "$RUNNER_DIR")"
RUNNER_NAME="$(forgejo_runner_name "$RUNNER_DIR")"
fi
# --- service ---------------------------------------------------------------
# First, in both paths: stopping after the registration is wiped would strand a
@ -95,16 +112,23 @@ else
fi
# --- registration -----------------------------------------------------------
if [ -e "$RUNNER_DIR/.runner" ]; then
rm -f "$RUNNER_DIR/.runner"
log "wiped the local registration"
# Every path below is gated on RUNNER_DIR being non-empty. With the user gone
# it is "", and an unguarded "$RUNNER_DIR/.rig-labels" would expand to
# "/.rig-labels" — an rm at the filesystem root, as root. The repo already
# treats this class of expansion as a hazard worth spelling out (`rm -rf
# "${ir:?}/versions/$ver"` in bin/rig); same discipline here.
if [ -n "$RUNNER_DIR" ]; then
if [ -e "$RUNNER_DIR/.runner" ]; then
rm -f "$RUNNER_DIR/.runner"
log "wiped the local registration"
fi
rm -f "$RUNNER_DIR/.rig-labels"
fi
rm -f "$RUNNER_DIR/.rig-labels"
# END WITH THE ABSENCE ASSERT: "removed" is a claim, and claims get verified
# (the `rig uninstall` precedent).
leftover=""
[ -e "$RUNNER_DIR/.runner" ] && leftover="$leftover $RUNNER_DIR/.runner"
[ -n "$RUNNER_DIR" ] && [ -e "$RUNNER_DIR/.runner" ] && leftover="$leftover $RUNNER_DIR/.runner"
[ -e "$UNIT" ] && leftover="$leftover $UNIT"
if [ -n "$leftover" ]; then
printf 'rig-forgejo-runner: remove INCOMPLETE — still present:%s\n' "$leftover" >&2

View file

@ -18,6 +18,55 @@
# inside the box.
set -euo pipefail
# fetch_and_verify_sha256 <asset-url> <file> <sumfile> <label>
#
# The whole checksum POLICY, in one place: fetch the published .sha256 beside
# an asset and prove the download matches it. Prints the reason on stderr and
# returns 1 on any failure; the caller supplies the refusal in its own voice.
#
# BYTE-IDENTICAL to the copy in commands/forgejo-runner-install.sh, diffed by
# test/cli.sh — the valid_version / templates_archive_urls precedent. The two
# downloaders cannot share a lib: that one sources commands/lib/, and this one
# is a REGISTRY DEFINITION that runs standalone inside a mint from a fetched
# tarball, with rig's tree nowhere in reach. So the pin is the only mechanism
# that keeps one policy from becoming two.
#
# Review !110 is the evidence for why that matters: a fail-open branch lived in
# BOTH copies while a grep for "checksum mismatch" passed against both, because
# the string it looked for sat right beside the branch it could not see. The
# next checksum-policy change must not be able to land in one file only.
#
# AN UNFETCHABLE CHECKSUM REFUSES — it is a gate, not a courtesy. The earlier
# reasoning ("do not let an upstream layout change break installs") reasons
# about the wrong failure: a layout change moves the BINARY url too, so the
# download would already have died. "Binary yes, checksum no" is not what a
# layout change looks like — it is what an interfered fetch looks like, which
# is precisely what a checksum exists to catch. Failing open would hand an
# unverified root install to anyone able to block a single URL. There is
# deliberately no bypass flag: if upstream really does move its assets, that is
# a rig PR editing the URL, not an operator improvising past a security gate.
fetch_and_verify_sha256() {
local url="$1" file="$2" sumfile="$3" label="$4" want got
if ! curl -fsSL "${url}.sha256" -o "$sumfile" 2>/dev/null; then
printf 'no published .sha256 for %s at %s.sha256 — the binary itself downloaded, so this is not an upstream layout change; check what is intercepting the fetch\n' "$label" "$url" >&2
return 1
fi
# 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' < "$sumfile" 2>/dev/null | awk '{print $1}' | head -n1)"
got="$(sha256sum "$file" | awk '{print $1}')"
if [ -z "$want" ]; then
printf 'the published checksum for %s is unreadable — a fetch that succeeds but returns nothing usable is not a verified download\n' "$label" >&2
return 1
fi
if [ "$want" != "$got" ]; then
printf 'checksum mismatch for %s: published %s, downloaded %s\n' "$label" "$want" "$got" >&2
return 1
fi
printf 'checksum verified (%s)\n' "$got"
}
BIN=/usr/local/bin/forgejo-runner
if [ -x "$BIN" ]; then
@ -58,29 +107,8 @@ 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.
# 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})"
fetch_and_verify_sha256 "$URL" "$WORKDIR/forgejo-runner" "$WORKDIR/forgejo-runner.sha256" "$ASSET" \
|| { echo "ci-box install: refusing to install an unverified ${ASSET} — it lands as root inside every mint. See the checksum failure above." >&2; exit 1; }
install -m 0755 -o root -g root "$WORKDIR/forgejo-runner" "$BIN"
echo "ci-box install: installed ${BIN}"

View file

@ -3207,6 +3207,26 @@ check "forgejo-runner: remove --local explains why it is not a flag" 2 "always l
"$ROOT/commands/forgejo-runner-remove.sh" --local
check "forgejo-runner: repoint explains why it cannot exist" 2 "no deregistration endpoint" \
"$ROOT/bin/rig" forgejo-runner repoint --instance https://f.example.com
# remove used to exit 0 on a missing user BEFORE looking at the unit, so a
# deleted account with a leftover forgejo-runner.service reported "nothing to
# remove" and stranded it — while the absence-assert that never ran implied the
# opposite (review !110). The unit check must not sit behind the user check.
remove_checks_unit_independently() {
# The early-exit must require BOTH the user to be absent AND the unit to be
# missing; a user-only guard is the regression.
# shellcheck disable=SC2016 # the '$RUNNER_DIR'/'$UNIT' are LITERAL text being grepped for
grep -q 'if \[ -z "\$RUNNER_DIR" \] && \[ ! -e "\$UNIT" \]' "$ROOT/commands/forgejo-runner-remove.sh"
}
check "forgejo-runner: remove checks the unit even when the user is gone" 0 "" \
remove_checks_unit_independently
check "forgejo-runner: remove warns about an orphaned unit" 0 "orphaned unit" \
grep -o "orphaned unit" "$ROOT/commands/forgejo-runner-remove.sh"
# With the user gone RUNNER_DIR is "", and an unguarded "$RUNNER_DIR/.rig-labels"
# would expand to "/.rig-labels" — an rm at the filesystem root, as root.
# shellcheck disable=SC2016 # the '$RUNNER_DIR' is LITERAL text being grepped for
check "forgejo-runner: remove never rm's an unguarded \$RUNNER_DIR path" 1 "" \
grep -qE '^rm -f "\$RUNNER_DIR' "$ROOT/commands/forgejo-runner-remove.sh"
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
@ -3327,7 +3347,9 @@ check "ci-box: a matching checksum installs" 0 "checksum verified" \
cibox_run CB_SUM="$CB_GOOD"
check "ci-box: ...and the binary really landed" 0 "" cibox_installed
# The regression this replaces: a 404 on .sha256 used to warn and install.
check "ci-box: a MISSING checksum refuses (exit 1)" 1 "refusing to install an unverified binary" \
check "ci-box: a MISSING checksum refuses (exit 1)" 1 "refusing to install an unverified" \
cibox_run
check "ci-box: ...naming the fetch, not blaming an upstream layout change" 1 "check what is intercepting the fetch" \
cibox_run
check "ci-box: ...and installed NOTHING" 1 "" cibox_installed
check "ci-box: a MISMATCHED checksum refuses" 1 "checksum mismatch" \
@ -3337,15 +3359,30 @@ check "ci-box: an EMPTY checksum file refuses" 1 "unreadable" cibox_run CB_SUM=E
check "ci-box: ...and installed nothing there either" 1 "" cibox_installed
rm -rf "$CBW"
# The rig command's copy of the gate must not drift back to failing open.
# Grep-pinned only for the branch SHAPE (driving it needs root and systemd);
# the behaviour itself is proven above on the byte-equivalent template path.
check "forgejo-runner: install refuses an unfetchable checksum" 0 "refusing to install an unverified binary" \
grep -o "refusing to install an unverified binary" "$FR"
# ONE checksum policy, two files that cannot share a lib — the rig command
# sources commands/lib/, the template is a registry definition that runs
# standalone inside a mint with rig's tree nowhere in reach. So the copies are
# byte-identical and diffed, the valid_version precedent (review !110).
#
# This pin is what the driven tests above cannot give on their own: they
# exercise the TEMPLATE's copy, and without the diff a fix could land there
# while the rig command kept a stale policy. That is exactly how fail-open
# survived in both while a grep for "checksum mismatch" passed against both.
FVLIB="$(mktemp)"; FVTPL="$(mktemp)"
awk '/^fetch_and_verify_sha256\(\) \{/,/^\}/' "$FR" > "$FVLIB"
awk '/^fetch_and_verify_sha256\(\) \{/,/^\}/' "$CIBOX" > "$FVTPL"
check "checksum policy: extracted from the command (guards the awk)" 0 "sha256sum" cat "$FVLIB"
check "checksum policy: extracted from the template (guards the awk)" 0 "sha256sum" cat "$FVTPL"
check "checksum policy: the two copies are byte-identical" 0 "" diff "$FVLIB" "$FVTPL"
rm -f "$FVLIB" "$FVTPL"
# ...and neither may drift back to warn-and-continue.
check "forgejo-runner: install has no warn-and-continue checksum branch" 1 "" \
grep -q "WITHOUT checksum verification" "$FR"
check "ci-box: no warn-and-continue checksum branch either" 1 "" \
grep -q "WITHOUT checksum verification" "$CIBOX"
check "forgejo-runner: install routes through the shared checksum policy" 0 "fetch_and_verify_sha256" \
grep -o "fetch_and_verify_sha256 \"\$URL\"" "$FR"
# --- --version must converge, not be swallowed (review !110) ----------------
# forgejo-runner does NOT self-update, and a ci-box's template preinstalls the