ae2033865c
The setup script refused to provision, reporting /opt/flutter/bin/cache and /opt/android-sdk unwritable by the service account, while a touch as that same account succeeded. Cause: this machine's /usr/bin/test is uutils coreutils 0.8.0, whose -w does not credit supplementary group membership. Write access to both directories comes from the flutterdev group rather than the owner bits, so -w reports false for any account that should be building here — including the developer's own. -r and -x pass only because `other` carries r-x, which is what made the failure look ACL-shaped. bash's builtin [ -w ] evaluates it correctly, but sudo execs the external binary, so the builtin was never in play. Replaced with a real write probe: create a file as the service account and remove it. That tests the operation a build actually performs and is immune to whichever test implementation is installed, which is the better check regardless of the bug that prompted it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
280 lines
11 KiB
Bash
Executable File
280 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.
|
|
#
|
|
# By actually writing a file, not by asking `test -w`. This machine's
|
|
# /usr/bin/test is uutils coreutils 0.8.0, whose -w does not credit
|
|
# supplementary group membership: it reports /opt/android-sdk unwritable for
|
|
# any account whose write access comes from the flutterdev group rather than
|
|
# from the owner bits — which is every account that should be running builds
|
|
# here. (-r and -x pass only because `other` has r-x.) bash's builtin `[ -w ]`
|
|
# gets it right, but sudo execs the external binary, so the builtin is not
|
|
# available here. A write probe is immune to the whole question.
|
|
say "Verifying $RUNNER_USER can write the SDKs"
|
|
write_ok=1
|
|
for d in "/opt/flutter/bin/cache" "$SDK"; do
|
|
if sudo -u "$RUNNER_USER" env probe="$d/.gitea-runner-probe.$$" \
|
|
sh -c 'touch "$probe" && rm -f "$probe"' 2>/dev/null; then
|
|
printf ' ok writable: %s\n' "$d"
|
|
else
|
|
printf ' FAIL not writable: %s\n' "$d"
|
|
sudo -u "$RUNNER_USER" rm -f "$d/.gitea-runner-probe.$$" 2>/dev/null || true
|
|
write_ok=0
|
|
fi
|
|
done
|
|
if [[ $write_ok -ne 1 ]]; then
|
|
echo
|
|
echo "$RUNNER_USER is in $SDK_GROUP but still cannot write. Check:" >&2
|
|
echo " getfacl /opt/flutter /opt/android-sdk" >&2
|
|
echo " id $RUNNER_USER" >&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
|