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>
This commit is contained in:
Executable
+311
@@ -0,0 +1,311 @@
|
||||
#!/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}"
|
||||
Reference in New Issue
Block a user