Publishing to Firefox Add-ons and Safari

Ship an MV3 extension beyond Chrome: the Firefox extension id and signing flow, AMO source review, converting for Safari with xcrun, and the manifest differences each store requires.

Published July 25, 2026 Updated July 25, 2026 9 min read
Table of Contents

The same source tree reaches three stores through three genuinely different processes. Firefox needs an extension id in the manifest that Chrome rejects, and may ask for the sources that produced your bundle. Safari does not take a zip at all — it takes an app, built by Xcode, signed with an Apple certificate. This page is part of Store Submission & Permissions Compliance and covers what each store needs that Chrome does not.

Root cause: three packaging models, not three upload buttons

Chrome and Firefox both consume a zip of your built directory, but they disagree about the manifest and about signing. Safari consumes a macOS or iOS app that embeds the extension, which means a different artifact, a different toolchain and a different review queue. Treating all three as one pipeline stage is what produces a release day with two stores done and one blocked on a certificate.

What each store consumes and requiresArtifact, manifest requirements, signing, source expectations and typical turnaround for the three stores.RequirementChrome Web StoreFirefox AMOApp StoreArtifactzipzipApp bundleExtension id in manifestRejectedRequiredXcode projectSigningStore-sideAMO signs the zipApple certificateSource reviewNot requestedIf minifiedProject submittedBuild machineAnyAnymacOSTypical turnaroundHours to daysDaysDays
The first two columns share a pipeline; the third shares almost nothing with them.

Step 1. Add the Firefox-only manifest keys

Firefox needs a stable extension id to sign against and to key the user’s stored data. Chrome rejects the key that carries it, which is why the per-target manifest patch exists.

 1// manifest.firefox.json — merged over the shared base at build time
 2{
 3  "browser_specific_settings": {
 4    "gecko": {
 5      "id": "review-queue@example.com",     // stable forever; changing it orphans user data
 6      "strict_min_version": "121.0"          // the oldest release you actually test against
 7    }
 8  },
 9  // Firefox accepts an event page as well as a service worker. Declaring the worker form keeps
10  // one code path, at the cost of requiring a recent Firefox.
11  "background": {
12    "service_worker": "background.js",
13    "type": "module"
14  }
15}

Execution context: Merged into the built manifest by the build script, never present in the Chrome output. The id is permanent: changing it produces a different add-on as far as Firefox is concerned, and every user’s stored settings are stranded under the old one. strict_min_version should reflect what you test, not what you hope — a lower value ships to users on releases where your service worker may not behave as expected.

Step 2. Upload and sign through the AMO API

AMO signs the package and returns a signed file, so the upload is a two-stage exchange rather than a single PUT. The credentials are a JWT signed from an issuer and a secret.

 1#!/usr/bin/env bash
 2# scripts/upload-amo.sh — expects AMO_JWT_ISSUER, AMO_JWT_SECRET, AMO_ADDON_ID, VERSION
 3set -euo pipefail
 4
 5# web-ext handles the JWT and the polling for the signed result.
 6npx web-ext sign \
 7  --source-dir "dist/firefox" \
 8  --artifacts-dir "dist/artifacts" \
 9  --api-key "${AMO_JWT_ISSUER}" \
