@@ -45,6 +45,7 @@ interface ScaffoldVars {
4545 bundleId : string
4646 extBundleId : string
4747 version : string
48+ teamId : string
4849}
4950
5051function 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+ }
0 commit comments