Vydání Expo aplikace bez EAS: lokální podepsané buildy pro Android

★ ★ ★ ★ ★ Nehodnoceno

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á EASLokálněOddíl
Vytvoří a uloží keystorekeytool a správce hesel2
Sestaví nativní projektexpo prebuild a Gradle3, 5, 6
Podepíše bundleKonfigurace podpisu vložená config pluginem4
Odešle do obchoduNahrá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

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' }],
  ],
});

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=1024m

Protož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 test

Na 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

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" }

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 ios pro 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.jvmargs nastavuje config plugin, ne úprava android/.
  • ANDROID_HOME nastavený; GOOGLE_MAPS_ANDROID_API_KEY exportovaný, pokud aplikace používá Maps.
  • version, versionCode a buildNumber zvýšené v app.json.
  • keytool -printcert ukazuje očekávaný SHA-1 a jarsigner -verify projde.
  • První nahrání provedené přes Internal testing; SHA-1 z Play App Signing přidaný do omezených klíčů API.
0.0
★ ★ ★ ★ ★
0 hodnocení
5 ★
0 %
4 ★
0 %
3 ★
0 %
2 ★
0 %
1 ★
0 %

Přidejte komentář

Nic tu není.

Zatím sem nebyl vložen žádný komentář.