10  --api-secret "${AMO_JWT_SECRET}" \
11  --channel listed \
12  --no-input
13
14echo "signed artifact:"
15ls -1 dist/artifacts/*.xpi

Execution context: Bash on any Linux or macOS runner. --channel listed submits to the public listing; unlisted produces a signed file for self-distribution and skips full review, which is useful for enterprise builds. web-ext polls until signing completes, so the step can take minutes — budget for it rather than treating it as an upload. If the version already exists AMO rejects it, exactly as Chrome does.

Step 3. Prepare for a source-code request

AMO reviewers may ask for the sources that produced a minified bundle. Answering that quickly is a matter of having a reproducible build, not of assembling one under time pressure.

 1README-REVIEWERS.md — shipped in the source archive
 2
 3Build environment
 4  Node 20.11.0, npm 10.x, on Linux or macOS.
 5
 6To reproduce dist/firefox from this archive:
 7  npm ci
 8  node scripts/build.mjs --target=firefox
 9
10Output
11  dist/firefox/ matches the uploaded package byte for byte apart from the timestamp
12  entries in the zip container.
13
14Notes
15  - No remote code is fetched at build time or at runtime.
16  - Source maps are emitted alongside the bundle and are included in this archive.

Execution context: A file in the source archive uploaded on request. Two commands and a pinned Node version is the whole requirement — a build that needs a private registry, an undocumented environment variable or a specific machine is the case that turns a routine request into a stalled review. The byte-for-byte claim is worth verifying yourself before making it.

The three release trainsChrome and Firefox build from the same job on a Linux runner; Safari needs a conversion and an Xcode build on macOS.Shared sourceone repositoryLinux runnerchrome + firefoxTwo zipsuploaded per storeSafari branches off before the artifact stagemacOS runnerxcrun conversionXcode buildsigned app bundleApp Store Connectits own review queue
Two trains share a runner and a schedule; the third has its own, and pretending otherwise blocks releases.

Step 4. Convert and build for Safari

Safari extensions are distributed inside an app. Apple’s converter turns a built web extension directory into an Xcode project, which you then build and sign like any other app.

The Safari release train, end to endBuild the web extension, convert it to an Xcode project, build and sign the app, upload to App Store Connect, then wait for a review queue independent of the other stores.tag pushedavailable to usersBuild we…dist/safa…xcrun co…generates…xcodebuild archivesigned app bundleUploadApp Store…Review queuedays, independentread the unsupported-key warnings hereChrome has usually shipped by now
The conversion and signing steps are why Safari cannot share a release schedule with the other two.
 1#!/usr/bin/env bash
 2# scripts/convert-safari.sh — macOS only, requires Xcode command line tools
 3set -euo pipefail
 4
 5xcrun safari-web-extension-converter "dist/safari" \
 6  --project-location "build/safari" \
 7  --app-name "Review Queue" \
 8  --bundle-identifier "com.example.reviewqueue" \
 9  --macos-only \
10  --no-open \
11  --force            # regenerate over the previous conversion
12
13xcodebuild -project "build/safari/Review Queue/Review Queue.xcodeproj" \
14  -scheme "Review Queue" \
15  -configuration Release \
16  archive -archivePath "build/safari/ReviewQueue.xcarchive"

Execution context: macOS runner with Xcode installed. --force matters in CI because the converter refuses to overwrite an existing project, which turns the second run of a workflow into a failure. The converter reports which manifest keys Safari does not support — read that output rather than discarding it, because it is the most direct list of what will silently not work: offscreen documents, the side panel and tab groups are the usual absentees.

Step 5. Keep one version number across all three

Each store consumes versions independently, but users and issue reports do not distinguish them. Derive all three from the same tag so a reported version is traceable to one commit.

 1// scripts/versions.mjs
 2import { storeVersion } from "./store-version.mjs";
 3
 4export function versionsFor(tag) {
 5  const base = storeVersion(tag);         // e.g. 4.3.0
 6  return {
 7    chrome: base,
 8    firefox: base,
 9    // Apple keeps a marketing version and a monotonic build number.
10    safariMarketing: base,
11    safariBuild: base.split(".").map(Number).reduce((acc, n) => acc * 100 + n, 0),
12  };
13}

Execution context: Node on the build machine. Folding the components into a single integer gives Apple the strictly-increasing build number it requires while remaining derivable from the tag, so a Safari build number can always be mapped back to a release. Keeping the marketing version identical to the other two is what makes a user’s “I’m on 4.3.0” useful regardless of which store they installed from.

Because several APIs are simply absent under Safari, the build for that target should also disable the features that depend on them rather than shipping code that fails at runtime:

1// features.js — one place that decides what this build offers
2export const FEATURES = {
3  offscreenAudio: typeof chrome !== "undefined" && Boolean(chrome.offscreen),
4  sidePanel: typeof chrome !== "undefined" && Boolean(chrome.sidePanel),
5  tabGroups: typeof chrome !== "undefined" && Boolean(chrome.tabGroups),
6};

Execution context: Shared module imported by the background context and by every surface that renders a control for one of these features. Resolving capabilities once and reading them everywhere means a Safari build hides the side-panel button instead of showing one that throws — which matters at review as much as at runtime, because a visibly broken control is a rejection reason in its own right.

Cross-browser variation

  • Firefox AMO: requires browser_specific_settings.gecko.id, signs the package itself, and may request build sources. Unlisted signing is available for self-distribution.
  • Safari / App Store: requires conversion with safari-web-extension-converter, an Apple developer certificate and a macOS build machine. Several MV3 APIs are unsupported and the converter says which.
  • Chrome Web Store: rejects the Gecko id key, signs server-side, and does not request sources.
  • All three: version numbers are consumed on upload and cannot be reused, so a failed submission costs a number in that store only.

Verification

  1. Build the Firefox target and confirm the Gecko id is present, then build the Chrome target and confirm it is absent.
  2. Sign an unlisted build through web-ext sign and confirm a signed file is produced without going through full review.
  3. From a clean checkout, follow the reviewer instructions verbatim and diff the result against the uploaded package.
  4. Run the Safari conversion twice in a row and confirm the second run succeeds — that is what --force is protecting.

FAQ

Can I use one manifest for all three stores?

No. Firefox requires a key Chrome rejects, and Safari needs its own project metadata. A shared base with per-target patches is the smallest arrangement that works.

What happens if I change the Firefox extension id?

Firefox treats it as a different add-on. Existing users keep the old one, their stored data stays with it, and updates from the new id never reach them. Choose it once.

Do I need a Mac to ship to Safari?

Yes. The converter and Xcode are macOS-only, so the Safari job needs a macOS runner or a local machine. That is the practical reason it belongs in a separate release train.

Should all three stores release on the same day?

They rarely can — the review queues are independent and Safari’s is usually the slowest. Decouple them and let each ship when it clears review.

Other MV3 Architecture & Extension Lifecycle Resources