mirror of
https://github.com/stablyai/orca.git
synced 2026-11-14 18:07:45 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user