Skip to content

Merge channels/google-play #60

Merge channels/google-play

Merge channels/google-play #60

Workflow file for this run

# Android patch-set check (LW-M0-09), ported to GitHub.
#
# LOCATION: .github/workflows/, because this repository is hosted on GitHub.
# The .forgejo/ copy is dead here — GitHub Actions does not read it (HANDOVER.md).
#
# This job answers exactly one question: does the Android patch list still apply
# to the Firefox tree the Android track is pinned to?
#
# The first job does NOT build Gecko: it answers the patch-set question in minutes.
# The second job (build-android, LW-M2-08) does build, on pushes and dispatches
# only, with three caches that live on the self-hosted runner's disk because
# actions/checkout wipes the workspace (git clean -ffdx) on every run:
#
# 1. the ESR tarball, keyed on version.android and re-verified against
# Mozilla's GPG signature on every hit -- a corrupt hit is discarded and
# re-fetched, never used;
# 2. the extracted+patched tree with its per-ABI objdirs, keyed on a stamp of
# every input that shapes it (assets/ incl. mozconfig.android and
# Dockerfile.android, patches/, the patcher, the two build scripts, the
# settings submodule commit, version/release, the container image id, and
# the ABI flags) -- a miss re-extracts and rebuilds from cold, a hit runs
# mach incrementally;
# 3. the Gradle user home (the Maven graph mach's Gradle stage downloads),
# which survives both hits and misses.
#
# The cache is keyed on content, not on the branch, so two branches with the
# same inputs share it and a mozconfig or toolchain change can never be served
# a stale objdir. What it does NOT do yet: sccache. moz.configure reads
# --with-ccache from the CCACHE environment variable (toolchain.configure:826),
# but scripts/android-fat-aar.sh and android-apk.sh forward a fixed env list
# into the container and mount only src/out, so an object cache needs a small
# change to both scripts (a --cache-dir mount and CCACHE=sccache) plus sccache in
# the image. That is the next step once a stamp miss is measured too slow.
#
# NO `container:` BLOCK, DELIBERATELY (same measured finding as android-release.yaml).
# The first draft ran this on `codeberg.org/librewolf/bsys6:dind`. Two reasons to
# drop it:
# 1. actions `container:` jobs do not work on this runner regardless of image —
# rootless podman behind the podman-docker shim, a dangling docker.sock,
# SELinux Enforcing with no :z mount flags (measured in android-release.yaml).
# 2. the bare runner already carries the toolchain this job needs (make, python3,
# curl, gpg, tar, xz, patch, unzip): android-release.yaml's preflight checks
# for exactly these and its full mode runs the same `make fetch`/`make dir`
# this job runs, with no container.
#
# It references no secrets. Signing never happens in CI — see LW-M6-01 and
# "What not to do" in docs/android/AGENTS.md.
name: Test Android Patch Set
on:
push:
branches:
- main
- beta
# The port lands on these until it reaches main. Without them this workflow
# would not run once during M0-M5, which is precisely when it is needed.
- android-port
- android/**
# The Firefox release watcher runs on a GitHub-hosted runner and tests
# itself (firefox-release-watch.yaml). A push that touches nothing else
# must not start the hour-long self-hosted build below. paths-ignore skips
# the run only when EVERY changed file matches, so any other change in the
# same push still runs it. pull_request is left unfiltered on purpose.
paths-ignore:
- .github/workflows/firefox-release-watch.yaml
- scripts/firefox-release-watch.py
- scripts/tests/test-firefox-release-watch.py
- scripts/tests/fixtures/firefox-release-watch/**
# The website (pages.yaml) builds and checks itself on a GitHub-hosted
# runner, and nothing in the APK is built from site/.
- site/**
- .github/workflows/pages.yaml
- scripts/site-check.py
# The F-Droid repository tooling (LW-M6-03): owner-run and Pages-run
# scripts and their data; nothing in the APK is built from them.
- scripts/fdroid-repo.sh
- scripts/fdroid-pages.py
- scripts/tests/test-fdroid-pages.py
- assets/fdroid/**
workflow_dispatch:
pull_request:
types:
- opened
- synchronize
jobs:
test-android:
# Box B since 2026-10-04, when box A's runner (librewolf-android) was retired.
runs-on: [self-hosted, redoubt-boxb]
steps:
- name: Checkout Repository
uses: actions/checkout@v4
with:
# settings/ is a submodule and scripts/librewolf-patches.py copies
# librewolf.cfg, policies.json and local-settings.js out of it, so
# `make dir` fails outright without this.
submodules: recursive
- name: Check the Android board
run: |
python3 docs/android/board.py --check
- name: Lint patch scope
run: |
# LW-M1-01 has landed, so this is blocking now: it exits non-zero on a
# common/desktop/android split that is not authoritative, which is a real
# failure, not an in-flight state. (Verified: exits 0 on this tree.)
python3 scripts/lint-patch-scope.py
- name: Check patch order
run: |
# LW-M1-10's acceptance says its test is "wired into
# .github/workflows/android-test.yaml", and it owns only
# scripts/check-patch-order.py and cannot edit this file. Wired here; it
# arms itself when that script lands.
if [ -f scripts/check-patch-order.py ]; then
python3 scripts/check-patch-order.py
else
echo "scripts/check-patch-order.py is not present yet (LW-M1-10); skipping."
fi
- name: Fetching Firefox ESR Source
run: |
# No detect-firefox-version.sh here: that script knows the release, beta
# and rc channels, and the Android track is pinned to an ESR version in
# ./version.android (docs/android/TRACK.md). TARGETS=android is what
# selects that pair, so the tarball fetched is the ESR one.
#
# This is ~766 MiB from archive.mozilla.org plus a GPG verify on every
# run, and it is deliberately uncached: caching is LW-M2-08's, keyed on
# the mozconfig and toolchain, and a half-measure here would be thrown
# away by it. For scale, source-test.yaml already pulls this tarball
# three times per pull request and then builds Firefox three times.
make fetch TARGETS=android
- name: Checking patches for rejects
run: |
# check-patchfail.sh is target-blind until LW-M1-11: it reads ./version
# (the desktop track), requires firefox-<desktop version>.source.tar.xz
# which this job never downloads, and walks the common+desktop shim.
# Unguarded it would fail every run for a reason that has nothing to do
# with the Android patch set. It arms itself when LW-M1-11 lands.
#
# It runs before `make dir` on purpose: it reports every rejected hunk,
# where the patcher stops at the first failing patch. On a rebase pull
# request that difference is the whole value of the step.
if grep -q -- '--targets' scripts/check-patchfail.sh; then
./scripts/check-patchfail.sh --targets=android
else
echo "scripts/check-patchfail.sh has no --targets yet (LW-M1-11); skipping."
echo "The patch-application test below is the blocking one."
fi
- name: Applying the Android patch set
run: |
# The test. Extracts the ESR tarball and runs
# scripts/librewolf-patches.py --targets=android over it, which applies
# assets/patches/common.txt then assets/patches/android.txt and exits 1
# on the first patch that does not apply — so this step is green exactly
# when the Android patch list applies and red when it does not.
#
# TARGETS=desktop,android is not tested here: the Makefile refuses it at
# recipe time whenever ./version and ./version.android differ, and it
# only reaches that guard after downloading a second tarball.
make dir TARGETS=android
- name: Check every Firefox brand image has a Redoubt replacement
run: |
# LW-M4-07. Needs the extracted tree, so it runs after `make dir`.
# A signed beta candidate shipped the Firefox flame as its home-screen
# wordmark on 2026-09-08 because nothing looked at images; this is what
# looks at them.
python3 scripts/android-brand-check.py "librewolf-$(cat version.android)-$(cat release.android)"
- name: Check the shipped prefs against GeckoView's declarations
run: |
# --check-policies needs the extracted tree (it reads GeckoRuntimeSettings
# and ContentBlocking) and exits 2 without one, so it runs here, after
# `make dir`, and nowhere earlier.
LW_TREE=librewolf-$(cat version.android)-$(cat release.android) \
python3 docs/android/board.py --check-policies
# ---------------------------------------------------------------------------
# LW-M2-08: the real build, with the runner-side caches described at the top.
#
# Pushes to main and workflow_dispatch only. A pull request from a fork must
# not be able to spend an hour of the self-hosted runner, and the patch-set
# job above already answers the question a PR needs answered. Pushes to the
# other branches (android-port is pushed at the same commit as main) would
# only queue a second build of the same tree.
#
# Runs on box B's on-demand VM (docs/android/CI-VM.md, "Box B": 16 vCPUs,
# 24 GiB, runner slice 22 GiB + 8 GiB swap), not on box A's runner, whose
# memory gate defers for hours while box A is busy. It used to `need`
# test-android: that job runs on box A, so the build was skipped every time
# test-android failed (2026-09-24: check-patch-order) and waited on box A
# otherwise. The cheap board/scope/order checks run here instead; the tree
# cache miss re-applies the patch set, and test-android still reports on its
# own.
#
# One build at a time, globally: the tree cache is shared, the runner is one
# machine. mach runs at -j8: at -j16 a Gecko pass peaks at ~31 GB, at -j8 it
# stays at 17-25 GB, inside the VM. cancel-in-progress is off so a
# half-finished objdir is never left behind by this workflow; a stamp is
# written only after a build succeeds, so a torn tree from any other cause is
# treated as a miss and rebuilt.
#
# Output: the UNSIGNED release APKs (--disable-debug-signing; LW-M6-01: no
# signing in CI, no secrets) plus SHA256SUMS, uploaded as a workflow artifact.
# ---------------------------------------------------------------------------
build-android:
if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main') }}
runs-on: [self-hosted, redoubt-boxb]
concurrency:
group: redoubt-android-build
cancel-in-progress: false
timeout-minutes: 360
env:
# Everything persistent lives here, outside the workspace.
LW_CI_ROOT: ${{ github.workspace }}/../../../redoubt-ci
# ALL THREE ABIs, and not as a nicety. scripts/android-apk.sh refuses to
# emit a universal APK whose ABI directories are not all backed by a
# libxul.so, and three AndroidX/JNA dependencies ship prebuilt .so files
# for armeabi-v7a whether or not that ABI was built -- so a one- or
# two-ABI run dies at that check with a message about the universal APK,
# which reads as a broken pipeline rather than the deliberate refusal it
# is. Measured 2026-09-06: exactly this killed a local two-ABI build after
# the Gradle stage had already succeeded.
#
# Cost, from build-times.txt: a COLD run is roughly 950 s per ABI plus a
# ~530 s merge plus the ~530 s Gradle stage, so about an hour -- inside the
# 360-minute timeout, and it is the price of the tree cache below being
# able to make every later run incremental.
LW_CI_ABIS: armeabi-v7a,arm64-v8a,x86_64
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
submodules: recursive
- name: Board, patch scope and patch order (seconds; test-android runs elsewhere)
run: |
python3 docs/android/board.py --check
python3 scripts/lint-patch-scope.py
python3 scripts/check-patch-order.py
- name: Preflight — engine, image, disk, cache root
run: |
set -eu
command -v podman >/dev/null || { echo "FATAL: podman is required" >&2; exit 1; }
command -v adb >/dev/null || { echo "FATAL: adb is required by the smoke harness's static checks" >&2; exit 1; }
# The image is built here, from this commit's Dockerfile, and labelled
# with its hash: a Dockerfile change (e.g. bd3cb07e's SDK pins) rebuilds
# it instead of reusing a stale image that merely exists.
want=$(sha256sum assets/Dockerfile.android | cut -c1-64)
have=$(podman image inspect --format '{{ index .Labels "org.redoubt.dockerfile-sha256" }}' librewolf-android-build 2>/dev/null || true)
if [ "$have" != "$want" ]; then
echo "build image missing or built from another Dockerfile (${have:-none}); building it from assets/Dockerfile.android ($want)"
podman build --no-cache --label "org.redoubt.dockerfile-sha256=$want" \
-t librewolf-android-build - < assets/Dockerfile.android
else
echo "build image matches assets/Dockerfile.android ($want)"
fi
avail=$(df -BG --output=avail "$HOME" | tail -1 | tr -dc '0-9')
echo "${avail} GB free under $HOME"
[ "${avail:-0}" -ge 120 ] || { echo "FATAL: a tree with one objdir is ~30 GB; refusing under 120 GB" >&2; exit 1; }
mkdir -p "$LW_CI_ROOT/tarballs" "$LW_CI_ROOT/out" "$LW_CI_ROOT/gradle-home"
echo "LW_CI_ROOT=$(cd "$LW_CI_ROOT" && pwd)" >> "$GITHUB_ENV"
- name: Tarball cache — hit only if Mozilla's signature still verifies
run: |
set -eu
ver=$(cat version.android)
tb="firefox-$ver.source.tar.xz"
cache="$LW_CI_ROOT/tarballs"
if [ -f "$cache/$tb" ] && [ -f "$cache/$tb.asc" ]; then
gpg --import assets/mozilla-release-key.asc # pinned; no keyserver
if gpg --verify "$cache/$tb.asc" "$cache/$tb"; then
ln -sf "$cache/$tb" "$tb"
ln -sf "$cache/$tb.asc" "$tb.asc"
echo "tarball cache HIT: $tb (signature verified)"
else
echo "tarball cache CORRUPT: $tb -- discarding, will re-fetch" >&2
rm -f "$cache/$tb" "$cache/$tb.asc"
fi
else
echo "tarball cache MISS: $tb"
fi
- name: Fetch the ESR tarball (no-op on a verified hit)
run: |
set -eu
ver=$(cat version.android)
tb="firefox-$ver.source.tar.xz"
# The Makefile rule is a file target: an existing (symlinked) tarball is
# not re-downloaded. A miss downloads and GPG-verifies as before.
make fetch TARGETS=android
if [ ! -L "$tb" ]; then
cp "$tb" "$LW_CI_ROOT/tarballs/$tb"
cp "$tb.asc" "$LW_CI_ROOT/tarballs/$tb.asc"
echo "tarball cached for the next run"
fi
- name: Tree cache — stamp every input that shapes the patched tree
id: stamp
run: |
set -eu
ver=$(cat version.android); rel=$(cat release.android)
tree="$LW_CI_ROOT/tree-$ver-$rel"
image=$(podman image inspect --format '{{.Id}}' librewolf-android-build)
stamp=$( {
git rev-parse HEAD:assets HEAD:patches HEAD:scripts/librewolf-patches.py \
HEAD:scripts/android-fat-aar.sh HEAD:scripts/android-apk.sh \
HEAD:settings
echo "$ver $rel $image abis=$LW_CI_ABIS"
} | sha256sum | cut -c1-64 )
echo "stamp=$stamp"
echo "tree=$tree" >> "$GITHUB_OUTPUT"
echo "stamp=$stamp" >> "$GITHUB_OUTPUT"
if [ -d "$tree" ] && [ "$(cat "$tree/.lw-ci-stamp" 2>/dev/null || true)" = "$stamp" ]; then
echo "tree cache HIT: $tree"
echo "hit=1" >> "$GITHUB_OUTPUT"
else
if [ -d "$tree" ]; then
echo "tree cache MISS: stamp changed ($(cat "$tree/.lw-ci-stamp" 2>/dev/null || echo none) -> $stamp)"
else
echo "tree cache MISS: no tree"
fi
echo "hit=0" >> "$GITHUB_OUTPUT"
fi
- name: Tree cache miss — extract, patch, move into place, drop stale outputs
if: ${{ steps.stamp.outputs.hit == '0' }}
run: |
set -eu
ver=$(cat version.android); rel=$(cat release.android)
tree="${{ steps.stamp.outputs.tree }}"
make dir TARGETS=android
rm -rf "$tree"
mv "librewolf-$ver-$rel" "$tree"
# The AAR and APK outputs were built from the previous tree; only the
# Gradle home (a dependency cache, content-addressed by Gradle) carries
# over. build-times.txt is what `make android-package` keys the AAR
# rebuild on, so it must go with the rest.
rm -rf "$LW_CI_ROOT/out/aar" "$LW_CI_ROOT/out/apk"
- name: Build (AAR skipped on a hit; APK passes run incrementally)
run: |
set -eu
tree="${{ steps.stamp.outputs.tree }}"
test -x "$tree/mach" || { echo "FATAL: no mach in $tree" >&2; exit 1; }
# Never let an APK from an earlier run reach the gates or the upload.
rm -f "$LW_CI_ROOT"/out/apk/apk/*.apk
start=$(date +%s)
make android-package \
TARGETS=android \
CONTAINER_ENGINE=podman \
LW_ANDROID_SRCDIR="$tree" \
ANDROID_AAR_OUTDIR="$LW_CI_ROOT/out/aar" \
ANDROID_APK_OUTDIR="$LW_CI_ROOT/out/apk" \
ANDROID_AAR_FLAGS="--abis=$LW_CI_ABIS --fat-host-abi=x86_64 --jobs=8" \
ANDROID_APK_FLAGS="--variant=release --disable-debug-signing --jobs=8 --fat-host-abi=x86_64 --gradle-home $LW_CI_ROOT/gradle-home"
end=$(date +%s)
# Keep the Gradle home warm for the next run (copied, never written through).
rm -rf "$LW_CI_ROOT/gradle-home"
cp -a "$LW_CI_ROOT/out/apk/gradle-home" "$LW_CI_ROOT/gradle-home"
echo "$(date -u +%FT%TZ) commit=$(git rev-parse --short HEAD) hit=${{ steps.stamp.outputs.hit }} seconds=$((end-start))" \
| tee -a "$LW_CI_ROOT/times.log"
echo "== AAR =="; cat "$LW_CI_ROOT/out/aar/build-times.txt"
echo "== APK =="; cat "$LW_CI_ROOT/out/apk/build-times.txt"
echo "== history (this runner) =="; tail -n 20 "$LW_CI_ROOT/times.log"
- name: Gates that need the artifact
run: |
set -eu
tree="${{ steps.stamp.outputs.tree }}"
apkdir="$LW_CI_ROOT/out/apk/apk"
# --disable-debug-signing names them fenix-<abi>-release-unsigned.apk.
apk=$(ls "$apkdir"/fenix-x86_64-release*.apk | head -1)
test -f "$apk" || { echo "FATAL: no x86_64 release APK in $apkdir" >&2; exit 1; }
# Hard gate, as in android-release.yaml: every APK must be UNSIGNED.
for f in "$apkdir"/*.apk; do
if unzip -l "$f" | grep -qiE 'META-INF/.*\.(RSA|DSA|EC)$'; then
echo "FATAL: $f carries a v1 JAR signature" >&2; exit 1
fi
if grep -qa 'APK Sig Block 42' "$f"; then
echo "FATAL: $f carries a v2/v3 APK Signing Block" >&2; exit 1
fi
echo " unsigned: $(basename "$f")"
done
LW_TREE="$tree" python3 docs/android/board.py --check-policies
# Every ABI directory in the universal APK must carry the engine. The
# build script already refuses otherwise; assert it here too, because a
# universal APK that installs on an ABI it has no libxul.so for is
# installable and dead on arrival, and that is the artifact a direct
# download hands people.
uni=$(ls "$apkdir"/fenix-universal-release*.apk | head -1)
for a in armeabi-v7a arm64-v8a x86_64; do
unzip -l "$uni" "lib/$a/libxul.so" | grep -q libxul.so \
|| { echo "FATAL: $uni has no libxul.so for $a" >&2; exit 1; }
echo " universal: $a carries libxul.so"
done
# Static halves of the smoke harness: dex string tables, no device needed.
LW_SMOKE_REPO=. ./scripts/android-smoke.sh --apk "$apk" --check-no-gms --check-no-adjust \
--json ci-smoke-static.json
mkdir -p ci-out
cp "$LW_CI_ROOT/out/aar/build-times.txt" ci-out/aar-build-times.txt
cp "$LW_CI_ROOT/out/apk/build-times.txt" ci-out/apk-build-times.txt
cp "$LW_CI_ROOT/out/apk/apk/output-metadata.json" ci-out/
cp ci-smoke-static.json ci-out/
mkdir -p ci-apk
cp "$apkdir"/*.apk ci-apk/
( cd ci-apk && sha256sum *.apk > SHA256SUMS && cat SHA256SUMS )
cp ci-apk/SHA256SUMS ci-out/SHA256SUMS
- name: Stamp the tree (only after a successful build and gates)
run: |
echo "${{ steps.stamp.outputs.stamp }}" > "${{ steps.stamp.outputs.tree }}/.lw-ci-stamp"
echo "stamped ${{ steps.stamp.outputs.tree }}"
- name: Upload build record
uses: actions/upload-artifact@v4
with:
name: redoubt-android-ci-build
path: ci-out/
retention-days: 30
- name: Upload the unsigned APKs and their SHA256SUMS
uses: actions/upload-artifact@v4
with:
name: redoubt-android-unsigned-apks
path: |
ci-apk/*.apk
ci-apk/SHA256SUMS
retention-days: 30