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:
@@ -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); });
|
||||
Executable
+26
@@ -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
|
||||
@@ -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`);
|
||||
@@ -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}`);
|
||||
@@ -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)));
|
||||
Executable
+99
@@ -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
|
||||
Reference in New Issue
Block a user