162 lines
7.8 KiB
JavaScript
162 lines
7.8 KiB
JavaScript
#!/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); });
|