#!/usr/bin/env node 'use strict'; // upload-play.js — push a signed .aab to Google Play, unattended. // // node scripts/upload-play.js [--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 [--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)));