Vydání Expo aplikace bez EAS: lokální podepsané buildy pro Android
V tomto průvodci
1. Proč přeskočit EAS
Rozhodnutí bylo jednoduché: žádná závislost na další službě a jejích podmínkách a žádné platby za buildy. EAS Build je dobrý produkt, ale jeho bezplatný plán dovoluje 15 buildů pro Android a 15 pro iOS měsíčně a bezplatné buildy řadí do fronty s nízkou prioritou. Lokální build nemá kvótu ani frontu a nepotřebuje účet.
Pomáhá také ujasnit si, co EAS při vydání vlastně dělá. Jsou to jen čtyři úlohy a každou lze zvládnout lokálně:
| Co dělá EAS | Lokálně | Oddíl |
|---|---|---|
| Vytvoří a uloží keystore | keytool a správce hesel | 2 |
| Sestaví nativní projekt | expo prebuild a Gradle | 3, 5, 6 |
| Podepíše bundle | Konfigurace podpisu vložená config pluginem | 4 |
| Odešle do obchodu | Nahrání souboru .aab v Play Console (nebo servisní účet, volitelně) | 8 |
2. Vygenerujte keystore pro nahrávání (upload keystore) mimo repozitář
Keystore vytvořte jednou, v adresáři, který není součástí žádného projektu:
mkdir -p ~/.keys && chmod 700 ~/.keys
keytool -genkeypair -v \
-keystore ~/.keys/myapp-upload.jks -storetype PKCS12 \
-alias upload -keyalg RSA -keysize 2048 -validity 10000 \
-dname "CN=Your Name, O=Your Company, C=CZ"Vedle něj vytvořte soubor s vlastnostmi, který bude build číst, a omezte ho na svého uživatele:
# ~/.keys/myapp-keystore.properties
storeFile=/path/to/.keys/myapp-upload.jks
storePassword=...
keyAlias=upload
keyPassword=...chmod 600 ~/.keys/myapp-keystore.properties ~/.keys/myapp-upload.jks.jks a hesla uložte do správce hesel a na druhé místo, například NAS nebo cloudové úložiště. Bez nahrávacího klíče nemůžete publikovat aktualizace. Play App Signing to zmírňuje: skutečný klíč pro podpis aplikace drží Google, takže ztracený nahrávací klíč lze nechat resetovat přes podporu Googlu. Ten proces je pomalý a je to záchrana, ne strategie zálohování. Žádný z těchto souborů nikdy necommitujte.Vypište otisky certifikátu. SHA-1 porovnáte v oddílu 6 a omezuje se jím použití klíčů API (například klíče Google Maps omezeného na název balíčku a podpisový certifikát):
keytool -list -v -keystore ~/.keys/myapp-upload.jks -alias upload | grep -E "SHA1|SHA256"
3. Problém: android/ se generuje
V managed projektu nebo projektu s Continuous Native Generation je android/ artefakt buildu. npx expo prebuild přepíše android/app/build.gradle a šablona podepisuje typ buildu release debug keystorem. Pokud konfiguraci podpisu upravíte ručně, zmizí při příštím prebuild --clean, často těsně před vydáním.
Trvalé řešení je config plugin: kód, který běží při prebuildu a generované soubory pokaždé upraví stejně.
4. Config plugin pro podpis vydání
Plugin níže dělá čtyři věci. Cestu k souboru s vlastnostmi čte z proměnné prostředí (výchozí ~/.keys/myapp-keystore.properties), před android { vloží krátký blok v Groovy, který soubor načte, za konfiguraci debug přidá signingConfigs.release a když soubor s vlastnostmi existuje, přepne buildTypes.release na konfiguraci release. Pokud soubor chybí, Gradle jen vypíše varování a podepíše klíčem debug, takže běžný vývoj se nikdy nerozbije. Komentář-značka dělá úpravu idempotentní.
// tools/withReleaseSigning.js
const { withAppBuildGradle, withGradleProperties } = require('expo/config-plugins');
const MARKER = 'MYAPP_RELEASE_SIGNING';
module.exports = function withReleaseSigning(config, props = {}) {
const envName = props.propertiesEnv || 'MYAPP_KEYSTORE_PROPERTIES';
const defaultPath = props.propertiesPath || '~/.keys/myapp-keystore.properties';
// Raise the Gradle JVM limits (see section 5); survives prebuild.
config = withGradleProperties(config, (cfg) => {
const items = cfg.modResults.filter(
(i) => !(i.type === 'property' && i.key === 'org.gradle.jvmargs')
);
items.push({
type: 'property',
key: 'org.gradle.jvmargs',
value: '-Xmx4096m -XX:MaxMetaspaceSize=1024m',
});
cfg.modResults = items;
return cfg;
});
return withAppBuildGradle(config, (cfg) => {
let g = cfg.modResults.contents;
if (g.includes(MARKER)) return cfg; // already patched
// 1) Load the properties file before the `android {` block.
const loader = `// ${MARKER}
def myappKeystorePath = System.getenv('${envName}') ?: '${defaultPath}'
if (myappKeystorePath.startsWith('~')) {
myappKeystorePath = System.getProperty('user.home') + myappKeystorePath.substring(1)
}
def myappKeystoreFile = new File(myappKeystorePath)
def myappSigning = new Properties()
if (myappKeystoreFile.exists()) {
myappKeystoreFile.withInputStream { myappSigning.load(it) }
} else {
logger.warn("WARNING: keystore properties not found (" + myappKeystorePath + "); release will be signed with the DEBUG key and cannot be uploaded to Google Play.")
}
`;
const androidIdx = g.search(/^android\s*\{/m);
if (androidIdx < 0) throw new Error('no android { block in build.gradle');
g = g.slice(0, androidIdx) + loader + g.slice(androidIdx);
// 2) signingConfigs.release right after the debug config.
const debugRe = /(signingConfigs\s*\{\s*debug\s*\{[^}]*\}\s*)/;
if (!debugRe.test(g)) throw new Error('signingConfigs.debug not found');
g = g.replace(debugRe, `$1if (myappKeystoreFile.exists()) {
release {
storeFile file(myappSigning['storeFile'])
storePassword myappSigning['storePassword']
keyAlias myappSigning['keyAlias']
keyPassword myappSigning['keyPassword']
}
}
`);
// 3) Use it in buildTypes.release only; leave debug alone.
const relRe = /(buildTypes\s*\{[\s\S]*?\brelease\s*\{[\s\S]*?)signingConfig signingConfigs\.debug/;
if (!relRe.test(g)) throw new Error('signingConfig not found in buildTypes.release');
g = g.replace(relRe,
`$1signingConfig myappKeystoreFile.exists() ? signingConfigs.release : signingConfigs.debug`);
cfg.modResults.contents = g;
return cfg;
});
};Zaregistrujte ho v app.config.js, dynamické konfiguraci, která obaluje statický app.json:
// app.config.js
module.exports = ({ config }) => ({
...config,
plugins: [
...(config.plugins ?? []),
[require('./tools/withReleaseSigning'), { propertiesEnv: 'MYAPP_KEYSTORE_PROPERTIES' }],
],
});build.gradle, je selhávající prebuild s jasnou zprávou mnohem lepší než bundle tiše podepsaný klíčem debug. Oddíl 6 přidává druhou linii obrany kontrolou otisku.5. Pád Gradle kvůli Metaspace
Příznak. První bundleRelease běžel asi 100 sekund a pak skončil s java.lang.OutOfMemoryError: Metaspace.
Příčina. Výchozí MaxMetaspaceSize démona Gradle je 512 MB, což pro pipeline vydání (R8 a plugin Gradle pro React Native) nestačí.
Oprava. V android/gradle.properties:
org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=1024mProtože se android/ generuje znovu, nastavuje to stejný plugin přes withGradleProperties (už je v kódu výše), takže to přežije prebuild --clean.
6. Sestavení a ověření
Otestováno na Expo SDK 57 s React Native 0.86, Node 24, JDK 21 a Gradle 9:
export ANDROID_HOME=$HOME/Library/Android/sdk
npx expo prebuild -p android --no-install # add --clean only when you must
cd android && ./gradlew bundleRelease -x lint -x testNa Macu s Apple Silicon trvá build asi 7 minut. Výsledkem je bundle o velikosti zhruba 76 MB v android/app/build/outputs/bundle/release/app-release.aab.
Ověřte, kdo ho podepsal, a porovnejte s SHA-1, který jste vypsali v oddílu 2:
keytool -printcert -jarfile android/app/build/outputs/bundle/release/app-release.aab
jarsigner -verify android/app/build/outputs/bundle/release/app-release.aab-dname, SHA1 se rovná otisku nahrávacího klíče a jarsigner ohlásí „jar verified“. Bundle podepsaný klíčem debug ukáže CN=Android Debug.Verze
Nastavte všechny tři výslovně v app.json, aby vydání nikdy nebylo nejednoznačné:
{ "expo": {
"version": "1.0.0",
"android": { "versionCode": 1 },
"ios": { "buildNumber": "1" }
} }Play odmítne nahrání s versionCode, který už viděl, takže ho při každém vydání ručně zvyšte o jedna.
7. Skript pro vydání
Kroky 2 až 6 se vejdou do jednoho skriptu, který skončí při prvním problému. Zkontroluje soubor s vlastnostmi, spustí prebuild a build, porovná otisk s očekávaným a zkopíruje bundle na plochu s verzí v názvu:
#!/usr/bin/env bash
# tools/release-android.sh [--clean]
set -euo pipefail
EXPECTED_SHA1="AA:BB:CC:...:ZZ" # SHA-1 of your upload key (public information)
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
export ANDROID_HOME="${ANDROID_HOME:-$HOME/Library/Android/sdk}"
PROPS="${MYAPP_KEYSTORE_PROPERTIES:-$HOME/.keys/myapp-keystore.properties}"
[ -f "$PROPS" ] || { echo "Missing $PROPS - release would be debug-signed." >&2; exit 1; }
CLEAN=""
[ "${1:-}" = "--clean" ] && CLEAN="--clean"
# The signing plugin only takes effect during prebuild, so always run it.
CI=1 EXPO_OFFLINE=1 npx expo prebuild -p android $CLEAN --no-install
(cd android && ./gradlew bundleRelease -x lint -x test)
AAB="$ROOT/android/app/build/outputs/bundle/release/app-release.aab"
[ -f "$AAB" ] || { echo "No bundle: $AAB" >&2; exit 1; }
ACTUAL="$(keytool -printcert -jarfile "$AAB" | awk '/SHA1:/ {print $2; exit}')"
if [ "$ACTUAL" != "$EXPECTED_SHA1" ]; then
echo "Signature mismatch! Expected $EXPECTED_SHA1, got ${ACTUAL:-<none>}" >&2
exit 1
fi
VERSION="$(node -p "require('./app.json').expo.version")"
OUT="$HOME/Desktop/myapp-$VERSION-release.aab"
cp "$AAB" "$OUT"
echo "Done: $OUT (SHA1 $ACTUAL)"Napojte ho v package.json:
"scripts": { "release:android": "bash tools/release-android.sh" }react-native-maps, před spuštěním skriptu exportujte GOOGLE_MAPS_ANDROID_API_KEY. Plugin ho při prebuildu zapíše do manifestu, takže chybějící proměnná vytvoří bundle s mapou, která zůstane prázdná.8. Ruční nahrání do Google Play
V Play Console otevřete Testing → Internal testing → Create new release a nahrajte .aab. První nahrání zařadí aplikaci do Play App Signing: Google vytvoří a spravuje vlastní klíč pro podpis aplikace a znovu podepíše to, co si uživatelé stahují. Ten klíč má jiný SHA-1, který zkopírujete v Protect with Play → App signing (dříve App integrity). Celý postup v Play Console je v článku Publikace Android aplikace v Google Play.
Servisní účet pro automatizovaná nahrání je volitelný. Nastavuje se v Google Cloud a Play Console a s EAS nemá nic společného. U pár vydání ročně zabere ruční nahrání minutu.
9. A co iOS
npx expo prebuild -p iospro vygenerování projektu Xcode.- Otevřete workspace v Xcode, zapněte automatický podpis a vyberte svůj tým.
- Product → Archive, potom Distribute App → App Store Connect.
- V Xcode 27 zapněte podporu scén přes
expo-build-properties(ios.enableSceneSupport: true), jinak aplikace při startu spadne. Podrobnosti v článku Aplikace v Expo SDK 57 na Xcode 27: terénní průvodce.
10. Kontrolní seznam
- Upload keystore a soubor s vlastnostmi leží v
~/.keys/s oprávněním 600, nikdy v repozitáři. - Keystore a hesla zálohované na dvou místech mimo počítač.
- SHA-1 nahrávacího klíče zaznamenaný a použitý ve skriptu pro vydání.
- Podpis a
org.gradle.jvmargsnastavuje config plugin, ne úpravaandroid/. ANDROID_HOMEnastavený;GOOGLE_MAPS_ANDROID_API_KEYexportovaný, pokud aplikace používá Maps.version,versionCodeabuildNumberzvýšené vapp.json.keytool -printcertukazuje očekávaný SHA-1 ajarsigner -verifyprojde.- První nahrání provedené přes Internal testing; SHA-1 z Play App Signing přidaný do omezených klíčů API.
Nic tu není.
Zatím sem nebyl vložen žádný komentář.