name: Android APK Release on: push: tags: - "v*" - "android-v*" workflow_dispatch: inputs: ref: description: "Branch, tag, or commit to build. Leave blank to build the selected workflow ref." required: false default: "" type: string publish: description: "Upload the APK to an existing or new GitHub release." required: false default: false type: boolean tag: description: "Release tag to rebuild and publish. Required when publish is enabled." required: false default: "" type: string concurrency: group: android-apk-release-${{ github.event_name == 'workflow_dispatch' && (inputs.ref || inputs.tag || github.ref) || github.ref }} cancel-in-progress: false jobs: build-apk: permissions: contents: read packages: read runs-on: ubuntu-latest outputs: artifact-name: ${{ steps.mode.outputs.artifact-name }} publish: ${{ steps.mode.outputs.publish }} release-tag: ${{ steps.mode.outputs.release-tag }} source-tag: ${{ steps.mode.outputs.source-tag }} steps: - uses: actions/checkout@v4 with: fetch-depth: 0 ref: ${{ github.event_name == 'workflow_dispatch' && (inputs.publish && inputs.tag || inputs.ref || github.ref) || github.ref }} - name: Resolve build mode id: mode env: INPUT_PUBLISH: ${{ inputs.publish }} INPUT_REF: ${{ inputs.ref }} INPUT_TAG: ${{ inputs.tag }} shell: bash run: | set -euo pipefail publish=false release_tag="" if [[ "${GITHUB_EVENT_NAME}" == "push" ]]; then publish=true source_tag="${GITHUB_REF_NAME}" elif [[ "${INPUT_PUBLISH:-false}" == "true" ]]; then if [[ -z "${INPUT_TAG:-}" ]]; then echo "::error::tag is required when publish is enabled." exit 1 fi if [[ -n "${INPUT_REF:-}" ]]; then echo "::error::ref cannot be combined with publish; the release tag is always the source." exit 1 fi publish=true source_tag="${INPUT_TAG}" fi if [[ "${publish}" == "true" ]]; then release_env="$(node scripts/emit-release-env.mjs --source-tag "${source_tag}")" printf '%s\n' "${release_env}" >> "$GITHUB_ENV" release_tag="$(printf '%s\n' "${release_env}" | sed -n 's/^RELEASE_TAG=//p')" artifact_name="paseo-${release_tag}-android.apk" else package_version="$(node -p "require('./package.json').version")" short_sha="$(git rev-parse --short=12 HEAD)" artifact_name="paseo-v${package_version}-${short_sha}-android.apk" fi { echo "artifact-name=${artifact_name}" echo "publish=${publish}" echo "release-tag=${release_tag}" echo "source-tag=${source_tag:-}" } >> "$GITHUB_OUTPUT" echo "Building ${artifact_name}; publish=${publish}" - name: Setup Node uses: actions/setup-node@v4 with: node-version: "22" cache: "npm" registry-url: "https://npm.pkg.github.com" scope: "@boudra" - name: Setup Java uses: actions/setup-java@v4 with: distribution: temurin java-version: "21" cache: gradle - name: Setup Android SDK uses: android-actions/setup-android@v3 - name: Install JS dependencies run: node scripts/npm-retry.mjs ci env: NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Restore production Android credentials env: ANDROID_GOOGLE_SERVICES_JSON_BASE64: ${{ secrets.ANDROID_GOOGLE_SERVICES_JSON_BASE64 }} ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} shell: bash run: | set -euo pipefail required=( ANDROID_GOOGLE_SERVICES_JSON_BASE64 ANDROID_KEY_ALIAS ANDROID_KEY_PASSWORD ANDROID_KEYSTORE_BASE64 ANDROID_KEYSTORE_PASSWORD ) for name in "${required[@]}"; do if [[ -z "${!name:-}" ]]; then echo "::error::Missing required repository secret: ${name}" exit 1 fi done google_services_file="$RUNNER_TEMP/google-services.prod.json" keystore_file="$RUNNER_TEMP/paseo-upload.jks" printf '%s' "$ANDROID_GOOGLE_SERVICES_JSON_BASE64" | base64 --decode > "$google_services_file" printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 --decode > "$keystore_file" jq -n \ --arg keystorePath "$keystore_file" \ --arg keystorePassword "$ANDROID_KEYSTORE_PASSWORD" \ --arg keyAlias "$ANDROID_KEY_ALIAS" \ --arg keyPassword "$ANDROID_KEY_PASSWORD" \ '{android: {keystore: {keystorePath: $keystorePath, keystorePassword: $keystorePassword, keyAlias: $keyAlias, keyPassword: $keyPassword}}}' \ > packages/app/credentials.json echo "GOOGLE_SERVICES_FILE_PROD=$google_services_file" >> "$GITHUB_ENV" - name: Build signed production APK on GitHub env: APP_VARIANT: production EAS_BUILD: "1" EXPO_UPDATES_CHANNEL: production GRADLE_OPTS: >- -Dorg.gradle.jvmargs="-Xmx4g -XX:MaxMetaspaceSize=1g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8" -Dorg.gradle.parallel=false -Dorg.gradle.workers.max=1 -Dorg.gradle.daemon=false shell: bash run: | set -euo pipefail npm run build:app-deps npm --prefix packages/app run build:terminal-webview cd packages/app npx expo prebuild --platform android --clean --non-interactive node -e '(async () => require("expo/node_modules/@expo/config-plugins").AndroidConfig.EasBuild.configureEasBuildAsync(process.cwd()))().catch((error) => { console.error(error); process.exitCode = 1; })' cd android ./gradlew \ :app:assembleRelease \ --no-daemon \ --max-workers=1 \ -Dorg.gradle.parallel=false \ -x lint \ -x lintVitalAnalyzeRelease \ -x lintVitalRelease \ -x generateReleaseLintModel \ -x generateReleaseLintVitalModel artifact="$RUNNER_TEMP/${{ steps.mode.outputs.artifact-name }}" cp app/build/outputs/apk/release/app-release.apk "$artifact" apksigner="$(find "$ANDROID_HOME/build-tools" -type f -name apksigner | sort -V | tail -n 1)" if [[ -z "$apksigner" ]]; then echo "::error::Unable to locate apksigner." exit 1 fi expected_sha256="421698bdca5bb9168c970e24539781509b5aa23fab8ab406a15b3f9c5f04c647" actual_sha256="$($apksigner verify --print-certs "$artifact" | sed -n 's/^Signer #1 certificate SHA-256 digest: //p' | tr -d ':[:space:]' | tr '[:upper:]' '[:lower:]')" if [[ "$actual_sha256" != "$expected_sha256" ]]; then echo "::error::APK signing certificate does not match the production key." exit 1 fi echo "Verified production signing certificate: $actual_sha256" - name: Upload APK to workflow artifacts uses: actions/upload-artifact@v4 with: name: ${{ steps.mode.outputs.artifact-name }} path: ${{ runner.temp }}/${{ steps.mode.outputs.artifact-name }} if-no-files-found: error retention-days: 14 publish-apk: needs: build-apk if: needs.build-apk.outputs.publish == 'true' permissions: contents: write runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: sparse-checkout: scripts ref: ${{ needs.build-apk.outputs.source-tag }} - name: Resolve release metadata shell: bash run: node scripts/emit-release-env.mjs --source-tag "${{ needs.build-apk.outputs.source-tag }}" >> "$GITHUB_ENV" - name: Ensure GitHub release exists env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} shell: bash run: | set -euo pipefail if gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" >/dev/null 2>&1; then exit 0 fi release_args=( release create "$RELEASE_TAG" --repo "${{ github.repository }}" --title "Paseo $RELEASE_TAG" --notes "" ) if [[ "$IS_PRERELEASE" == "true" ]]; then release_args+=(--prerelease) fi if ! gh "${release_args[@]}"; then echo "Release creation raced with another workflow; continuing." fi - name: Download APK workflow artifact uses: actions/download-artifact@v4 with: name: ${{ needs.build-apk.outputs.artifact-name }} path: ${{ runner.temp }}/apk - name: Upload APK to GitHub Release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} shell: bash run: | gh release upload \ "$RELEASE_TAG" \ "$RUNNER_TEMP/apk/${{ needs.build-apk.outputs.artifact-name }}" \ --clobber \ --repo "${{ github.repository }}"