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.

Published October 2, 2026 Updated October 2, 2026 7 min read
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.

Safari release pipelineThe shared web build produces dist/safari; a sync step copies it into the Xcode project's resources folder; versions are set from the tag; xcodebuild archives and exports a signed app; the export is uploaded to App Store Connect for review.npm run build:safaridist/safariSync resourcesinto Xcode projectSet versionsfrom tagmacOS runnerxcodebuild archivesigned-exportArchiveApp StoreUploadApp Store Connect
Same web build as other browsers, wrapped by Xcode.

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.

Signing approaches on CIAutomatic signing with an App Store Connect API key versus manual signing with an imported certificate and profiles, compared on setup, maintenance and fit for CI.ApproachSetupMaintenanceCI fitAutomatic + API keyKey in secretsXcode manages profilesGoodManual cert + profilesp12 + profiles in secretsRenew yearlyGood, more stepsDeveloper's Mac onlyNonePerson-dependentNot CI
API-key automatic signing is simplest to maintain on ephemeral runners.

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.

Archive, export, uploadThe runner archives the signed app, exports it with an ExportOptions plist for App Store distribution, and uploads the package to App Store Connect, which processes the build for TestFlight and review.macOS runnerApple signingApp Store Connectarchive (API key auth)profiles + signature-exportArchive (app-store)upload .pkgprocessing → Te…
Three xcodebuild/altool steps replace the Organizer clicks.

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 --delete so 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 sign produces 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

  1. Run the workflow on a tag and confirm a build appears in App Store Connect with the right version and build number.
  2. Diff the Resources folder in the archive with dist/safari and confirm they match.
  3. Install the TestFlight build and confirm the extension works in Safari.
  4. 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.

Other Testing, Debugging & Performance Optimization Resources