Skip to content

Commit 4cb300b

Browse files
committed
refactor: extract App Store Connect API logic into shared script
1 parent 7830e79 commit 4cb300b

File tree

2 files changed

+126
-152
lines changed

2 files changed

+126
-152
lines changed
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
/**
2+
* App Store Connect Build Lookup
3+
*
4+
* Queries the App Store Connect API to find TestFlight builds.
5+
*
6+
* Usage:
7+
* node appstore-connect-build.js latest - Get the latest build ID
8+
* node appstore-connect-build.js poll <buildId> - Poll for a new build (different from <buildId>)
9+
*
10+
* Required env vars: API_KEY_ID, ISSUER_ID, BUNDLE_ID
11+
* The API key .p8 file must be at ~/private_keys/AuthKey_<API_KEY_ID>.p8
12+
*
13+
* Outputs the build ID to stdout. Debug/progress messages go to stderr.
14+
*/
15+
const crypto = require('crypto');
16+
const https = require('https');
17+
const fs = require('fs');
18+
19+
const keyId = process.env.API_KEY_ID;
20+
const issuerId = process.env.ISSUER_ID;
21+
const keyPath = `${process.env.HOME}/private_keys/AuthKey_${keyId}.p8`;
22+
const bundleId = process.env.BUNDLE_ID;
23+
24+
function base64url(buf) {
25+
return buf.toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
26+
}
27+
28+
function generateJWT() {
29+
const key = fs.readFileSync(keyPath, 'utf8');
30+
const header = { alg: 'ES256', kid: keyId, typ: 'JWT' };
31+
const now = Math.floor(Date.now() / 1000);
32+
const payload = { iss: issuerId, iat: now, exp: now + 1200, aud: 'appstoreconnect-v1' };
33+
const h = base64url(Buffer.from(JSON.stringify(header)));
34+
const p = base64url(Buffer.from(JSON.stringify(payload)));
35+
const input = `${h}.${p}`;
36+
const sig = crypto.sign('SHA256', Buffer.from(input), { key, dsaEncoding: 'ieee-p1363' });
37+
return `${input}.${base64url(sig)}`;
38+
}
39+
40+
function apiGet(token, path) {
41+
return new Promise((resolve, reject) => {
42+
https.get({
43+
hostname: 'api.appstoreconnect.apple.com',
44+
path,
45+
headers: { Authorization: `Bearer ${token}` }
46+
}, res => {
47+
let data = '';
48+
res.on('data', c => data += c);
49+
res.on('end', () => {
50+
if (res.statusCode !== 200) reject(new Error(`HTTP ${res.statusCode}: ${data.substring(0, 300)}`));
51+
else resolve(JSON.parse(data));
52+
});
53+
}).on('error', reject);
54+
});
55+
}
56+
57+
const sleep = ms => new Promise(r => setTimeout(r, ms));
58+
59+
async function getAppId(token) {
60+
process.stderr.write(`Looking up app: ${bundleId}\n`);
61+
const apps = await apiGet(token, `/v1/apps?filter[bundleId]=${encodeURIComponent(bundleId)}`);
62+
if (!apps.data?.length) throw new Error('App not found');
63+
const appId = apps.data[0].id;
64+
process.stderr.write(`App API ID: ${appId}\n`);
65+
return appId;
66+
}
67+
68+
async function getLatestBuildId(token, appId) {
69+
const builds = await apiGet(token, `/v1/builds?filter[app]=${appId}&sort=-uploadedDate&limit=1`);
70+
return builds.data?.length ? builds.data[0].id : null;
71+
}
72+
73+
async function cmdLatest() {
74+
const token = generateJWT();
75+
const appId = await getAppId(token);
76+
const buildId = await getLatestBuildId(token, appId);
77+
if (buildId) {
78+
process.stderr.write(`Latest build: ${buildId}\n`);
79+
process.stdout.write(buildId);
80+
}
81+
}
82+
83+
async function cmdPoll(preUploadBuildId) {
84+
const token = generateJWT();
85+
const appId = await getAppId(token);
86+
process.stderr.write(`Pre-upload latest build: ${preUploadBuildId || 'none'}\n`);
87+
88+
// Builds typically appear in the API ~2 min after altool upload.
89+
// Wait 90s before first check, then poll every 15s for up to 8 more attempts.
90+
process.stderr.write('Waiting 90s for Apple to register the build...\n');
91+
await sleep(90000);
92+
93+
for (let i = 1; i <= 8; i++) {
94+
if (i > 1) await sleep(15000);
95+
process.stderr.write(`Attempt ${i}/8: checking for new build...\n`);
96+
97+
const builds = await apiGet(token,
98+
`/v1/builds?filter[app]=${appId}&sort=-uploadedDate&limit=1&fields[builds]=version,processingState`
99+
);
100+
101+
if (builds.data?.length) {
102+
const latest = builds.data[0];
103+
if (latest.id !== preUploadBuildId) {
104+
process.stderr.write(`Found new build: ${latest.id} (version: ${latest.attributes?.version}, state: ${latest.attributes?.processingState})\n`);
105+
process.stdout.write(latest.id);
106+
return;
107+
}
108+
process.stderr.write(`Latest build ${latest.id} is still the pre-upload build, waiting...\n`);
109+
}
110+
}
111+
112+
throw new Error('New build not found after polling');
113+
}
114+
115+
const [command, ...args] = process.argv.slice(2);
116+
117+
const run = command === 'latest' ? cmdLatest() : command === 'poll' ? cmdPoll(args[0] || '') : Promise.reject(new Error('Usage: node appstore-connect-build.js <latest|poll> [preUploadBuildId]'));
118+
119+
run.catch(e => {
120+
process.stderr.write(`Error: ${e.message}\n`);
121+
process.exit(1);
122+
});

