Repository navigation
Merge channels/google-play #13
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # 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)." |