Repository navigation
Fork Android #18
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
| 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 |