Automating Safari Builds with xcodebuild
Build, sign and upload a Safari Web Extension from CI: converting with safari-web-extension-converter, syncing web resources into the Xcode project, xcodebuild archive and export, signing on macOS runners, and uploading to App Store Connect.
Table of Contents
The Chrome and Firefox packages are built and published by CI on every tag. The Safari version is built by one person on their Mac, by opening Xcode, copying the latest dist/ folder into the project, bumping two version numbers, choosing Product → Archive and clicking through the Organizer — so Safari users get updates weeks late, and occasionally an archive built from the wrong dist/. Safari extensions ship inside a macOS (and optionally iOS) app, which makes the build an Xcode build, but every step can be scripted with xcodebuild on a macOS CI runner. This guide automates it end to end. It belongs to CI and release automation.
The shape of a Safari extension build
A Safari Web Extension is an app extension target inside an Xcode project, alongside a containing app target. Apple’s safari-web-extension-converter creates that project from a web extension folder once; after that, the project references the extension’s web resources (manifest, scripts, HTML, icons) as files in the extension target. The release build has four steps: put the current web build into the place the project expects; set version numbers; xcodebuild archive with signing; xcodebuild -exportArchive for App Store distribution, then upload. CI needs a macOS runner with the right Xcode, a signing certificate and provisioning profiles (or automatic signing with an App Store Connect API key), and App Store Connect credentials.
Step-by-step: Safari builds in CI
1. Convert once and commit the project
1xcrun safari-web-extension-converter dist/safari \
2 --project-location safari --app-name "Readable" \
3 --bundle-identifier com.example.readable --swift --macos-only --no-open --copy-resources
Execution context: a developer’s Mac, once. --copy-resources copies the web files into the project instead of referencing your dist/ path, which makes the project self-contained. Commit the generated safari/ folder. Re-running the converter on every build would regenerate the project and discard customisations, so CI only syncs resources. See porting a Chrome extension to Safari with Xcode.
2. Sync the web build into the project
1# scripts/sync-safari.sh
2set -euo pipefail
3RES="safari/Readable/Shared (Extension)/Resources"
4rsync -a --delete --exclude '.DS_Store' --exclude '*.map' dist/safari/ "$RES/"
Execution context: CI on macOS, after the web build. --delete removes files that no longer exist in the build, so stale scripts never ship. The resources folder path depends on the converter version and app name; check it in your project. Because the Xcode project references the folder, newly added files inside it are included automatically if the folder is a folder reference — verify that in Xcode, or new files will be missing from the bundle.
3. Set version numbers from the tag
1VERSION="${GITHUB_REF_NAME#v}" # v2.4.0 → 2.4.0
2BUILD="${GITHUB_RUN_NUMBER}"
3cd safari
4xcrun agvtool new-marketing-version "$VERSION"
5xcrun agvtool new-version -all "$BUILD"
Execution context: CI. The marketing version (CFBundleShortVersionString) should match the extension’s manifest.json version; the build number (CFBundleVersion) must increase with every upload to App Store Connect, even for the same marketing version. agvtool updates both app and extension targets when the project uses Apple Generic versioning; with MARKETING_VERSION build settings, pass them to xcodebuild instead.
4. Archive with signing
1xcodebuild archive \
2 -project safari/Readable.xcodeproj \
3 -scheme "Readable (macOS)" \
4 -configuration Release \
5 -archivePath build/Readable.xcarchive \
6 -destination "generic/platform=macOS" \
7 -allowProvisioningUpdates \
8 -authenticationKeyPath "$RUNNER_TEMP/AuthKey.p8" \
9 -authenticationKeyID "$ASC_KEY_ID" \
10 -authenticationKeyIssuerID "$ASC_ISSUER_ID" \
11 MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD"
Execution context: a macOS CI runner with the pinned Xcode selected (sudo xcode-select -s /Applications/Xcode_16.app). With automatic signing and an App Store Connect API key, Xcode fetches or creates the needed profiles; the key file is written from a secret at runtime and deleted afterwards. The scheme name comes from the converter ("Readable (macOS)"); list schemes with xcodebuild -list.
5. Export for the App Store
1<!-- safari/ExportOptions.plist -->
2<plist version="1.0"><dict>
3 <key>method</key><string>app-store-connect</string>
4 <key>destination</key><string>export</string>
5 <key>signingStyle</key><string>automatic</string>
6 <key>teamID</key><string>ABCDE12345</string>
7</dict></plist>
1xcodebuild -exportArchive -archivePath build/Readable.xcarchive \
2 -exportOptionsPlist safari/ExportOptions.plist -exportPath build/export \
3 -allowProvisioningUpdates -authenticationKeyPath "$RUNNER_TEMP/AuthKey.p8" \
4 -authenticationKeyID "$ASC_KEY_ID" -authenticationKeyIssuerID "$ASC_ISSUER_ID"
Execution context: CI. The export produces a signed installer package for macOS App Store distribution. Older Xcode versions use app-store as the method name; match it to your Xcode. Setting destination to upload uploads directly as part of the export, which removes the separate upload step.
6. Upload to App Store Connect
1xcrun altool --upload-app --type macos --file build/export/Readable.pkg \
2 --apiKey "$ASC_KEY_ID" --apiIssuer "$ASC_ISSUER_ID"
Execution context: CI. altool looks for the key in ~/.appstoreconnect/private_keys/AuthKey_<ID>.p8, so place it there from the secret. After processing, the build appears in App Store Connect for TestFlight and submission; submitting for review can stay manual or be automated with the App Store Connect API. Tools such as fastlane wrap these steps if you prefer.
7. Pin Xcode and the runner image
Xcode updates change signing behaviour and build settings. Pin the runner image (macos-14) and select an exact Xcode version in the workflow; upgrade deliberately, with a test build, when Apple requires a newer SDK for submissions.
8. Write and remove credentials safely
1- name: Install App Store Connect key
2 run: |
3 mkdir -p ~/.appstoreconnect/private_keys
4 echo "$ASC_KEY_P8" | base64 --decode > ~/.appstoreconnect/private_keys/AuthKey_${ASC_KEY_ID}.p8
5 cp ~/.appstoreconnect/private_keys/AuthKey_${ASC_KEY_ID}.p8 "$RUNNER_TEMP/AuthKey.p8"
6 env:
7 ASC_KEY_P8: ${{ secrets.ASC_KEY_P8 }}
8 ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
9- name: Clean up
10 if: always()
11 run: rm -rf ~/.appstoreconnect "$RUNNER_TEMP/AuthKey.p8"
Execution context: GitHub Actions on a macOS runner. Store the .p8 key base64-encoded as a secret, decode it into the locations xcodebuild and altool expect, and delete it in an always() step so it is removed even when the build fails. Hosted runners are ephemeral, but self-hosted Macs are not, and leaving keys on disk there is a real risk.
Common mistakes
- Re-running the converter in CI. Overwrites project customisations.
- Stale resources. Sync with
--deleteso removed files don’t ship. - Reusing build numbers. App Store Connect rejects duplicate
CFBundleVersion. - Certificates on one person’s Mac. Releases depend on them; use API-key signing.
- Unpinned Xcode. Builds change under you.
Cross-browser variation
- Chrome / Edge: ZIP upload via store APIs; no native build step.
- Firefox:
web-ext signproduces a signed XPI. - Safari: requires a macOS runner, Xcode, Apple Developer Program membership, and App Store review of the containing app; iOS/iPadOS targets add their own schemes and destinations.
Verification
- Run the workflow on a tag and confirm a build appears in App Store Connect with the right version and build number.
- Diff the Resources folder in the archive with
dist/safariand confirm they match. - Install the TestFlight build and confirm the extension works in Safari.
- Re-run the job and confirm the build number increments.
FAQ
Can I build Safari extensions on Linux?
No. Xcode and signing require macOS.
Do I need the iOS target?
Only if you ship on iPhone and iPad. The converter can create both; each needs its own archive and export.
How much do macOS runners cost?
Hosted macOS minutes are billed at a higher rate than Linux; build Safari only on release tags, not every push.
Can I distribute outside the App Store?
Yes — export with the Developer ID method and notarise the app with notarytool. Users then install the app directly and enable the extension in Safari settings.
Related
- Porting a Chrome extension to Safari with Xcode — the initial conversion.
- Building reproducible release ZIPs — the web build feeding Xcode.
- Managing store credentials in CI secrets — API keys.
- CI and release automation — the parent topic.