Skip to content

Fork Android

Fork Android #18

Workflow file for this run

name: Fork Android
# Builds the signed fork Android APK and adds it to the release that Fork
# Release published for the same commit. It runs after Fork Release, outside
# its concurrency group, so the slow Gradle build never delays the next
# release. It never creates a release, and a failure only marks this workflow.
# A run from a branch builds that branch and uploads the APK as a workflow
# artifact instead. After a release, it builds only when the app changed since
# the newest earlier release with an APK. See docs/fork/operations.md "Android APK build".
on:
workflow_run:
workflows: [Fork Release]
types: [completed]
workflow_dispatch:
inputs:
sha:
description: Commit of main whose release gets the APK. Empty means the newest main. Ignored on a branch.
type: string
default: ""
release_tag:
description: Release that gets the APK, for example v0.0.46-nightly.20261011.180000. Replaces sha. Ignored on a branch.
type: string
default: ""
permissions:
contents: read
jobs:
# Decides before `apk` joins its concurrency group, so a skipped release never
# cancels a running build.
plan:
name: Decide whether to build
if: >-
github.event_name != 'workflow_run' ||
(github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.head_branch == 'main' &&
github.event.workflow_run.head_repository.full_name == github.repository)
runs-on: ubuntu-24.04
timeout-minutes: 5
outputs:
sha: ${{ steps.target.outputs.sha }}
tag: ${{ steps.target.outputs.tag }}
version: ${{ steps.target.outputs.version }}
publish: ${{ steps.target.outputs.publish }}
build: ${{ steps.changes.outputs.build }}
steps:
# `publish` is true for main. A branch run names the APK after the newest release.
- id: target
name: Resolve commit and release
env:
GH_TOKEN: ${{ github.token }}
RUN_SHA: ${{ github.event.workflow_run.head_sha || inputs.sha }}
RELEASE_TAG: ${{ inputs.release_tag }}
run: |
set -euo pipefail
if [[ "$GITHUB_EVENT_NAME" != "workflow_run" && "$GITHUB_REF" != "refs/heads/main" ]]; then
build_sha="$GITHUB_SHA"
tag="$(gh release list --repo "$GITHUB_REPOSITORY" --exclude-drafts --limit 1 --json tagName --jq '.[0].tagName // empty')"
publish=false
elif [[ -n "$RELEASE_TAG" ]]; then
if ! gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --json tagName >/dev/null; then
echo "::error title=No release::The fork has no release $RELEASE_TAG. See https://github.com/$GITHUB_REPOSITORY/releases for the tags."
exit 1
fi
tag="$RELEASE_TAG"
build_sha="$(gh api "repos/$GITHUB_REPOSITORY/commits/$RELEASE_TAG" --jq .sha)"
publish=true
else
build_sha="${RUN_SHA:-$(gh api "repos/$GITHUB_REPOSITORY/commits/main" --jq .sha)}"
if [[ ! "$build_sha" =~ ^[0-9a-f]{40}$ ]]; then
echo "::error title=Bad commit::The commit must be 40 lowercase hexadecimal characters."
exit 1
fi
# Fork Release names each release "... (<first 12 characters of the commit>)".
tag="$(gh release list --repo "$GITHUB_REPOSITORY" --exclude-drafts --limit 50 --json tagName,name \
--jq "[.[] | select(.name | contains(\"(${build_sha:0:12})\"))][0].tagName // empty")"
publish=true
fi
if [[ -z "$tag" ]]; then
echo "::error title=No release::Fork Release has no release for ${build_sha}. This workflow only adds the APK to an existing release."
exit 1
fi
{
echo "sha=$build_sha"
echo "tag=$tag"
echo "version=${tag#v}"
echo "publish=$publish"
} >> "$GITHUB_OUTPUT"
# Known limit: a release that changed none of these paths has no APK, so
# update tools must fall back to an older release. Revisit when the app
# starts to depend on a path outside this list.
- id: changes
name: Check whether the app changed
env:
GH_TOKEN: ${{ github.token }}
SHA: ${{ steps.target.outputs.sha }}
TAG: ${{ steps.target.outputs.tag }}
run: |
set -euo pipefail
# A manual run forces a build.
if [[ "$GITHUB_EVENT_NAME" != "workflow_run" ]]; then
echo "build=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# Releases come newest first; take the first older one with an APK.
base="$(gh api "repos/$GITHUB_REPOSITORY/releases?per_page=100" --jq '
[.[] | select(.draft | not)] as $releases
| ($releases | map(.tag_name) | index(env.TAG)) as $index
| if $index == null then "" else
[$releases[$index + 1:][] | select(any(.assets[]; .name | endswith("-android.apk")))][0].tag_name // ""
end')"
if [[ -z "$base" ]]; then
echo "No earlier release has an APK. Building."
echo "build=true" >> "$GITHUB_OUTPUT"
exit 0
fi
files="$(gh api "repos/$GITHUB_REPOSITORY/compare/${base}...${SHA}" --jq '.files[].filename')"
# The compare API lists at most 300 files, so a longer list may hide an app change.
if (( $(grep -c '' <<<"$files") >= 300 )); then
echo "More than 300 files changed since ${base}. Building."
echo "build=true" >> "$GITHUB_OUTPUT"
exit 0
fi
while IFS= read -r file; do
case "$file" in
apps/mobile/* | packages/* | pnpm-lock.yaml | patches/* | \
scripts/fork-android-apk.sh | .github/workflows/fork-android.yml)
echo "${file} changed since ${base}. Building."
echo "build=true" >> "$GITHUB_OUTPUT"
exit 0
;;
esac
done <<<"$files"
echo "::notice title=No APK for ${TAG}::No app files changed since ${base}, the newest earlier release with an APK. Skipping the build."
echo "build=false" >> "$GITHUB_OUTPUT"
apk:
name: Android APK
needs: plan
if: needs.plan.outputs.build == 'true'
# On the job, so only a run that passes `if` joins the group: a cancelled or
# failed Fork Release, or a release without app changes, never cancels a
# running build. A newer main release makes an APK for an older one
# pointless. A branch run has its own group.
concurrency:
group: fork-android-${{ github.event_name == 'workflow_run' && 'main' || github.ref_name }}
cancel-in-progress: true
runs-on: ubuntu-24.04
timeout-minutes: 60
permissions:
contents: write
steps:
- uses: actions/checkout@v6
with:
ref: ${{ needs.plan.outputs.sha }}
sparse-checkout: |
/*
!/.repos/
sparse-checkout-cone-mode: false
# `persist-credentials: false` makes checkout walk the submodules, and a
# submodule link under .repos/ has no URL. Drop the stored token here
# instead, before any build code runs. checkout v6 keeps it in a config
# file that .git/config includes.
- name: Remove the checkout token
run: |
set -euo pipefail
for key in $(git config --local --name-only --get-regexp '^includeif\.' || true); do
rm -f "$(git config --local --get "$key")"
git config --local --unset-all "$key"
done
git config --local --unset-all http.https://github.com/.extraheader || true
if git config --get-regexp 'extraheader' >/dev/null; then
echo "The checkout token is still configured." >&2
exit 1
fi
- uses: voidzero-dev/setup-vp@v1
with:
node-version-file: package.json
cache: true
run-install: false
- run: vp install --filter=@t3tools/mobile...
- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 17
- uses: gradle/actions/setup-gradle@v5
# Android refuses an update with a lower version code, and a run number
# restarts with each new workflow. The commit time in minutes since
# 2026-01-01 only grows along main.
- name: Build unsigned APK
env:
T3CODE_MOBILE_VERSION: ${{ needs.plan.outputs.version }}
run: |
set -euo pipefail
T3CODE_ANDROID_VERSION_CODE="$(( ($(git show -s --format=%ct HEAD) - 1767225600) / 60 ))"
export T3CODE_ANDROID_VERSION_CODE
echo "Version code ${T3CODE_ANDROID_VERSION_CODE}, version name ${T3CODE_MOBILE_VERSION}."
scripts/fork-android-apk.sh build "$RUNNER_TEMP/unsigned.apk"
# The only step that sees the signing secrets. Known limit: it runs in the
# job that ran the build, so code from a build dependency could still be
# on the runner. Revisit with a separate sign job (fresh checkout, fixed
# build-tools version) if a third-party build dependency becomes untrusted.
- name: Sign APK
env:
VERSION: ${{ needs.plan.outputs.version }}
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
run: |
set -euo pipefail
if [[ -z "$ANDROID_KEYSTORE_BASE64" || -z "$ANDROID_KEYSTORE_PASSWORD" || -z "$ANDROID_KEY_ALIAS" ]]; then
echo "ANDROID_KEYSTORE_BASE64, ANDROID_KEYSTORE_PASSWORD and ANDROID_KEY_ALIAS are required." >&2
exit 1
fi
export ANDROID_KEYSTORE="$RUNNER_TEMP/fork-release.jks"
trap 'rm -f "$ANDROID_KEYSTORE"' EXIT
printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 --decode > "$ANDROID_KEYSTORE"
mkdir -p release-android
scripts/fork-android-apk.sh sign "$RUNNER_TEMP/unsigned.apk" \
"release-android/t3code-${VERSION}-android.apk"
- name: Add the APK to the release
if: needs.plan.outputs.publish == 'true'
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.plan.outputs.tag }}
run: gh release upload "$TAG" release-android/* --repo "$GITHUB_REPOSITORY" --clobber
- name: Upload the APK of a branch run
if: needs.plan.outputs.publish != 'true'
uses: actions/upload-artifact@v7
with:
name: release-android
path: release-android/*
if-no-files-found: error
retention-days: 3