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,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)));
|
||||
Reference in New Issue
Block a user