Compare commits

..

6 Commits

Author SHA1 Message Date
mikkeli 32575530dc Merge branch 'ci/self-hosted-x86-64-runner'
CI / Analyze & Test (push) Successful in 19s
CI / Release APK (push) Successful in 1m53s
Move CI to a self-hosted x86_64 runner registered at android-stuff org scope.
Flutter cannot build an Android APK on a Linux arm64 host, so the aarch64 Gitea
box could never have run this build regardless of its container networking.

Adds tool/setup_ci_runner.sh to provision it in host mode, using the machine's
existing proven toolchain, and points both jobs at the capability label
flutter-android.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 23:21:28 +09:00
mikkeli 0dd0b95afb ci: run on the self-hosted x86_64 runner and drop the toolchain installers
Targets runs-on: flutter-android for both jobs. Since that runner is host mode
with the toolchain preinstalled, subosito/flutter-action and
actions/setup-java are removed — they would have downloaded a second Flutter
and JDK over the ones being used, and CI would no longer be building with the
toolchain the release was verified against. A Toolchain step fails loudly if
flutter or ANDROID_HOME is missing rather than letting a later step fail
obscurely.

Documents in docs/README.md why the runner cannot be the Gitea host, what host
mode trades away, and what each job protects — including why building the
release APK is necessary but not sufficient, which is the reason the JNI name
guard exists at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 23:21:17 +09:00
mikkeli 66826bbce6 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>
2026-08-26 23:21:17 +09:00
mikkeli 85dc74247c Merge branch 'ci/release-apk-and-jni-name-guard'
CI / Analyze & Test (push) Failing after 28s
CI / Release APK (push) Failing after 1m48s
Add a CI job that builds the release APK and verifies the names JNI resolves
survived R8, so the broken-release-APK failure mode cannot recur silently.

CI's existing failures are unrelated to workflow content: every run has failed
at actions/checkout because the runner's job containers cannot resolve the
'gitea' hostname. That needs container.network set in the runner's config.yaml.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 22:54:33 +09:00
mikkeli 94d96297f8 ci: build the release APK and check the JNI names
CI only ran analyze and test, so it would have passed for every commit that
carried the broken release APK. Adds a release-apk job that builds the
minified artifact, runs tool/check_jni_keep_rules.sh against it, and uploads
it — turning "the shipping artifact works" into something checked rather than
assumed at tag time.

Enables flutter-action's dependency cache on both jobs, and notes that the
test step now includes the layering guard.

This does not fix the current failures: every run so far has failed at
actions/checkout, before Flutter is reached, because the runner's job
containers cannot resolve the `gitea` hostname. That is runner configuration,
not workflow content.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 22:54:19 +09:00
mikkeli a96f302dc7 test: guard the JNI-visible names against R8 renaming
A renamed JNI callback does not fail the build — it produces a release APK
that aborts on the first callback of every test. That shipped once, because
every on-device check used the debug build, which does not minify.

This reads R8's own mapping file for the classes and callback methods, and the
DEX string pool for the native entry points. Native methods are deliberately
not checked against mapping.txt: R8 only emits entries for members with a body
or a rename, so a native method is absent either way and absence would prove
nothing.

Verified by deleting the callback keep rules and rebuilding: the check reports
MainThreadCallback renamed along with onOutput, onError and onComplete, which
is exactly the failure that shipped — with no device involved.

