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