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:
Waters Church
2026-09-24 13:00:57 -04:00
co-authored by Claude Opus 5
commit 469db5bc60
177 changed files with 33247 additions and 0 deletions
+161
View File
@@ -0,0 +1,161 @@
#!/usr/bin/env node
'use strict';
// asc-status.js — ask App Store Connect what it actually thinks of our builds.
//
// node scripts/asc-status.js
//
// TestFlight's web UI is vague while a build is in flight ("No Builds Available" on a tester
// row can mean processing, or export-compliance unanswered, or the build was rejected). This
// asks the API instead and prints the real processingState, so you know whether to wait or
// to go fix something.
//
// Reuses the same key release.sh uploads with. The App Store Connect API wants a short-lived
// ES256 JWT; Node can sign that with its built-in crypto, so there is no dependency here.
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const MOBILE = path.resolve(__dirname, '..');
const CFG = path.join(MOBILE, 'ios-signing.config');
function cfgValue(key) {
const m = fs.readFileSync(CFG, 'utf8').match(new RegExp('^' + key + '=(.*)$', 'm'));
return m ? m[1].trim() : '';
}
const KEY_ID = cfgValue('ASC_KEY_ID');
const ISSUER_ID = cfgValue('ASC_ISSUER_ID');
const BUNDLE_ID = JSON.parse(fs.readFileSync(path.join(MOBILE, 'capacitor.config.json'), 'utf8')).appId;
const KEY_PATH = path.join(process.env.HOME, '.appstoreconnect', 'private_keys', `AuthKey_${KEY_ID}.p8`);
if (!KEY_ID || !ISSUER_ID) { console.error('✗ ASC_KEY_ID / ASC_ISSUER_ID not set in ios-signing.config'); process.exit(1); }
if (!fs.existsSync(KEY_PATH)) { console.error(`✗ missing ${KEY_PATH}`); process.exit(1); }
function b64url(buf) {
return Buffer.from(buf).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
function makeJwt() {
const now = Math.floor(Date.now() / 1000);
const header = { alg: 'ES256', kid: KEY_ID, typ: 'JWT' };
// Apple caps token lifetime at 20 minutes and rejects anything longer outright.
const payload = { iss: ISSUER_ID, iat: now, exp: now + 600, aud: 'appstoreconnect-v1' };
const signingInput = `${b64url(JSON.stringify(header))}.${b64url(JSON.stringify(payload))}`;
// dsaEncoding ieee-p1363 gives the raw r||s signature JWT requires. Node's default is DER,
// which Apple rejects with a bare 401 and no explanation — an easy hour to lose.
const sig = crypto.sign(null, Buffer.from(signingInput), {
key: fs.readFileSync(KEY_PATH),
dsaEncoding: 'ieee-p1363',
});
return `${signingInput}.${b64url(sig)}`;
}
async function api(pathname) {
const res = await fetch(`https://api.appstoreconnect.apple.com${pathname}`, {
headers: { Authorization: `Bearer ${makeJwt()}` },
});
const body = await res.json().catch(() => ({}));
if (!res.ok) {
const detail = (body.errors || []).map((e) => `${e.title}: ${e.detail}`).join('\n ') || res.statusText;
throw new Error(`HTTP ${res.status}\n ${detail}`);
}
return body;
}
(async () => {
const apps = await api(`/v1/apps?filter[bundleId]=${encodeURIComponent(BUNDLE_ID)}`);
if (!apps.data || !apps.data.length) {
console.error(`✗ no app in App Store Connect with bundle id ${BUNDLE_ID}`);
process.exit(1);
}
const app = apps.data[0];
console.log(` ${app.attributes.name} (${BUNDLE_ID})`);
console.log(` app id ${app.id}\n`);
const builds = await api(`/v1/builds?filter[app]=${app.id}&limit=10&sort=-uploadedDate`);
if (!builds.data.length) {
console.log(' No builds yet. If you just uploaded, Apple can take a few minutes to even');
console.log(' register it — re-run shortly.');
return;
}
console.log(' BUILD STATE UPLOADED');
console.log(' ───── ────────── ────────────────────');
for (const b of builds.data) {
const a = b.attributes;
console.log(` ${String(a.version).padEnd(7)} ${String(a.processingState).padEnd(12)} ${new Date(a.uploadedDate).toLocaleString()}`);
}
// processingState only says whether Apple finished ingesting the binary. Whether a TESTER
// can actually install it is a different field entirely — buildBetaDetail.internalBuildState
// — and it is the one that explains "No Builds Available".
const latestId = builds.data[0].id;
try {
const detail = await api(`/v1/builds/${latestId}/buildBetaDetail`);
const st = detail.data && detail.data.attributes;
if (st) {
console.log(` internal state ${st.internalBuildState}`);
console.log(` external state ${st.externalBuildState}`);
console.log(` auto-notify ${st.autoNotifyEnabled}`);
}
} catch (e) { console.log(` (buildBetaDetail unavailable: ${e.message.split('\n')[0]})`); }
// Groups, and which builds are actually attached to each. /v1/builds/{id}/betaGroups
// 403s for an App Manager key, but the app-scoped route works — use that one.
console.log('\n GROUPS');
try {
const groups = await api(`/v1/apps/${app.id}/betaGroups`);
for (const g of groups.data) {
const a = g.attributes;
console.log(` "${a.name}" internal=${a.isInternalGroup} allBuilds=${a.hasAccessToAllBuilds}`);
try {
const gb = await api(`/v1/betaGroups/${g.id}/builds`);
console.log(` builds: ${gb.data.length ? gb.data.map((x) => x.attributes.version).join(', ') : 'NONE — testers in this group see nothing'}`);
} catch (_) { /* non-fatal */ }
}
} catch (e) { console.log(` (unavailable: ${e.message.split('\n')[0]})`); }
// The one that cost us an afternoon. A tester sitting at NOT_INVITED was never actually
// sent an invitation — adding someone to a group does NOT invite them. TestFlight then
// shows that Apple ID its "enter an invite code" empty screen, which looks for all the
// world like the build is broken or still processing. It is neither.
console.log('\n TESTERS');
try {
const testers = await api(`/v1/betaTesters?filter[apps]=${app.id}&limit=50`);
if (!testers.data.length) console.log(' (none)');
for (const t of testers.data) {
const a = t.attributes;
const note = a.state === 'NOT_INVITED' ? ' ← never invited; they will see the invite-code screen' : '';
console.log(` ${a.email} ${a.state}${note}`);
}
} catch (e) { console.log(` (unavailable: ${e.message.split('\n')[0]})`); }
const latest = builds.data[0].attributes;
console.log();
switch (latest.processingState) {
case 'PROCESSING':
console.log(' Still processing. Testers see "No Builds Available" until this finishes —');
console.log(' usually 5–30 minutes. Nothing to fix; just wait and re-run this.');
break;
case 'VALID':
console.log(' ✓ Build is ready. If a tester still sees "No Builds Available" or an');
console.log(' invite-code screen, read the TESTERS block above — in order of likelihood:');
console.log(' NOT_INVITED → they were never sent an invite. Adding someone to a');
console.log(' group does not invite them. Resend it from TestFlight,');
console.log(' or POST /v1/betaTesterInvitations.');
console.log(' INVITED → invite sent, not yet accepted. Open the email ON THE');
console.log(' PHONE and tap "View in TestFlight".');
console.log(' groups: NONE → attach the build: TestFlight → group → Builds → +');
console.log(' And check the phone\'s App Store Apple ID matches the tester email.');
break;
case 'FAILED':
case 'INVALID':
console.log(' ✗ Apple rejected this build during processing. The reason is emailed to the');
console.log(' account holder, and shown under TestFlight → Builds. Fix, then re-run');
console.log(' ./release.sh --ios (it will use a fresh build number automatically).');
break;
default:
console.log(` Unrecognised state "${latest.processingState}" — check TestFlight directly.`);
}
})().catch((e) => { console.error(`✗ ${e.message}`); process.exit(1); });
+26
View File
@@ -0,0 +1,26 @@
#!/bin/bash
# Toolchain paths for the Android build. Source it, don't run it: . scripts/env.sh
#
# This Mac has no Homebrew, no system Java and no Android Studio, so the Android toolchain
# was installed by hand into the user's home directory (see mobile/README.md → Toolchain).
# Nothing here is on the default PATH, which is why every Android command has to source
# this file first — including release.sh. Without it Gradle fails with the misleading
# "Unable to locate a Java Runtime".
export JAVA_HOME="$HOME/.jdks/temurin-21/Contents/Home"
export ANDROID_HOME="$HOME/Library/Android/sdk"
export ANDROID_SDK_ROOT="$ANDROID_HOME"
export PATH="$JAVA_HOME/bin:$ANDROID_HOME/platform-tools:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/emulator:$PATH"
if [ ! -x "$JAVA_HOME/bin/java" ]; then
echo "✗ No JDK at $JAVA_HOME" >&2
echo " Reinstall: curl -L -o /tmp/jdk.tar.gz 'https://api.adoptium.net/v3/binary/latest/21/ga/mac/aarch64/jdk/hotspot/normal/eclipse'" >&2
echo " mkdir -p ~/.jdks && tar -xzf /tmp/jdk.tar.gz -C ~/.jdks && mv ~/.jdks/jdk-21* ~/.jdks/temurin-21" >&2
return 1 2>/dev/null || exit 1
fi
if [ ! -d "$ANDROID_HOME/platforms" ]; then
echo "✗ No Android SDK at $ANDROID_HOME" >&2
echo " Reinstall: see mobile/README.md → Toolchain" >&2
return 1 2>/dev/null || exit 1
fi
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env node
'use strict';
// stamp-version.js — push release.config.json's numbers into the Xcode project.
//
// Android reads release.config.json directly from build.gradle, but Xcode keeps
// MARKETING_VERSION / CURRENT_PROJECT_VERSION inside project.pbxproj, so they have to be
// written in. Both stores then get the same marketing version from one edited file, which
// is the whole point: a build number that silently disagrees between platforms is how you
// end up with "1.4 on Android, 1.2 on iPhone" bug reports that nobody can reproduce.
//
// Run with --check to verify without writing (used by release.sh as a pre-flight).
const fs = require('fs');
const path = require('path');
const MOBILE = path.resolve(__dirname, '..');
const PBXPROJ = path.join(MOBILE, 'ios', 'App', 'App.xcodeproj', 'project.pbxproj');
const release = JSON.parse(fs.readFileSync(path.join(MOBILE, 'release.config.json'), 'utf8'));
const version = String(release.version || '').trim();
const iosBuild = release.iosBuild;
// Apple rejects a CFBundleShortVersionString that isn't 1-3 dot-separated integers, and it
// rejects it at upload time — after the whole archive has already been built and signed.
if (!/^\d+(\.\d+){0,2}$/.test(version)) {
console.error(`✗ version must be 1-3 dot-separated numbers (e.g. 1.2.3) — got ${JSON.stringify(release.version)}`);
process.exit(1);
}
if (!Number.isInteger(iosBuild) || iosBuild < 1) {
console.error(`✗ iosBuild must be a positive integer — got ${JSON.stringify(iosBuild)}`);
process.exit(1);
}
if (!Number.isInteger(release.androidVersionCode) || release.androidVersionCode < 1) {
console.error(`✗ androidVersionCode must be a positive integer — got ${JSON.stringify(release.androidVersionCode)}`);
process.exit(1);
}
let pbx = fs.readFileSync(PBXPROJ, 'utf8');
const before = pbx;
// Both the Debug and Release configurations carry these, hence the global replace: an
// archive built from the wrong configuration must not ship a stale build number.
pbx = pbx.replace(/MARKETING_VERSION = [^;]+;/g, `MARKETING_VERSION = ${version};`);
pbx = pbx.replace(/CURRENT_PROJECT_VERSION = [^;]+;/g, `CURRENT_PROJECT_VERSION = ${iosBuild};`);
const marketingHits = (pbx.match(/MARKETING_VERSION = /g) || []).length;
const buildHits = (pbx.match(/CURRENT_PROJECT_VERSION = /g) || []).length;
if (marketingHits < 2 || buildHits < 2) {
console.error(`✗ expected MARKETING_VERSION and CURRENT_PROJECT_VERSION in both build configs (found ${marketingHits}/${buildHits})`);
process.exit(1);
}
if (process.argv.includes('--check')) {
console.log(pbx === before
? `✓ Xcode already at ${version} (${iosBuild})`
: `• Xcode would change to ${version} (${iosBuild})`);
process.exit(0);
}
if (pbx !== before) fs.writeFileSync(PBXPROJ, pbx);
console.log(`✓ Xcode stamped ${version} (build ${iosBuild})`);
console.log(`✓ Gradle reads ${version} (code ${release.androidVersionCode}) from release.config.json`);
+156
View File
@@ -0,0 +1,156 @@
#!/usr/bin/env node
'use strict';
// sync-web.js — rebuild mobile/www from the one true copy of the front-end.
//
// The website in ../server/public IS the source of truth. The store apps never get their
// own fork of the UI; they get a byte-for-byte copy of these files plus a small, explicit
// set of native-only additions. That means a UI fix is written once and reaches all three
// targets — but it also means the apps are only as current as the last time this ran,
// which is why release.sh always runs it before a build.
//
// What it does:
// 1. Wipes and re-copies server/public -> www (a wipe, so a file DELETED from the web
// app can never linger inside a shipped app bundle).
// 2. Drops sw.js — the app bundle is already local; a service worker would only add a
// second invisible cache layer, and iOS's capacitor:// scheme can't host one.
// 3. Writes native-config.js — the API base and bundle stamp. This is why no server URL
// is hard-coded in server/public: the website reads its API from its own origin.
// 4. Bundles src/native-shell.js -> www/native-shell.js with esbuild.
// 5. Injects both scripts into www/index.html ahead of native-bridge.js, which needs
// window.CapPlugins and window.CHECKLIST_NATIVE_CONFIG to already exist.
const fs = require('fs');
const path = require('path');
const { execFileSync } = require('child_process');
const MOBILE = path.resolve(__dirname, '..');
const WEB_SRC = path.resolve(MOBILE, '..', 'server', 'public');
const WWW = path.join(MOBILE, 'www');
const release = JSON.parse(fs.readFileSync(path.join(MOBILE, 'release.config.json'), 'utf8'));
const apiBase = String(release.apiBase || '').replace(/\/+$/, '');
if (!/^https:\/\//i.test(apiBase)) {
// Not style policing: both platforms block cleartext HTTP by default (iOS App Transport
// Security, Android's cleartextTrafficPermitted=false), so an http:// base produces an
// app that builds, ships, installs and then fails every single request.
console.error(`✗ apiBase must be an https:// URL — got ${JSON.stringify(release.apiBase)}`);
process.exit(1);
}
// Files that exist for the website only and must not ship inside an app bundle.
const WEB_ONLY = new Set(['sw.js', '.DS_Store']);
function copyTree(src, dest) {
fs.mkdirSync(dest, { recursive: true });
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
if (WEB_ONLY.has(entry.name)) continue;
const from = path.join(src, entry.name);
const to = path.join(dest, entry.name);
if (entry.isDirectory()) copyTree(from, to);
else if (entry.isFile()) fs.copyFileSync(from, to);
}
}
// 1 + 2. Full wipe, then copy.
fs.rmSync(WWW, { recursive: true, force: true });
copyTree(WEB_SRC, WWW);
// A missing native-bridge.js means the copy silently produced an app that would resolve
// every /api call against its own bundle. Fail the build instead of shipping that.
if (!fs.existsSync(path.join(WWW, 'native-bridge.js'))) {
console.error('✗ native-bridge.js is missing from server/public — the app cannot reach the API without it');
process.exit(1);
}
// 3. Native-only config. Regenerated every sync, so bundledAt doubles as "when did this
// binary's UI last get refreshed from the web app".
const bundledAt = new Date().toISOString();
fs.writeFileSync(path.join(WWW, 'native-config.js'),
`// GENERATED by mobile/scripts/sync-web.js — do not edit, do not commit as source.\n` +
`// Present only inside the iOS/Android app bundles; native-bridge.js is inert without it.\n` +
`window.CHECKLIST_NATIVE_CONFIG = ${JSON.stringify({
apiBase,
bundleVersion: release.version,
bundledAt,
}, null, 2)};\n`);
// 4. Bundle the native-only shell.
const esbuild = path.join(MOBILE, 'node_modules', '.bin', 'esbuild');
try {
execFileSync(esbuild, [
path.join(MOBILE, 'src', 'native-shell.js'),
'--bundle',
'--format=iife', // a classic <script>: the web app has no module loader
'--target=es2019', // floor for older Android System WebView builds still in the wild
'--minify',
`--outfile=${path.join(WWW, 'native-shell.js')}`,
], { stdio: ['ignore', 'ignore', 'pipe'] }); // capture: esbuild's banner would break release.sh's indented output
} catch (err) {
console.error('✗ esbuild failed to bundle src/native-shell.js:');
console.error(String((err.stderr || err.message) || '').trim());
process.exit(1);
}
// 4b. Bundle the LOCAL RUNTIME: the real server/server.js, compiled for the WebView behind
// Node shims (see src/local/). This is what makes "just on this device" work without
// reimplementing fifty API routes — local mode runs the same server code every other
// mode talks to. Aliases swap Node's modules for browser-capable stand-ins; __dirname is
// defined because server.js resolves its data and public directories from it.
const LOCAL_ALIASES = [
'express=./src/local/shims/express.js',
'fs=./src/local/shims/fs.js',
'fs/promises=./src/local/shims/fs-promises.js',
'path=./src/local/shims/path.js',
'crypto=./src/local/shims/crypto.js',
'nodemailer=./src/local/shims/nodemailer.js',
'node-cron=./src/local/shims/node-cron.js',
'dotenv=./src/local/shims/dotenv.js',
];
try {
execFileSync(esbuild, [
path.join(MOBILE, 'src', 'local', 'boot.js'),
'--bundle',
'--format=iife',
'--global-name=LocalServer',
'--target=es2019',
'--minify',
...LOCAL_ALIASES.map((a) => `--alias:${a}`),
'--define:__dirname="/"',
`--outfile=${path.join(WWW, 'local-server.js')}`,
], { cwd: MOBILE, stdio: ['ignore', 'ignore', 'pipe'] });
} catch (err) {
console.error('✗ esbuild failed to bundle the local runtime (src/local/boot.js):');
console.error(String((err.stderr || err.message) || '').trim());
process.exit(1);
}
// 5. Inject into the bundle's index.html only. Order is load-bearing: native-config.js
// defines the API base, native-shell.js defines window.CapPlugins, and native-bridge.js
// reads both while it parses.
const indexPath = path.join(WWW, 'index.html');
let html = fs.readFileSync(indexPath, 'utf8');
const anchor = ' <script src="native-bridge.js"></script>';
if (!html.includes(anchor)) {
console.error('✗ could not find the native-bridge.js <script> tag in index.html — was it renamed?');
process.exit(1);
}
// local-server.js must load BEFORE native-bridge.js: the bridge wires local-mode routing
// while it parses and reads window.LocalServer to do it.
html = html.replace(anchor,
' <!-- GENERATED by mobile/scripts/sync-web.js (native bundles only). -->\n' +
' <script src="native-config.js"></script>\n' +
' <script src="local-server.js"></script>\n' +
' <script src="native-shell.js"></script>\n' +
anchor);
fs.writeFileSync(indexPath, html);
const count = (function walk(dir) {
return fs.readdirSync(dir, { withFileTypes: true }).reduce((n, e) =>
n + (e.isDirectory() ? walk(path.join(dir, e.name)) : 1), 0);
})(WWW);
console.log(`✓ www/ rebuilt from server/public — ${count} files`);
console.log(` api base ${apiBase}`);
console.log(` version ${release.version} (ios build ${release.iosBuild}, android code ${release.androidVersionCode})`);
console.log(` bundled at ${bundledAt}`);
+144
View File
@@ -0,0 +1,144 @@
#!/usr/bin/env node
'use strict';
// upload-play.js — push a signed .aab to Google Play, unattended.
//
// node scripts/upload-play.js <path-to.aab> [--track internal] [--dry-run]
//
// Why a hand-written script rather than fastlane: fastlane needs Ruby >= 2.7 and this Mac
// has 2.6 (the same wall that pushed iOS onto SPM instead of CocoaPods). The Play Developer
// API is straightforward enough to call directly, and doing so keeps the whole toolchain on
// Node, which is already here.
//
// Auth is a Google Cloud SERVICE ACCOUNT — not your Google login. The JSON key goes at
// mobile/play-service-account.json (gitignored). Setup is in README.md → Android uploads.
//
// Play's upload is a TRANSACTION: open an edit, upload into it, point a track at it, then
// commit. Nothing is visible to anyone until the commit succeeds, so a crash halfway through
// leaves an abandoned edit and changes nothing — which is exactly the behaviour you want
// from something that ships to volunteers.
const fs = require('fs');
const path = require('path');
const { androidpublisher, auth: gauth } = require('@googleapis/androidpublisher');
const MOBILE = path.resolve(__dirname, '..');
const KEY_FILE = path.join(MOBILE, 'play-service-account.json');
const CAP_CONFIG = JSON.parse(fs.readFileSync(path.join(MOBILE, 'capacitor.config.json'), 'utf8'));
const PACKAGE_NAME = CAP_CONFIG.appId;
const args = process.argv.slice(2);
const aabPath = args.find((a) => !a.startsWith('--'));
const trackIdx = args.indexOf('--track');
const track = trackIdx !== -1 ? args[trackIdx + 1] : 'internal';
const dryRun = args.includes('--dry-run');
function die(msg, hint) {
console.error(`✗ ${msg}`);
if (hint) console.error(String(hint).split('\n').map((l) => ` ${l}`).join('\n'));
process.exit(1);
}
if (!aabPath && !dryRun) die('usage: node scripts/upload-play.js <path-to.aab> [--track internal]');
if (aabPath && !fs.existsSync(aabPath)) die(`no such file: ${aabPath}`);
if (!fs.existsSync(KEY_FILE)) {
die(`no ${path.relative(process.cwd(), KEY_FILE)}`,
'This is the Google Cloud service-account JSON that lets Play accept uploads.\n' +
'One-time setup: mobile/README.md → "Android uploads (Play Developer API)".');
}
(async () => {
let creds;
try {
creds = JSON.parse(fs.readFileSync(KEY_FILE, 'utf8'));
} catch (e) {
die('play-service-account.json is not valid JSON', 'Re-download the key from Google Cloud Console.');
}
if (creds.type !== 'service_account' || !creds.client_email || !creds.private_key) {
die('that JSON is not a service-account key',
'It should contain "type": "service_account" and a client_email ending in\n' +
'.iam.gserviceaccount.com. An OAuth client ID download is a different file.');
}
const client = new gauth.GoogleAuth({
credentials: creds,
scopes: ['https://www.googleapis.com/auth/androidpublisher'],
});
const play = androidpublisher({ version: 'v3', auth: client });
console.log(` package ${PACKAGE_NAME}`);
console.log(` service acct ${creds.client_email}`);
console.log(` track ${track}`);
// Opening an edit is also the cheapest possible permission check: it fails immediately and
// specifically if the service account isn't linked to this app in the Play Console.
let editId;
try {
const edit = await play.edits.insert({ packageName: PACKAGE_NAME });
editId = edit.data.id;
} catch (e) {
const status = e && e.code;
const reason = (e && e.message) || String(e);
if (status === 401 || status === 403) {
die(`Play rejected the service account (HTTP ${status})`,
`${reason}\n\n` +
'Usually one of:\n' +
' · the service account has not been invited in Play Console →\n' +
' Users and permissions → Invite user → (the client_email above)\n' +
' · it lacks "Release to testing tracks" permission for this app\n' +
' · the Google Play Android Developer API is not enabled on the Cloud project');
}
if (status === 404) {
die(`Play has no app with package name ${PACKAGE_NAME}`,
'Create it first: Play Console → Create app, then upload one build by hand.\n' +
'Play requires the FIRST bundle of a brand-new app to be uploaded through the\n' +
'Console UI; the API can only take over from the second release onward.');
}
die(`could not open a Play edit: ${reason}`);
}
console.log(` edit ${editId}`);
if (dryRun) {
// Abandon rather than commit — proves credentials and permissions end to end while
// changing absolutely nothing.
await play.edits.delete({ packageName: PACKAGE_NAME, editId });
console.log('✓ credentials and permissions OK (dry run — edit abandoned, nothing changed)');
return;
}
console.log(` uploading ${path.basename(aabPath)} (${(fs.statSync(aabPath).size / 1048576).toFixed(1)} MB)…`);
let versionCode;
try {
const res = await play.edits.bundles.upload({
packageName: PACKAGE_NAME,
editId,
media: { mimeType: 'application/octet-stream', body: fs.createReadStream(aabPath) },
});
versionCode = res.data.versionCode;
} catch (e) {
const reason = (e && e.message) || String(e);
await play.edits.delete({ packageName: PACKAGE_NAME, editId }).catch(() => {});
if (/already.*used|not allowed to be used|APK.*version code/i.test(reason)) {
die('Play has already seen that versionCode',
`${reason}\n\n` +
'Play permanently reserves every versionCode it has ever accepted — they cannot be\n' +
'reused even after deleting the release. Bump androidVersionCode in\n' +
'mobile/release.config.json and rebuild.');
}
die(`bundle upload failed: ${reason}`);
}
console.log(` versionCode ${versionCode}`);
// Point the track at the build we just uploaded. status 'completed' means testers on that
// track get it straight away; 'draft' would park it in the Console for a human to release.
await play.edits.tracks.update({
packageName: PACKAGE_NAME,
editId,
track,
requestBody: { releases: [{ versionCodes: [String(versionCode)], status: 'completed' }] },
});
await play.edits.commit({ packageName: PACKAGE_NAME, editId });
console.log(`✓ released to the "${track}" track (versionCode ${versionCode})`);
console.log(' Play processes the bundle for a few minutes before testers see the update.');
})().catch((e) => die(String((e && e.message) || e)));
+99
View File
@@ -0,0 +1,99 @@
#!/bin/bash
# verify-asc-key.sh — confirm the App Store Connect API key actually works.
#
# Run this once after creating the key, before trusting release.sh to upload:
#
# ./mobile/scripts/verify-asc-key.sh
#
# It calls `altool --list-providers`, which authenticates and returns the teams the key can
# see. Nothing is uploaded, nothing is created, no build number is consumed — so it is safe
# to run as often as you like. A bad key fails here in seconds instead of fifty seconds into
# an upload with an opaque error.
set -uo pipefail
MOBILE="$(cd "$(dirname "$0")/.." && pwd)"
CFG="$MOBILE/ios-signing.config"
G=$'\033[32m'; Y=$'\033[33m'; R=$'\033[31m'; DIM=$'\033[2m'; N=$'\033[0m'
ok() { echo " ${G}✓${N} $*"; }
warn() { echo " ${Y}!${N} $*"; }
bad() { echo " ${R}✗${N} $*" >&2; }
[ -f "$CFG" ] || { bad "no $CFG — see mobile/README.md → iOS one-time setup"; exit 1; }
# shellcheck source=/dev/null
. "$CFG"
if [ -z "${ASC_KEY_ID:-}" ] || [ -z "${ASC_ISSUER_ID:-}" ]; then
warn "ASC_KEY_ID / ASC_ISSUER_ID not set in ios-signing.config"
echo " ${DIM}Uploads stay manual (Xcode ▸ Organizer). Everything else still works.${N}"
exit 0
fi
# altool locates the key by NAME, not path — AuthKey_<KEYID>.p8 in one of its search dirs.
KEYFILE="$HOME/.appstoreconnect/private_keys/AuthKey_${ASC_KEY_ID}.p8"
echo " key id $ASC_KEY_ID"
echo " issuer $ASC_ISSUER_ID"
echo " key file $KEYFILE"
if [ ! -f "$KEYFILE" ]; then
bad "that file does not exist"
echo " ${DIM}The filename must match the key id EXACTLY, including the AuthKey_ prefix${N}"
echo " ${DIM}and the .p8 extension. Apple's download is already named correctly — move it:${N}"
echo " ${DIM} mv ~/Downloads/AuthKey_${ASC_KEY_ID}.p8 ~/.appstoreconnect/private_keys/${N}"
exit 1
fi
ok "key file present ($(wc -c < "$KEYFILE" | tr -d ' ') bytes)"
# A .p8 that got mangled (opened and re-saved, or downloaded as HTML) fails auth with a
# generic error. Checking the PEM header names the real problem instead.
head -1 "$KEYFILE" | grep -q "BEGIN PRIVATE KEY" \
&& ok "looks like a valid PEM private key" \
|| { bad "not a PEM private key — re-download it from App Store Connect"; exit 1; }
# Tighten permissions: this file can upload builds as your team.
chmod 600 "$KEYFILE" 2>/dev/null
# NOT --list-providers: it rejects API-key auth outright ("list-providers does not support
# APIKey authentication"), so it can never verify a key like this one. --validate-app is the
# real check — it authenticates AND runs Apple's acceptance checks on the binary, without
# uploading anything or consuming the build number.
IPA="$(ls -t "$MOBILE/../dist-release/"*.ipa 2>/dev/null | head -1)"
if [ -z "$IPA" ]; then
warn "no .ipa in dist-release/ to test with — run ./release.sh --ios first"
exit 0
fi
echo
echo " authenticating with $(basename "$IPA")…"
OUT="$(xcrun altool --validate-app -f "$IPA" -t ios --api-key "$ASC_KEY_ID" --api-issuer "$ASC_ISSUER_ID" 2>&1)"
RC=$?
if [ $RC -eq 0 ]; then
ok "authenticated, and the build passed Apple's validation"
echo
ok "release.sh will now upload iOS builds automatically"
exit 0
fi
# Authentication and validation fail the same way at the shell level, so separate them —
# "your key is wrong" and "your binary is wrong" need completely different fixes.
if echo "$OUT" | grep -qiE "Unable to authenticate|Authentication failed|NOT_AUTHORIZED|invalid.*(key|issuer)"; then
bad "authentication FAILED — the key or issuer is wrong"
echo "$OUT" | grep -iE "error|message" | head -6 | sed 's/^/ /'
echo " ${DIM}· Issuer ID is the UUID above the key TABLE, not the key's own id${N}"
echo " ${DIM}· Key needs App Manager or Admin access${N}"
exit 1
fi
# "Cannot determine the Apple ID from Bundle ID" is what Apple actually says when the key
# authenticated fine but no app record exists yet — it reads like a failure and is not one.
if echo "$OUT" | grep -qiE "Cannot determine the Apple ID|no.*app.*with.*bundle|cannot be found|No suitable application"; then
ok "authentication WORKED — the key is good"
warn "but this app does not exist in App Store Connect yet"
echo " ${DIM}Create it: App Store Connect → Apps → + → New App${N}"
echo " ${DIM}Bundle ID: $(python3 -c "import json;print(json.load(open('$MOBILE/capacitor.config.json'))['appId'])" 2>/dev/null)${N}"
exit 0
fi
bad "validation failed"
echo "$OUT" | grep -iE "error|message|warning" | head -10 | sed 's/^/ /'
exit 1