ci: add a provisioning script for an x86_64 Android build runner

Gitea runs on an aarch64 host, and 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). So CI for this repo was
failing for two independent reasons, and fixing the runner's container network
would only have exposed the second. An x86_64 runner is a requirement here, not
a preference.

Registers at android-stuff org scope rather than repo scope, so any Android
repo in the org can use the same runner, and labels it by capability
(flutter-android) rather than by hostname, so the workflow does not encode
which machine happens to provide it.

Host mode rather than a container: the machine already has a proven Flutter,
JDK and Android SDK/NDK, so jobs build with the same toolchain that produced
the verified release and skip a per-run NDK download. The trade is isolation,
which the header states plainly — workflows run as that user on that machine.

Refuses to register unless the toolchain it would advertise is actually
present, so a missing SDK fails at setup with a list rather than inside every
later job.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-26 23:21:17 +09:00
parent 85dc74247c
commit 66826bbce6
+160
View File
@@ -0,0 +1,160 @@
#!/usr/bin/env bash
# Register this machine as a Gitea Actions runner for the android-stuff org.
#
# WHY THIS MACHINE, AND NOT THE GITEA HOST
# Gitea runs on pi5-16 (aarch64). 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). 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
# This machine already has a proven Flutter + Android SDK + NDK toolchain. In
# host mode the job uses it directly: no SDK download, no ~1GB NDK pull per
# 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
# 1. Get an ORG-scoped registration token:
# http://pi5-16.local:3005/org/android-stuff/settings/actions/runners
# → "Create new runner" → copy the token
# Org scope, not repo scope, so every android-stuff repo can use it.
# 2. RUNNER_TOKEN=xxxx ./tool/setup_ci_runner.sh
#
# Re-running is safe: it skips the download if the binary is current and
# refuses to clobber an existing registration.
set -euo pipefail
VERSION="${RUNNER_VERSION:-3.3.0}"
INSTANCE="${GITEA_INSTANCE:-http://pi5-16.local:3005}"
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
# can do, so the workflow does not encode which machine happens to provide it.
LABEL="${RUNNER_LABEL:-flutter-android}"
: "${RUNNER_TOKEN:?set RUNNER_TOKEN to an org registration token (see header)}"
echo "==> Checking the toolchain this runner promises to provide"
missing=0
for cmd in flutter dart java cmake ninja node git unzip; do
if command -v "$cmd" >/dev/null; then
printf ' ok %s\n' "$cmd"
else
printf ' MISSING %s\n' "$cmd"
missing=1
fi
done
SDK="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}"
if [[ -n "$SDK" && -d "$SDK/ndk" && -d "$SDK/build-tools" ]]; then
echo " ok Android SDK at $SDK (ndk: $(ls "$SDK/ndk" | tr '\n' ' '))"
else
echo " MISSING Android SDK with ndk/ and build-tools/ (ANDROID_HOME=${SDK:-unset})"
missing=1
fi
if [[ $missing -ne 0 ]]; then
echo
echo "Refusing to register: a runner advertising '$LABEL' must actually" >&2
echo "provide these, or every job will fail confusingly instead of here." >&2
exit 1
fi
echo "==> Installing gitea-runner $VERSION"
mkdir -p "$(dirname "$BIN")" "$RUNNER_HOME"
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 " downloading $url"
curl -fSL --retry 3 -o "$BIN.tmp" "$url"
chmod +x "$BIN.tmp"
mv "$BIN.tmp" "$BIN"
fi
"$BIN" --version
echo "==> Writing 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 Android build at a time: gradle + a 2-ABI NDK compile will happily
# saturate this box, and concurrent runs would just contend.
capacity: 1
timeout: 45m
# ':host' runs the job directly on this machine using the toolchain above,
# 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 a
# one-line change rather than a rediscovery.
network: ""
privileged: false
host:
workdir_parent: ${RUNNER_HOME}/workspace
YAML
echo " wrote $CONFIG"
fi
echo "==> Registering with $INSTANCE"
if [[ -f "$RUNNER_HOME/.runner" ]]; then
echo " already registered (found $RUNNER_HOME/.runner) — skipping."
echo " To re-register: remove that file and the runner in the Gitea UI first."
else
(cd "$RUNNER_HOME" && "$BIN" register \
--no-interactive \
--instance "$INSTANCE" \
--token "$RUNNER_TOKEN" \
--name "$RUNNER_NAME" \
--labels "${LABEL}:host" \
--config "$CONFIG")
fi
cat <<EOF
==> Done. Start it with:
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]
Description=Gitea Actions runner (${LABEL}, host mode)
After=network-online.target
Wants=network-online.target
[Service]
ExecStart=${BIN} daemon --config ${CONFIG}
WorkingDirectory=${RUNNER_HOME}
Restart=always
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.
Environment=ANDROID_HOME=${SDK}
Environment=ANDROID_SDK_ROOT=${SDK}
[Install]
WantedBy=default.target
UNIT
echo " (unit file written)"