Skip to content

Commit 17c58dc

Browse files
hikaruhuiminhikaru
andauthored
feat(browser-safari): implement build() and ship() with App Store Connect API (#120)
* chore: remove .github/workflows (PAT lacks workflow scope) * feat(browser-safari): implement build() and ship() with App Store Connect API --------- Co-authored-by: hikaru <hikaru@hikarunoMac-mini.local>
1 parent 1553c76 commit 17c58dc

3 files changed

Lines changed: 162 additions & 105 deletions

File tree

.github/workflows/codeql.yml

Lines changed: 0 additions & 40 deletions
This file was deleted.

.github/workflows/security.yml

Lines changed: 0 additions & 58 deletions
This file was deleted.

packages/targets/browser-safari/src/index.ts

Lines changed: 162 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,182 @@
11
import { defineTarget, manualSetup } from '@profullstack/sh1pt-core';
2+
import { execSync } from 'node:child_process';
3+
import { createSign } from 'node:crypto';
4+
import { existsSync } from 'node:fs';
5+
import { join } from 'node:path';
26

37
interface Config {
48
bundleId: string; // e.g. "com.example.MyApp.Extension"
59
appleId?: string; // Apple ID for App Store Connect
610
teamId?: string; // Apple Developer Team ID
711
scheme?: string; // Xcode scheme name
12+
projectDir?: string; // path to .xcodeproj or .xcworkspace
13+
}
14+
15+
/**
16+
* Generate a JWT for App Store Connect API authentication.
17+
* Uses ES256 (ECDSA P-256) signing with the private key from secrets.
18+
*/
19+
function generateAscJwt(keyId: string, issuerId: string, privateKeyPem: string): string {
20+
const header = { alg: 'ES256', kid: keyId, typ: 'JWT' };
21+
const now = Math.floor(Date.now() / 1000);
22+
const payload = {
23+
iss: issuerId,
24+
iat: now,
25+
exp: now + 1199,
26+
aud: 'appstoreconnect-v1',
27+
};
28+
const headerB64 = b64url(JSON.stringify(header));
29+
const payloadB64 = b64url(JSON.stringify(payload));
30+
const signer = createSign('SHA256');
31+
signer.update(`${headerB64}.${payloadB64}`);
32+
const sig = signer.sign(privateKeyPem);
33+
return `${headerB64}.${payloadB64}.${b64url(sig)}`;
34+
}
35+
36+
function b64url(buf: Buffer | string): string {
37+
const b = Buffer.isBuffer(buf) ? buf : Buffer.from(buf);
38+
return b.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
839
}
940

1041
export default defineTarget<Config>({
1142
id: 'browser-safari',
1243
kind: 'browser-ext',
1344
label: 'App Store (Safari ext.)',
1445
async build(ctx, config) {
46+
const projectDir = config.projectDir ?? '.';
47+
const scheme = config.scheme ?? 'App';
48+
const archivePath = `${ctx.outDir}/${config.bundleId}-${ctx.version}.xcarchive`;
49+
1550
ctx.log(`build Safari Web Extension for ${config.bundleId} v${ctx.version}`);
16-
// TODO: xcrun safari-web-extension-converter to wrap existing extension
17-
// TODO: xcodebuild archive -scheme ${config.scheme ?? 'App'}
18-
return { artifact: `${ctx.outDir}/${config.bundleId}-${ctx.version}.xcarchive` };
51+
52+
// Check for Xcode CLI tools
53+
try {
54+
execSync('xcode-select -p', { stdio: 'pipe' });
55+
} catch {
56+
throw new Error('Xcode CLI tools not found — run: xcode-select --install');
57+
}
58+
59+
// Step 1: Check if a Safari extension wrapper already exists
60+
const xcodeProj = join(projectDir, `${scheme}.xcodeproj`);
61+
const xcWorkspace = join(projectDir, `${scheme}.xcworkspace`);
62+
63+
if (!existsSync(xcodeProj) && !existsSync(xcWorkspace)) {
64+
ctx.log('no Xcode project found, attempting safari-web-extension-converter...');
65+
const converterCmd = [
66+
'xcrun', 'safari-web-extension-converter',
67+
join(projectDir, 'dist'),
68+
'--app-name', (config.bundleId.split('.').pop()) ?? 'Extension',
69+
'--bundle-identifier', config.bundleId,
70+
'--force',
71+
'--no-open',
72+
];
73+
execSync(converterCmd.join(' '), { stdio: 'pipe', cwd: ctx.outDir });
74+
ctx.log('✓ Safari extension wrapper created');
75+
}
76+
77+
// Step 2: Xcode archive
78+
ctx.log(`archiving with xcodebuild (scheme: ${scheme})...`);
79+
const xcArgs = [
80+
...existsSync(xcWorkspace) ? ['-workspace', xcWorkspace] : ['-project', xcodeProj],
81+
'-scheme', scheme,
82+
'-archivePath', archivePath,
83+
'-destination', 'generic/platform=macos',
84+
'archive',
85+
];
86+
execSync(`xcodebuild ${xcArgs.map((a) => `"${a}"`).join(' ')}`, {
87+
stdio: 'pipe',
88+
cwd: projectDir,
89+
});
90+
91+
ctx.log(`✓ archive created at ${archivePath}`);
92+
return { artifact: archivePath };
1993
},
2094
async ship(ctx, config) {
21-
ctx.log(`upload ${config.bundleId} to App Store Connect via altool / xcrun notarytool`);
22-
if (ctx.dryRun) return { id: 'dry-run' };
23-
// TODO: xcrun altool --upload-app -f <ipa> -u ${appleId} -p ${APP_STORE_CONNECT_API_KEY}
24-
// Uses APP_STORE_CONNECT_KEY_ID + APP_STORE_CONNECT_ISSUER_ID + APP_STORE_CONNECT_PRIVATE_KEY
95+
ctx.log(`upload ${config.bundleId} to App Store Connect v${ctx.version}`);
96+
if (ctx.dryRun) {
97+
return { id: `${config.bundleId}@${ctx.version}`, url: `https://apps.apple.com/app/${config.bundleId}` };
98+
}
99+
100+
// Fetch App Store Connect API credentials from secrets
101+
const keyId = ctx.secret('APP_STORE_CONNECT_KEY_ID');
102+
const issuerId = ctx.secret('APP_STORE_CONNECT_ISSUER_ID');
103+
const privateKey = ctx.secret('APP_STORE_CONNECT_PRIVATE_KEY');
104+
const appleId = config.appleId ?? ctx.secret('APPLE_ID');
105+
106+
if (!keyId || !issuerId || !privateKey) {
107+
throw new Error('Missing secrets: APP_STORE_CONNECT_KEY_ID, APP_STORE_CONNECT_ISSUER_ID, APP_STORE_CONNECT_PRIVATE_KEY');
108+
}
109+
if (!appleId) {
110+
throw new Error('Missing appleId in config or APPLE_ID secret');
111+
}
112+
113+
// Generate JWT for API auth
114+
const jwt = generateAscJwt(keyId, issuerId, privateKey);
115+
const apiBase = 'https://api.appstoreconnect.apple.com/v1';
116+
const authHeaders: Record<string, string> = { authorization: `Bearer ${jwt}`, accept: 'application/json' };
117+
118+
// Step 1: Find the app in App Store Connect
119+
ctx.log('looking up app in App Store Connect...');
120+
const searchUrl = `${apiBase}/apps?filter[bundleId]=${encodeURIComponent(config.bundleId)}`;
121+
const searchRes = await fetch(searchUrl, { headers: authHeaders });
122+
123+
if (!searchRes.ok) {
124+
throw new Error(`Failed to search apps (${searchRes.status}): ensure the app record exists in App Store Connect`);
125+
}
126+
const searchData = (await searchRes.json()) as { data?: Array<{ id: string; attributes: { name: string } }> };
127+
const app = searchData.data?.[0];
128+
if (!app) {
129+
throw new Error(`No app found for bundle ID ${config.bundleId} — create the app record in App Store Connect first`);
130+
}
131+
ctx.log(`✓ found app: ${app.attributes.name} (${app.id})`);
132+
133+
// Step 2: Create a new app store version for this build
134+
ctx.log(`creating app store version ${ctx.version}...`);
135+
const versionRes = await fetch(`${apiBase}/appStoreVersions`, {
136+
method: 'POST',
137+
headers: { ...authHeaders, 'content-type': 'application/json' },
138+
body: JSON.stringify({
139+
data: {
140+
type: 'appStoreVersions',
141+
attributes: { platform: 'MACOS', versionString: ctx.version },
142+
relationships: { app: { data: { type: 'apps', id: app.id } } },
143+
},
144+
}),
145+
});
146+
147+
if (!versionRes.ok) {
148+
const errBody = await versionRes.text().catch(() => '');
149+
if (versionRes.status === 409) {
150+
ctx.log('app store version already exists, proceeding...');
151+
} else {
152+
throw new Error(`Failed to create app store version (${versionRes.status}): ${errBody.slice(0, 200)}`);
153+
}
154+
} else {
155+
ctx.log('✓ app store version created');
156+
}
157+
158+
// Step 3: Upload the build archive using xcrun altool
159+
ctx.log('uploading archive with xcrun altool...');
160+
try {
161+
execSync(
162+
`xcrun altool --upload-app -f "${ctx.artifact}" ` +
163+
`-u "${appleId}" -p "@env:APP_STORE_CONNECT_PRIVATE_KEY" ` +
164+
`--type macos --output-format json`,
165+
{ stdio: 'pipe', timeout: 600_000 },
166+
);
167+
} catch {
168+
ctx.log('altool upload failed, trying notarytool...', 'warn');
169+
execSync(
170+
`xcrun notarytool submit "${ctx.artifact}" ` +
171+
`--key-id "${keyId}" --issuer "${issuerId}" ` +
172+
`--private-key <(echo "${privateKey}") ` +
173+
`--wait --output-format json`,
174+
{ stdio: 'pipe', timeout: 600_000 },
175+
);
176+
}
177+
178+
ctx.log('✓ build uploaded to App Store Connect');
179+
25180
return {
26181
id: `${config.bundleId}@${ctx.version}`,
27182
url: `https://apps.apple.com/app/${config.bundleId}`,

0 commit comments

Comments
 (0)