86b290d978
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>
268 lines
11 KiB
Bash
Executable File
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
|