312 lines
16 KiB
Bash
312 lines
16 KiB
Bash
#!/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" <<PLIST
|
||
|
|
<?xml version="1.0" encoding="UTF-8"?>
|
||
|
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||
|
|
<plist version="1.0"><dict>
|
||
|
|
<key>method</key><string>app-store-connect</string>
|
||
|
|
<key>teamID</key><string>$DEVELOPMENT_TEAM</string>
|
||
|
|
<key>destination</key><string>export</string>
|
||
|
|
<key>uploadSymbols</key><true/>
|
||
|
|
</dict></plist>
|
||
|
|
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_<id>.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}"
|