Files
recurring-checklist/release.sh
T
Waters ChurchandClaude Opus 5 469db5bc60 Bring the checklist app under version control
The project has lived only as a folder on the Mac Studio and, briefly, as a
transfer tarball. This is the first commit: the Express server and its web
front end, the Capacitor iOS and Android wrappers, and the release and backup
scripts.

The repo is the source, not a backup of the machine. Left out on purpose, by
the .gitignore files that were already in the tree: node_modules, build output,
mobile/www (regenerated from server/public on every build), and server/data,
which holds the live checklists and belongs on the NAS rather than in git.

Every credential is excluded too — server/.env, the Play upload keystore and
its properties file, the App Store Connect key and ios-signing.config. The one
secret that was not in an ignored file was the NAS SSH password, hard-coded in
build-deploy.sh and grepped back out of it by backup-to-drive.sh. Both now read
it from the login keychain (service checklist-nas, account Christian), falling
back to NAS_PASS in the environment, so rotating it is one keychain entry
rather than a history rewrite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 13:00:57 -04:00

312 lines
16 KiB
Bash
Executable File

#!/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}"