#!/usr/bin/env bash # # scripts/ci-android-sign.sh # --------------------------------------------------------------------------- # Wire a RELEASE signing config into a freshly-prebuilt Android project. # # Run this AFTER `expo prebuild --platform android` has generated ./android, # and BEFORE `./gradlew assembleRelease`. # # It does two things, both IDEMPOTENTLY (safe to re-run): # # (a) Decodes the base64 keystore from $ANDROID_KEYSTORE_BASE64 into # android/app/wisp.keystore. # # (b) Patches android/app/build.gradle to: # - add a `signingConfigs.release { ... }` block that reads the keystore # path + passwords from Gradle properties (which we source from env), # - point `buildTypes.release.signingConfig` at it. # The patch is marker-based: it only injects once, and re-running detects # the marker and skips. # # Secrets / env consumed (provided by CI as repository secrets, exported into # the build container's environment): # ANDROID_KEYSTORE_BASE64 base64 of the .keystore file # ANDROID_KEYSTORE_PASSWORD keystore (store) password # ANDROID_KEY_ALIAS key alias (we generate keystores with alias 'wisp') # ANDROID_KEY_PASSWORD key (alias) password # # Generate the keystore + learn the exact secret-setting commands with # scripts/gen-keystore.sh. Full runbook: docs/DEPLOY.md. # --------------------------------------------------------------------------- # Strict mode: exit on error, error on unset vars, fail pipelines on any stage. set -euo pipefail # --- Resolve paths relative to the repo root (this script lives in scripts/) --- # So the script works regardless of the caller's current working directory. SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)" REPO_ROOT="$(cd "${SCRIPT_DIR}/.." >/dev/null 2>&1 && pwd)" ANDROID_DIR="${REPO_ROOT}/android" APP_DIR="${ANDROID_DIR}/app" GRADLE_FILE="${APP_DIR}/build.gradle" # Keep the keystore filename in sync with the signingConfigs block we inject. KEYSTORE_FILE="${APP_DIR}/wisp.keystore" # Unique marker so we can detect a prior injection and stay idempotent. MARKER="// WISP_RELEASE_SIGNING (managed by scripts/ci-android-sign.sh)" # --- Small logging helpers -------------------------------------------------- log() { printf '[ci-android-sign] %s\n' "$*"; } fail() { printf '[ci-android-sign] ERROR: %s\n' "$*" >&2; exit 1; } # --- Preconditions ---------------------------------------------------------- # The android project must already exist (run `expo prebuild` first). [[ -d "${ANDROID_DIR}" ]] || fail "android/ not found. Run 'expo prebuild --platform android' first." [[ -f "${GRADLE_FILE}" ]] || fail "Not found: ${GRADLE_FILE}" # Validate required secrets are present (don't print their values). : "${ANDROID_KEYSTORE_BASE64:?ANDROID_KEYSTORE_BASE64 is required (base64 of the keystore)}" : "${ANDROID_KEYSTORE_PASSWORD:?ANDROID_KEYSTORE_PASSWORD is required}" : "${ANDROID_KEY_ALIAS:?ANDROID_KEY_ALIAS is required}" : "${ANDROID_KEY_PASSWORD:?ANDROID_KEY_PASSWORD is required}" # --------------------------------------------------------------------------- # (a) Decode the keystore from base64 into android/app/wisp.keystore # --------------------------------------------------------------------------- log "Decoding keystore -> ${KEYSTORE_FILE}" # `base64 -d` reads the secret from stdin. We feed it via printf to avoid an # "Argument list too long" / arg-leak from putting the blob on the command line. # Tolerate base64 with or without newlines/whitespace (-w0 producers vs. wrapped). printf '%s' "${ANDROID_KEYSTORE_BASE64}" | base64 -d > "${KEYSTORE_FILE}" \ || fail "Failed to base64-decode ANDROID_KEYSTORE_BASE64" # Sanity check: a real JKS/PKCS12 keystore is more than a few bytes. if [[ ! -s "${KEYSTORE_FILE}" ]] || [[ "$(wc -c < "${KEYSTORE_FILE}")" -lt 256 ]]; then fail "Decoded keystore looks too small — is ANDROID_KEYSTORE_BASE64 correct?" fi # Lock down the keystore file permissions (best effort). chmod 600 "${KEYSTORE_FILE}" || true log "Keystore decoded ($(wc -c < "${KEYSTORE_FILE}") bytes)." # --------------------------------------------------------------------------- # (b1) Make the signing credentials available to Gradle. # --------------------------------------------------------------------------- # We DON'T hardcode passwords in build.gradle. Instead the injected Groovy reads # project properties (project.findProperty(...)) which we supply by writing to # the project-local gradle.properties. This keeps secrets out of the source and # out of the process list. `assembleRelease` also still works if a CI step # passes the same values via -P flags. # # We APPEND our properties under a marker and strip any previous managed block # first, so re-runs don't accumulate duplicates. GRADLE_PROPS="${ANDROID_DIR}/gradle.properties" PROPS_MARKER="# WISP_RELEASE_SIGNING_PROPS (managed by scripts/ci-android-sign.sh)" touch "${GRADLE_PROPS}" # Remove any previously-managed block (from the marker line to EOF). Using a temp # file keeps this portable across GNU/BSD sed. if grep -qF "${PROPS_MARKER}" "${GRADLE_PROPS}"; then log "Refreshing managed signing props in gradle.properties" # Print everything BEFORE the marker line, then we re-append the fresh block. awk -v m="${PROPS_MARKER}" 'index($0, m){found=1} !found{print}' \ "${GRADLE_PROPS}" > "${GRADLE_PROPS}.tmp" mv "${GRADLE_PROPS}.tmp" "${GRADLE_PROPS}" fi # Append the fresh managed properties block. The store FILE path is relative to # the app module (android/app), so just the filename is correct. { printf '\n%s\n' "${PROPS_MARKER}" printf 'WISP_RELEASE_STORE_FILE=wisp.keystore\n' printf 'WISP_RELEASE_STORE_PASSWORD=%s\n' "${ANDROID_KEYSTORE_PASSWORD}" printf 'WISP_RELEASE_KEY_ALIAS=%s\n' "${ANDROID_KEY_ALIAS}" printf 'WISP_RELEASE_KEY_PASSWORD=%s\n' "${ANDROID_KEY_PASSWORD}" } >> "${GRADLE_PROPS}" chmod 600 "${GRADLE_PROPS}" || true # --------------------------------------------------------------------------- # (b2) Inject the signingConfigs.release block + wire buildTypes.release. # --------------------------------------------------------------------------- # Idempotency: if our marker is already present, the gradle file is patched. if grep -qF "${MARKER}" "${GRADLE_FILE}"; then log "build.gradle already patched (marker present) — skipping injection." exit 0 fi # The Groovy snippet we add INSIDE the existing `signingConfigs { ... }` block. # It reads from the gradle.properties values written above. We use def-with- # fallback so a missing property yields a clear gradle error rather than NPE. # `IFS=` keeps `read` from trimming the leading indentation of the first line. IFS= read -r -d '' RELEASE_SIGNING_CONFIG <<'GROOVY' || true release { // -- WISP managed release signing config -- // Values come from android/gradle.properties (written by // scripts/ci-android-sign.sh from CI secrets). storeFile is resolved // relative to this module (android/app). def wispStoreFile = project.findProperty('WISP_RELEASE_STORE_FILE') ?: 'wisp.keystore' storeFile file(wispStoreFile) storePassword project.findProperty('WISP_RELEASE_STORE_PASSWORD') keyAlias project.findProperty('WISP_RELEASE_KEY_ALIAS') keyPassword project.findProperty('WISP_RELEASE_KEY_PASSWORD') } GROOVY # We patch with a small, robust Node script (Node is present in the build image # via Bun/Expo tooling, and string surgery is far safer in JS than in sed for # multi-line, brace-sensitive Groovy). It performs two edits: # # 1. Insert the `release { ... }` config as the FIRST entry inside the # `signingConfigs {` block (the prebuild template ships a `debug` config # there; we add `release` alongside it). # 2. Inside the `buildTypes { ... }`'s `release { ... }` block SPECIFICALLY, # set `signingConfig signingConfigs.release`. We must scope to the release # block via brace matching: a naive global replace of # `signingConfig signingConfigs.debug` would also (wrongly) rewrite the # DEBUG build type, which legitimately keeps the debug signing config. # (The Expo/RN template defaults release to the debug signing config so that # `assembleRelease` works out-of-the-box; we override only the release one.) # # Both edits are guarded so the script fails LOUDLY if the expected anchors are # missing (e.g. a future template change), rather than silently producing an # unsigned/debug-signed APK. log "Patching ${GRADLE_FILE}" WISP_MARKER="${MARKER}" \ WISP_RELEASE_BLOCK="${RELEASE_SIGNING_CONFIG}" \ WISP_GRADLE_FILE="${GRADLE_FILE}" \ node <<'NODE' const fs = require('fs'); const file = process.env.WISP_GRADLE_FILE; const marker = process.env.WISP_MARKER; const block = process.env.WISP_RELEASE_BLOCK; let src = fs.readFileSync(file, 'utf8'); // --- Edit 1: insert `release { ... }` inside `signingConfigs { ... }` -------- // Match the literal `signingConfigs {` opener (Groovy DSL). We insert right // after the opening brace so our release config sits beside the template's // `debug` config. const scRe = /signingConfigs\s*\{/; if (!scRe.test(src)) { console.error("[ci-android-sign] Could not find `signingConfigs {` in build.gradle."); process.exit(1); } src = src.replace( scRe, (m) => `${m}\n ${marker}\n${block}` ); // --- Helper: find the body span of ` {` via brace matching ---------- // Returns {open, close} string indices of the block's content (between the // matching braces) for the FIRST occurrence of ` {` starting at/after // `fromIndex`, or null if not found / unbalanced. This lets us scope edits to a // specific nested block (e.g. the `release` block INSIDE `buildTypes`) instead // of relying on greedy regexes that cross block boundaries. function findBlockBody(text, keyword, fromIndex = 0) { const opener = new RegExp(`\\b${keyword}\\s*\\{`, 'g'); opener.lastIndex = fromIndex; const m = opener.exec(text); if (!m) return null; // Index of the `{` that opens the block. let i = m.index + m[0].length - 1; let depth = 0; for (; i < text.length; i++) { const ch = text[i]; if (ch === '{') depth++; else if (ch === '}') { depth--; if (depth === 0) { // body spans (just after the opening brace) .. (this closing brace) return { open: m.index + m[0].length, close: i }; } } } return null; // unbalanced braces } // --- Edit 2: point the RELEASE buildType at signingConfigs.release ----------- // Scope to buildTypes { ... } first, then to its release { ... } sub-block. const buildTypes = findBlockBody(src, 'buildTypes'); if (!buildTypes) { console.error("[ci-android-sign] Could not find buildTypes { } in build.gradle."); process.exit(1); } const release = findBlockBody(src, 'release', buildTypes.open); // Guard: the release block must lie INSIDE buildTypes (defensive against a // future template where `release` appears elsewhere first). if (!release || release.close > buildTypes.close) { console.error("[ci-android-sign] Could not find buildTypes.release { } in build.gradle."); process.exit(1); } let releaseBody = src.slice(release.open, release.close); if (/signingConfig\s+signingConfigs\.debug/.test(releaseBody)) { // Normal case: swap the template default (debug) for our release config. releaseBody = releaseBody.replace( /signingConfig\s+signingConfigs\.debug/, 'signingConfig signingConfigs.release' ); } else if (/signingConfig\s+signingConfigs\.release/.test(releaseBody)) { // Already points at release (e.g. a template that ships it) — nothing to do. } else { // No signingConfig line present — inject one as the first statement. releaseBody = '\n signingConfig signingConfigs.release' + releaseBody; } src = src.slice(0, release.open) + releaseBody + src.slice(release.close); fs.writeFileSync(file, src); console.log("[ci-android-sign] build.gradle patched: added signingConfigs.release and wired release buildType."); NODE log "Done. Release signing is configured. You can now run: (cd android && ./gradlew assembleRelease)"