Files
iperf3-gui/tool/setup_ci_runner.sh
T
mikkeli 86b290d978
CI / Analyze & Test (push) Successful in 13s
CI / Release APK (push) Successful in 24s
ci: run the runner as a dedicated account under a system service
The earlier script installed a systemd *user* service, which conflated two
separate decisions. What matters in host mode is which account jobs run as,
because there is no container boundary and the service account is the only
sandbox. A system service with User= set gets unprivileged jobs *and* a
root-managed lifecycle that starts at boot with no lingering session.

Jobs now run as a dedicated `gitea-runner` account in the flutterdev group,
with its own HOME so its Gradle and pub caches are its own. The alternative —
a human account — would hand every workflow that user's home directory and SSH
keys, which is a sharper edge than usual here because a workflow file is code
and much of this repo is agent-authored. Running as root would be worse still,
and would also miss the warm caches in a human HOME and re-download
dependencies every run.

flutterdev is what grants write access to /opt/flutter and /opt/android-sdk.
Group membership alone does not prove it, since both carry ACLs, so the script
tests writability as the service account and fails with the getfacl command to
run. Tool lookups use systemd's default PATH rather than an interactive shell's,
because that is what the service will actually have.

Adds --check, which runs pre-flight and exits without creating anything.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 23:44:05 +09:00

268 lines
11 KiB
Bash
Executable File

