# Safari

> How a build reaches the App Store from extension.dev, from the signing assets and the listing to the TestFlight upload, App Review and the release, and what still has to be done by hand.

Canonical: https://docs.extension.dev/publish/safari
This page as Markdown: https://docs.extension.dev/publish/safari.md

Map of every page: https://docs.extension.dev/llms.txt · Whole corpus: https://docs.extension.dev/llms-full.txt

---

# Safari

Safari extensions ship through the App Store inside a wrapper app, which the
platform assembles, signs and uploads for you. Reach for this page when a
project should reach App Store Connect, App Review and the App Store from a
build on extension.dev, and when you need to know which steps Apple still
leaves to you.

Chrome, Edge and Firefox follow a shorter path, see
[Publishing](/publish/overview). Safari is the one store where a build also
needs an app record, signing assets and a macOS runner, so this page carries
those legs.


## Quick start

1. In the console, open **Project settings, Stores, Safari**. Paste the App
   Store Connect key (key ID, issuer ID and the `.p8` content), save the
   bundle ID, and run **Mint signing assets**. The platform registers both
   bundle IDs, mints the two certificates and the two profiles, and stores
   the six secrets on the project.
2. Create the app record once in App Store Connect (**New App**, platform
   macOS). The card shows the exact values to type. Apple has no API for
   this step. Then connect the store on the card, which finds the record by
   bundle ID and saves the app's id and name on the project.
3. Add a `## Safari` section to `STORE.md` and sync the listing. From an
   agent, `extension_submit` with `action: "sync-listing"` and
   `browsers: ["safari"]` pushes it to App Store Connect after an owner
   approves the change.
4. Submit a build. The default stops at a TestFlight draft; ask for review
   on the run:

```
POST /api/projects/{id}/stores/safari/submissions
{ "buildId": "<sha>", "submitForReview": true }
```

The run signs the wrapper app on a macOS runner, uploads the installer to
App Store Connect, waits for TestFlight to process it, and sends the App
Store version holding that build to App Review. The version is held for a
manual release unless `STORE.md` says otherwise, so Apple's approval puts
nothing on sale on its own.

## How it works

### One paid path

Safari and the App Store are one paid feature, builds and submissions
together. The build runs on Linux like the other three, but the submit job
needs a macOS runner, which bills ten times a Linux minute. On the free plan
a Safari build and an App Store submission are both refused `403` with
`MACOS_RUNNER_NOT_INCLUDED` before anything is dispatched, and no allowance
is spent.

