Compare commits

...

2 Commits

Author SHA1 Message Date
Mohamed Boudra
5e8eec44c1 fix(android): keep signing secrets out of build logs
Run Expo prebuild and serialized Gradle directly instead of passing signing credentials through the local EAS payload. Verify the finished APK against the production signing fingerprint before upload.
2026-07-16 17:53:20 +02:00
Mohamed Boudra
0b820d729a ci(android): stop consuming EAS credits for APKs
Build production-signed APKs on GitHub while leaving AAB store builds on EAS. Manual dispatches default to artifact-only dry runs so arbitrary refs can be verified without publishing.
2026-07-16 17:42:56 +02:00
3 changed files with 233 additions and 76 deletions

View File

@@ -7,34 +7,229 @@ on:
- "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: "Existing tag to build (e.g. v0.1.0)"
required: true
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' && github.event.inputs.tag || github.ref }}
group: android-apk-release-${{ github.event_name == 'workflow_dispatch' && (inputs.ref || inputs.tag || github.ref) || github.ref }}
cancel-in-progress: false
env:
SOURCE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
jobs:
publish-android-apk:
build-apk:
permissions:
contents: write
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' && github.event.inputs.tag || github.ref }}
ref: ${{ github.event_name == 'workflow_dispatch' && (inputs.publish && inputs.tag || inputs.ref || github.ref) || github.ref }}
- name: Resolve release tag
- name: Resolve build mode
id: mode
env:
INPUT_PUBLISH: ${{ inputs.publish }}
INPUT_REF: ${{ inputs.ref }}
INPUT_TAG: ${{ inputs.tag }}
shell: bash
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
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:
@@ -62,71 +257,19 @@ jobs:
echo "Release creation raced with another workflow; continuing."
fi
- name: Setup Node
uses: actions/setup-node@v4
- name: Download APK workflow artifact
uses: actions/download-artifact@v4
with:
node-version: "22"
cache: "npm"
registry-url: "https://npm.pkg.github.com"
scope: "@boudra"
- name: Install JS dependencies
run: node scripts/npm-retry.mjs ci
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Expo and EAS
uses: expo/expo-github-action@v8
with:
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
- name: Build Android APK on EAS
id: eas_build
shell: bash
run: |
set -euo pipefail
cd packages/app
build_json="$(npx eas build --platform android --profile production-apk --non-interactive --wait --json)"
echo "$build_json" > "$RUNNER_TEMP/eas-build.json"
build_id="$(jq -r 'if type == "array" then .[0].id // empty else .id // empty end' "$RUNNER_TEMP/eas-build.json")"
if [ -z "$build_id" ]; then
echo "Failed to determine EAS build ID."
cat "$RUNNER_TEMP/eas-build.json"
exit 1
fi
echo "build_id=$build_id" >> "$GITHUB_OUTPUT"
- name: Resolve APK artifact URL
id: artifact
shell: bash
run: |
set -euo pipefail
cd packages/app
build_view_json="$(npx eas build:view '${{ steps.eas_build.outputs.build_id }}' --json)"
echo "$build_view_json" > "$RUNNER_TEMP/eas-build-view.json"
artifact_url="$(jq -r '.artifacts.buildUrl // .artifacts.applicationArchiveUrl // empty' "$RUNNER_TEMP/eas-build-view.json")"
if [ -z "$artifact_url" ]; then
echo "Failed to determine APK artifact URL."
cat "$RUNNER_TEMP/eas-build-view.json"
exit 1
fi
asset_name="paseo-${RELEASE_TAG}-android.apk"
asset_path="$RUNNER_TEMP/$asset_name"
curl --fail --location --output "$asset_path" "$artifact_url"
echo "asset_name=$asset_name" >> "$GITHUB_OUTPUT"
echo "asset_path=$asset_path" >> "$GITHUB_OUTPUT"
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" "${{ steps.artifact.outputs.asset_path }}" --clobber --repo "${{ github.repository }}"
gh release upload \
"$RELEASE_TAG" \
"$RUNNER_TEMP/apk/${{ needs.build-apk.outputs.artifact-name }}" \
--clobber \
--repo "${{ github.repository }}"

View File

@@ -137,13 +137,20 @@ adb exec-out screencap -p > screenshot.png
Stable tag pushes like `v0.1.0` trigger:
- The EAS GitHub app on Expo servers (iOS + Android production builds + store submit). There is no workflow file in this repo for it.
- `.github/workflows/android-apk-release.yml` on GitHub Actions (APK asset on GitHub Release).
- `.github/workflows/android-apk-release.yml` on GitHub Actions (locally built, production-signed APK asset on GitHub Release). The workflow runs Expo prebuild and Gradle directly on the GitHub runner, so it does not consume an EAS cloud build.
iOS auto-submits to App Store review via a Fastlane lane after EAS uploads to TestFlight. Android auto-submits to the Play Store via EAS-managed credentials.
Beta tags like `v0.1.1-beta.1` only trigger the GitHub APK workflow. They publish a GitHub prerelease APK for testing and do not submit to the stores.
`android-v*` tags also trigger only the GitHub APK workflow — useful when you want to ship an APK without going through stores. The GitHub APK workflow supports `workflow_dispatch` with an existing `tag` input so you can rebuild without cutting a new tag.
`android-v*` tags also trigger only the GitHub APK workflow — useful when you want to ship an APK without going through stores.
The GitHub APK workflow also supports `workflow_dispatch`:
- Leave `publish` disabled and optionally provide `ref` to build any branch, tag, or commit as a workflow artifact without changing a GitHub release.
- Enable `publish` and provide an existing `tag` to rebuild that tag and upload the APK to its GitHub release. Manual publishes always build the tag itself; they cannot publish an arbitrary ref under a release tag.
The workflow restores the production keystore and Firebase configuration from protected GitHub repository secrets, configures the production EAS Update channel, and verifies the finished APK's signing certificate before upload. Keep the protected values synchronized with the production Android credentials in EAS. The AAB profile continues using EAS-managed credentials and store submission.
### Useful commands

View File

@@ -130,6 +130,13 @@ export default {
},
updates: {
url: "https://u.expo.dev/0e7f65ce-0367-46c8-a238-2b65963d235a",
...(process.env.EXPO_UPDATES_CHANNEL
? {
requestHeaders: {
"expo-channel-name": process.env.EXPO_UPDATES_CHANNEL,
},
}
: {}),
...buildProfile.updates,
},
ios: {