#!/usr/bin/env bash
# Provision this machine as a Gitea Actions runner for the android-stuff org.
#
# sudo ./tool/setup_ci_runner.sh --check # pre-flight only, changes nothing
# sudo RUNNER_TOKEN=xxxx ./tool/setup_ci_runner.sh
#
# Get an ORG-scoped token from
# <gitea>/org/android-stuff/settings/actions/runners → "Create new runner"
# Org scope, not repo scope, so every android-stuff repo shares one runner.
#
# ── WHY THIS MACHINE, NOT THE GITEA HOST ────────────────────────────────────
# Gitea runs on an aarch64 box. Flutter cannot build an Android APK on a Linux
# arm64 host at all: the SDK ships no arm64-host gen_snapshot
# (flutter/flutter#189724, #75864, open as of July 2026). An x86_64 runner is a
# requirement here, not a preference.
#
# ── WHY HOST MODE, NOT A CONTAINER ──────────────────────────────────────────
# This machine has a working Flutter + Android SDK + NDK. In host mode jobs use
# it directly, so CI builds with the same toolchain that produced the verified
# release and skips a per-run NDK download. The cost is that there is no
# container boundary — which is exactly why the service account below matters.
#
# ── WHY A DEDICATED USER, AND A SYSTEM SERVICE ──────────────────────────────
# Host mode means the service account IS the sandbox; there is no other. A
# workflow file is code, and it runs as whoever this service runs as. Running
# as a human user would give every workflow read access to that user's home,
# SSH keys included. Running as root would be worse, and would also miss the
# warm Gradle/pub caches in that home (HOME=/root), re-downloading dependencies
# every run for no benefit.
#
# So: a dedicated unprivileged account, in the flutterdev group for SDK access,
# with its own HOME so its caches are its own. A *system* service rather than a
# user service, so it starts at boot under plain systemctl with no lingering
# session — the service being root-managed is unrelated to the jobs being
# unprivileged, and conflating those two was the earlier mistake here.
set -euo pipefail
VERSION="${RUNNER_VERSION:-3.3.0}"
INSTANCE="${GITEA_INSTANCE:-http://pi5-16.local:3005}"
RUNNER_USER="${RUNNER_USER:-gitea-runner}"
SDK_GROUP="${SDK_GROUP:-flutterdev}"
RUNNER_HOME="${RUNNER_HOME:-/var/lib/gitea-runner}"
BIN="${RUNNER_BIN:-/usr/local/bin/gitea-runner}"
UNIT="/etc/systemd/system/gitea-runner.service"
RUNNER_NAME="${RUNNER_NAME:-$(hostname)-android}"
# Capability label, deliberately not a hostname: it says what the runner can
# do, so a workflow does not encode which machine happens to provide it.
LABEL="${RUNNER_LABEL:-flutter-android}"
CHECK_ONLY=0
[[ "${1:-}" == "--check" ]] && CHECK_ONLY=1
[[ $EUID -eq 0 ]] || { echo "Run with sudo: sudo RUNNER_TOKEN=... $0" >&2; exit 1; }
# --check is for validating this machine before committing to anything, so it
# deliberately does not require a token.
if [[ $CHECK_ONLY -eq 0 ]]; then
: "${RUNNER_TOKEN:?set RUNNER_TOKEN to an org registration token (see header)}"
fi
say() { printf '\n==> %s\n' "$1"; }
# ── Pre-flight, before anything is created ─────────────────────────────────
say "Pre-flight"
missing=0
for cmd in flutter dart java cmake ninja node git unzip curl; do
# Resolved from systemd's own PATH, not an interactive shell's, since that
# is what the service will actually have.
if PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \
command -v "$cmd" >/dev/null; then
printf ' ok %s\n' "$cmd"
else
printf ' MISSING %s (not on systemd default PATH)\n' "$cmd"
missing=1
fi
done
SDK="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-/opt/android-sdk}}"
for d in "$SDK/ndk" "$SDK/build-tools" "$SDK/platforms"; do
[[ -d "$d" ]] && printf ' ok %s\n' "$d" \
|| { printf ' MISSING %s\n' "$d"; missing=1; }
done
getent group "$SDK_GROUP" >/dev/null \
&& printf ' ok group %s\n' "$SDK_GROUP" \
|| { printf ' MISSING group %s\n' "$SDK_GROUP"; missing=1; }
if [[ $missing -ne 0 ]]; then
echo
echo "Refusing to register: a runner advertising '$LABEL' must actually" >&2
echo "provide these, or every job fails confusingly instead of here." >&2
exit 1
fi
if [[ $CHECK_ONLY -eq 1 ]]; then
say "Pre-flight passed"
echo " Nothing was created. Re-run with RUNNER_TOKEN set to provision."
echo " Note: SDK write access is verified as $RUNNER_USER, which can only"
echo " be checked once that account exists — so it is not covered here."
exit 0
fi
# ── Service account ────────────────────────────────────────────────────────
say "Service account: $RUNNER_USER"
if id "$RUNNER_USER" &>/dev/null; then
echo " exists; ensuring membership of $SDK_GROUP"
usermod -aG "$SDK_GROUP" "$RUNNER_USER"
else
# --shell /bin/bash, not nologin: host-mode steps are executed as shell
# scripts, and a login shell costs nothing here — the account has no
# password, so it is only reachable via root anyway.
useradd --system \
--create-home --home-dir "$RUNNER_HOME" \
--shell /bin/bash \
--groups "$SDK_GROUP" \
--comment "Gitea Actions runner ($LABEL)" \
"$RUNNER_USER"
echo " created"
fi
install -d -o "$RUNNER_USER" -g "$SDK_GROUP" -m 0750 \
"$RUNNER_HOME" "$RUNNER_HOME/workspace"
id "$RUNNER_USER"
# Group membership is worthless if the ACLs disagree, so prove it rather than
# assume it — as the service user, against the paths a build actually writes.
say "Verifying $RUNNER_USER can write the SDKs"
acl_ok=1
for d in "/opt/flutter/bin/cache" "$SDK"; do
if sudo -u "$RUNNER_USER" test -w "$d"; then
printf ' ok writable: %s\n' "$d"
else
printf ' FAIL not writable: %s\n' "$d"
acl_ok=0
fi
done
if [[ $acl_ok -ne 1 ]]; then
echo
echo "$RUNNER_USER is in $SDK_GROUP but still cannot write. Check the ACLs:" >&2
echo " getfacl /opt/flutter /opt/android-sdk" >&2
exit 1
fi
# ── Binary ─────────────────────────────────────────────────────────────────
say "Installing gitea-runner $VERSION"
if [[ -x "$BIN" ]] && "$BIN" --version 2>/dev/null | grep -qF "$VERSION"; then
echo " already at $VERSION"
else
url="https://dl.gitea.com/gitea-runner/${VERSION}/gitea-runner-${VERSION}-linux-amd64"
echo " $url"
curl -fSL --retry 3 -o "$BIN.tmp" "$url"
chmod 0755 "$BIN.tmp"
mv "$BIN.tmp" "$BIN"
fi
"$BIN" --version
# ── Config ─────────────────────────────────────────────────────────────────
say "Config"
CONFIG="$RUNNER_HOME/config.yaml"
if [[ -f "$CONFIG" ]]; then
echo " $CONFIG exists, leaving it alone"
else
cat > "$CONFIG" <<YAML
log:
level: info
runner:
file: .runner
# One at a time: a 2-ABI NDK compile plus Gradle will saturate this box, and
# concurrent jobs would only contend for it.
capacity: 1
timeout: 45m
# ':host' runs steps directly on this machine as ${RUNNER_USER}, using the
# toolchain verified in pre-flight, rather than in a container that would
# have to fetch its own.
labels:
- "${LABEL}:host"
container:
# Unused in host mode. Kept so switching to container mode later is an edit
# rather than a rediscovery — it would need the Docker network that the
# Gitea host's own runner also needs.
network: ""
privileged: false
host:
workdir_parent: ${RUNNER_HOME}/workspace
YAML
chown "$RUNNER_USER:$SDK_GROUP" "$CONFIG"
chmod 0640 "$CONFIG"
echo " wrote $CONFIG"
fi
# ── Registration ───────────────────────────────────────────────────────────
say "Registering with $INSTANCE"
if [[ -f "$RUNNER_HOME/.runner" ]]; then
echo " already registered ($RUNNER_HOME/.runner) — skipping."
echo " To re-register: delete that file AND remove the runner in Gitea."
else
sudo -u "$RUNNER_USER" HOME="$RUNNER_HOME" \
"$BIN" register \
--no-interactive \
--instance "$INSTANCE" \
--token "$RUNNER_TOKEN" \
--name "$RUNNER_NAME" \
--labels "${LABEL}:host" \
--config "$CONFIG"
# register writes .runner into the cwd it was given; make sure it landed.
[[ -f "$RUNNER_HOME/.runner" ]] || {
echo " registration produced no .runner in $RUNNER_HOME" >&2
exit 1
}
fi
# ── Service ────────────────────────────────────────────────────────────────
say "systemd unit"
cat > "$UNIT" <<UNITFILE
[Unit]
Description=Gitea Actions runner (${LABEL}, host mode)
Documentation=https://gitea.com/gitea/act_runner
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=${RUNNER_USER}
Group=${SDK_GROUP}
WorkingDirectory=${RUNNER_HOME}
ExecStart=${BIN} daemon --config ${CONFIG}
Restart=always
RestartSec=5
# HOME must be explicit: it decides where Gradle and pub put their caches, and
# a service does not inherit a login environment.
Environment=HOME=${RUNNER_HOME}
Environment=GRADLE_USER_HOME=${RUNNER_HOME}/.gradle
Environment=PUB_CACHE=${RUNNER_HOME}/.pub-cache
Environment=ANDROID_HOME=${SDK}
Environment=ANDROID_SDK_ROOT=${SDK}
[Install]
WantedBy=multi-user.target
UNITFILE
echo " wrote $UNIT"
systemctl daemon-reload
systemctl enable --now gitea-runner
sleep 2
systemctl --no-pager --lines=15 status gitea-runner || true
cat <<EOF
==> Done.
Logs: journalctl -u gitea-runner -f
Stop: systemctl stop gitea-runner
Remove: systemctl disable --now gitea-runner
rm $UNIT $BIN && rm -rf $RUNNER_HOME
userdel $RUNNER_USER
...and delete the runner in the Gitea org settings.
The runner should now appear under
${INSTANCE}/org/android-stuff/settings/actions/runners
with label '$LABEL'. The queued run for this repo should start on its own.
First Android build will be slow: this account's Gradle and pub caches
start empty by design. Subsequent runs reuse them.
EOF