Mirror the desktop-build retry loop so a transient tarball download failure no longer fails the whole Android build.
363 lines
15 KiB
YAML
363 lines
15 KiB
YAML
name: Build EdgeEver Mobile
|
|
|
|
on:
|
|
workflow_dispatch:
|
|
inputs:
|
|
release_tag:
|
|
description: "Upload the signed arm64 APK to this existing Draft GitHub Release"
|
|
required: true
|
|
type: string
|
|
release:
|
|
types: [published]
|
|
push:
|
|
branches:
|
|
- main
|
|
paths:
|
|
- "apps/mobile/**"
|
|
- "packages/client/**"
|
|
- "packages/shared/**"
|
|
- "bun.lock"
|
|
- ".github/workflows/mobile-build.yml"
|
|
- "scripts/build-android-local.sh"
|
|
- "scripts/verify-android-apk-signature.mjs"
|
|
- "scripts/download-play-universal-apk.mjs"
|
|
|
|
permissions:
|
|
actions: read
|
|
contents: write
|
|
|
|
concurrency:
|
|
group: edgeever-mobile-android-${{ github.ref }}-${{ github.event_name }}
|
|
cancel-in-progress: true
|
|
|
|
jobs:
|
|
release-plan:
|
|
if: >-
|
|
${{
|
|
github.repository == 'tianma-if/edgeever' &&
|
|
(
|
|
github.event_name == 'release' ||
|
|
github.event_name == 'workflow_dispatch'
|
|
)
|
|
}}
|
|
name: Plan Android release asset
|
|
runs-on: ubuntu-latest
|
|
outputs:
|
|
asset_ready: ${{ steps.plan.outputs.asset_ready }}
|
|
previous_tag: ${{ steps.plan.outputs.previous_tag }}
|
|
rebuild: ${{ steps.plan.outputs.rebuild }}
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@v5
|
|
with:
|
|
fetch-depth: 0
|
|
|
|
- name: Validate Draft Release target
|
|
if: github.event_name == 'workflow_dispatch'
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
RELEASE_TAG: ${{ inputs.release_tag }}
|
|
run: |
|
|
expected_version="$(node -p "JSON.parse(require('fs').readFileSync('package.json', 'utf8')).version")"
|
|
test "$RELEASE_TAG" = "v${expected_version}"
|
|
test "$(gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --json isDraft --jq '.isDraft')" = "true"
|
|
target_commitish="$(gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --json targetCommitish --jq '.targetCommitish')"
|
|
test "$(git rev-parse "${target_commitish}^{commit}")" = "$(git rev-parse "${GITHUB_SHA}^{commit}")"
|
|
|
|
- name: Set up Bun for release validation
|
|
uses: oven-sh/setup-bun@v2
|
|
|
|
- name: Install release validation dependencies
|
|
run: |
|
|
for attempt in 1 2 3; do
|
|
if bun install --frozen-lockfile; then
|
|
exit 0
|
|
fi
|
|
if [[ "$attempt" = "3" ]]; then
|
|
exit 1
|
|
fi
|
|
sleep "$((15 * attempt))"
|
|
done
|
|
|
|
- name: Run full project regression tests
|
|
run: bun run test
|
|
|
|
- name: Compare with previous formal release
|
|
id: plan
|
|
env:
|
|
CURRENT_TAG: ${{ inputs.release_tag || github.event.release.tag_name }}
|
|
GH_TOKEN: ${{ github.token }}
|
|
HEAD_REF: ${{ github.event_name == 'release' && github.event.release.tag_name || github.sha }}
|
|
run: |
|
|
mapfile -t release_tags < <(
|
|
gh api "repos/${GITHUB_REPOSITORY}/releases?per_page=100" \
|
|
--jq '.[] | select(.draft == false and .prerelease == false) | .tag_name'
|
|
)
|
|
previous_tag=""
|
|
for release_tag in "${release_tags[@]}"; do
|
|
if [[ "$release_tag" != "$CURRENT_TAG" ]]; then
|
|
previous_tag="$release_tag"
|
|
break
|
|
fi
|
|
done
|
|
test -n "$previous_tag"
|
|
git fetch origin "refs/tags/${previous_tag}:refs/tags/${previous_tag}"
|
|
echo "previous_tag=$previous_tag" >> "$GITHUB_OUTPUT"
|
|
plan_output="$(node scripts/plan-native-release.mjs mobile "$previous_tag" "$HEAD_REF")"
|
|
printf '%s\n' "$plan_output" >> "$GITHUB_OUTPUT"
|
|
rebuild="$(printf '%s\n' "$plan_output" | sed -n 's/^rebuild=//p')"
|
|
if [[ "$rebuild" = "true" ]]; then
|
|
root_version="$(node -p "JSON.parse(require('fs').readFileSync('package.json', 'utf8')).version")"
|
|
mobile_version="$(node -p "JSON.parse(require('fs').readFileSync('apps/mobile/app.json', 'utf8')).expo.version")"
|
|
if [[ "$mobile_version" != "$root_version" ]]; then
|
|
echo "::error::Mobile rebuild requires apps/mobile/app.json expo.version ($mobile_version) to match package.json version ($root_version)."
|
|
exit 1
|
|
fi
|
|
|
|
current_version_code="$(node -p "JSON.parse(require('fs').readFileSync('apps/mobile/app.json', 'utf8')).expo.android.versionCode")"
|
|
previous_version_code="$(
|
|
git show "${previous_tag}:apps/mobile/app.json" |
|
|
node -e "const fs = require('fs'); console.log(JSON.parse(fs.readFileSync(0, 'utf8')).expo.android.versionCode)"
|
|
)"
|
|
if (( current_version_code <= previous_version_code )); then
|
|
echo "::error::Mobile rebuild requires android.versionCode ($current_version_code) to be greater than the previous release ($previous_version_code)."
|
|
exit 1
|
|
fi
|
|
fi
|
|
|
|
# `gh release view --json assets` stays empty after a draft is published.
|
|
# The API URL is addressed by release id and still lists the uploads.
|
|
release_api="$(gh release view "$CURRENT_TAG" --repo "$GITHUB_REPOSITORY" --json apiUrl --jq .apiUrl)"
|
|
mapfile -t current_assets < <(
|
|
gh api "$release_api" --jq '.assets[].name'
|
|
)
|
|
asset_ready="$(
|
|
printf '%s\n' "${current_assets[@]}" |
|
|
node scripts/check-native-release-assets.mjs mobile "$rebuild" "$CURRENT_TAG"
|
|
)"
|
|
echo "asset_ready=$asset_ready" >> "$GITHUB_OUTPUT"
|
|
if [[ "$GITHUB_EVENT_NAME" = "release" && "$asset_ready" != "true" ]]; then
|
|
echo "::error::Published Release assets are incomplete or incompatible. Restore the Release to Draft and prepare assets with workflow_dispatch."
|
|
exit 1
|
|
fi
|
|
|
|
- name: Verify existing APK uses the pinned signer
|
|
if: steps.plan.outputs.asset_ready == 'true'
|
|
env:
|
|
ANDROID_PLAY_APP_SIGNER_SHA256: ${{ secrets.ANDROID_PLAY_APP_SIGNER_SHA256 }}
|
|
EDGE_EVER_ANDROID_ALLOWED_SIGNER_SHA256: ${{ github.event_name == 'release' && secrets.ANDROID_PLAY_APP_SIGNER_SHA256 || format('22bf52a9501c89020f5acc966960152c826bfa64f31e578e858d088f8cd75d87,{0}', secrets.ANDROID_PLAY_APP_SIGNER_SHA256) }}
|
|
CURRENT_TAG: ${{ inputs.release_tag || github.event.release.tag_name }}
|
|
GH_TOKEN: ${{ github.token }}
|
|
run: |
|
|
mkdir -p "$RUNNER_TEMP/native-assets"
|
|
node scripts/download-release-assets.mjs \
|
|
--repo "$GITHUB_REPOSITORY" \
|
|
--tag "$CURRENT_TAG" \
|
|
--dir "$RUNNER_TEMP/native-assets" \
|
|
--pattern 'edgeever-android-v*-arm64-v8a.apk'
|
|
apk_path="$(find "$RUNNER_TEMP/native-assets" -type f -name 'edgeever-android-v*-arm64-v8a.apk' -print -quit)"
|
|
test -n "$apk_path"
|
|
test -n "$ANDROID_PLAY_APP_SIGNER_SHA256"
|
|
node scripts/verify-android-apk-signature.mjs "$apk_path"
|
|
|
|
restore-invalid-published-release:
|
|
needs: release-plan
|
|
if: >-
|
|
${{
|
|
always() &&
|
|
github.repository == 'tianma-if/edgeever' &&
|
|
github.event_name == 'release' &&
|
|
needs.release-plan.result == 'failure'
|
|
}}
|
|
name: Restore invalid Android Release to Draft
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- name: Restore Draft state
|
|
env:
|
|
CURRENT_TAG: ${{ github.event.release.tag_name }}
|
|
GH_TOKEN: ${{ github.token }}
|
|
run: gh release edit "$CURRENT_TAG" --repo "$GITHUB_REPOSITORY" --draft=true
|
|
|
|
reuse-release-apk:
|
|
needs: release-plan
|
|
if: >-
|
|
${{
|
|
github.repository == 'tianma-if/edgeever' &&
|
|
github.event_name == 'workflow_dispatch' &&
|
|
needs.release-plan.result == 'success' &&
|
|
needs.release-plan.outputs.asset_ready == 'false' &&
|
|
needs.release-plan.outputs.rebuild == 'false'
|
|
}}
|
|
name: Reuse Android release APK
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@v5
|
|
|
|
- name: Copy latest compatible APK without renaming
|
|
env:
|
|
CURRENT_TAG: ${{ inputs.release_tag || github.event.release.tag_name }}
|
|
ANDROID_PLAY_APP_SIGNER_SHA256: ${{ secrets.ANDROID_PLAY_APP_SIGNER_SHA256 }}
|
|
EDGE_EVER_ANDROID_ALLOWED_SIGNER_SHA256: ${{ secrets.ANDROID_PLAY_APP_SIGNER_SHA256 }}
|
|
GH_TOKEN: ${{ github.token }}
|
|
PREVIOUS_TAG: ${{ needs.release-plan.outputs.previous_tag }}
|
|
run: |
|
|
mkdir -p "$RUNNER_TEMP/native-assets"
|
|
gh release download "$PREVIOUS_TAG" \
|
|
--repo "$GITHUB_REPOSITORY" \
|
|
--pattern 'edgeever-android-v*-arm64-v8a.apk' \
|
|
--dir "$RUNNER_TEMP/native-assets"
|
|
test "$(find "$RUNNER_TEMP/native-assets" -type f -name 'edgeever-android-v*-arm64-v8a.apk' | wc -l | tr -d ' ')" = "1"
|
|
apk_path="$(find "$RUNNER_TEMP/native-assets" -type f -name 'edgeever-android-v*-arm64-v8a.apk' -print -quit)"
|
|
test -n "$ANDROID_PLAY_APP_SIGNER_SHA256"
|
|
node scripts/verify-android-apk-signature.mjs "$apk_path"
|
|
gh release upload "$CURRENT_TAG" "$RUNNER_TEMP/native-assets/"* \
|
|
--repo "$GITHUB_REPOSITORY" \
|
|
--clobber
|
|
|
|
android:
|
|
needs: release-plan
|
|
if: >-
|
|
${{
|
|
always() &&
|
|
github.repository == 'tianma-if/edgeever' &&
|
|
(
|
|
(github.event_name == 'push' && github.ref == 'refs/heads/main') ||
|
|
(
|
|
github.event_name == 'workflow_dispatch' &&
|
|
needs.release-plan.result == 'success' &&
|
|
needs.release-plan.outputs.asset_ready == 'false' &&
|
|
needs.release-plan.outputs.rebuild == 'true'
|
|
)
|
|
)
|
|
}}
|
|
name: Build Android packages
|
|
runs-on: [self-hosted, macOS, ARM64, edgeever-mobile]
|
|
timeout-minutes: 45
|
|
env:
|
|
ANDROID_HOME: /opt/homebrew/share/android-commandlinetools
|
|
ANDROID_SDK_ROOT: /opt/homebrew/share/android-commandlinetools
|
|
JAVA_HOME: /opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@v5
|
|
with:
|
|
clean: true
|
|
|
|
- name: Install dependencies
|
|
run: |
|
|
for attempt in 1 2 3; do
|
|
if bun install --frozen-lockfile; then
|
|
exit 0
|
|
fi
|
|
if [[ "$attempt" = "3" ]]; then
|
|
exit 1
|
|
fi
|
|
sleep "$((15 * attempt))"
|
|
done
|
|
|
|
- name: Check Expo dependencies
|
|
run: |
|
|
if ! bun --cwd apps/mobile expo install --check; then
|
|
echo "::warning::Expo reported compatible patch-version updates; continuing with the frozen lockfile for this build."
|
|
fi
|
|
|
|
- name: Typecheck
|
|
run: bun run typecheck:mobile
|
|
|
|
- name: Reset Metro cache for this checkout
|
|
run: bun --cwd apps/mobile expo export --platform android --clear --max-workers 1 --output-dir "$RUNNER_TEMP/edgeever-metro-warmup"
|
|
|
|
- name: Restore Android signing keystore
|
|
env:
|
|
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
|
run: |
|
|
test -n "$ANDROID_KEYSTORE_BASE64"
|
|
printf '%s' "$ANDROID_KEYSTORE_BASE64" | openssl base64 -d -A > "$RUNNER_TEMP/edgeever-upload.p12"
|
|
chmod 600 "$RUNNER_TEMP/edgeever-upload.p12"
|
|
|
|
- name: Build fast main-branch APK
|
|
if: github.event_name == 'push'
|
|
env:
|
|
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
|
ANDROID_KEYSTORE_FILE: ${{ runner.temp }}/edgeever-upload.p12
|
|
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
|
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
|
EDGE_EVER_ANDROID_APK_ARCHS: arm64-v8a
|
|
run: bun run build:android:fast
|
|
|
|
- name: Build signed release APK for GitHub Release
|
|
if: github.event_name == 'workflow_dispatch'
|
|
env:
|
|
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
|
ANDROID_KEYSTORE_FILE: ${{ runner.temp }}/edgeever-upload.p12
|
|
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
|
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
|
EDGE_EVER_ANDROID_APK_ARCHS: arm64-v8a
|
|
run: bun run build:android:apk
|
|
|
|
- name: Verify release APK signature
|
|
if: github.event_name == 'workflow_dispatch'
|
|
env:
|
|
EDGE_EVER_ANDROID_ALLOWED_SIGNER_SHA256: 22bf52a9501c89020f5acc966960152c826bfa64f31e578e858d088f8cd75d87,${{ secrets.ANDROID_PLAY_APP_SIGNER_SHA256 }}
|
|
run: node scripts/verify-android-apk-signature.mjs apps/mobile/android/app/build/outputs/apk/release/app-release.apk
|
|
|
|
- name: Upload APK
|
|
if: github.event_name == 'push'
|
|
uses: actions/upload-artifact@v7
|
|
with:
|
|
name: edgeever-android-main-apk
|
|
path: apps/mobile/android/app/build/outputs/apk/release/*.apk
|
|
if-no-files-found: error
|
|
compression-level: 0
|
|
|
|
- name: Upload release APK to GitHub Release
|
|
if: github.event_name == 'workflow_dispatch'
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
RELEASE_TAG: ${{ inputs.release_tag }}
|
|
run: |
|
|
release_asset="$RUNNER_TEMP/edgeever-android-${RELEASE_TAG}-arm64-v8a.apk"
|
|
cp apps/mobile/android/app/build/outputs/apk/release/app-release.apk "$release_asset"
|
|
gh release upload "$RELEASE_TAG" "$release_asset" --repo "$GITHUB_REPOSITORY" --clobber
|
|
|
|
- name: Remove temporary upload keystore
|
|
if: always()
|
|
run: rm -f "$RUNNER_TEMP/edgeever-upload.p12"
|
|
|
|
native-build-timings:
|
|
needs: android
|
|
if: >-
|
|
${{
|
|
always() &&
|
|
github.repository == 'tianma-if/edgeever' &&
|
|
needs.android.result != 'skipped'
|
|
}}
|
|
name: Report Android build timings
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- name: Checkout timing reporter
|
|
uses: actions/checkout@v5
|
|
|
|
- name: Collect native build timings
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
run: |
|
|
gh api \
|
|
"repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/jobs?filter=latest&per_page=100" \
|
|
> "$RUNNER_TEMP/actions-jobs.json"
|
|
node scripts/report-native-build-timings.mjs \
|
|
--input "$RUNNER_TEMP/actions-jobs.json" \
|
|
--platform android \
|
|
--json "$RUNNER_TEMP/native-build-timings.json" \
|
|
--markdown "$RUNNER_TEMP/native-build-timings.md"
|
|
cat "$RUNNER_TEMP/native-build-timings.md" >> "$GITHUB_STEP_SUMMARY"
|
|
|
|
- name: Upload machine-readable timing report
|
|
uses: actions/upload-artifact@v7
|
|
with:
|
|
name: edgeever-android-build-timings-attempt-${{ github.run_attempt }}
|
|
path: ${{ runner.temp }}/native-build-timings.json
|
|
if-no-files-found: error
|
|
retention-days: 90
|