Skip to content

Commit 2625fad

Browse files
committed
feat(browser-extension): publish Safari apps to App Store Connect
1 parent 4bbc4f4 commit 2625fad

8 files changed

Lines changed: 159 additions & 6 deletions

File tree

storage/framework/core/browser-extension/README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,14 @@ Safari with MAIN-world content scripts + `match_about_blank`).
2929
```sh
3030
buddy extension:safari:init # scaffold the Xcode container app into safari/
3131
buddy extension:safari:app # build + sync into the appex + xcodebuild
32+
buddy extension:safari:publish # signed archive + App Store Connect upload
3233
```
3334

3435
Set `safariBundleId` in the config (the appex gets `<safariBundleId>.Extension`)
36+
and `safariTeamId` to the Apple Developer team used for signing. Publishing
37+
reads `APP_STORE_CONNECT_API_KEY_ID`, `APP_STORE_CONNECT_API_ISSUER_ID`, and
38+
`APP_STORE_CONNECT_API_KEY_PATH` from the environment. Run with
39+
`--validate-only` to exercise Apple's validation without uploading a build.
3540
and list any build output that is not part of the extension (marketing pages,
3641
etc.) in `safariExclude` so it stays out of the appex. The scaffold mirrors
3742
what `xcrun safari-web-extension-converter` generates, so day-to-day work

storage/framework/core/browser-extension/safari-template/README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@ Safari > Settings > Extensions, granting it website access.
3636

3737
## Signing
3838

39-
The project ships with `DEVELOPMENT_TEAM` empty so anyone can build.
39+
The project uses `safariTeamId` when configured and otherwise leaves
40+
`DEVELOPMENT_TEAM` empty so anyone can build.
4041

4142
Local, unsigned (no Apple account): the default build uses
4243
`CODE_SIGNING_ALLOWED=NO`. Unsigned extensions require

