feat(mobile): sign Android release builds with a dedicated release key (#38924)

* feat(mobile): sign Android release builds with a dedicated release key

Add an Expo config plugin that gives the generated app/build.gradle an
env-driven release signing config, and have the Android release workflow
require the keystore secrets, fail before building when they are missing,
and check the APK's signing certificate against a pinned fingerprint.

* fix(mobile): keep the signing setup hint intact in the workflow error

* test(mobile): type the plugin import without a cast

* feat(mobile): keep debug signing until release signing is enabled

Release signing now turns on when the ORCA_ANDROID_RELEASE_CERT_SHA256
(or ORCA_ANDROID_REQUIRE_RELEASE_SIGNING) repository variable is set.
Until then the workflow keeps today's debug-signed build and adds a
warning and job summary note; once enabled, missing secrets fail.

* feat(mobile): enable v2 and v3 APK signing for the release key
This commit is contained in:
Neil
2026-11-06 14:33:18 -08:00
committed by GitHub
parent be3b87f3ac
commit fc67787090
5 changed files with 270 additions and 2 deletions
+84 -2
View File
@@ -37,6 +37,38 @@ jobs:
- name: Checkout
uses: actions/checkout@v6
# Release signing is enforced once ORCA_ANDROID_RELEASE_CERT_SHA256 (or ORCA_ANDROID_REQUIRE_RELEASE_SIGNING=true)
# is set as a repository variable. Until then the build keeps the Expo debug key, with a warning.
- name: Check release signing configuration
id: signing
env:
ORCA_ANDROID_KEYSTORE_BASE64: ${{ secrets.ORCA_ANDROID_KEYSTORE_BASE64 }}
ORCA_ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ORCA_ANDROID_KEYSTORE_PASSWORD }}
ORCA_ANDROID_KEY_ALIAS: ${{ secrets.ORCA_ANDROID_KEY_ALIAS }}
ORCA_ANDROID_KEY_PASSWORD: ${{ secrets.ORCA_ANDROID_KEY_PASSWORD }}
ORCA_ANDROID_RELEASE_CERT_SHA256: ${{ vars.ORCA_ANDROID_RELEASE_CERT_SHA256 }}
ORCA_ANDROID_REQUIRE_RELEASE_SIGNING: ${{ vars.ORCA_ANDROID_REQUIRE_RELEASE_SIGNING }}
run: |
set -euo pipefail
if [ -z "${ORCA_ANDROID_RELEASE_CERT_SHA256:-}" ] && [ "${ORCA_ANDROID_REQUIRE_RELEASE_SIGNING:-}" != 'true' ]; then
message="Android release signing is not enabled, so this APK is signed with the Expo debug key as before. Set the ORCA_ANDROID_RELEASE_CERT_SHA256 repository variable to enable it; see the Android Release Signing section of mobile/README.md."
echo "::warning::$message"
printf '### Android release signing not enabled\n\n%s\n' "$message" >> "$GITHUB_STEP_SUMMARY"
echo "mode=debug" >> "$GITHUB_OUTPUT"
exit 0
fi
missing=()
for name in ORCA_ANDROID_KEYSTORE_BASE64 ORCA_ANDROID_KEYSTORE_PASSWORD ORCA_ANDROID_KEY_ALIAS ORCA_ANDROID_KEY_PASSWORD ORCA_ANDROID_RELEASE_CERT_SHA256; do
if [ -z "${!name:-}" ]; then
missing+=("$name")
fi
done
if [ "${#missing[@]}" -ne 0 ]; then
echo "::error::Android release signing is enabled but not fully configured. Missing secrets or variables: ${missing[*]}. See the Android Release Signing section of mobile/README.md."
exit 1
fi
echo "mode=release" >> "$GITHUB_OUTPUT"
- name: Setup Node.js
uses: actions/setup-node@v6
with:
@@ -67,9 +99,54 @@ jobs:
- name: Expo prebuild
run: npx expo prebuild --platform android --no-install
- name: Decode release keystore
if: steps.signing.outputs.mode == 'release'
env:
ORCA_ANDROID_KEYSTORE_BASE64: ${{ secrets.ORCA_ANDROID_KEYSTORE_BASE64 }}
run: |
set -euo pipefail
umask 077
printf '%s' "$ORCA_ANDROID_KEYSTORE_BASE64" | base64 --decode > "$RUNNER_TEMP/orca-release.keystore"
- name: Build Android release APK
env:
# Empty in debug mode, which leaves Gradle on the debug key exactly as before.
ORCA_ANDROID_REQUIRE_RELEASE_SIGNING: ${{ steps.signing.outputs.mode == 'release' && 'true' || '' }}
ORCA_ANDROID_KEYSTORE_PATH: ${{ steps.signing.outputs.mode == 'release' && format('{0}/orca-release.keystore', runner.temp) || '' }}
ORCA_ANDROID_KEYSTORE_PASSWORD: ${{ steps.signing.outputs.mode == 'release' && secrets.ORCA_ANDROID_KEYSTORE_PASSWORD || '' }}
ORCA_ANDROID_KEY_ALIAS: ${{ steps.signing.outputs.mode == 'release' && secrets.ORCA_ANDROID_KEY_ALIAS || '' }}
ORCA_ANDROID_KEY_PASSWORD: ${{ steps.signing.outputs.mode == 'release' && secrets.ORCA_ANDROID_KEY_PASSWORD || '' }}
run: cd android && ./gradlew assembleRelease
- name: Remove release keystore
if: always()
run: rm -f "$RUNNER_TEMP/orca-release.keystore"
# Why pin the certificate: a regenerated or swapped key would make every installed copy refuse the update.
- name: Verify APK signing certificate
id: signer
if: steps.signing.outputs.mode == 'release'
env:
EXPECTED_CERT_SHA256: ${{ vars.ORCA_ANDROID_RELEASE_CERT_SHA256 }}
run: |
set -euo pipefail
# Expo template debug keystore, public in expo-template-bare-minimum.
debug_cert="fac61745dc0903786fb9ede62a962b399f7348f0bb6f899b8332667591033b9c"
apksigner="$(ls -d "$ANDROID_HOME"/build-tools/*/apksigner | sort -V | tail -1)"
apks=(android/app/build/outputs/apk/release/*.apk)
certs="$("$apksigner" verify --print-certs "${apks[0]}" | sed -n 's/^Signer #[0-9]* certificate SHA-256 digest: //p' | sort -u)"
expected="$(printf '%s' "$EXPECTED_CERT_SHA256" | tr -d ': ' | tr 'A-F' 'a-f')"
if [ "$certs" = "$debug_cert" ]; then
echo "::error::The APK is signed with the public Expo debug key."
exit 1
fi
if [ "$certs" != "$expected" ]; then
echo "::error::The APK signing certificate ($certs) does not match ORCA_ANDROID_RELEASE_CERT_SHA256 ($expected)."
exit 1
fi
echo "cert_sha256=$(printf '%s' "$certs" | tr 'a-f' 'A-F' | sed 's/../&:/g; s/:$//')" >> "$GITHUB_OUTPUT"
echo "APK signing certificate SHA-256: $certs"
# Publish size/digest to distinguish truncated downloads (missing tail signature block) from bad APK builds.
- name: Checksum the APK
id: apk
@@ -129,9 +206,14 @@ jobs:
# printf rather than a heredoc: YAML block indentation would leak into the Markdown.
# Both platforms' commands are named because a reader on macOS has no sha256sum.
# Only release-signed builds list a certificate, since only they are pinned.
signer_line=''
if [ -n '${{ steps.signer.outputs.cert_sha256 }}' ]; then
signer_line='- Signing certificate SHA-256: `${{ steps.signer.outputs.cert_sha256 }}`\n'
fi
verification_section() {
printf '\n### Verify your download\n\n- Size: `%s` bytes\n- SHA-256: `%s`\n\nA "no certificate" or "problem parsing the package" install failure is usually a truncated download — check the size first, then `sha256sum -c app-release.apk.sha256` (`shasum -a 256 -c` on macOS).\n' \
'${{ steps.apk.outputs.bytes }}' '${{ steps.apk.outputs.sha256 }}'
printf '\n### Verify your download\n\n- Size: `%s` bytes\n- SHA-256: `%s`\n%b\nA "no certificate" or "problem parsing the package" install failure is usually a truncated download — check the size first, then `sha256sum -c app-release.apk.sha256` (`shasum -a 256 -c` on macOS).\n' \
'${{ steps.apk.outputs.bytes }}' '${{ steps.apk.outputs.sha256 }}' "$signer_line"
}
# Verification is last; replace from heading to EOF, accepting API-roundtripped CRLFs.
+25
View File
@@ -186,6 +186,31 @@ Run `pnpm sync:mobile-localization` from the repo root to add new keys to `src/i
other catalogs may lag and fall back to English. `pnpm verify:mobile-localization` and
`pnpm verify:mobile-localization-coverage` run in CI. iOS permission prompts live in `locales/<lang>.json`.
## Android Release Signing
`plugins/android-release-signing.js` adds a `release` signing config to the generated
`android/app/build.gradle`. It reads `ORCA_ANDROID_KEYSTORE_PATH`, `ORCA_ANDROID_KEYSTORE_PASSWORD`,
`ORCA_ANDROID_KEY_ALIAS` and `ORCA_ANDROID_KEY_PASSWORD` at build time; nothing is written into the
project. Without them, local release builds keep Expo's debug key.
`.github/workflows/mobile-android-release.yml` turns release signing on once the
`ORCA_ANDROID_RELEASE_CERT_SHA256` repository variable (or `ORCA_ANDROID_REQUIRE_RELEASE_SIGNING=true`)
is set. Until then it signs with the debug key as before and adds a warning to the run. Once it is
on, the workflow fails before building if any of these is missing:
| Name | Kind | Value |
| ---------------------------------- | -------- | ------------------------------------------------------- |
| `ORCA_ANDROID_KEYSTORE_BASE64` | secret | the release keystore, base64-encoded |
| `ORCA_ANDROID_KEYSTORE_PASSWORD` | secret | keystore password |
| `ORCA_ANDROID_KEY_ALIAS` | secret | key alias |
| `ORCA_ANDROID_KEY_PASSWORD` | secret | key password (same as the keystore password for PKCS12) |
| `ORCA_ANDROID_RELEASE_CERT_SHA256` | variable | the key's certificate SHA-256 fingerprint |
After a release-signed build, the workflow checks that the APK's signing certificate matches
`ORCA_ANDROID_RELEASE_CERT_SHA256` and is not the debug key, and publishes the fingerprint in the
release notes. Android only installs an update signed by the same key as the installed app, so
the keystore must be backed up: losing it means no existing install can be updated in place.
## Protocol Version Compatibility
Mobile and desktop talk over a versioned protocol. Because mobile updates lag desktop by 24-48h via the App Store, both sides exchange version numbers on `status.get` so a genuinely incompatible combo can hard-block instead of silently misbehaving.
+1
View File
@@ -112,6 +112,7 @@
"expo-notifications",
"./plugins/android-respect-rotation-lock.js",
"./plugins/android-window-density.js",
"./plugins/android-release-signing.js",
"./plugins/android-navigation-bar-theme.js",
"./plugins/ios-pod-deployment-target-floor.js",
"./plugins/ios-scene-lifecycle.js",
+101
View File
@@ -0,0 +1,101 @@
const { withAppBuildGradle } = require('expo/config-plugins')
// Why: Expo's template signs `release` with its public debug keystore. This adds a `release`
// signing config read from environment variables at build time, so no key or password is ever
// written into the generated project. Local release builds without the variables keep the debug
// key; once release signing is enabled, the release workflow sets ORCA_ANDROID_REQUIRE_RELEASE_SIGNING=true
// so a missing key fails the build instead of shipping a debug-signed APK.
const MARKER = 'orca: release signing'
const DEBUG_SIGNING_CONFIG = ` debug {
storeFile file('debug.keystore')
storePassword 'android'
keyAlias 'androiddebugkey'
keyPassword 'android'
}
`
const RELEASE_SIGNING_CONFIG = ` // ${MARKER}, generated by plugins/android-release-signing.js.
release {
// v3 alongside v2 so a later key rotation can ship a signing-certificate lineage.
enableV2Signing = true
enableV3Signing = true
def orcaKeystorePath = System.getenv('ORCA_ANDROID_KEYSTORE_PATH')
if (orcaKeystorePath) {
storeFile file(orcaKeystorePath)
storePassword System.getenv('ORCA_ANDROID_KEYSTORE_PASSWORD')
keyAlias System.getenv('ORCA_ANDROID_KEY_ALIAS')
keyPassword System.getenv('ORCA_ANDROID_KEY_PASSWORD')
}
}
`
const RELEASE_BUILD_TYPE_SIGNING =
/(\n release \{\n(?:\s*\/\/[^\n]*\n)*)\s*signingConfig signingConfigs\.debug\n/
const RELEASE_SIGNING_SELECTION = `$1 signingConfig orcaReleaseSigningConfig(signingConfigs)
`
const RELEASE_SIGNING_HELPER = `
// ${MARKER}: pick the release key when its variables are set; required in the release workflow.
def orcaReleaseSigningConfig(signingConfigs) {
def names = [
'ORCA_ANDROID_KEYSTORE_PATH',
'ORCA_ANDROID_KEYSTORE_PASSWORD',
'ORCA_ANDROID_KEY_ALIAS',
'ORCA_ANDROID_KEY_PASSWORD'
]
def missing = names.findAll { !System.getenv(it) }
def keystorePath = System.getenv('ORCA_ANDROID_KEYSTORE_PATH')
if (missing.isEmpty() && !new File(keystorePath).isFile()) {
missing = ["ORCA_ANDROID_KEYSTORE_PATH (no file at \${keystorePath})"]
}
if (missing.isEmpty()) {
return signingConfigs.release
}
if (System.getenv('ORCA_ANDROID_REQUIRE_RELEASE_SIGNING') == 'true') {
throw new GradleException(
"Android release signing is required but not configured. Missing: \${missing.join(', ')}"
)
}
if (missing.size() < names.size()) {
throw new GradleException(
"Android release signing is partially configured. Missing: \${missing.join(', ')}"
)
}
return signingConfigs.debug
}
`
function patchAppBuildGradle(contents) {
if (contents.includes(MARKER)) {
return contents
}
// A changed template must fail prebuild rather than leave release builds on the debug key.
if (!contents.includes(DEBUG_SIGNING_CONFIG) || !RELEASE_BUILD_TYPE_SIGNING.test(contents)) {
throw new Error(
"android-release-signing: Expo's app/build.gradle template changed; update the plugin before building."
)
}
// Select first: the inserted signing config is also a `release {` block.
const withSelection = contents.replace(RELEASE_BUILD_TYPE_SIGNING, RELEASE_SIGNING_SELECTION)
const withSigningConfig = withSelection.replace(
DEBUG_SIGNING_CONFIG,
`${DEBUG_SIGNING_CONFIG}${RELEASE_SIGNING_CONFIG}`
)
return `${withSigningConfig.trimEnd()}\n${RELEASE_SIGNING_HELPER}`
}
module.exports = function withAndroidReleaseSigning(config) {
return withAppBuildGradle(config, (cfg) => {
if (cfg.modResults.language !== 'groovy') {
throw new Error(
`android-release-signing: expected a Groovy app/build.gradle, got ${cfg.modResults.language}`
)
}
cfg.modResults.contents = patchAppBuildGradle(cfg.modResults.contents)
return cfg
})
}
module.exports.patchAppBuildGradle = patchAppBuildGradle
@@ -0,0 +1,59 @@
import { createRequire } from 'node:module'
import { describe, expect, it } from 'vitest'
const require = createRequire(import.meta.url)
const plugin: {
patchAppBuildGradle: (contents: string) => string
} = require('../../plugins/android-release-signing.js')
const { patchAppBuildGradle } = plugin
// The signing section of Expo's bare template app/build.gradle.
const TEMPLATE = `android {
signingConfigs {
debug {
storeFile file('debug.keystore')
storePassword 'android'
keyAlias 'androiddebugkey'
keyPassword 'android'
}
}
buildTypes {
debug {
signingConfig signingConfigs.debug
}
release {
// Caution! In production, you need to generate your own keystore file.
// see https://reactnative.dev/docs/signed-apk-android.
signingConfig signingConfigs.debug
minifyEnabled enableMinifyInReleaseBuilds
}
}
}
`
describe('android-release-signing plugin', () => {
it('signs release builds through the env-driven release config', () => {
const patched = patchAppBuildGradle(TEMPLATE)
expect(patched).toContain('storeFile file(orcaKeystorePath)')
expect(patched).toContain('enableV3Signing = true')
expect(patched).toContain('signingConfig orcaReleaseSigningConfig(signingConfigs)')
expect(patched).toContain("System.getenv('ORCA_ANDROID_REQUIRE_RELEASE_SIGNING') == 'true'")
// Debug builds keep the debug key; only the release build type changes.
expect(patched.match(/signingConfig signingConfigs\.debug/g)).toHaveLength(1)
expect(patched).not.toMatch(/storePassword '(?!android')/)
})
it('is idempotent across repeated prebuilds', () => {
const once = patchAppBuildGradle(TEMPLATE)
expect(patchAppBuildGradle(once)).toBe(once)
})
it('fails prebuild when the template no longer matches', () => {
const changed = TEMPLATE.replace(
/(release \{[\s\S]*?)signingConfig signingConfigs\.debug/,
'$1signingConfig signingConfigs.other'
)
expect(() => patchAppBuildGradle(changed)).toThrow(/template changed/)
})
})