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.
Table of Contents
- Root cause: three packaging models, not three upload buttons
- Step 1. Add the Firefox-only manifest keys
- Step 2. Upload and sign through the AMO API
- Step 3. Prepare for a source-code request
- Step 4. Convert and build for Safari
- Step 5. Keep one version number across all three
- Cross-browser variation
- Verification
- FAQ
- Related
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.
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.
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.
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
- Build the Firefox target and confirm the Gecko id is present, then build the Chrome target and confirm it is absent.
- Sign an unlisted build through
web-ext signand confirm a signed file is produced without going through full review. - From a clean checkout, follow the reviewer instructions verbatim and diff the result against the uploaded package.
- Run the Safari conversion twice in a row and confirm the second run succeeds — that is what
--forceis 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.
Related
- Writing a permission justification that passes — the text every store wants.
- Versioning and changelogs for extension releases — deriving all three version numbers.
- CI & Release Automation — the pipeline these steps slot into.
- Store Submission & Permissions Compliance — the guide this page belongs to.