Also avoids `... | strings | grep -q` and dumps to a file first. Under
`set -o pipefail`, grep -q exits at the first match and SIGPIPEs strings, so
the pipeline reports failure for a name found early and success for one found
late. It reported two of four names missing while all four were present.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 22:54:19 +09:00
4 changed files with 352 additions and 5 deletions
+45 -5
View File
@@ -6,17 +6,28 @@ on:
pull_request:
branches: [ master ]
# Both jobs target a self-hosted runner, by capability rather than by hostname.
# See tool/setup_ci_runner.sh for how that runner is provisioned and why it is
# not the Gitea host: Flutter cannot build an Android APK on a Linux arm64 host
# (flutter/flutter#189724), and the Gitea box is aarch64. The runner is
# registered at android-stuff org scope so any repo in the org can use it.
#
# The runner is host-mode, so Flutter, the JDK and the Android SDK/NDK are the
# machine's own — the same toolchain that produced the verified release, and no
# multi-hundred-MB download per run. Nothing here installs a toolchain; the
# Toolchain step fails loudly if one is missing.
jobs:
analyze:
name: Analyze & Test
runs-on: ubuntu-latest
runs-on: flutter-android
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
flutter-version: '3.47.1'
channel: 'stable'
- name: Toolchain
run: |
flutter --version
echo "ANDROID_HOME=${ANDROID_HOME:?not set on this runner}"
- name: Get dependencies
run: flutter pub get
@@ -24,5 +35,34 @@ jobs:
- name: Analyze
run: flutter analyze
# Includes test/architecture_test.dart, which fails the build if a layer
# boundary is crossed.
- name: Run tests
run: flutter test
release-apk:
name: Release APK
runs-on: flutter-android
steps:
- uses: actions/checkout@v4
- name: Get dependencies
run: flutter pub get
# The debug build does not minify, so it cannot catch an R8 problem. This
# job exists because a release APK that aborted on every test shipped
# once while the debug build passed every check.
- name: Build release APK
run: flutter build apk --release
# Building is not enough on its own: a renamed JNI callback still
# produces a successful build and a broken APK. This reads R8's mapping
# and the DEX string pool to confirm the names JNI resolves survived.
- name: Check JNI names survived minification
run: ./tool/check_jni_keep_rules.sh
- uses: actions/upload-artifact@v4
with:
name: app-release-apk
path: build/app/outputs/flutter-apk/app-release.apk
if-no-files-found: error
+35
View File
@@ -183,6 +183,41 @@ Dart uses `Completer<Iperf3Result>` to await native completion. The MethodChanne
| `isTestRunning()` | `isTestRunning` | Check if test is in progress |
| `setTempDir(String)` | `setTempDir` | Point `mkstemp()` at the app cache dir |
## CI
Both jobs run on a **self-hosted runner registered at `android-stuff` org scope**,
targeted by the capability label `flutter-android` rather than by hostname.
`tool/setup_ci_runner.sh` provisions it.
It does not run on the Gitea host. Gitea lives on an aarch64 box, and Flutter
cannot build an Android APK on a Linux arm64 host at all — the SDK ships no
arm64-host `gen_snapshot` ([flutter#189724](https://github.com/flutter/flutter/issues/189724),
[#75864](https://github.com/flutter/flutter/issues/75864), open as of July 2026).
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
SDK/NDK. That means CI builds with the same toolchain that produced the verified
release, and no NDK download per run. The trade is isolation — workflows run as
that user on that machine — so the runner is only for repos whose workflows are
trusted.
Two jobs:
| Job | What it protects |
|---|---|
| `analyze` | `flutter analyze`, then `flutter test` — which includes `architecture_test.dart`, failing the build if a layer boundary is crossed |
| `release-apk` | builds the **minified** APK and runs `tool/check_jni_keep_rules.sh` |
The second exists because of a specific escape. A release APK that aborted on
every test shipped once, while every on-device check passed — because those
checks all used the debug build, which does not minify. Building the release APK
is necessary but not sufficient: a renamed JNI callback still produces a
*successful build* and a broken app. So the guard reads R8's `mapping.txt` for
the callback classes and methods, and the DEX string pool for the native entry
points. It is verified to fail: delete the keep rules from
`android/app/proguard-rules.pro`, rebuild, and it reports `MainThreadCallback`,
`onOutput`, `onError` and `onComplete` renamed.
## Building
Prerequisites:
+112
View File
@@ -0,0 +1,112 @@
#!/usr/bin/env bash
# Assert that R8 left the names JNI resolves at runtime alone.
#
# iperf_jni.c looks up the callback methods by name with GetMethodID. R8 renames
# by default, and a renamed callback does not fail the build — it produces an
# APK that aborts on the first callback of every test:
#
# NoSuchMethodError: no non-static method "Lp0;.onOutput(Ljava/lang/String;)V"
#
# That shipped once. Building the release APK is not enough to catch it, because
# the build succeeds; this reads R8's own mapping file and checks the names
# survived. Run after `flutter build apk --release`.
set -euo pipefail
MAPPING="${1:-build/app/outputs/mapping/release/mapping.txt}"
APK="${2:-build/app/outputs/flutter-apk/app-release.apk}"
if [[ ! -f "$MAPPING" ]]; then
echo "FAIL: no R8 mapping at $MAPPING"
echo " Run 'flutter build apk --release' first. If the release build"
echo " no longer minifies, this check is meaningless — say so"
echo " explicitly rather than deleting it."
exit 1
fi
failed=0
# A class kept by name maps to itself: "a.b.C -> a.b.C:".
require_class_kept() {
local fqcn="$1"
if grep -qF -- "$fqcn -> $fqcn:" "$MAPPING"; then
echo " ok class $fqcn"
else
echo " FAIL class $fqcn was renamed or removed"
grep -F -- "$fqcn ->" "$MAPPING" | head -3 | sed 's/^/ got: /' || true
failed=1
fi
}
# A method kept by name ends with "-> <name>".
require_method_kept() {
local signature="$1" name="$2"
if grep -qE -- "$signature.*-> ${name}\$" "$MAPPING"; then
echo " ok method $name"
else
echo " FAIL method $name was renamed"
grep -E -- "$signature" "$MAPPING" | head -3 | sed 's/^/ got: /' || true
failed=1
fi
}
echo "Checking JNI-visible names in $MAPPING"
require_class_kept 'com.novoyuuparosk.iperf3gui.IperfRunner'
require_class_kept 'com.novoyuuparosk.iperf3gui.IperfRunner$IperfCallback'
require_class_kept 'com.novoyuuparosk.iperf3gui.IperfRunner$MainThreadCallback'
# Resolved by iperf_jni.c on the concrete callback object's class.
require_method_kept 'void onOutput\(java\.lang\.String\)' 'onOutput'
require_method_kept 'void onError\(java\.lang\.String\)' 'onError'
require_method_kept 'void onComplete\(\)' 'onComplete'
# Native methods are NOT checked against mapping.txt: R8 only emits entries for
# members it has line-number info or a rename for, and a native method has no
# body, so it is absent whether or not it was renamed. Absence would prove
# nothing. Check the DEX string pool instead — a surviving name is interned
# there, a renamed one is not.
check_dex_names() {
local apk="$1"
shift
if [[ ! -f "$apk" ]]; then
echo " skip DEX name check: no APK at $apk"
return
fi
local tmp
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' RETURN
if ! unzip -o -q "$apk" 'classes*.dex' -d "$tmp" 2>/dev/null; then
echo " FAIL could not extract classes*.dex from $apk"
failed=1
return
fi
# Dump the string pool once to a file, then grep the file. Never
# `... | strings | grep -q`: grep -q exits at the first match, SIGPIPEs
# strings, and `set -o pipefail` reports the whole pipeline as failed — so
# a name found early "fails" and one found late passes. That flakiness cost
# an afternoon; leave the intermediate file in place.
cat "$tmp"/classes*.dex | strings > "$tmp/dex-strings.txt"
local name
for name in "$@"; do
if grep -qxF -- "$name" "$tmp/dex-strings.txt"; then
echo " ok native $name"
else
echo " FAIL native $name is absent from the DEX string pool"
failed=1
fi
done
}
check_dex_names "${2:-build/app/outputs/flutter-apk/app-release.apk}" \
runIperfLive forceStopIperfTest setTempDir isTestRunning
if [[ $failed -ne 0 ]]; then
echo
echo "R8 renamed something JNI resolves by name. The release APK will abort"
echo "on the first callback. Fix android/app/proguard-rules.pro."
exit 1
fi
echo "All JNI-visible names survived minification."
+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)"