storage/framework/core/browser-extension/safari-template/__APP_NAME__.xcodeproj/project.pbxproj

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -371,7 +371,7 @@
371371
CODE_SIGN_ENTITLEMENTS = __APP_NAME__/__APP_NAME__.entitlements;
372372
CODE_SIGN_STYLE = Automatic;
373373
COMBINE_HIDPI_IMAGES = YES;
374-
DEVELOPMENT_TEAM = "";
374+
DEVELOPMENT_TEAM = "__DEVELOPMENT_TEAM__";
375375
ENABLE_HARDENED_RUNTIME = YES;
376376
GENERATE_INFOPLIST = NO;
377377
INFOPLIST_FILE = __APP_NAME__/Info.plist;
@@ -394,7 +394,7 @@
394394
CODE_SIGN_ENTITLEMENTS = __APP_NAME__/__APP_NAME__.entitlements;
395395
CODE_SIGN_STYLE = Automatic;
396396
COMBINE_HIDPI_IMAGES = YES;
397-
DEVELOPMENT_TEAM = "";
397+
DEVELOPMENT_TEAM = "__DEVELOPMENT_TEAM__";
398398
ENABLE_HARDENED_RUNTIME = YES;
399399
GENERATE_INFOPLIST = NO;
400400
INFOPLIST_FILE = __APP_NAME__/Info.plist;
@@ -415,7 +415,7 @@
415415
buildSettings = {
416416
CODE_SIGN_ENTITLEMENTS = "__APP_NAME__ Extension/__APP_NAME___Extension.entitlements";
417417
CODE_SIGN_STYLE = Automatic;
418-
DEVELOPMENT_TEAM = "";
418+
DEVELOPMENT_TEAM = "__DEVELOPMENT_TEAM__";
419419
ENABLE_HARDENED_RUNTIME = YES;
420420
GENERATE_INFOPLIST = NO;
421421
INFOPLIST_FILE = "__APP_NAME__ Extension/Info.plist";
@@ -437,7 +437,7 @@
437437
buildSettings = {
438438
CODE_SIGN_ENTITLEMENTS = "__APP_NAME__ Extension/__APP_NAME___Extension.entitlements";
439439
CODE_SIGN_STYLE = Automatic;
440-
DEVELOPMENT_TEAM = "";
440+
DEVELOPMENT_TEAM = "__DEVELOPMENT_TEAM__";
441441
ENABLE_HARDENED_RUNTIME = YES;
442442
GENERATE_INFOPLIST = NO;
443443
INFOPLIST_FILE = "__APP_NAME__ Extension/Info.plist";

storage/framework/core/browser-extension/src/safari.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ interface ScaffoldVars {
4545
bundleId: string
4646
extBundleId: string
4747
version: string
48+
teamId: string
4849
}
4950

5051
function fillTemplate(text: string, vars: ScaffoldVars): string {
@@ -54,6 +55,7 @@ function fillTemplate(text: string, vars: ScaffoldVars): string {
5455
.replaceAll('__APP_DISPLAY_NAME__', vars.displayName)
5556
.replaceAll('__APP_NAME__', vars.appName)
5657
.replaceAll('__MARKETING_VERSION__', vars.version)
58+
.replaceAll('__DEVELOPMENT_TEAM__', vars.teamId)
5759
.replaceAll('__YEAR__', String(new Date().getFullYear()))
5860
}
5961

@@ -76,6 +78,8 @@ export interface SafariScaffoldOptions {
7678
version?: string
7779
/** Directory of source PNG icons for the AppIcon set. @default the built chrome outdir's icons, else `public/icons` */
7880
iconsDir?: string
81+
/** Apple Developer team used for automatic signing. @default config.safariTeamId */
82+
teamId?: string
7983
cwd?: string
8084
}
8185

@@ -96,6 +100,7 @@ export async function scaffoldSafariApp(config: ExtensionConfig, options: Safari
96100
bundleId,
97101
extBundleId: `${bundleId}.Extension`,
98102
version: options.version ?? '0.1.0',
103+
teamId: options.teamId ?? config.safariTeamId ?? '',
99104
}
100105

101106
if (!options.bundleId && !config.safariBundleId)
@@ -275,3 +280,112 @@ export async function buildSafariApp(config: ExtensionConfig, options: SafariApp
275280

276281
return { appPath: join(derivedData, 'Build', 'Products', configuration, `${appName}.app`), resources }
277282
}
283+
284+
export interface AppStoreConnectAuth {
285+
/** App Store Connect API key ID. @default APP_STORE_CONNECT_API_KEY_ID */
286+
keyId?: string
287+
/** App Store Connect API issuer ID. @default APP_STORE_CONNECT_API_ISSUER_ID */
288+
issuerId?: string
289+
/** Filesystem path to the AuthKey_*.p8 file. @default APP_STORE_CONNECT_API_KEY_PATH */
290+
keyPath?: string
291+
}
292+
293+
export interface SafariPublishOptions extends SafariSyncOptions, AppStoreConnectAuth {
294+
/** Extension marketing version. */
295+
version: string
296+
/** Monotonically increasing CFBundleVersion. @default GITHUB_RUN_NUMBER or current Unix time */
297+
buildNumber?: string
298+
/** Apple Developer team. @default config.safariTeamId */
299+
teamId?: string
300+
/** Build and validate without uploading to App Store Connect. @default false */
301+
validateOnly?: boolean
302+
/** Rebuild the Safari web-extension payload before archiving. @default true */
303+
build?: boolean
304+
}
305+
306+
function appStoreConnectAuth(options: AppStoreConnectAuth): Required<AppStoreConnectAuth> {
307+
const keyId = options.keyId ?? process.env.APP_STORE_CONNECT_API_KEY_ID
308+
const issuerId = options.issuerId ?? process.env.APP_STORE_CONNECT_API_ISSUER_ID
309+
const keyPath = options.keyPath ?? process.env.APP_STORE_CONNECT_API_KEY_PATH
310+
const missing = [
311+
!keyId && 'APP_STORE_CONNECT_API_KEY_ID',
312+
!issuerId && 'APP_STORE_CONNECT_API_ISSUER_ID',
313+
!keyPath && 'APP_STORE_CONNECT_API_KEY_PATH',
314+
].filter(Boolean)
315+
if (missing.length)
316+
throw new Error(`[browser-extension] missing App Store Connect credentials: ${missing.join(', ')}`)
317+
if (!existsSync(resolve(keyPath!)))
318+
throw new Error(`[browser-extension] App Store Connect API key not found: ${resolve(keyPath!)}`)
319+
return { keyId: keyId!, issuerId: issuerId!, keyPath: resolve(keyPath!) }
320+
}
321+
322+
function xcodeAuthArgs(auth: Required<AppStoreConnectAuth>): string[] {
323+
return [
324+
'-allowProvisioningUpdates',
325+
'-authenticationKeyPath', auth.keyPath,
326+
'-authenticationKeyID', auth.keyId,
327+
'-authenticationKeyIssuerID', auth.issuerId,
328+
]
329+
}
330+
331+
function exportOptionsPlist(method: 'app-store-connect' | 'validation', teamId: string): string {
332+
return `<?xml version="1.0" encoding="UTF-8"?>
333+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
334+
<plist version="1.0">
335+
<dict>
336+
<key>destination</key>
337+
<string>upload</string>
338+
<key>manageAppVersionAndBuildNumber</key>
339+
<false/>
340+
<key>method</key>
341+
<string>${method}</string>
342+
<key>signingStyle</key>
343+
<string>automatic</string>
344+
<key>teamID</key>
345+
<string>${teamId}</string>
346+
<key>uploadSymbols</key>
347+
<true/>
348+
</dict>
349+
</plist>
350+
`
351+
}
352+
353+
/**
354+
* Create a signed Release archive and either validate it or upload it to App
355+
* Store Connect. Xcode owns certificate/profile creation and the upload so the
356+
* same command works locally and in macOS CI with an API key.
357+
*/
358+
export async function publishSafariApp(config: ExtensionConfig, options: SafariPublishOptions): Promise<{ archivePath: string, exportPath: string, buildNumber: string }> {
359+
const cwd = options.cwd ?? process.cwd()
360+
const teamId = options.teamId ?? config.safariTeamId
361+
if (!teamId)
362+
throw new Error('[browser-extension] Safari publishing needs safariTeamId in config/extension.ts or --team-id')
363+
const auth = appStoreConnectAuth(options)
364+
const buildNumber = options.buildNumber ?? process.env.GITHUB_RUN_NUMBER ?? String(Math.floor(Date.now() / 1000))
365+
if (!/^\d+(?:\.\d+){0,2}$/.test(buildNumber))
366+
throw new Error(`[browser-extension] invalid Safari build number ${buildNumber}; use one to three dot-separated integers`)
367+
368+
if (options.build !== false)
369+
await buildExtension(config, { target: 'safari', version: options.version, cwd })
370+
await syncSafariResources(config, options)
371+
372+
const appName = safariAppName(config)
373+
const dir = safariProjectDir(cwd, options.dir)
374+
const buildDir = join(dir, 'build')
375+
const archivePath = join(buildDir, `${appName}.xcarchive`)
376+
const exportPath = join(buildDir, options.validateOnly ? 'validation' : 'upload')
377+
const plistPath = join(buildDir, options.validateOnly ? 'ExportOptions.validation.plist' : 'ExportOptions.app-store.plist')
378+
const authArgs = xcodeAuthArgs(auth)
379+
await mkdir(buildDir, { recursive: true })
380+
await rm(archivePath, { recursive: true, force: true })
381+
await rm(exportPath, { recursive: true, force: true })
382+
383+
const project = join(dir, `${appName}.xcodeproj`)
384+
await Bun.$`xcodebuild -project ${project} -scheme ${appName} -configuration Release -destination generic/platform=macOS -archivePath ${archivePath} MARKETING_VERSION=${options.version} CURRENT_PROJECT_VERSION=${buildNumber} DEVELOPMENT_TEAM=${teamId} ${authArgs} archive`
385+
386+
const method = options.validateOnly ? 'validation' : 'app-store-connect'
387+
await Bun.write(plistPath, exportOptionsPlist(method, teamId))
388+
await Bun.$`xcodebuild -exportArchive -archivePath ${archivePath} -exportPath ${exportPath} -exportOptionsPlist ${plistPath} ${authArgs}`
389+
390+
return { archivePath, exportPath, buildNumber }
391+
}

storage/framework/core/browser-extension/src/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,8 @@ export interface ExtensionConfig {
9797
* (`extension:safari:init`); the appex uses `<safariBundleId>.Extension`.
9898
*/
9999
safariBundleId?: string
100+
/** Apple Developer team used to sign and publish the Safari container app. */
101+
safariTeamId?: string
100102
/**
101103
* Build-output files to keep out of the Safari appex Resources when syncing
102104
* (e.g. marketing-site pages that are built into dist but are not part of

storage/framework/core/browser-extension/tests/safari.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ const config: ExtensionConfig = {
1313
description: 'Fixture config for the safari target.',
1414
geckoId: 'extension@example.com',
1515
safariBundleId: 'com.example.TestExtension',
16+
safariTeamId: 'TEAM123456',
1617
safariExclude: ['marketing.html', 'marketing.js'],
1718
background: 'src/background/index.ts',
1819
content: [
@@ -114,6 +115,7 @@ describe('safari scaffold + sync', () => {
114115
expect(pbxproj).toContain('PRODUCT_BUNDLE_IDENTIFIER = com.example.TestExtension;')
115116
expect(pbxproj).toContain('PRODUCT_BUNDLE_IDENTIFIER = com.example.TestExtension.Extension;')
116117
expect(pbxproj).toContain('MARKETING_VERSION = 1.2.3;')
118+
expect(pbxproj).toContain('DEVELOPMENT_TEAM = "TEAM123456";')
117119
expect(pbxproj).not.toContain('__APP_NAME__')
118120

119121
const appPlist = await Bun.file(join(dir, 'TestExtension', 'Info.plist')).text()

storage/framework/core/buddy/src/commands/extension.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,13 +93,15 @@ export function extension(buddy: CLI): void {
9393
.option('--bundle-id <id>', 'Base bundle identifier (defaults to config safariBundleId)')
9494
.option('--dir <dir>', 'Output directory for the Xcode project (default safari)')
9595
.option('--force', 'Overwrite existing scaffold files')
96-
.action(async (options: { bundleId?: string, dir?: string, force?: boolean }) => {
96+
.option('--team-id <id>', 'Apple Developer team used for signing')
97+
.action(async (options: { bundleId?: string, dir?: string, force?: boolean, teamId?: string }) => {
9798
const { scaffoldSafariApp } = await import('@stacksjs/browser-extension')
9899
const { config } = await load()
99100
const { dir, written, skipped } = await scaffoldSafariApp(config, {
100101
bundleId: options.bundleId,
101102
dir: options.dir,
102103
force: Boolean(options.force),
104+
teamId: options.teamId,
103105
})
104106
log.success(`Scaffolded the Safari container app → ${dir} (${written.length} files)`)
105107
if (skipped.length)
@@ -129,4 +131,30 @@ export function extension(buddy: CLI): void {
129131
log.success(`Extension payload synced → ${resources}`)
130132
}
131133
})
134+
135+
buddy
136+
.command('extension:safari:publish', 'Archive and validate or upload the Safari app to App Store Connect')
137+
.option('--version <version>', 'Override the marketing version (defaults to package.json)')
138+
.option('--build-number <number>', 'CFBundleVersion (defaults to GITHUB_RUN_NUMBER or Unix time)')
139+
.option('--team-id <id>', 'Apple Developer team (defaults to config safariTeamId)')
140+
.option('--api-key-id <id>', 'App Store Connect API key ID')
141+
.option('--api-issuer-id <id>', 'App Store Connect API issuer ID')
142+
.option('--api-key-path <path>', 'Path to the App Store Connect AuthKey_*.p8 file')
143+
.option('--validate-only', 'Create and validate the archive without uploading it')
144+
.action(async (options: { version?: string, buildNumber?: string, teamId?: string, apiKeyId?: string, apiIssuerId?: string, apiKeyPath?: string, validateOnly?: boolean }) => {
145+
const { publishSafariApp } = await import('@stacksjs/browser-extension')
146+
const { config, version } = await load()
147+
const result = await publishSafariApp(config, {
148+
version: options.version ?? version,
149+
buildNumber: options.buildNumber,
150+
teamId: options.teamId,
151+
keyId: options.apiKeyId,
152+
issuerId: options.apiIssuerId,
153+
keyPath: options.apiKeyPath,
154+
validateOnly: Boolean(options.validateOnly),
155+
})
156+
log.success(options.validateOnly
157+
? `Validated Safari archive ${result.archivePath} (build ${result.buildNumber})`
158+
: `Uploaded Safari build ${result.buildNumber} to App Store Connect`)
159+
})
132160
}

storage/framework/core/buddy/src/lazy-commands.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ const commandRegistry: Record<string, CommandLoader> = {
4444
'extension:package': { path: './commands/extension.ts', exportName: 'extension' },
4545
'extension:safari:init': { path: './commands/extension.ts', exportName: 'extension' },
4646
'extension:safari:app': { path: './commands/extension.ts', exportName: 'extension' },
47+
'extension:safari:publish': { path: './commands/extension.ts', exportName: 'extension' },
4748
// Feature install / uninstall — single file registers all pairs.
4849
'dashboard:install': { path: './commands/features.ts', exportName: 'features' },
4950
'dashboard:uninstall': { path: './commands/features.ts', exportName: 'features' },

0 commit comments

Comments
 (0)