ci: run the runner as a dedicated account under a system service
CI / Analyze & Test (push) Successful in 13s
CI / Release APK (push) Successful in 24s

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>
This commit is contained in:
2026-08-26 23:44:05 +09:00
parent 32575530dc
commit 86b290d978
2 changed files with 215 additions and 87 deletions
+25 -4
View File
@@ -196,10 +196,31 @@ arm64-host `gen_snapshot` ([flutter#189724](https://github.com/flutter/flutter/i
An x86_64 runner is a requirement, not a preference. An x86_64 runner is a requirement, not a preference.
The runner is **host mode**: jobs use the machine's own Flutter, JDK and Android The runner is **host mode**: jobs use the machine's own Flutter, JDK and Android
SDK/NDK. That means CI builds with the same toolchain that produced the verified SDK/NDK. CI therefore builds with the same toolchain that produced the verified
release, and no NDK download per run. The trade is isolation — workflows run as release, and no NDK is downloaded per run. The cost is that there is no
that user on that machine — so the runner is only for repos whose workflows are container boundary, which is what decides the next paragraph.
trusted.
Jobs run as a dedicated unprivileged account, `gitea-runner`, in the
`flutterdev` group. In host mode the service account *is* the sandbox — there
is no other — and a workflow file is code that runs as whoever the service runs
as. A human account would hand every workflow that user's home directory and
SSH keys; root would be worse and would also miss the warm caches in that home,
re-downloading dependencies every run. The dedicated account keeps its own
Gradle and pub caches under `/var/lib/gitea-runner`, so the first Android build
is slow by design and later ones are not.
`flutterdev` is what grants write access to `/opt/flutter` and
`/opt/android-sdk`; both carry ACLs with `group:flutterdev:rwx` and matching
`default:` entries, so new files inherit it. The setup script proves that access
as the service account rather than assuming group membership is sufficient.
It installs a **system** service, not a user service: root-managed lifecycle,
starts at boot, no lingering session. That the service is root-managed is
unrelated to the jobs being unprivileged — `User=` in the unit settles the
second, and conflating the two is easy to do.
`sudo ./tool/setup_ci_runner.sh --check` validates this machine's toolchain
without creating anything.
Two jobs: Two jobs:
+190 -83
View File
@@ -1,82 +1,160 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Register this machine as a Gitea Actions runner for the android-stuff org. # Provision this machine as a Gitea Actions runner for the android-stuff org.
# #
# WHY THIS MACHINE, AND NOT THE GITEA HOST # sudo ./tool/setup_ci_runner.sh --check # pre-flight only, changes nothing
# Gitea runs on pi5-16 (aarch64). Flutter cannot build an Android APK on a # sudo RUNNER_TOKEN=xxxx ./tool/setup_ci_runner.sh
# Linux arm64 host at all — the SDK ships no arm64-host gen_snapshot
# (flutter/flutter#189724, #75864, open as of July 2026). So an Android build
# runner has to be x86_64 regardless of how the Gitea host is configured.
# #
# WHY HOST MODE, AND NOT A CONTAINER # Get an ORG-scoped token from
# This machine already has a proven Flutter + Android SDK + NDK toolchain. In # <gitea>/org/android-stuff/settings/actions/runners → "Create new runner"
# host mode the job uses it directly: no SDK download, no ~1GB NDK pull per # Org scope, not repo scope, so every android-stuff repo shares one runner.
# run, and CI builds with the exact toolchain that produced the verified
# release. The trade is isolation — a workflow in this org runs commands as
# this user on this machine. That is the normal self-hosted-runner bargain, but
# it is a real one: only grant it for repos whose workflows you trust.
# #
# USAGE # ── WHY THIS MACHINE, NOT THE GITEA HOST ────────────────────────────────────
# 1. Get an ORG-scoped registration token: # Gitea runs on an aarch64 box. Flutter cannot build an Android APK on a Linux
# http://pi5-16.local:3005/org/android-stuff/settings/actions/runners # arm64 host at all: the SDK ships no arm64-host gen_snapshot
# → "Create new runner" → copy the token # (flutter/flutter#189724, #75864, open as of July 2026). An x86_64 runner is a
# Org scope, not repo scope, so every android-stuff repo can use it. # requirement here, not a preference.
# 2. RUNNER_TOKEN=xxxx ./tool/setup_ci_runner.sh
# #
# Re-running is safe: it skips the download if the binary is current and # ── WHY HOST MODE, NOT A CONTAINER ──────────────────────────────────────────
# refuses to clobber an existing registration. # 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 set -euo pipefail
VERSION="${RUNNER_VERSION:-3.3.0}" VERSION="${RUNNER_VERSION:-3.3.0}"
INSTANCE="${GITEA_INSTANCE:-http://pi5-16.local:3005}" 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}" RUNNER_NAME="${RUNNER_NAME:-$(hostname)-android}"
RUNNER_HOME="${RUNNER_HOME:-$HOME/.local/share/gitea-runner}"
BIN="${RUNNER_BIN:-$HOME/.local/bin/gitea-runner}"
# Capability-based label, deliberately not host-based: it says what the runner # Capability label, deliberately not a hostname: it says what the runner can
# can do, so the workflow does not encode which machine happens to provide it. # do, so a workflow does not encode which machine happens to provide it.
LABEL="${RUNNER_LABEL:-flutter-android}" LABEL="${RUNNER_LABEL:-flutter-android}"
: "${RUNNER_TOKEN:?set RUNNER_TOKEN to an org registration token (see header)}" CHECK_ONLY=0
[[ "${1:-}" == "--check" ]] && CHECK_ONLY=1
echo "==> Checking the toolchain this runner promises to provide" [[ $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 missing=0
for cmd in flutter dart java cmake ninja node git unzip; do for cmd in flutter dart java cmake ninja node git unzip curl; do
if command -v "$cmd" >/dev/null; then # Resolved from systemd's own PATH, not an interactive shell's, since that
printf ' ok %s\n' "$cmd" # 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 else
printf ' MISSING %s\n' "$cmd" printf ' MISSING %s (not on systemd default PATH)\n' "$cmd"
missing=1 missing=1
fi fi
done done
SDK="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}"
if [[ -n "$SDK" && -d "$SDK/ndk" && -d "$SDK/build-tools" ]]; then SDK="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-/opt/android-sdk}}"
echo " ok Android SDK at $SDK (ndk: $(ls "$SDK/ndk" | tr '\n' ' '))" for d in "$SDK/ndk" "$SDK/build-tools" "$SDK/platforms"; do
else [[ -d "$d" ]] && printf ' ok %s\n' "$d" \
echo " MISSING Android SDK with ndk/ and build-tools/ (ANDROID_HOME=${SDK:-unset})" || { printf ' MISSING %s\n' "$d"; missing=1; }
missing=1 done
fi 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 if [[ $missing -ne 0 ]]; then
echo echo
echo "Refusing to register: a runner advertising '$LABEL' must actually" >&2 echo "Refusing to register: a runner advertising '$LABEL' must actually" >&2
echo "provide these, or every job will fail confusingly instead of here." >&2 echo "provide these, or every job fails confusingly instead of here." >&2
exit 1 exit 1
fi fi
echo "==> Installing gitea-runner $VERSION" if [[ $CHECK_ONLY -eq 1 ]]; then
mkdir -p "$(dirname "$BIN")" "$RUNNER_HOME" 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 if [[ -x "$BIN" ]] && "$BIN" --version 2>/dev/null | grep -qF "$VERSION"; then
echo " already at $VERSION" echo " already at $VERSION"
else else
url="https://dl.gitea.com/gitea-runner/${VERSION}/gitea-runner-${VERSION}-linux-amd64" url="https://dl.gitea.com/gitea-runner/${VERSION}/gitea-runner-${VERSION}-linux-amd64"
echo " downloading $url" echo " $url"
curl -fSL --retry 3 -o "$BIN.tmp" "$url" curl -fSL --retry 3 -o "$BIN.tmp" "$url"
chmod +x "$BIN.tmp" chmod 0755 "$BIN.tmp"
mv "$BIN.tmp" "$BIN" mv "$BIN.tmp" "$BIN"
fi fi
"$BIN" --version "$BIN" --version
echo "==> Writing config" # ── Config ─────────────────────────────────────────────────────────────────
say "Config"
CONFIG="$RUNNER_HOME/config.yaml" CONFIG="$RUNNER_HOME/config.yaml"
if [[ -f "$CONFIG" ]]; then if [[ -f "$CONFIG" ]]; then
echo " $CONFIG exists, leaving it alone" echo " $CONFIG exists, leaving it alone"
@@ -87,74 +165,103 @@ log:
runner: runner:
file: .runner file: .runner
# One Android build at a time: gradle + a 2-ABI NDK compile will happily # One at a time: a 2-ABI NDK compile plus Gradle will saturate this box, and
# saturate this box, and concurrent runs would just contend. # concurrent jobs would only contend for it.
capacity: 1 capacity: 1
timeout: 45m timeout: 45m
# ':host' runs the job directly on this machine using the toolchain above, # ':host' runs steps directly on this machine as ${RUNNER_USER}, using the
# rather than in a container that would have to fetch its own. # toolchain verified in pre-flight, rather than in a container that would
# have to fetch its own.
labels: labels:
- "${LABEL}:host" - "${LABEL}:host"
container: container:
# Unused in host mode, kept so switching to container mode later is a # Unused in host mode. Kept so switching to container mode later is an edit
# one-line change rather than a rediscovery. # rather than a rediscovery — it would need the Docker network that the
# Gitea host's own runner also needs.
network: "" network: ""
privileged: false privileged: false
host: host:
workdir_parent: ${RUNNER_HOME}/workspace workdir_parent: ${RUNNER_HOME}/workspace
YAML YAML
chown "$RUNNER_USER:$SDK_GROUP" "$CONFIG"
chmod 0640 "$CONFIG"
echo " wrote $CONFIG" echo " wrote $CONFIG"
fi fi
echo "==> Registering with $INSTANCE" # ── Registration ───────────────────────────────────────────────────────────
say "Registering with $INSTANCE"
if [[ -f "$RUNNER_HOME/.runner" ]]; then if [[ -f "$RUNNER_HOME/.runner" ]]; then
echo " already registered (found $RUNNER_HOME/.runner) — skipping." echo " already registered ($RUNNER_HOME/.runner) — skipping."
echo " To re-register: remove that file and the runner in the Gitea UI first." echo " To re-register: delete that file AND remove the runner in Gitea."
else else
(cd "$RUNNER_HOME" && "$BIN" register \ sudo -u "$RUNNER_USER" HOME="$RUNNER_HOME" \
--no-interactive \ "$BIN" register \
--instance "$INSTANCE" \ --no-interactive \
--token "$RUNNER_TOKEN" \ --instance "$INSTANCE" \
--name "$RUNNER_NAME" \ --token "$RUNNER_TOKEN" \
--labels "${LABEL}:host" \ --name "$RUNNER_NAME" \
--config "$CONFIG") --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 fi
cat <<EOF # ── Service ────────────────────────────────────────────────────────────────
say "systemd unit"
==> Done. Start it with: cat > "$UNIT" <<UNITFILE
cd $RUNNER_HOME && $BIN daemon --config $CONFIG
Or install a user service that survives logout:
systemctl --user enable --now gitea-runner
loginctl enable-linger $USER # so it runs without an active session
A unit file is written alongside this script's output at:
$RUNNER_HOME/gitea-runner.service
Copy it to ~/.config/systemd/user/ and 'systemctl --user daemon-reload'.
EOF
cat > "$RUNNER_HOME/gitea-runner.service" <<UNIT
[Unit] [Unit]
Description=Gitea Actions runner (${LABEL}, host mode) Description=Gitea Actions runner (${LABEL}, host mode)
Documentation=https://gitea.com/gitea/act_runner
After=network-online.target After=network-online.target
Wants=network-online.target Wants=network-online.target
[Service] [Service]
ExecStart=${BIN} daemon --config ${CONFIG} Type=simple
User=${RUNNER_USER}
Group=${SDK_GROUP}
WorkingDirectory=${RUNNER_HOME} WorkingDirectory=${RUNNER_HOME}
ExecStart=${BIN} daemon --config ${CONFIG}
Restart=always Restart=always
RestartSec=5 RestartSec=5
# Host mode uses the login environment's toolchain; make the SDK explicit so a
# job does not depend on which shell rc happened to be sourced. # 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_HOME=${SDK}
Environment=ANDROID_SDK_ROOT=${SDK} Environment=ANDROID_SDK_ROOT=${SDK}
[Install] [Install]
WantedBy=default.target WantedBy=multi-user.target
UNIT UNITFILE
echo " (unit file written)" 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