Files

162 lines
7.8 KiB
JavaScript
Raw Permalink Normal View History

#!/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); });