#!/bin/bash # release.sh — rebuild and ship EVERY target of the checklist app from one source of truth. # # ───────────────────────────────────────────────────────────────────────────────────────── # THE WHOLE POINT OF THIS SCRIPT # # There are now THREE places the app runs, and they do NOT update together: # # 1. The website check.watersmedia.org (the NAS) — updates the moment you deploy # 2. The iOS app App Store — only updates when you ship a build # 3. The Android app Play Store — only updates when you ship a build # # All three are built from the SAME files in server/public/. That means a UI fix is written # once — but it also means that deploying the website alone leaves the two store apps # running whatever code they were last built with. They do not "catch up" on their own, # ever. A volunteer on the App Store build will keep seeing last month's UI while the # website shows today's, and there is nothing in the app that will tell them why. # # So: EVERY time you change the front-end, run this with no flags. It rebuilds all three. # ───────────────────────────────────────────────────────────────────────────────────────── # # Usage: # ./release.sh # everything: bump builds, deploy web, build both apps # ./release.sh --web # website only (a server-side or copy fix, no app change) # ./release.sh --android # Android app only # ./release.sh --ios # iOS app only # ./release.sh --version 1.1.0 # also set the marketing version both stores show # ./release.sh --no-bump # rebuild without consuming a build number (testing) # # One-time setup lives in mobile/README.md. Android is fully automated here. iOS needs a # signing identity this script can't create for you (it needs your Apple ID and 2FA) — see # mobile/ios-signing.config.example. set -euo pipefail ROOT="$(cd "$(dirname "$0")" && pwd)" MOBILE="$ROOT/mobile" RELEASE_CFG="$MOBILE/release.config.json" OUT="$ROOT/dist-release" DO_WEB=0; DO_ANDROID=0; DO_IOS=0; BUMP=1; NEW_VERSION="" if [ $# -eq 0 ]; then DO_WEB=1; DO_ANDROID=1; DO_IOS=1; fi while [ $# -gt 0 ]; do case "$1" in --web) DO_WEB=1 ;; --android) DO_ANDROID=1 ;; --ios) DO_IOS=1 ;; --all) DO_WEB=1; DO_ANDROID=1; DO_IOS=1 ;; --no-bump) BUMP=0 ;; --version) shift; NEW_VERSION="${1:-}" ;; -h|--help) sed -n '2,40p' "$0"; exit 0 ;; *) echo "Unknown option: $1 (try --help)" >&2; exit 2 ;; esac shift done # A lone --version with no target still means "ship everywhere". if [ $DO_WEB -eq 0 ] && [ $DO_ANDROID -eq 0 ] && [ $DO_IOS -eq 0 ]; then DO_WEB=1; DO_ANDROID=1; DO_IOS=1 fi B=$'\033[1m'; G=$'\033[32m'; Y=$'\033[33m'; R=$'\033[31m'; DIM=$'\033[2m'; N=$'\033[0m' step() { echo; echo "${B}━━ $* ${N}"; } ok() { echo " ${G}✓${N} $*"; } warn() { echo " ${Y}!${N} $*"; } fail() { echo " ${R}✗${N} $*" >&2; exit 1; } mkdir -p "$OUT" WEB_OK=0; ANDROID_AAB=""; IOS_IPA=""; IOS_SKIPPED=""; IOS_UPLOADED=0; ANDROID_UPLOADED=0 # ── Version numbers ───────────────────────────────────────────────────────────────────── # Both stores permanently reserve every build number they have ever accepted, so these are # bumped BEFORE anything is built. A rejected upload still burns the number. step "Version" if [ -n "$NEW_VERSION" ]; then echo "$NEW_VERSION" | grep -Eq '^[0-9]+(\.[0-9]+){0,2}$' || fail "version must look like 1.2.3 — got '$NEW_VERSION'" fi NEW_VERSION="$NEW_VERSION" BUMP="$BUMP" DO_IOS="$DO_IOS" DO_ANDROID="$DO_ANDROID" \ node -e ' const fs=require("fs"),p=process.env.HOME&&process.argv[1]; const f="'"$RELEASE_CFG"'"; const c=JSON.parse(fs.readFileSync(f,"utf8")); if(process.env.NEW_VERSION) c.version=process.env.NEW_VERSION; if(process.env.BUMP==="1"){ // Only consume a number for a platform actually being built. if(process.env.DO_IOS==="1") c.iosBuild=(c.iosBuild|0)+1; if(process.env.DO_ANDROID==="1") c.androidVersionCode=(c.androidVersionCode|0)+1; } fs.writeFileSync(f,JSON.stringify(c,null,2)+"\n"); console.log(` version ${c.version} · ios build ${c.iosBuild} · android code ${c.androidVersionCode}`); ' VERSION="$(node -e "console.log(require('$RELEASE_CFG').version)")" # ── Front-end: syntax gate ────────────────────────────────────────────────────────────── # A syntax error here would deploy a blank website AND ship a blank app to both stores, so # it is checked once, up front, for every target. step "Front-end check" for f in server/public/app.js server/public/md.js server/public/native-bridge.js server/server.js; do node --check "$ROOT/$f" || fail "$f has a syntax error — nothing was built" ok "$f" done # ── Website ───────────────────────────────────────────────────────────────────────────── if [ $DO_WEB -eq 1 ]; then step "Website → check.watersmedia.org" # build-deploy.sh reliably hangs locally AFTER the remote deploy has already succeeded # (the expect session stalls waiting on EOF). Cap it and judge by the server, not by the # exit code — see memory/reference-deploy-hang.md. set +e ( cd "$ROOT" && ./build-deploy.sh ) 2>&1 | sed 's/^/ '"$DIM"'/;s/$/'"$N"'/' & DEPLOY_PID=$! ( sleep 300; kill $DEPLOY_PID 2>/dev/null ) & WATCHDOG=$! # disown, or bash reports the watchdog's own death as "Terminated: 15 ( sleep 300; … )" # in the middle of a successful deploy, which reads exactly like a failure. disown $WATCHDOG 2>/dev/null || true wait $DEPLOY_PID 2>/dev/null kill $WATCHDOG 2>/dev/null set -e sleep 3 CODE="$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 https://check.watersmedia.org/api/me || true)" # 401 is the healthy answer: the server is up and correctly refusing an unauthenticated # request. 000 means it never answered. if [ "$CODE" = "401" ]; then WEB_OK=1; ok "live and healthy (HTTP 401 = up, not signed in)" else fail "server did not come back (HTTP $CODE) — re-run ./build-deploy.sh"; fi fi # ── Shared app bundle ─────────────────────────────────────────────────────────────────── if [ $DO_ANDROID -eq 1 ] || [ $DO_IOS -eq 1 ]; then step "App bundle ← server/public" ( cd "$MOBILE" && node scripts/sync-web.js ) | sed 's/^/ /' ( cd "$MOBILE" && node scripts/stamp-version.js ) | sed 's/^/ /' ( cd "$MOBILE" && npx cap sync >/dev/null 2>&1 ) || fail "cap sync failed" ok "native projects synced" fi # ── Android ───────────────────────────────────────────────────────────────────────────── if [ $DO_ANDROID -eq 1 ]; then step "Android → Play Store (.aab)" # shellcheck source=mobile/scripts/env.sh . "$MOBILE/scripts/env.sh" || fail "Android toolchain missing — see mobile/README.md" [ -f "$MOBILE/keystore.properties" ] || fail "no mobile/keystore.properties — the AAB would be unsigned and Play would reject it" ( cd "$MOBILE/android" && ./gradlew :app:bundleRelease --no-daemon -q ) \ || fail "Gradle build failed" SRC="$MOBILE/android/app/build/outputs/bundle/release/app-release.aab" [ -f "$SRC" ] || fail "expected an AAB at $SRC" CODE_N="$(node -e "console.log(require('$RELEASE_CFG').androidVersionCode)")" ANDROID_AAB="$OUT/checklist-$VERSION-$CODE_N.aab" cp "$SRC" "$ANDROID_AAB" # An unsigned AAB builds happily and is rejected on upload, so confirm the signature # exists here rather than in the Play Console ten minutes from now. # Ask jarsigner, not the zip listing. An .aab has no APK-style META-INF/*.RSA, so a # filename grep silently passes on the always-present BUNDLE-METADATA and would happily # rubber-stamp an unsigned bundle (which builds fine and is rejected only on upload). if jarsigner -verify "$ANDROID_AAB" 2>&1 | grep -q "jar verified"; then ok "signature verified" else fail "the AAB is UNSIGNED — Play will reject it. Check mobile/keystore.properties" fi ok "$(basename "$ANDROID_AAB") ($(du -h "$ANDROID_AAB" | cut -f1))" # Hands-off upload to Play, mirroring the iOS path below. Without the service-account key # the .aab is still sitting in dist-release/ for a manual Console upload. if [ -f "$MOBILE/play-service-account.json" ]; then set +e ( cd "$MOBILE" && node scripts/upload-play.js "$ANDROID_AAB" --track "${PLAY_TRACK:-internal}" ) 2>&1 | sed 's/^/ /' PRC=${PIPESTATUS[0]} set -e # Check the node script's OWN exit code, never the pipeline's last command — a `| sed` # swallows failures and would report a dead upload as a success. [ $PRC -eq 0 ] || fail "Play upload failed (see above) — the .aab is still at $ANDROID_AAB" ANDROID_UPLOADED=1 else warn "no mobile/play-service-account.json — upload the .aab in the Play Console by hand" fi fi # ── iOS ───────────────────────────────────────────────────────────────────────────────── if [ $DO_IOS -eq 1 ]; then step "iOS → App Store (.ipa)" SIGNING="$MOBILE/ios-signing.config" if [ ! -f "$SIGNING" ]; then IOS_SKIPPED="no mobile/ios-signing.config" warn "skipped — $IOS_SKIPPED" warn "one-time setup: cp mobile/ios-signing.config.example mobile/ios-signing.config" warn "then fill in your Apple Team ID (see mobile/README.md → iOS one-time setup)" else # shellcheck source=/dev/null . "$SIGNING" [ -n "${DEVELOPMENT_TEAM:-}" ] || fail "DEVELOPMENT_TEAM is empty in mobile/ios-signing.config" ARCHIVE="$MOBILE/build/App.xcarchive" rm -rf "$ARCHIVE" "$MOBILE/build/ios" cat > "$MOBILE/ExportOptions.plist" < methodapp-store-connect teamID$DEVELOPMENT_TEAM destinationexport uploadSymbols PLIST # -allowProvisioningUpdates lets Xcode mint the distribution certificate and profile on # first run. That needs an Apple ID already signed in to Xcode; it cannot prompt here. ( cd "$MOBILE/ios/App" && xcodebuild \ -project App.xcodeproj -scheme App -configuration Release \ -destination 'generic/platform=iOS' \ -archivePath "$ARCHIVE" \ DEVELOPMENT_TEAM="$DEVELOPMENT_TEAM" \ CODE_SIGN_STYLE=Automatic \ -allowProvisioningUpdates \ archive ) 2>&1 | tail -5 | sed 's/^/ '"$DIM"'/;s/$/'"$N"'/' [ -d "$ARCHIVE" ] || fail "archive failed — open mobile/ios/App/App.xcodeproj in Xcode and check Signing & Capabilities" ( cd "$MOBILE/ios/App" && xcodebuild -exportArchive \ -archivePath "$ARCHIVE" \ -exportOptionsPlist "$MOBILE/ExportOptions.plist" \ -exportPath "$MOBILE/build/ios" \ -allowProvisioningUpdates ) 2>&1 | tail -5 | sed 's/^/ '"$DIM"'/;s/$/'"$N"'/' SRC_IPA="$(find "$MOBILE/build/ios" -name '*.ipa' | head -1)" [ -n "$SRC_IPA" ] || fail "export produced no .ipa" BUILD_N="$(node -e "console.log(require('$RELEASE_CFG').iosBuild)")" IOS_IPA="$OUT/checklist-$VERSION-$BUILD_N.ipa" cp "$SRC_IPA" "$IOS_IPA" ok "$(basename "$IOS_IPA") ($(du -h "$IOS_IPA" | cut -f1))" # Optional hands-off upload. Without an API key the .ipa is still ready to drag into # Xcode's Organizer or Transporter. if [ -n "${ASC_KEY_ID:-}" ] && [ -n "${ASC_ISSUER_ID:-}" ]; then # altool finds the private key by NAME, not by path: it looks for AuthKey_.p8 in # ./private_keys, ~/private_keys, ~/.private_keys, ~/.appstoreconnect/private_keys and # $API_PRIVATE_KEYS_DIR. Checking here turns an opaque auth failure fifty seconds into # an upload into a one-line "the file isn't where altool looks". KEYFILE="$HOME/.appstoreconnect/private_keys/AuthKey_${ASC_KEY_ID}.p8" if [ ! -f "$KEYFILE" ]; then warn "ASC key configured but $KEYFILE is missing — skipping upload" warn "put the .p8 there (exact filename), or clear ASC_KEY_ID in mobile/ios-signing.config" else # Validate first. This runs Apple's full acceptance checks — icons, Info.plist, # entitlements, bundle ID, duplicate build number — WITHOUT consuming the build # number or creating a processing record. Far better to fail here than to discover # it twenty minutes later in App Store Connect. echo " validating with App Store Connect…" set +e xcrun altool --validate-app -f "$IOS_IPA" -t ios \ --api-key "$ASC_KEY_ID" --api-issuer "$ASC_ISSUER_ID" > "$OUT/.altool.log" 2>&1 VRC=$? set -e if [ $VRC -ne 0 ]; then sed 's/^/ /' "$OUT/.altool.log" | tail -12 fail "validation failed — the .ipa was NOT uploaded (see above)" fi ok "validation passed" echo " uploading to App Store Connect…" set +e xcrun altool --upload-app -f "$IOS_IPA" -t ios \ --api-key "$ASC_KEY_ID" --api-issuer "$ASC_ISSUER_ID" > "$OUT/.altool.log" 2>&1 URC=$? set -e # Check altool's OWN status, not a pipeline's last command — piping straight into # `tail | sed` masked the real exit code and reported every failure as a success. if [ $URC -ne 0 ]; then sed 's/^/ /' "$OUT/.altool.log" | tail -12 fail "upload failed (see above) — the .ipa is still at $IOS_IPA" fi rm -f "$OUT/.altool.log" ok "uploaded — appears in App Store Connect / TestFlight after processing (~15 min)" IOS_UPLOADED=1 fi else warn "no ASC API key configured — upload the .ipa with Xcode ▸ Organizer or Transporter" fi fi fi # ── Summary ───────────────────────────────────────────────────────────────────────────── echo echo "${B}━━ Done · version $VERSION ${N}" [ $DO_WEB -eq 1 ] && { [ $WEB_OK -eq 1 ] && ok "Website live now at https://check.watersmedia.org" || warn "Website FAILED"; } [ $DO_WEB -eq 0 ] && echo " ${DIM}·${N} Website not rebuilt — still serving the previous deploy" if [ $DO_ANDROID -eq 1 ]; then [ -n "$ANDROID_AAB" ] && ok "Android $ANDROID_AAB" || warn "Android FAILED" if [ "$ANDROID_UPLOADED" = "1" ]; then echo " ${DIM}↳ released to the ${PLAY_TRACK:-internal} track — testers get it after Play processes it${N}" else echo " ${DIM}↳ upload at play.google.com/console → Testing → your track → Create release${N}" fi else echo " ${DIM}·${N} Android not rebuilt — Play still serves the previous build" fi if [ $DO_IOS -eq 1 ]; then if [ -n "$IOS_IPA" ]; then ok "iOS $IOS_IPA" if [ "$IOS_UPLOADED" = "1" ]; then echo " ${DIM}↳ uploaded — it lands in TestFlight after ~15 min of processing${N}" else echo " ${DIM}↳ upload with Xcode ▸ Organizer or Transporter${N}" fi else warn "iOS not built — $IOS_SKIPPED"; fi else echo " ${DIM}·${N} iOS not rebuilt — the App Store still serves the previous build" fi echo echo " ${DIM}Reminder: the store apps only change when a NEW build is uploaded AND released.${N}" echo " ${DIM}Deploying the website alone leaves phones on their old bundled UI.${N}"