Skip to content

Commit 12205ef

Browse files
committed
feat(appium): add ASC provisioning profile scripts
1 parent 2da7764 commit 12205ef

3 files changed

Lines changed: 263 additions & 1 deletion

File tree

appium/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ node_modules/
22
dist/
33
results/
44
.env
5+
*.p8

appium/scripts/generate_certs.ts

Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
#!/usr/bin/env bun
2+
/**
3+
* Create (or regenerate) iOS Development provisioning profiles for the OneSignal
4+
* demo app (main target, Notification Service Extension, Live Activity widget)
5+
* via the App Store Connect API.
6+
*
7+
* Idempotent: if a profile with the target name already exists, it is deleted
8+
* and recreated with all currently registered dev certs + enabled iOS devices.
9+
* Re-run whenever you register a new device or rotate a signing cert.
10+
*
11+
* Prerequisites
12+
* -------------
13+
* - The bundle IDs below must already be registered in the Apple Developer
14+
* portal as Explicit App IDs with the required capabilities (App Groups for
15+
* both, plus Live Activities on .LA). This script does not configure
16+
* capabilities.
17+
* - An App Store Connect API key with role Developer or Admin.
18+
* - Bun (or `tsx`) to run the script.
19+
*
20+
* Environment variables
21+
* ---------------------
22+
* ASC_KEY_ID App Store Connect API Key ID (e.g., ABCD1234)
23+
* ASC_ISSUER_ID App Store Connect API Issuer ID (UUID)
24+
* ASC_KEY_FILE Path to the .p8 private key file
25+
*
26+
* Usage
27+
* -----
28+
* ASC_KEY_ID=... ASC_ISSUER_ID=... ASC_KEY_FILE=~/Downloads/AuthKey_XXX.p8 \
29+
* bun appium/scripts/generate_certs.ts
30+
*
31+
* # or with tsx:
32+
* ASC_KEY_ID=... ASC_ISSUER_ID=... ASC_KEY_FILE=... \
33+
* npx tsx appium/scripts/generate_certs.ts
34+
*/
35+
36+
import { createPrivateKey, sign } from 'node:crypto';
37+
import { readFileSync } from 'node:fs';
38+
39+
interface ProfileTarget {
40+
bundleId: string;
41+
profileName: string;
42+
}
43+
44+
const TARGETS: ProfileTarget[] = [
45+
{ bundleId: 'com.onesignal.example', profileName: 'Appium OneSignal Main' },
46+
{ bundleId: 'com.onesignal.example.NSE', profileName: 'Appium OneSignal NSE' },
47+
{ bundleId: 'com.onesignal.example.LA', profileName: 'Appium OneSignal LA' },
48+
];
49+
50+
const API = 'https://api.appstoreconnect.apple.com/v1';
51+
52+
// ─── Env ───────────────────────────────────────────────────────────────────
53+
54+
function requireEnv(name: string): string {
55+
const v = process.env[name];
56+
if (!v) {
57+
console.error(`Missing required env var: ${name} (run with --help for details)`);
58+
process.exit(1);
59+
}
60+
return v;
61+
}
62+
63+
if (process.argv.includes('-h') || process.argv.includes('--help')) {
64+
const banner = readFileSync(new URL(import.meta.url)).toString();
65+
const match = banner.match(/\/\*\*([\s\S]*?)\*\//);
66+
console.log(match ? match[1].replace(/^\s*\* ?/gm, '') : banner);
67+
process.exit(0);
68+
}
69+
70+
const ASC_KEY_ID = requireEnv('ASC_KEY_ID');
71+
const ASC_ISSUER_ID = requireEnv('ASC_ISSUER_ID');
72+
const ASC_KEY_FILE = requireEnv('ASC_KEY_FILE');
73+
const keyPem = readFileSync(ASC_KEY_FILE, 'utf8');
74+
75+
// ─── JWT (ES256) ───────────────────────────────────────────────────────────
76+
77+
function buildJWT(): string {
78+
const header = { alg: 'ES256', kid: ASC_KEY_ID, typ: 'JWT' };
79+
const now = Math.floor(Date.now() / 1000);
80+
const payload = { iss: ASC_ISSUER_ID, exp: now + 600, aud: 'appstoreconnect-v1' };
81+
82+
const b64 = (b: Buffer): string => b.toString('base64url');
83+
const headerB64 = b64(Buffer.from(JSON.stringify(header)));
84+
const payloadB64 = b64(Buffer.from(JSON.stringify(payload)));
85+
const signingInput = `${headerB64}.${payloadB64}`;
86+
87+
const privateKey = createPrivateKey(keyPem);
88+
const signature = sign('sha256', Buffer.from(signingInput), {
89+
key: privateKey,
90+
dsaEncoding: 'ieee-p1363',
91+
});
92+
return `${signingInput}.${b64(signature)}`;
93+
}
94+
95+
const JWT = buildJWT();
96+
97+
// ─── API types & helpers ───────────────────────────────────────────────────
98+
99+
interface ApiError {
100+
title?: string;
101+
detail?: string;
102+
code?: string;
103+
status?: string;
104+
}
105+
106+
interface ResourceRef {
107+
id: string;
108+
type: string;
109+
}
110+
111+
interface BundleIdResource extends ResourceRef {
112+
type: 'bundleIds';
113+
attributes?: { identifier?: string; name?: string };
114+
}
115+
116+
interface CertificateResource extends ResourceRef {
117+
type: 'certificates';
118+
}
119+
120+
interface DeviceResource extends ResourceRef {
121+
type: 'devices';
122+
}
123+
124+
interface ProfileResource extends ResourceRef {
125+
type: 'profiles';
126+
attributes?: { name?: string; profileState?: string };
127+
}
128+
129+
interface ListResponse<T> {
130+
data: T[];
131+
errors?: ApiError[];
132+
links?: { next?: string };
133+
}
134+
135+
interface SingleResponse<T> {
136+
data: T;
137+
errors?: ApiError[];
138+
}
139+
140+
async function apiRequest<T>(
141+
method: 'GET' | 'POST' | 'DELETE',
142+
path: string,
143+
body?: unknown,
144+
): Promise<T> {
145+
const res = await fetch(`${API}${path}`, {
146+
method,
147+
headers: {
148+
Authorization: `Bearer ${JWT}`,
149+
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
150+
},
151+
body: body !== undefined ? JSON.stringify(body) : undefined,
152+
});
153+
154+
if (method === 'DELETE' && res.status === 204) {
155+
const empty: unknown = {};
156+
return empty as T; // DELETE returns 204 No Content
157+
}
158+
159+
const text = await res.text();
160+
const parsed: T & { errors?: ApiError[] } = text ? JSON.parse(text) : {};
161+
162+
if (!res.ok || (parsed.errors && parsed.errors.length > 0)) {
163+
const detail = parsed.errors ? JSON.stringify(parsed.errors, null, 2) : text;
164+
throw new Error(`${method} ${path} → HTTP ${res.status}\n${detail}`);
165+
}
166+
return parsed;
167+
}
168+
169+
const apiGet = <T>(path: string): Promise<T> => apiRequest<T>('GET', path);
170+
const apiPost = <T>(path: string, body: unknown): Promise<T> => apiRequest<T>('POST', path, body);
171+
const apiDelete = (path: string): Promise<unknown> => apiRequest<unknown>('DELETE', path);
172+
173+
// ─── Main ──────────────────────────────────────────────────────────────────
174+
175+
/**
176+
* Look up a bundle ID by its exact identifier.
177+
*
178+
* `filter[identifier]` on /bundleIds is a substring/prefix match (e.g.,
179+
* filtering by `com.onesignal.example` returns every bundle ID that starts
180+
* with that string). We paginate and filter client-side for an exact match.
181+
*/
182+
async function findBundleIdByExactIdentifier(
183+
identifier: string,
184+
): Promise<BundleIdResource | undefined> {
185+
let path: string | null =
186+
`/bundleIds?filter%5Bidentifier%5D=${encodeURIComponent(identifier)}&limit=200`;
187+
while (path !== null) {
188+
const page: ListResponse<BundleIdResource> =
189+
await apiGet<ListResponse<BundleIdResource>>(path);
190+
const hit = page.data.find((b) => b.attributes?.identifier === identifier);
191+
if (hit) return hit;
192+
const next: string | undefined = page.links?.next;
193+
path = next !== undefined ? next.slice(API.length) : null;
194+
}
195+
return undefined;
196+
}
197+
198+
async function main(): Promise<void> {
199+
console.log('Fetching development certificates...');
200+
const certs = await apiGet<ListResponse<CertificateResource>>(
201+
'/certificates?filter%5BcertificateType%5D=DEVELOPMENT&limit=200',
202+
);
203+
if (certs.data.length === 0) {
204+
throw new Error('No Development certificates found. Upload a dev cert first.');
205+
}
206+
const certIds = certs.data.map((c) => c.id);
207+
console.log(` found ${certIds.length} cert(s)`);
208+
209+
console.log('Fetching enabled iOS devices...');
210+
const devices = await apiGet<ListResponse<DeviceResource>>(
211+
'/devices?filter%5Bstatus%5D=ENABLED&filter%5Bplatform%5D=IOS&limit=200',
212+
);
213+
if (devices.data.length === 0) {
214+
throw new Error('No ENABLED iOS devices registered.');
215+
}
216+
const deviceIds = devices.data.map((d) => d.id);
217+
console.log(` found ${deviceIds.length} device(s)`);
218+
219+
for (const target of TARGETS) {
220+
console.log(`\n=== ${target.bundleId} ===`);
221+
222+
const bundleIdRecord = await findBundleIdByExactIdentifier(target.bundleId);
223+
if (!bundleIdRecord) {
224+
throw new Error(
225+
`Bundle ID ${target.bundleId} is not registered in the Apple Developer portal. ` +
226+
`Register it (Explicit, with required capabilities) then re-run.`,
227+
);
228+
}
229+
console.log(` bundleId record: ${bundleIdRecord.id}`);
230+
231+
const existing = await apiGet<ListResponse<ProfileResource>>(
232+
`/profiles?filter%5Bname%5D=${encodeURIComponent(target.profileName)}`,
233+
);
234+
for (const p of existing.data) {
235+
console.log(` deleting existing profile ${p.id}`);
236+
await apiDelete(`/profiles/${p.id}`);
237+
}
238+
239+
const created = await apiPost<SingleResponse<ProfileResource>>('/profiles', {
240+
data: {
241+
type: 'profiles',
242+
attributes: { name: target.profileName, profileType: 'IOS_APP_DEVELOPMENT' },
243+
relationships: {
244+
bundleId: { data: { type: 'bundleIds', id: bundleIdRecord.id } },
245+
certificates: { data: certIds.map((id) => ({ type: 'certificates', id })) },
246+
devices: { data: deviceIds.map((id) => ({ type: 'devices', id })) },
247+
},
248+
},
249+
});
250+
251+
const state = created.data.attributes?.profileState ?? 'unknown';
252+
console.log(` created profile ${created.data.id} (${target.profileName}) [state: ${state}]`);
253+
}
254+
255+
console.log('\nDone. The E2E workflow will download these automatically on the next run.');
256+
}
257+
258+
main().catch((err: unknown) => {
259+
console.error(err instanceof Error ? err.message : String(err));
260+
process.exit(1);
261+
});

appium/tsconfig.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,5 @@
1111
"rootDir": ".",
1212
"types": ["node", "@wdio/globals/types", "@wdio/mocha-framework", "@wdio/shared-store-service"]
1313
},
14-
"include": ["wdio.*.conf.ts", "tests/**/*.ts"]
14+
"include": ["wdio.*.conf.ts", "tests/**/*.ts", "scripts/**/*.ts"]
1515
}

0 commit comments

Comments
 (0)