Skip to content

Merge channels/google-play #13

Merge channels/google-play

Merge channels/google-play #13

# Android release pipeline (LW-M6-05)
#
# LOCATION: .github/workflows/, because this repository is hosted on GitHub.
# The .forgejo/ directory is dead here — GitHub Actions does not read it.
#
# CUSTODY MODEL (LW-M6-01): the signing material never touches CI. This
# pipeline builds the RELEASE build type *unsigned* and publishes only the
# unsigned APK plus its sha256 sums, as a CI artifact. A maintainer then signs
# offline on a machine that holds the signing material and publishes the
# *signed* APK as the actual release.
#
# The unsigned/signed boundary is structural, not a convention:
# * there is no signing step anywhere in this file, and
# * there is no release-creation step anywhere in this file, and
# * the build job hard-fails if the APK it is about to publish is not
# provably unsigned.
# A build therefore cannot reach "published" without passing the unsigned
# gate, and this workflow has no code path that turns any build into a release.
#
# NO `container:` BLOCK, DELIBERATELY. Three measured reasons:
# 1. The image the first draft named (ghcr.io/librewolf/android-build) does
# not exist — HTTP 401. It was never real. The Makefile builds
# `librewolf-android-build` LOCALLY from assets/Dockerfile.android and
# publishes it to no registry, exactly as
# .forgejo/workflows/android-test.yaml:37 already documented.
# 2. Actions `container:` jobs do not work on this runner regardless of
# image. It is rootless podman behind the podman-docker shim; the runner
# mounts a dangling /var/run/docker.sock (podman-docker points it at the
# ROOT socket /run/podman/podman.sock, which does not exist rootless),
# and SELinux is Enforcing while the runner emits bind mounts with no
# :z flags and no way to add them. Measured 2026-08-22, three attempts,
# all `statfs ...: no such file or directory` at container create.
# 3. The runner is a dedicated build machine. Its toolchain is the point.
# If a step ever genuinely needs the build image, invoke `podman run`
# explicitly inside a `run:` step, where the mount flags are ours to set.
#
# RELEASE INPUTS (2026-10-04, for the stable Redoubt 157.0-2 build): a full
# dispatch takes update_check (true = --update-check, the direct-APK shape
# with the committed public key; the gate below fails if the dex does not
# carry the key, or carries it when false) and build_date (MOZ_BUILD_DATE,
# passed as --build-date to both the fat-AAR and the APK pass, so the
# versionCode is chosen at dispatch and can be checked to exceed the previous
# release's). The artifact is the four unsigned APKs, output-metadata.json
# (input of scripts/update-manifest.py generate) and SHA256SUMS. Preflight is
# unchanged. Example (docs/android/DISTRIBUTION.md, "Stable release"):
# gh workflow run android-release.yaml --repo CPlusPlus17/Redoubt --ref main \
# -f mode=full -f update_check=true -f build_date=20261004200000
#
# GOOGLE PLAY BUNDLE (LW-M6-12, docs/android/PLAY.md): bundle=true adds
# --bundle, a third Gradle pass (fenix:bundleRelease) in the same run, so the
# Android App Bundle shares the APKs' tree, fat AAR and build_date (its
# versionCode is the build's universal code, >= every APK's). The update check
# is compiled OUT of the bundle whatever update_check says (Play allows no
# self-update outside Play); the gate below fails if its dex carries the key.
# The bundle is a SEPARATE artifact, redoubt-android-aab-unsigned, so the APK
# artifact and its SHA256SUMS stay exactly the four APKs sign.sh expects. It is
# unsigned: the owner signs it with the Play upload key on box A
# (evidence/lw-m6-01/sign-aab.sh) and uploads it to Play Console by hand. This
# workflow never talks to Google Play.
#
# NO apksigner DEPENDENCY, DELIBERATELY. The first draft resolved apksigner
# from $ANDROID_SDK_ROOT/build-tools/*/. That variable was never set anywhere
# in the workflow, and this machine has no build-tools directory at all. The
# unsigned check below inspects the APK itself and needs only unzip + grep,
# which is both dependency-free and strictly more honest: it detects v1, v2
# and v3 signatures rather than trusting one tool's exit code.
name: Android release (unsigned artifacts only)
on:
# Changes to this pipeline preflight themselves. A workflow that has never
# executed is not a pipeline; this makes "it runs" observable on every edit.
push:
# main only: android-port is pushed at the same commit, and a second
# preflight would only start the box B VM twice for one edit.
branches: [main]
paths: ['.github/workflows/android-release.yaml']
workflow_dispatch:
inputs:
mode:
description: "preflight = verify the runner can do the build (minutes). full = fetch, patch, build, gate, publish (hours, ~40 GB)."
required: true
default: preflight
type: choice
options: [preflight, full]
update_check:
description: "full only: compile the opt-in in-app update check in (--update-check; needs assets/update-check.android.pubkey). true for direct-APK releases, false for F-Droid/Accrescent-shaped builds."
required: false
default: false
type: boolean
bundle:
description: "full only: also build the unsigned Google Play bundle (.aab, update check always compiled out) as a separate artifact. docs/android/PLAY.md."
required: false
default: false
type: boolean
build_date:
description: "full only: MOZ_BUILD_DATE, YYYYMMDDHHMMSS (UTC), passed as --build-date to the fat-AAR and the APK pass. Pins the versionCode; a release must use a value later than the previous release's. Empty = now (UTC) at the AAR pass."
required: false
default: ""
type: string
jobs:
build-unsigned:
# Box B's on-demand VM (docs/android/CI-VM.md, "Box B"): 16 vCPUs, 24 GiB.
# A preflight starts it too; box B's memory gate may defer the start while
# the host is busy, and the job then waits in GitHub's queue.
runs-on: [self-hosted, redoubt-boxb]
env:
# `inputs` only exists for workflow_dispatch; a push always preflights.
MODE: ${{ github.event_name == 'workflow_dispatch' && inputs.mode || 'preflight' }}
# Inputs reach the scripts only through the environment, never by
# ${{ }} interpolation into a run: block. On push both are empty/false.
UPDATE_CHECK: ${{ github.event_name == 'workflow_dispatch' && inputs.update_check && 'true' || 'false' }}
BUILD_DATE_IN: ${{ github.event_name == 'workflow_dispatch' && inputs.build_date || '' }}
BUNDLE: ${{ github.event_name == 'workflow_dispatch' && inputs.bundle && 'true' || 'false' }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
submodules: recursive
- name: Preflight — the runner must be able to do this at all
run: |
set -eu
fail=0
need() {
if command -v "$1" >/dev/null 2>&1; then
echo " ok $1 -> $(command -v "$1")"
else
echo " MISSING $1 ${2:+($2)}" >&2; fail=1
fi
}
echo "== tools =="
need make; need python3; need curl; need gpg; need tar; need xz
need patch; need unzip; need java "JDK, for the Gradle stage"
need podman "optional: only if a step invokes the build image"
echo "== disk =="
avail=$(df -BG --output=avail /home | tail -1 | tr -dc '0-9')
echo " ${avail} GB free on /home"
if [ "${avail:-0}" -lt 150 ]; then
echo " FATAL: a Gecko tree is ~40 GB; refusing to start under 150 GB" >&2
fail=1
fi
echo "== board gates =="
python3 docs/android/board.py --check
python3 docs/android/board.py --check-scope
python3 docs/android/board.py --check-cfg-split
python3 scripts/lint-patch-scope.py
python3 scripts/check-patch-order.py
echo "== version pinning =="
echo " android track: $(cat version.android) release $(cat release.android)"
[ "$fail" -eq 0 ] || { echo "PREFLIGHT FAILED" >&2; exit 1; }
echo "PREFLIGHT PASSED"
- name: Stop here unless a full build was requested
if: ${{ env.MODE != 'full' }}
run: |
echo "mode=$MODE — preflight only. No build was run."
echo "Re-dispatch with mode=full to fetch, patch, build and publish."
- name: Validate the release inputs (build_date, update_check, bundle)
if: ${{ env.MODE == 'full' }}
run: |
set -eu
# Fail in seconds, not after an hour of fetch and Gecko build.
if [ -n "$BUILD_DATE_IN" ]; then
printf '%s' "$BUILD_DATE_IN" | grep -Eq '^[0-9]{14}$' \
|| { echo "FATAL: build_date '$BUILD_DATE_IN' is not 14 digits (YYYYMMDDHHMMSS)" >&2; exit 1; }
# A real calendar time, not just 14 digits (e.g. not 20261332...).
python3 -c 'import sys, datetime; datetime.datetime.strptime(sys.argv[1], "%Y%m%d%H%M%S")' "$BUILD_DATE_IN" \
|| { echo "FATAL: build_date '$BUILD_DATE_IN' is not a valid UTC date/time" >&2; exit 1; }
# The versionCode is derived from MOZ_BUILD_DATE; a value in the
# future would make every later build look older than this one.
now=$(date -u +%Y%m%d%H%M%S)
if [ "$BUILD_DATE_IN" \> "$now" ]; then
echo "FATAL: build_date $BUILD_DATE_IN is in the future (now $now UTC)" >&2; exit 1
fi
echo "build_date: $BUILD_DATE_IN (pinned for the AAR and the APK pass)"
echo "LW_DATE_FLAG=--build-date=$BUILD_DATE_IN" >> "$GITHUB_ENV"
else
echo "build_date: not given -- the AAR pass uses now (UTC) and the APK pass reads it back"
echo "LW_DATE_FLAG=" >> "$GITHUB_ENV"
fi
if [ "$UPDATE_CHECK" = true ]; then
test -s assets/update-check.android.pubkey \
|| { echo "FATAL: update_check=true but assets/update-check.android.pubkey is not committed" >&2; exit 1; }
echo "update_check: COMPILED IN (key $(sha256sum assets/update-check.android.pubkey | cut -c1-16)...)"
echo "LW_UPDATE_FLAG=--update-check" >> "$GITHUB_ENV"
else
echo "update_check: compiled out"
echo "LW_UPDATE_FLAG=" >> "$GITHUB_ENV"
fi
if [ "$BUNDLE" = true ]; then
echo "bundle: YES -- unsigned Play AAB, update check compiled out of it"
echo "LW_BUNDLE_FLAG=--bundle" >> "$GITHUB_ENV"
else
echo "bundle: no"
echo "LW_BUNDLE_FLAG=" >> "$GITHUB_ENV"
fi
- name: Fetch and patch the Android tree
if: ${{ env.MODE == 'full' }}
run: |
set -eu
# The build happens in the EXTRACTED tree, not at the repo root:
# ./mach does not exist here. `make dir` produces
# librewolf-<version>-<release>/ and that is where mach lives.
make fetch TARGETS=android
make dir TARGETS=android
test -x "librewolf-$(cat version.android)-$(cat release.android)/mach" \
|| { echo "FATAL: mach not found in the extracted tree" >&2; exit 1; }
- name: Build image from this commit's Dockerfile
if: ${{ env.MODE == 'full' }}
run: |
set -eu
# Same rule as android-test.yaml build-android: rebuild when the
# Dockerfile changed, not only when the image is missing.
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
podman build --no-cache --label "org.redoubt.dockerfile-sha256=$want" \
-t librewolf-android-build - < assets/Dockerfile.android
fi
- name: "Build the unsigned release APK (Gecko/AAR pass, then Fenix)"
if: ${{ env.MODE == 'full' }}
run: |
set -eu
# The raw `./mach gradle` in the first draft cannot work in this tree:
# mach's is_android precondition demands a CONFIGURED Android-build
# objdir, and the fat AAR must exist before fenix:assembleRelease runs.
# scripts/android-apk.sh does both passes in the build container
# (gecko: ./mach configure + ./mach build, which produces the fat AAR;
# then apk: fenix:assembleRelease) -- the path proven locally, where a
# release build finished in ~790s end to end.
# --disable-debug-signing release build type, NO signature applied
# (LW-M6-01 custody); the script's final
# check asserts the ABSENCE of v1/v2/v3.
# R8 stays ON: LW-M6-07 fixed the JNA/uniffi keep rules and the
# minified release build now boots. Do not reintroduce
# -PdisableOptimization without re-measuring.
#
# make android-package (alias android-apk) is the one-command path: its
# build-times.txt prerequisite builds the fat AAR first (scripts/
# android-fat-aar.sh -> librewolf-android-aar-<v>-<r>/), then feeds it to
# scripts/android-apk.sh, which runs pass 1 (Gecko, producing the objdir)
# and pass 2 (fenix:assembleRelease). The script REQUIRES --aar-dir, so
# calling it directly without that (as the previous revision did) dies
# with 'fatal: --aar-dir is required'. ANDROID_APK_FLAGS carries the
# variant + the unsigned flag through to the script.
# -j8: at the default min(16, nproc) = 16 a Gecko pass peaks at ~31 GB,
# more than box B's 24 GiB VM; at -j8 it peaks at 17-25 GB.
# LW_DATE_FLAG --build-date=<build_date> to BOTH passes, so the
# versionCode is the one chosen at dispatch, or empty.
# LW_UPDATE_FLAG --update-check (update_check=true), or empty.
# LW_BUNDLE_FLAG --bundle (bundle=true), or empty: the Play AAB.
# All three were set by the validation step above.
make android-package \
TARGETS=android \
CONTAINER_ENGINE=podman \
ANDROID_AAR_FLAGS="--jobs=8 $LW_DATE_FLAG" \
ANDROID_APK_FLAGS="--variant=release --disable-debug-signing --jobs=8 $LW_DATE_FLAG $LW_UPDATE_FLAG $LW_BUNDLE_FLAG"
APKDIR="librewolf-android-apk-$(cat version.android)-$(cat release.android)/apk"
ls "$APKDIR"/*.apk >/dev/null 2>&1 \
|| { echo "FATAL: no release APK produced under $APKDIR/" >&2; exit 1; }
- name: Hard gate — the artifact MUST be unsigned
if: ${{ env.MODE == 'full' }}
run: |
set -eu
APKDIR="librewolf-android-apk-$(cat version.android)-$(cat release.android)/apk"
found=0
for apk in "$APKDIR"/*.apk; do
[ -e "$apk" ] || continue
found=1
signed=0
# v1 (JAR signing): a signature block file under META-INF/.
if unzip -l "$apk" | grep -qiE 'META-INF/.*\.(RSA|DSA|EC)$'; then
echo "FATAL: $apk carries a v1 JAR signature." >&2; signed=1
fi
# v2/v3 (APK Signing Block): magic string before the central dir.
if grep -qa 'APK Sig Block 42' "$apk"; then
echo "FATAL: $apk carries a v2/v3 APK Signing Block." >&2; signed=1
fi
if [ "$signed" -eq 1 ]; then
echo "This pipeline may only publish UNSIGNED artifacts;" >&2
echo "a signed build belongs to the offline maintainer step." >&2
exit 1
fi
echo "OK: $apk is unsigned (no v1 block, no v2/v3 signing block)."
done
[ "$found" -eq 1 ] || { echo "FATAL: no release APK found to gate ($APKDIR/)." >&2; exit 1; }
- name: Gate — the artifact matches the inputs (APK set, build date, update check)
if: ${{ env.MODE == 'full' }}
run: |
set -eu
APKDIR="librewolf-android-apk-$(cat version.android)-$(cat release.android)/apk"
# Exactly the four direct-APK outputs: arm64-v8a, armeabi-v7a,
# x86_64 and universal. android-apk.sh already checks them against
# output-metadata.json; this guards the publish step's glob.
n=$(find "$APKDIR" -maxdepth 1 -name '*.apk' | wc -l)
[ "$n" -eq 4 ] || { echo "FATAL: expected 4 APKs in $APKDIR/, found $n" >&2; exit 1; }
test -s "$APKDIR/output-metadata.json" \
|| { echo "FATAL: $APKDIR/output-metadata.json is missing" >&2; exit 1; }
python3 - "$APKDIR/output-metadata.json" <<'PY'
import json, sys
els = json.load(open(sys.argv[1]))["elements"]
names = {e.get("versionName") for e in els}
codes = sorted(e.get("versionCode") for e in els)
print("versionName", sorted(names), "versionCodes", codes)
if len(els) != 4 or len(names) != 1:
sys.exit("FATAL: output-metadata.json does not describe one release of four APKs")
PY
if [ -n "$BUILD_DATE_IN" ]; then
got=$(sed -n 's/^MOZ_BUILD_DATE=\([0-9]*\).*/\1/p' \
"librewolf-android-aar-$(cat version.android)-$(cat release.android)/build-times.txt" | head -1)
[ "$got" = "$BUILD_DATE_IN" ] \
|| { echo "FATAL: the AAR was built with MOZ_BUILD_DATE '$got', not build_date $BUILD_DATE_IN" >&2; exit 1; }
echo "OK: MOZ_BUILD_DATE $got"
fi
# R8 inlines the public key into the dex (DISTRIBUTION.md step 1).
key=$(tr -d ' \t\r\n' < assets/update-check.android.pubkey 2>/dev/null || true)
for apk in "$APKDIR"/*.apk; do
hits=0
[ -z "$key" ] || hits=$(unzip -p "$apk" 'classes*.dex' | grep -c -aF "$key" || true)
if [ "$UPDATE_CHECK" = true ] && [ "$hits" -lt 1 ]; then
echo "FATAL: update_check=true but $apk does not carry the update-check key" >&2; exit 1
fi
if [ "$UPDATE_CHECK" != true ] && [ "$hits" -ne 0 ]; then
echo "FATAL: update_check=false but $apk carries the update-check key" >&2; exit 1
fi
echo "OK: $apk update check $([ "$hits" -ge 1 ] && echo compiled in || echo compiled out)"
done
- name: Gate — the Play bundle is unsigned, compiled without the update check, and matches the APKs
if: ${{ env.MODE == 'full' && env.BUNDLE == 'true' }}
run: |
set -eu
OUT="librewolf-android-apk-$(cat version.android)-$(cat release.android)"
AAB="$OUT/aab/fenix-release-unsigned.aab"
test -s "$AAB" || { echo "FATAL: bundle=true but $AAB was not produced" >&2; exit 1; }
# android-apk.sh --bundle already ran these checks; they are repeated
# here on the exact file this job uploads, against the committed key
# (whatever update_check was) and the APKs' own output-metadata.json.
extra=()
[ -z "$BUILD_DATE_IN" ] || extra+=("--build-date=$BUILD_DATE_IN")
[ ! -s assets/update-check.android.pubkey ] || extra+=(--forbid-key-file=assets/update-check.android.pubkey)
python3 scripts/android-aab.py inspect "$AAB" --expect-unsigned \
--expect-package org.redoubtbrowser --apk-metadata "$OUT/apk/output-metadata.json" \
"${extra[@]}"
# The same dependency-free signature probe as the APK gate.
if unzip -l "$AAB" | grep -qiE 'META-INF/.*\.(RSA|DSA|EC)$'; then
echo "FATAL: $AAB carries a JAR signature; the bundle must leave CI unsigned" >&2; exit 1
fi
- name: Collect unsigned APKs and publish sha256 sums
if: ${{ env.MODE == 'full' }}
run: |
set -eu
APKDIR="librewolf-android-apk-$(cat version.android)-$(cat release.android)/apk"
mkdir -p dist
cp "$APKDIR"/*.apk "$APKDIR/output-metadata.json" dist/
# SHA256SUMS lists the APKs only: it is the signer's input
# (SHA256SUMS.unsigned). output-metadata.json is the input of
# scripts/update-manifest.py generate.
( cd dist && sha256sum *.apk > SHA256SUMS )
cat dist/SHA256SUMS
sha256sum dist/output-metadata.json
- name: Upload unsigned artifacts + sums
if: ${{ env.MODE == 'full' }}
uses: actions/upload-artifact@v4
with:
name: redoubt-android-unsigned
path: |
dist/*.apk
dist/output-metadata.json
dist/SHA256SUMS
if-no-files-found: error
# Long enough to sign on box A, publish and keep the unsigned
# inputs for a re-verification after the release.
retention-days: 90
- name: Collect the unsigned Play bundle and its sha256
if: ${{ env.MODE == 'full' && env.BUNDLE == 'true' }}
run: |
set -eu
OUT="librewolf-android-apk-$(cat version.android)-$(cat release.android)"
mkdir -p dist-aab
cp "$OUT/aab/fenix-release-unsigned.aab" "$OUT/aab/bundle-metadata.json" dist-aab/
( cd dist-aab && sha256sum fenix-release-unsigned.aab > SHA256SUMS.aab )
cat dist-aab/SHA256SUMS.aab
- name: Upload the unsigned Play bundle
if: ${{ env.MODE == 'full' && env.BUNDLE == 'true' }}
uses: actions/upload-artifact@v4
with:
name: redoubt-android-aab-unsigned
path: |
dist-aab/fenix-release-unsigned.aab
dist-aab/bundle-metadata.json
dist-aab/SHA256SUMS.aab
if-no-files-found: error
retention-days: 90
- name: Boundary — CI stops here (no signing, no release)
if: ${{ env.MODE == 'full' }}
run: |
echo "CI is done. What was published is an UNSIGNED artifact plus"
echo "its sha256 sums -- not a release. Signing and release"
echo "publication happen offline, on a maintainer machine that holds"
echo "the signing material. This workflow has no signing step and no"
echo "release-creation step, by design (LW-M6-01 custody model)."