#!/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 # /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" <&2 exit 1 } fi # ── Service ──────────────────────────────────────────────────────────────── say "systemd unit" cat > "$UNIT" < 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