A paid workspace carries 15 App Store submissions a month. The count clears
when the month rolls. It is a sub-limit of the monthly build allowance, so
one submission spends one from each, and the sixteenth in a month answers
`403` with `SAFARI_SUBMISSIONS_EXHAUSTED`. Building and packaging a Safari
extension on your own machine with the `extension` CLI is free. See
[extension.dev/pricing](https://www.extension.dev/pricing) and
[promote gates](/builds/promote-gates).

### What runs today

| Leg | State |
| --- | --- |
| Safari as a build target | Offered on the paid plan. **Project settings, Build** carries a Safari row beside Chrome, Edge and Firefox, closed while the workspace is on the free plan. |
| The Safari payload | Built on Linux beside the other three. The build emits the web extension payload only; the Mac app that wraps it is assembled at submit time. |
| Signing assets | Minted through the App Store Connect API from the console card: both bundle IDs registered, the Mac App Distribution and Mac Installer Distribution certificates, both Mac App Store profiles, six secrets written to the project. Pasting your own exports still works. |
| The app record | Created by you once on App Store Connect's site. The card gives the values; connecting the store records the app's id and name. |
| The listing | Pushed to App Store Connect by the listing sync: name, subtitle, description, keywords, URLs, category, review contact and notes, age rating, content rights, screenshots, free price and worldwide availability. Fields Apple refuses are recorded with Apple's words. |
| Signing and packaging | Done on a macOS runner in a keychain that exists only for the run. |
| Upload to App Store Connect | Done. The installer uploads, TestFlight processes it, and the submission records the build number Apple holds. |
| App Review | Sent by the run when you ask for review, or by the publish act on an uploaded build. The version holding the build is created when none does. |
| Release | Its own approval. A version Apple approved for manual release waits as `approved` until you release it from the submission or in App Store Connect. |

Safari produces no launcher. [Executables](/publish/executables) covers
Chrome, Edge and Firefox only.

### Signing assets

The App Store Connect API authenticates with an ES256 JWT signed from a
`.p8` key. Signing the package needs two certificates and two provisioning
profiles on top of that key.

**Mint signing assets** on the Safari card does that work with the saved
key: it registers the app and extension bundle IDs with your team (an ID
that already exists is kept), mints both certificates from signing requests
the platform keeps, creates both Mac App Store profiles, and writes the six
secrets to the project. The card then reads **Minted** with the expiry date.
Nothing is ever revoked for you; a certificate you want gone, you revoke in
your developer account.

The six manual fields remain for assets you export yourself. Export both
`.p12` files in the legacy PKCS12 format. Keychain Access exports that
format by default. A `.p12` written by OpenSSL 3 with its own defaults is
refused when the runner imports it.

### The app record

Apple's API cannot create an app. The Safari card tells you to create it once
in App Store Connect with the bundle ID you saved, a name within Apple's 30
characters (App Store names are global and may be taken), the primary
language and the SKU. Connecting the store then finds the record by bundle
ID and stores its numeric id and name, which the card shows as **App Store
record**. The App Privacy answers are the other step that stays yours in App
Store Connect.

### The listing

The listing sync reads your `STORE.md` and writes App Store Connect. The
shared `## Listing` section gives the name, description, URLs and category.
The `## Safari` section adds what Apple alone asks for:

| Line or heading under `## Safari` | What it feeds |
| --- | --- |
| `- App Store name:` | The name within Apple's 30 characters that the preflight's `safari.name` check reads; a `## Listing` name over 30 characters leaves the record's own name in place |
| `- Release:` | `manual` (the default when absent) or `after approval`; see [Release type](#release-type) |
| `- Review contact name:`, `- Review contact email:`, `- Review contact phone:` | The App Review contact; Apple requires a phone number before review |
| `### Reviewer notes` | The notes App Review reads |
| `### Release notes` | What's new for the version |
| `### Age rating` | Bullets naming an attribute with `NONE`, `INFREQUENT_OR_MILD`, `FREQUENT_OR_INTENSE`, `yes` or `no` |
| `- Uses non-exempt encryption: yes` | Leaves the export compliance key out of the app, so Apple asks you |

The sync writes text first, then the category, the review details, the age
rating and the content rights declaration, then the screenshots, the free
price and worldwide availability. A field Apple refuses is recorded with
Apple's words and the rest still lands; only a run that wrote nothing
reports failed. The preflight reads that record, so a listing check passes
only after a sync that reached App Store Connect.

### Versions

Two version values go with an upload, and the platform derives both:

| Value | Meaning |
| --- | --- |
| Short version | `CFBundleShortVersionString`, taken from the build's manifest version |
| Bundle version | `CFBundleVersion`, the higher of the number the platform reserved and one above the highest build App Store Connect already holds for the app |

The submission records the bundle version that was uploaded, never the
reservation, so the TestFlight row and the platform record name the same
build. Apple stops at 65535; a run that would pass it uploads nothing.

### Review

A Safari submission stops at a TestFlight draft by default. The store
carries three submission modes:

| Mode | What a submit does |
| --- | --- |
| `draft` | Uploads to App Store Connect and stops at the TestFlight build. The default. |
| `review` | Uploads and sends the App Store version holding that build to App Review under the same approval. Turning it on is an audience change a workspace owner approves. |
| `dry-run` | Sends nothing to Apple. |

A single run can also ask for review with `submitForReview: true` on the
submission call. Sending `submitForReview` to any other store is refused with
`400 REVIEW_INTENT_NOT_SUPPORTED`. Asking for review makes the readiness
checks binding: an unmet check answers `409 REVIEW_READINESS_UNMET` and
names every check that failed, and nothing is dispatched.

An uploaded build can be sent to review later with the publish act on the
submission. Apple takes one review submission per app at a time, so while a
version is in review the act is locked, and the approval sentence says which
version holds the queue. Removing a version from review is the cancel act;
Apple records it as `DEVELOPER_REJECTED`, the build stays on TestFlight, and
the version can take a new build.

Apple review cannot be withdrawn by the platform once Apple answers. That is
why the review intent is stated per run or by a saved mode an owner
approved, never inferred.

### Release type {#release-type}

App Store Connect gives a version a release type: `MANUAL` holds an approved
version until you release it, `AFTER_APPROVAL` puts it on sale the moment
Apple approves, and an approval cannot be taken back from here.

The platform defaults to manual release. A version the platform creates is
`MANUAL`. Before a review submission, a version that reads `AFTER_APPROVAL`
is set to `MANUAL` unless the synced listing carries `- Release: after
approval` from `STORE.md`; the publish record names the change, or Apple's
refusal of it in Apple's words. The readiness check
`store.safari_release_type` warns, and never blocks, when the version Apple
holds would publish on approval and `STORE.md` does not ask for that. The
preflight, the approval sentence and `extension_release_status` all name the
type Apple last answered and what approval does to it.

### Release

A version Apple approved under manual release reads `approved` on the
[Releases page](/builds/store-releases) with Apple's word
`PENDING_DEVELOPER_RELEASE`. The release act on the submission puts it on
sale; it is its own approval, and the sentence says the version cannot be
pulled back by the platform afterward. A version set to release after
approval reads `live` as soon as Apple approves.

### Watching

The submission states are the eight on
[Publishing](/publish/overview#watching). The store release row for the
version moves `uploaded`, `in-review`, `approved` and `live`, or `rejected`
and `withdrawn`, as [Store releases](/builds/store-releases) describes.

## Reference

### Credentials

| Value | Secret | Notes |
| --- | --- | --- |
| App Store Connect key ID | `STORE_SAFARI_ASC_KEY_ID` | Identifies the `.p8` key |
| Issuer ID | `STORE_SAFARI_ASC_ISSUER_ID` | The API issuer from App Store Connect |
| Key, base64 | `STORE_SAFARI_ASC_KEY_BASE64` | The `.p8` key content |
| App certificate `.p12` | `STORE_SAFARI_APP_CERT_P12_BASE64` | Signs the wrapper app, base64 |
| App certificate password | `STORE_SAFARI_APP_CERT_PASSWORD` | Unlocks that `.p12` |
| Installer certificate `.p12` | `STORE_SAFARI_INSTALLER_CERT_P12_BASE64` | Signs the `.pkg`, base64 |
| Installer certificate password | `STORE_SAFARI_INSTALLER_CERT_PASSWORD` | Unlocks that `.p12` |
| Provisioning profile | `STORE_SAFARI_PROVISIONING_PROFILE_BASE64` | For the wrapper app, base64 |
| Appex provisioning profile | `STORE_SAFARI_APPEX_PROVISIONING_PROFILE_BASE64` | For the extension appex, base64 |

The first three are the key you paste. The mint writes the other six. The
app's numeric Apple id resolves from the bundle ID when it is not given.

### Acts

| Act | Where | Approval |
| --- | --- | --- |
| Mint signing assets | The Safari card in the console | Owner, on the card |
| Sync listing | `extension_submit` `sync-listing`, or the listing route | Owner, per store change |
| Submit | The submissions route, the console, `extension_submit` | Owner, per build; in `review` mode the same approval carries the send to App Review |
| Publish (send to App Review) | The publish route on an uploaded submission | Owner; the sentence names the release type and the hold |
| Cancel (remove from review) | The cancel route on the submission | Owner |
| Release | The release route on an approved submission | Owner; irreversible once Apple releases |

### Readiness checks

| Check | What it does |
| --- | --- |
| `store.safari_app_record` | Warns when no App Store id is saved |
| `store.safari_app_record_resolved` | Blocks when no app record matches the bundle ID |
| `store.safari_bundle_identifiers` | Blocks when the bundle ID is not a valid reverse-DNS identifier |
| `store.safari_signing_assets` | Blocks when the certificates or profiles are missing or expired; warns inside thirty days of expiry |
| `store.safari_app_review_lock` | Locks the publish act while a version of the app is in App Review |
| `store.safari_build_number_floor` | Blocks when the next bundle version would pass Apple's ceiling |
| `store.safari_release_type` | Warns when approval would publish the version and `STORE.md` does not ask for that; never blocks |
| `listing.safari_*` | Description, category, support URL, screenshots and their dimensions, and the App Store declarations, read from the synced record |

### Refusals

| Code | Status | Meaning |
| --- | --- | --- |
| `MACOS_RUNNER_NOT_INCLUDED` | `403` | A Safari build or submission on the free plan |
| `SAFARI_SUBMISSIONS_EXHAUSTED` | `403` | The sixteenth App Store submission of the month |
| `REVIEW_INTENT_NOT_SUPPORTED` | `400` | `submitForReview` sent to a store other than Safari |
| `REVIEW_READINESS_UNMET` | `409` | A run asked for review with a readiness check unmet |
| `SAFARI_REVIEW_MODE_REQUIRED` | `409` | A version approval tried to carry the publish line while the store is in `draft` mode |
| `RELEASE_STORE_NOT_SUPPORTED` | `400` | A release act on a store other than Safari |
| `SAFARI_BUILD_NUMBER_INVALID` | `400` | A release named a build number that is not a whole number above zero |
| `SAFARI_NOT_PENDING_RELEASE` | `409` | A release act while Apple holds no approved version for the developer to release |
| `SAFARI_RELEASE_NOT_MANUAL` | `409` | A release act on a version Apple releases itself |
| `RELEASE_IN_FLIGHT` | `409` | A release act while an earlier release run is still answering |

### What stays manual

- Creating the app record and answering App Privacy in App Store Connect.
- Release notes on an app's first version: Apple takes them only for an
  update, so the sync records them as owed until the next version.
- An App Store name over 30 characters is left as the record has it; add
  `- App Store name:` under `## Safari`.
- Age rating questions the platform does not know, and a category that maps
  to no macOS category, are named in the sync record for you to answer in
  App Store Connect.
- The console has no switch for the `review` submission mode yet; set it
  from an agent with `extension_submit` and `configure-store`, which asks a
  workspace owner to approve the audience change.

## When it fails

- **The Safari row in build settings will not turn on.** The workspace is on
  the free plan. Raise the plan at
  [extension.dev/pricing](https://www.extension.dev/pricing).
- **The submission cannot find its artifact.** The build that commit came
  from did not include Safari. Enable Safari in **Project settings, Build**
  and build the commit again. A free workspace that enables it anyway has
  the Safari leg dropped from the dispatch, and the other browsers still
  build.
- **The listing sync says no app record exists.** Create the app in App
  Store Connect with the bundle ID you saved, connect the store on the card
  again, and sync again.
- **The listing sync records fields as owed.** Read Apple's words beside
  each field in the preflight. A missing review contact phone, a name over
  30 characters and first-version release notes are the usual three.
- **The upload is refused as unsigned or badly signed.** Run **Mint signing
  assets** again, or check that both `.p12` files are in the legacy format.
- **The run asked for review and stopped at a draft.** The run fails instead
  of recording a review round that did not happen. Read the step log on the
  submission.
- **The publish act is locked.** A version of this app is in App Review.
  Wait for Apple, or remove it from review with the cancel act.
- **The preflight warns that approval publishes the version.** App Store
  Connect holds the version as `AFTER_APPROVAL`. The review act sets it to
  manual release; to publish on approval by choice, add
  `- Release: after approval` under `## Safari` and sync the listing.
- **Apple approved and nothing is on the App Store.** The version is held for
  manual release and reads `approved`. Release it from the submission, or in
  App Store Connect.
- **ES256 authentication fails.** Key ID, issuer ID and the base64 key must
  all come from the same App Store Connect key. Connecting the store on the
  card names which call failed, the key or the record.

Submission outcomes are subject to App Store review and policy.

## Next

- [Store releases](/builds/store-releases) for the row a submission advances
- [Publishing overview](/publish/overview) for the three ways in
- [Executables](/publish/executables) to ship a launcher outside the App Store