.github/workflows/ios-testflight.yml

Lines changed: 4 additions & 152 deletions
Original file line numberDiff line numberDiff line change
@@ -372,73 +372,13 @@ jobs:
372372
373373
- name: Record latest build before upload
374374
id: pre_upload
375+
working-directory: .
375376
env:
376377
API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
377378
ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
378379
BUNDLE_ID: ${{ vars.APP_BUNDLE_ID }}
379380
run: |
380-
LATEST_BUILD_ID=$(node << 'SCRIPT'
381-
const crypto = require('crypto');
382-
const https = require('https');
383-
const fs = require('fs');
384-
385-
const keyId = process.env.API_KEY_ID;
386-
const issuerId = process.env.ISSUER_ID;
387-
const keyPath = `${process.env.HOME}/private_keys/AuthKey_${keyId}.p8`;
388-
const bundleId = process.env.BUNDLE_ID;
389-
390-
function base64url(buf) {
391-
return buf.toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
392-
}
393-
394-
function generateJWT() {
395-
const key = fs.readFileSync(keyPath, 'utf8');
396-
const header = { alg: 'ES256', kid: keyId, typ: 'JWT' };
397-
const now = Math.floor(Date.now() / 1000);
398-
const payload = { iss: issuerId, iat: now, exp: now + 1200, aud: 'appstoreconnect-v1' };
399-
const h = base64url(Buffer.from(JSON.stringify(header)));
400-
const p = base64url(Buffer.from(JSON.stringify(payload)));
401-
const input = `${h}.${p}`;
402-
const sig = crypto.sign('SHA256', Buffer.from(input), { key, dsaEncoding: 'ieee-p1363' });
403-
return `${input}.${base64url(sig)}`;
404-
}
405-
406-
function apiGet(token, path) {
407-
return new Promise((resolve, reject) => {
408-
https.get({
409-
hostname: 'api.appstoreconnect.apple.com',
410-
path,
411-
headers: { Authorization: `Bearer ${token}` }
412-
}, res => {
413-
let data = '';
414-
res.on('data', c => data += c);
415-
res.on('end', () => {
416-
if (res.statusCode !== 200) reject(new Error(`HTTP ${res.statusCode}`));
417-
else resolve(JSON.parse(data));
418-
});
419-
}).on('error', reject);
420-
});
421-
}
422-
423-
async function main() {
424-
const token = generateJWT();
425-
const apps = await apiGet(token, `/v1/apps?filter[bundleId]=${encodeURIComponent(bundleId)}`);
426-
if (!apps.data?.length) throw new Error('App not found');
427-
428-
const appId = apps.data[0].id;
429-
const builds = await apiGet(token, `/v1/builds?filter[app]=${appId}&sort=-uploadedDate&limit=1`);
430-
if (builds.data?.length) {
431-
process.stdout.write(builds.data[0].id);
432-
}
433-
}
434-
435-
main().catch(e => {
436-
process.stderr.write(`Error: ${e.message}\n`);
437-
process.exit(1);
438-
});
439-
SCRIPT
440-
) || LATEST_BUILD_ID=""
441-
381+
LATEST_BUILD_ID=$(node .github/scripts/appstore-connect-build.js latest) || LATEST_BUILD_ID=""
442382
echo "latest_build_id=${LATEST_BUILD_ID}" >> $GITHUB_OUTPUT
443383
echo "Latest build before upload: ${LATEST_BUILD_ID:-none}"
444384
@@ -472,6 +412,7 @@ jobs:
472412
473413
- name: Get TestFlight build URL
474414
id: build_url
415+
working-directory: .
475416
env:
476417
API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
477418
ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
@@ -481,96 +422,7 @@ jobs:
481422
run: |
482423
FALLBACK_URL="https://appstoreconnect.apple.com/apps/${APP_APPLE_ID}/testflight/ios"
483424
484-
BUILD_ID=$(node << 'SCRIPT'
485-
const crypto = require('crypto');
486-
const https = require('https');
487-
const fs = require('fs');
488-
489-
const keyId = process.env.API_KEY_ID;
490-
const issuerId = process.env.ISSUER_ID;
491-
const keyPath = `${process.env.HOME}/private_keys/AuthKey_${keyId}.p8`;
492-
const bundleId = process.env.BUNDLE_ID;
493-
const preUploadBuildId = process.env.PRE_UPLOAD_BUILD_ID || '';
494-
495-
function base64url(buf) {
496-
return buf.toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
497-
}
498-
499-
function generateJWT() {
500-
const key = fs.readFileSync(keyPath, 'utf8');
501-
const header = { alg: 'ES256', kid: keyId, typ: 'JWT' };
502-
const now = Math.floor(Date.now() / 1000);
503-
const payload = { iss: issuerId, iat: now, exp: now + 1200, aud: 'appstoreconnect-v1' };
504-
const h = base64url(Buffer.from(JSON.stringify(header)));
505-
const p = base64url(Buffer.from(JSON.stringify(payload)));
506-
const input = `${h}.${p}`;
507-
const sig = crypto.sign('SHA256', Buffer.from(input), { key, dsaEncoding: 'ieee-p1363' });
508-
return `${input}.${base64url(sig)}`;
509-
}
510-
511-
function apiGet(token, path) {
512-
return new Promise((resolve, reject) => {
513-
https.get({
514-
hostname: 'api.appstoreconnect.apple.com',
515-
path,
516-
headers: { Authorization: `Bearer ${token}` }
517-
}, res => {
518-
let data = '';
519-
res.on('data', c => data += c);
520-
res.on('end', () => {
521-
if (res.statusCode !== 200) reject(new Error(`HTTP ${res.statusCode}: ${data.substring(0, 300)}`));
522-
else resolve(JSON.parse(data));
523-
});
524-
}).on('error', reject);
525-
});
526-
}
527-
528-
const sleep = ms => new Promise(r => setTimeout(r, ms));
529-
530-
async function main() {
531-
const token = generateJWT();
532-
533-
process.stderr.write(`Looking up app: ${bundleId}\n`);
534-
const apps = await apiGet(token, `/v1/apps?filter[bundleId]=${encodeURIComponent(bundleId)}`);
535-
if (!apps.data?.length) throw new Error('App not found');
536-
537-
const appId = apps.data[0].id;
538-
process.stderr.write(`App API ID: ${appId}\n`);
539-
process.stderr.write(`Pre-upload latest build: ${preUploadBuildId || 'none'}\n`);
540-
541-
// Builds typically appear in the API ~2 min after altool upload.
542-
// Wait 90s before first check, then poll every 15s for up to 8 more attempts.
543-
process.stderr.write('Waiting 90s for Apple to register the build...\n');
544-
await sleep(90000);
545-
546-
for (let i = 1; i <= 8; i++) {
547-
if (i > 1) await sleep(15000);
548-
process.stderr.write(`Attempt ${i}/8: checking for new build...\n`);
549-
550-
const builds = await apiGet(token,
551-
`/v1/builds?filter[app]=${appId}&sort=-uploadedDate&limit=1&fields[builds]=version,processingState`
552-
);
553-
554-
if (builds.data?.length) {
555-
const latest = builds.data[0];
556-
if (latest.id !== preUploadBuildId) {
557-
process.stderr.write(`Found new build: ${latest.id} (version: ${latest.attributes?.version}, state: ${latest.attributes?.processingState})\n`);
558-
process.stdout.write(latest.id);
559-
return;
560-
}
561-
process.stderr.write(`Latest build ${latest.id} is still the pre-upload build, waiting...\n`);
562-
}
563-
}
564-
565-
throw new Error('New build not found after polling');
566-
}
567-
568-
main().catch(e => {
569-
process.stderr.write(`Error: ${e.message}\n`);
570-
process.exit(1);
571-
});
572-
SCRIPT
573-
) || BUILD_ID=""
425+
BUILD_ID=$(node .github/scripts/appstore-connect-build.js poll "${PRE_UPLOAD_BUILD_ID}") || BUILD_ID=""
574426
575427
if [ -n "$BUILD_ID" ]; then
576428
BUILD_URL="https://appstoreconnect.apple.com/apps/${APP_APPLE_ID}/testflight/ios/${BUILD_ID}"

0 commit comments

Comments
 (0)