feat: Forgejo-native CI — a ci-box tenant and a forgejo-runner command family #110
6 changed files with 374 additions and 50 deletions
22
README.md
22
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.
|
answer — which is why these are two commands and not one with a `--forge` flag.
|
||||||
|
|
||||||
- `--version <pin>` — `forgejo-runner` release (default: latest at install
|
- `--version <pin>` — `forgejo-runner` release (default: latest at install
|
||||||
time). The published `.sha256` is **verified before the binary is
|
time). **Convergent, and it replaces an existing binary** — including
|
||||||
installed**; a mismatch refuses.
|
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 <name>` — runner name (default: this host's hostname)
|
- `--name <name>` — runner name (default: this host's hostname)
|
||||||
- `--labels <csv>` — replaces the default map:
|
- `--labels <csv>` — replaces the default map:
|
||||||
`ubuntu-latest:docker://ghcr.io/catthehacker/ubuntu:act-22.04,docker:docker://node:22-bookworm`,
|
`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 <name>` — service user (default: `ci` when it exists, else
|
- `--user <name>` — service user (default: `ci` when it exists, else
|
||||||
`forgejo-runner`)
|
`forgejo-runner`)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,12 @@ INSTANCE=""
|
||||||
VERSION=""
|
VERSION=""
|
||||||
RUNNER_NAME="$(hostname)"
|
RUNNER_NAME="$(hostname)"
|
||||||
LABELS="$DEFAULT_LABELS"
|
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=""
|
RUNNER_USER=""
|
||||||
while [ $# -gt 0 ]; do
|
while [ $# -gt 0 ]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
|
|
@ -93,7 +99,7 @@ while [ $# -gt 0 ]; do
|
||||||
RUNNER_NAME="$2"; shift 2 ;;
|
RUNNER_NAME="$2"; shift 2 ;;
|
||||||
--labels)
|
--labels)
|
||||||
[ $# -ge 2 ] || die "--labels needs a value" 2
|
[ $# -ge 2 ] || die "--labels needs a value" 2
|
||||||
LABELS="$2"; shift 2 ;;
|
LABELS="$2"; LABELS_EXPLICIT=1; shift 2 ;;
|
||||||
--user)
|
--user)
|
||||||
[ $# -ge 2 ] || die "--user needs a value" 2
|
[ $# -ge 2 ] || die "--user needs a value" 2
|
||||||
RUNNER_USER="$2"; shift 2 ;;
|
RUNNER_USER="$2"; shift 2 ;;
|
||||||
|
|
@ -207,9 +213,52 @@ fi
|
||||||
# Forgejo publishes BARE BINARIES (not a tarball) with a .sha256 beside each
|
# Forgejo publishes BARE BINARIES (not a tarball) with a .sha256 beside each
|
||||||
# one. Taking that checksum is nearly free and makes the install auditable —
|
# one. Taking that checksum is nearly free and makes the install auditable —
|
||||||
# the same instinct as `coolify install`'s mandatory version pin.
|
# 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
|
if [ -x "$BIN" ]; then
|
||||||
log "forgejo-runner binary already present at ${BIN}; skipping download"
|
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
|
else
|
||||||
|
log "forgejo-runner ${PRESENT_VER:-(version unreadable)} already present at ${BIN}; skipping download (pass --version <pin> 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
|
case "$(uname -m)" in
|
||||||
x86_64) ARCH="amd64" ;;
|
x86_64) ARCH="amd64" ;;
|
||||||
aarch64) ARCH="arm64" ;;
|
aarch64) ARCH="arm64" ;;
|
||||||
|
|
@ -237,7 +286,26 @@ else
|
||||||
log "downloading forgejo-runner ${VERSION} (${ARCH})"
|
log "downloading forgejo-runner ${VERSION} (${ARCH})"
|
||||||
curl -fsSL "$URL" -o "$WORKDIR/forgejo-runner" \
|
curl -fsSL "$URL" -o "$WORKDIR/forgejo-runner" \
|
||||||
|| die "could not download ${URL}"
|
|| die "could not download ${URL}"
|
||||||
if curl -fsSL "${URL}.sha256" -o "$WORKDIR/forgejo-runner.sha256" 2>/dev/null; then
|
# 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
|
# The published .sha256 names the asset, not our temp path. Compare the
|
||||||
# digest itself rather than rewriting the file into sha256sum -c's format:
|
# digest itself rather than rewriting the file into sha256sum -c's format:
|
||||||
# one comparison, no parsing of a file we did not write.
|
# one comparison, no parsing of a file we did not write.
|
||||||
|
|
@ -247,17 +315,25 @@ else
|
||||||
[ "$WANT" = "$GOT" ] \
|
[ "$WANT" = "$GOT" ] \
|
||||||
|| die "checksum mismatch for ${ASSET}: published ${WANT}, downloaded ${GOT} — refusing to install"
|
|| die "checksum mismatch for ${ASSET}: published ${WANT}, downloaded ${GOT} — refusing to install"
|
||||||
log "checksum verified (${GOT})"
|
log "checksum verified (${GOT})"
|
||||||
else
|
|
||||||
# A warning, not a refusal: rig should not become unable to install because
|
# Staged beside the target and RENAMED into place, never written over.
|
||||||
# upstream changed its asset layout. But it must be LOUD — an operator who
|
# Replacing a running executable in place fails with ETXTBSY, and this path
|
||||||
# needs a verified install has to know this one was not.
|
# now runs on boxes where the daemon is live (a --version converge). A
|
||||||
warn "no published .sha256 for ${ASSET} — installing WITHOUT checksum verification"
|
# rename is atomic and leaves the running process on the old inode until the
|
||||||
fi
|
# restart below picks up the new one.
|
||||||
install -m 0755 -o root -g root "$WORKDIR/forgejo-runner" "$BIN"
|
install -m 0755 -o root -g root "$WORKDIR/forgejo-runner" "$BIN.rig-new"
|
||||||
|
mv -f "$BIN.rig-new" "$BIN"
|
||||||
log "installed ${BIN}"
|
log "installed ${BIN}"
|
||||||
fi
|
fi
|
||||||
INSTALLED_VER="$("$BIN" --version 2>/dev/null | head -n1)"
|
INSTALLED_VER="$("$BIN" --version 2>/dev/null | head -n1)"
|
||||||
[ -n "$INSTALLED_VER" ] || die "${BIN} does not answer --version — the download landed but cannot run"
|
[ -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 ----------------------------------------------------------------
|
# --- register ----------------------------------------------------------------
|
||||||
# UPSTREAM MARKS `register` DEPRECATED (measured on v12.13.2: both `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"
|
install -d -m 0755 -o "$RUNNER_USER" -g "$RUNNER_GROUP" "$RUNNER_DIR"
|
||||||
if [ -e "$RUNNER_DIR/.runner" ]; then
|
if [ -e "$RUNNER_DIR/.runner" ]; then
|
||||||
log "already registered; skipping registration"
|
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
|
else
|
||||||
log "registering runner ${RUNNER_NAME} against ${INSTANCE}"
|
log "registering runner ${RUNNER_NAME} against ${INSTANCE}"
|
||||||
(cd "$RUNNER_DIR" && runuser -u "$RUNNER_USER" -- env HOME="$USER_HOME" \
|
(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"
|
|| die "registration failed — check the token is a RUNNER registration token from ${INSTANCE} and has not been used already"
|
||||||
[ -e "$RUNNER_DIR/.runner" ] \
|
[ -e "$RUNNER_DIR/.runner" ] \
|
||||||
|| die "register reported success but wrote no ${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
|
fi
|
||||||
# EVERY run, registration or not: .runner holds the runner's own long-lived
|
# 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.
|
# token, and a mode that drifted leaks it silently. See the lib.
|
||||||
forgejo_runner_secure "$RUNNER_DIR" "$RUNNER_USER" "$RUNNER_GROUP"
|
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 -------------------------------------------------------------
|
# --- service -------------------------------------------------------------
|
||||||
# Written by rig rather than shipped by upstream: forgejo-runner has no
|
# 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
|
# svc.sh, so there is no vendor unit to defer to (the GitHub sibling defers to
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,51 @@ forgejo_runner_secure() {
|
||||||
chown "$user:$group" "$dir/.runner"
|
chown "$user:$group" "$dir/.runner"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# runner_version_of <bin> — 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 <have-binary yes|no> <present-ver> <wanted-ver>
|
||||||
|
# -> "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 <runner_dir> <instance-url>
|
# assert_runner_instance <runner_dir> <instance-url>
|
||||||
#
|
#
|
||||||
# Returns 0 when the box has no runner, or has one already registered to
|
# Returns 0 when the box has no runner, or has one already registered to
|
||||||
|
|
|
||||||
|
|
@ -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
|
instance refuses and names both. Same trust-boundary reasoning as
|
||||||
`assert_runner_repo`, asked about the axis Forgejo actually has.
|
`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
|
### `.runner` holds a credential here
|
||||||
|
|
||||||
GitHub's `.runner` names a repo. Forgejo's holds `address`, `name`, `labels`
|
GitHub's `.runner` names a repo. Forgejo's holds `address`, `name`, `labels`
|
||||||
|
|
|
||||||
16
docs/templates/ci-box/install.sh
vendored
16
docs/templates/ci-box/install.sh
vendored
|
|
@ -58,7 +58,18 @@ curl -fsSL "$URL" -o "$WORKDIR/forgejo-runner" \
|
||||||
# Forgejo publishes a .sha256 beside each binary. Verifying it costs one
|
# Forgejo publishes a .sha256 beside each binary. Verifying it costs one
|
||||||
# request and makes the install auditable; this file executes as root inside
|
# 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.
|
# 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
|
# 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)"
|
WANT="$(tr -d '\r' < "$WORKDIR/forgejo-runner.sha256" | awk '{print $1}' | head -n1)"
|
||||||
GOT="$(sha256sum "$WORKDIR/forgejo-runner" | awk '{print $1}')"
|
GOT="$(sha256sum "$WORKDIR/forgejo-runner" | awk '{print $1}')"
|
||||||
if [ -z "$WANT" ]; then
|
if [ -z "$WANT" ]; then
|
||||||
|
|
@ -70,9 +81,6 @@ if curl -fsSL "${URL}.sha256" -o "$WORKDIR/forgejo-runner.sha256" 2>/dev/null; t
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "ci-box install: checksum verified (${GOT})"
|
echo "ci-box install: checksum verified (${GOT})"
|
||||||
else
|
|
||||||
echo "ci-box install: WARNING: no published .sha256 for ${ASSET} — installing WITHOUT checksum verification" >&2
|
|
||||||
fi
|
|
||||||
|
|
||||||
install -m 0755 -o root -g root "$WORKDIR/forgejo-runner" "$BIN"
|
install -m 0755 -o root -g root "$WORKDIR/forgejo-runner" "$BIN"
|
||||||
echo "ci-box install: installed ${BIN}"
|
echo "ci-box install: installed ${BIN}"
|
||||||
|
|
|
||||||
147
test/cli.sh
147
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"
|
grep -o "FORGEJO_RUNNER_FILE_MODE" "$ROOT/commands/forgejo-runner-status.sh"
|
||||||
rm -rf "$FRW"
|
rm -rf "$FRW"
|
||||||
|
|
||||||
# The download must be verified. This binary is installed as root and executed
|
# --- the checksum gate, DRIVEN not grepped (review !110) --------------------
|
||||||
# by a systemd unit; an unverified fetch is the one step here that silently
|
# This binary is installed as root and executed by a systemd unit, so the
|
||||||
# turns a network compromise into root on the box.
|
# verification is the one step here that silently turns a network compromise
|
||||||
check "forgejo-runner: install verifies the published checksum" 0 "checksum mismatch" \
|
# into root on the box. A grep for the mismatch string proved only that a
|
||||||
grep -o "checksum mismatch" "$FR"
|
# string existed — it could not have caught the fail-open branch that used to
|
||||||
check "ci-box: its install.sh verifies the published checksum too" 0 "checksum mismatch" \
|
# sit beside it. So drive the real script against a stub curl, the
|
||||||
grep -o "checksum mismatch" "$ROOT/docs/templates/ci-box/install.sh"
|
# 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" <<CBINST
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
exec /usr/bin/install -m 0755 "\${@: -2:1}" "$CBW/installed"
|
||||||
|
CBINST
|
||||||
|
chmod +x "$CBSTUB/install"
|
||||||
|
|
||||||
|
cibox_run() { # cibox_run [VAR=val ...] — the REAL template install.sh, stubbed
|
||||||
|
rm -f "$CBW/installed"
|
||||||
|
env PATH="$CBSTUB:$PATH" \
|
||||||
|
CB_REDIRECT=https://code.forgejo.org/forgejo/runner/releases/tag/v9.9.9 \
|
||||||
|
CB_PAYLOAD="$CBW/payload" "$@" bash "$CIBOX"
|
||||||
|
}
|
||||||
|
cibox_installed() { [ -e "$CBW/installed" ]; }
|
||||||
|
|
||||||
|
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" \
|
||||||
|
cibox_run
|
||||||
|
check "ci-box: ...and installed NOTHING" 1 "" cibox_installed
|
||||||
|
check "ci-box: a MISMATCHED checksum refuses" 1 "checksum mismatch" \
|
||||||
|
cibox_run CB_SUM=0000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
check "ci-box: ...and installed nothing either" 1 "" cibox_installed
|
||||||
|
check "ci-box: an EMPTY checksum file refuses" 1 "unreadable" cibox_run CB_SUM=EMPTY
|
||||||
|
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"
|
||||||
|
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"
|
||||||
|
|
||||||
|
# --- --version must converge, not be swallowed (review !110) ----------------
|
||||||
|
# forgejo-runner does NOT self-update, and a ci-box's template preinstalls the
|
||||||
|
# binary at mint — so a bare presence check would make --version dead on the
|
||||||
|
# exact path this command exists for.
|
||||||
|
FRLIB="$ROOT/commands/lib/forgejo-runner-config.sh"
|
||||||
|
vparse() { bash -c ". '$FRLIB'; runner_version_of '$1'"; }
|
||||||
|
VSTUB="$(mktemp -d)"
|
||||||
|
printf '#!/bin/sh\necho "forgejo-runner version v12.13.2"\n' > "$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
|
# 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
|
# GitHub one: Forgejo has no deregistration endpoint, so the ghost it strands
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue