CI & Release Automation

Automate MV3 extension releases: reproducible builds, per-browser packaging, version bumps, signed uploads to the Chrome Web Store and Firefox AMO, and staged rollouts from CI.

Extension releases fail in ways web deploys do not: the artifact is a zip whose contents are reviewed by a human, the version number is immutable once uploaded, and three stores want three different packages built from one source tree. Doing that by hand works until the first hotfix at the wrong moment. This guide is part of Testing, Debugging & Performance Optimization and covers a pipeline that builds each target reproducibly, gates on the end-to-end suite, and uploads without a person holding a credential.

One commit through to three storesA tagged commit is built once per target, gated on tests and package checks, then uploaded to each store with its own credentials.Tagged commitv4.2.0Build per targetchrome, firefox, safariGateunit + end-to-end + package checksonly a green gate reaches an upload stepChrome Web StoreOAuth refresh tokenFirefox AMOJWT signingSafariXcode archive, macOS runner
The gate sits before the upload because a store version number cannot be reused once it has been consumed.

Prerequisites checklist

  • A build that takes the target as input — one command per browser, producing an unpacked directory plus a zip. Per-target builds are what let you drop the polyfill from the Chrome bundle.
  • A single source of truth for the version. Either package.json or manifest.json, with the other generated from it; two hand-maintained numbers will diverge.
  • Store credentials in CI secrets, never in the repo. Chrome needs a client id, client secret and refresh token; AMO needs an issuer and secret.
  • A green end-to-end suite running against the built artifact, not the source tree.
  • A permission audit step. The review rejects unused permissions, and CI can catch them before a human does.

Manifest registration

The manifest is a build output, not a hand-edited file, once you support more than one engine. Keep a base document and apply a per-target patch:

 1// manifest.base.jsonc — the shared document, with per-target keys deliberately absent
 2{
 3  "manifest_version": 3,
 4  "name": "Review Queue",
 5  "version": "0.0.0",          // replaced at build time from the single source of truth
 6  "permissions": ["storage", "alarms", "notifications"],
 7  "background": { "service_worker": "background.js", "type": "module" },
 8  "action": { "default_icon": { "16": "icons/16.png", "128": "icons/128.png" } }
 9  // chrome-only keys (offscreen, side_panel) and firefox-only keys
10  // (browser_specific_settings) are merged in per target.
11}

Execution context: Consumed by the build script in Node, never shipped as-is. Keeping version as a placeholder means a stale committed version can never reach a store — the build fails if the substitution does not happen. Firefox requires browser_specific_settings.gecko.id for signed uploads, and Chrome rejects that key, which is the clearest single reason the per-target patch exists.

1. Building each target from one source tree

Two switches carry most of the value: which manifest patch to apply, and whether to include the namespace polyfill. Chrome does not need the polyfill and paying for it costs cold-start time on every event.

 1// scripts/build.mjs — node scripts/build.mjs --target=chrome
 2import { readFile, writeFile, mkdir, rm } from 'node:fs/promises';
 3import { join } from 'node:path';
 4
 5// Strip // comments so the base document can stay annotated in the repo.
 6const stripComments = (s) => s.replace(/^\s*\/\/.*$/gm, '');
 7
 8const target = process.argv.find((a) => a.startsWith('--target='))?.split('=')[1];
 9if (!['chrome', 'firefox', 'safari'].includes(target)) throw new Error(`bad target: ${target}`);
10
11const pkg = JSON.parse(await readFile('package.json', 'utf8'));
12const base = JSON.parse(stripComments(await readFile('manifest.base.jsonc', 'utf8')));
13const patch = JSON.parse(await readFile(`manifest.${target}.json`, 'utf8'));
14
15const manifest = { ...base, ...patch, version: pkg.version };
16if (manifest.version === '0.0.0') throw new Error('version substitution did not happen');
17
18const outDir = join('dist', target);
19await rm(outDir, { recursive: true, force: true });
20await mkdir(outDir, { recursive: true });
21await writeFile(join(outDir, 'manifest.json'), JSON.stringify(manifest, null, 2));
22
23// The bundler receives the target so it can drop the polyfill for chrome.
24await bundle({ outDir, polyfill: target !== 'chrome', sourcemap: 'external' });

Execution context: Node on the CI runner, not in the browser. The explicit 0.0.0 assertion is the cheapest possible guard against shipping an unbumped version, and it fires in CI rather than at the store. sourcemap: 'external' keeps maps out of the shipped bundle while still letting you upload them for AMO source review.

What differs per target in one buildManifest patch, polyfill, package format and signing requirements for Chrome, Firefox and Safari builds.Build aspectChromeFirefoxSafariNamespace polyfillOmittedIncludedIncludedExtension id in manifestRejectedRequiredXcode projectArtifactzipzipApp bundleSigningStore-sideAMO signsApple certificateCI runnerubuntuubuntumacosSource upload expectedNoIf minifiedProject
Only the Safari column needs a different runner — the other two build anywhere Node runs.

2. Gating the release on checks that catch store rejections

A CI gate is worth having only if it catches the things that actually block a release. For extensions those are: a permission nobody uses, a bundle that contains eval, a manifest that has drifted from the code, and a version that has already been consumed.

 1// scripts/audit.mjs — run against dist/<target>, not src
 2import { readFile } from 'node:fs/promises';
 3import { globby } from 'globby';
 4
 5const dir = process.argv[2];
 6const manifest = JSON.parse(await readFile(`${dir}/manifest.json`, 'utf8'));
 7const sources = await globby([`${dir}/**/*.js`]);
 8const code = (await Promise.all(sources.map((f) => readFile(f, 'utf8')))).join('\n');
 9
10const USAGE = {
11  storage: /chrome\.storage|browser\.storage/,
12  alarms: /chrome\.alarms|browser\.alarms/,
13  notifications: /chrome\.notifications|browser\.notifications/,
14  offscreen: /chrome\.offscreen/,
15  scripting: /chrome\.scripting|browser\.scripting/,
16  tabs: /chrome\.tabs|browser\.tabs/,
17};
18
19const problems = [];
20
21for (const permission of manifest.permissions ?? []) {
22  const probe = USAGE[permission];
23  if (probe && !probe.test(code)) problems.push(`permission "${permission}" is declared but never used`);
24}
25
26if (/\beval\s*\(|new\s+Function\s*\(/.test(code)) problems.push('bundle contains eval or new Function');
27if (/https?:\/\/[^"']+\.js["']/.test(code)) problems.push('bundle references a remote script URL');
28
29if (problems.length) {
30  console.error(problems.map((p) => `✗ ${p}`).join('\n'));
31  process.exit(1);
32}
33console.log('✓ package audit clean');

Execution context: Node on the CI runner, reading the built output. Auditing dist rather than src is the point — a permission removed from the code but left in the manifest survives a source-level grep of the diff, and a bundler plugin can reintroduce eval in a way no source file shows. The regex list is deliberately conservative: it reports candidates rather than proving absence, so treat a failure as a prompt to look, not as a verdict.

Where each check belongs in the pipelineFast static checks run first, then the browser suite, then packaging and audit, and only then the upload.pushstore uploadLint + ty…secondsUnit testsmocked chr…Build per targetmanifest + bundleEnd-to-end suitereal browserPackage a…permission…Uploadonly on a t…most failures should stop herethe version is consumed at this point
Order by cost: the checks that take seconds should reject a bad commit before the ones that take minutes start.

3. Uploading without a human holding the credential

Both major stores have an upload API that works from CI. Chrome uses an OAuth refresh token exchanged for a short-lived access token; AMO uses a JWT signed from an issuer and secret. The important operational detail is that upload and publish are separate steps, which lets you upload on every tag and publish deliberately.

 1#!/usr/bin/env bash
 2# scripts/upload-chrome.sh — expects CWS_CLIENT_ID, CWS_CLIENT_SECRET, CWS_REFRESH_TOKEN, CWS_APP_ID
 3set -euo pipefail
 4
 5ACCESS_TOKEN="$(curl -sf -X POST 'https://oauth2.googleapis.com/token' \
 6  -d "client_id=${CWS_CLIENT_ID}" \
 7  -d "client_secret=${CWS_CLIENT_SECRET}" \
 8  -d "refresh_token=${CWS_REFRESH_TOKEN}" \
 9  -d 'grant_type=refresh_token' | jq -r '.access_token')"
10
11# Upload the new package as a draft. A version already consumed fails here, loudly.
12curl -sf -X PUT \
13  -H "Authorization: Bearer ${ACCESS_TOKEN}" \
14  -H 'x-goog-api-version: 2' \
15  -T 'dist/chrome.zip' \
16  "https://www.googleapis.com/upload/chromewebstore/v1.1/items/${CWS_APP_ID}" | jq '.uploadState'
17
18# Publish separately, so a tag can be built and uploaded without going live.
19if [[ "${PUBLISH:-false}" == "true" ]]; then
20  curl -sf -X POST \
21    -H "Authorization: Bearer ${ACCESS_TOKEN}" \
22    -H 'x-goog-api-version: 2' \
23    -H 'Content-Length: 0' \
24    "https://www.googleapis.com/chromewebstore/v1.1/items/${CWS_APP_ID}/publish?publishTarget=default" \
25    | jq '.status'
26fi

Execution context: Bash on the CI runner. set -euo pipefail matters more than usual here: without it a failed token exchange yields an empty bearer token and the upload fails in a way that is easy to misread as a packaging problem. Separating publish behind an environment flag is what makes a re-run safe — uploading the same version twice fails cleanly, whereas publishing twice can start a rollout you did not intend.

4. Versioning that a store will accept

Store version numbers are dot-separated integers, strictly increasing, and permanently consumed on upload. That rules out pre-release suffixes and re-uploads, so the pipeline needs to derive a store-legal version from whatever your repository uses.

 1// scripts/store-version.mjs — turn a semver tag into a store-legal version
 2export function storeVersion(tag) {
 3  // v4.2.0            -> 4.2.0
 4  // v4.2.0-rc.3       -> 4.2.0.3   (pre-release becomes a fourth component)
 5  const m = /^v?(\d+)\.(\d+)\.(\d+)(?:-[a-z]+\.(\d+))?$/i.exec(tag);
 6  if (!m) throw new Error(`tag is not store-legal: ${tag}`);
 7  const [, major, minor, patch, pre] = m;
 8  const parts = [major, minor, patch, pre].filter(Boolean).map(Number);
 9  if (parts.some((n) => n > 65535)) throw new Error('each component must be 0–65535');
10  return parts.join('.');
11}

Execution context: Node on the CI runner, called before the manifest is written. The 65535 ceiling is a real store constraint that date-based schemes hit quickly — 20260725 as a single component is rejected. Mapping a pre-release counter to a fourth component keeps release-candidate builds uploadable while leaving the three-component number free for the actual release.

5. Designing the pipeline around what can go wrong

A release pipeline for an extension has an unusual failure profile. The build rarely breaks; what breaks is everything around it — a version number the store refuses, a permission added without a justification, a credential that expired, a package uploaded to the wrong channel, a rollout that went to every user at once. Designing the pipeline around those failures, rather than around the build, is what makes it trustworthy.

A robust shape has four stages. Build once per target from a tagged commit, and keep the artifacts: every later stage uses exactly those bytes. Verify the artifact, not the source — lint the packaged manifest, run end-to-end tests against the built extension, diff the permission list and network hosts against the previous release, and check size budgets. Upload behind an approval: a named person confirms before any store sees the package, and the upload steps are idempotent so a retried job does not fail on an already-used version. Roll out gradually where the store allows it, with error rates for the new version watched before the percentage increases.

1jobs:
2  build:     { steps: [checkout, "npm ci", "npm run build:all", upload-artifacts] }
3  verify:    { needs: build, steps: [download-artifacts, lint, e2e, permission-diff, budgets] }
4  publish:   { needs: verify, environment: stores, steps: [download-artifacts, cws-upload, amo-sign] }

Execution context: a CI workflow, sketched. The environment on the publish job carries the required-approval rule and the store credentials, so neither is available to the build or verify jobs — the arrangement described in managing store credentials in CI secrets.

The permission diff deserves special mention. A release that adds a permission can disable the extension for existing users until they accept it, and it lengthens review. Failing the pipeline when the permission list changes — unless the release is explicitly marked as intending it — turns an easy-to-miss manifest edit into a deliberate decision. Store-specific upload details are in automating Chrome Web Store uploads with the API and signing and publishing to AMO from CI.

A pipeline shaped this way also changes how releases feel for the team. Because the dangerous steps are gated and the risky changes are surfaced automatically, releasing becomes routine rather than an event that needs the one person who remembers how it works. That matters more for extensions than for most software: store review adds days of latency to any fix, so the cost of a bad release is measured in days of broken installs, and the value of a boring, repeatable process is correspondingly high. Keep the release runbook in the repository next to the workflow, and update both in the same pull request whenever either changes, so the written process never drifts from the automated one.

Measure the pipeline itself occasionally, too. Track how long a release takes from tag to submission, how often the publish stage is retried, and how often a release needed a follow-up hotfix. Those three numbers say more about release health than any single green run, and they show clearly when a new check is catching real problems and when it is only adding minutes. A pipeline that is fast, rarely retried and rarely followed by a hotfix is one the team will actually use for every release, which is the whole point of having it.

Finally, rehearse the unhappy paths deliberately once a quarter: revoke a test credential and confirm the pipeline fails cleanly at the first step, upload an already-used version and confirm the idempotence check skips it, and add a permission on a branch to confirm the diff check stops the release. A safeguard that has never been seen to trigger is one nobody can be sure still works.

MV3 constraints to design around

  • A store version number is consumed on upload. Re-running a failed publish is safe; re-uploading a version is not.
  • Each version component is capped at 65535. Date-derived versions overflow it.
  • Firefox needs an extension id in the manifest; Chrome rejects one. This alone forces per-target manifests.
  • Safari builds need a macOS runner and an Apple certificate. Plan for a separate job, not a separate step.
  • AMO may ask for build sources. Keep the build reproducible from a clean checkout, or the request becomes a blocker.
  • Audit the built artifact, not the source tree. Bundlers add and remove things your diff does not show.

Cross-browser notes

Chrome and Firefox upload from any Linux runner and differ only in credentials and packaging metadata. Safari is genuinely different: the artifact is an app bundle produced by Xcode from a converted extension, it needs a macOS runner and Apple signing, and its review cadence is independent — so treat it as its own release train rather than trying to keep all three in lockstep. A small dispatch table keeps the pipeline declarative:

1export const TARGETS = {
2  chrome:  { runner: 'ubuntu-latest', artifact: 'zip', upload: 'scripts/upload-chrome.sh' },
3  firefox: { runner: 'ubuntu-latest', artifact: 'zip', upload: 'scripts/upload-amo.sh' },
4  safari:  { runner: 'macos-latest',  artifact: 'app', upload: 'scripts/upload-apple.sh' },
5};

Execution context: Node, consumed by the workflow generator or read directly in a matrix step. Keeping the runner in the same table as the upload script prevents the classic failure where a Safari job is added to an existing Linux matrix and fails minutes in, on a missing xcodebuild. For the API-level differences see cross-browser API compatibility.

Further guides in this topic

The guides below go deeper into specific ci and release automation problems that the sections above only touch on — each one starts from a concrete symptom and ends with a way to verify the fix.

  • Automating Chrome Web Store Uploads with the API — Publish to the Chrome Web Store from CI — OAuth credentials for the API, uploading a package, submitting for review with a staged rollout percentage, and reading the resulting status.
  • Managing Store Credentials in CI Secrets — Protect the credentials that can publish your extension — Chrome refresh tokens, AMO JWT keys, App Store Connect keys and signing keys — with scoped secrets, approval gates and rotation.
  • Running Extension Tests in Headless CI — Run MV3 extension end-to-end tests on a CI runner — why classic headless mode cannot load extensions, the new headless mode, xvfb fallbacks, caching browsers, and keeping flaky runs visible.
  • Signing and Publishing to AMO from CI — Release a Firefox extension from CI with web-ext sign — AMO API keys, listed versus unlisted channels, submitting source code for minified builds, and handling the asynchronous review.
  • 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.
  • Building Reproducible Release ZIPs — Produce byte-identical extension packages from the same source: pinned toolchains and lockfiles, deterministic bundler output, normalised file order and timestamps in the ZIP, excluding stray files, and verifying builds with checksums.
  • Linting Extensions with web-ext lint in CI — Run Mozilla’s addons-linter through web-ext lint in CI to catch manifest errors, unsafe code patterns and store-policy problems before submission: configuration, output formats, failing on warnings, suppressions, and combining with ESLint.
  • Publishing to Edge Add-ons from CI — Automate Microsoft Edge Add-ons releases with the Partner Center publish API: API credentials, uploading a package, polling operation status, submitting with release notes, handling errors, and adding Edge to a multi-store pipeline.

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.

Building Reproducible Release ZIPs

Produce byte-identical extension packages from the same source: pinned toolchains and lockfiles, deterministic bundler output, normalised file order and timestamps in the ZIP, excluding stray files, and verifying builds with checksums.

Linting Extensions with web-ext lint in CI

Run Mozilla's addons-linter through web-ext lint in CI to catch manifest errors, unsafe code patterns and store-policy problems before submission: configuration, output formats, failing on warnings, suppressions, and combining with ESLint.

Publishing to Edge Add-ons from CI

Automate Microsoft Edge Add-ons releases with the Partner Center publish API: API credentials, uploading a package, polling operation status, submitting with release notes, handling errors, and adding Edge to a multi-store pipeline.

Automating Chrome Web Store Uploads with the API

Publish to the Chrome Web Store from CI — OAuth credentials for the API, uploading a package, submitting for review with a staged rollout percentage, and reading the resulting status.

Managing Store Credentials in CI Secrets

Protect the credentials that can publish your extension — Chrome refresh tokens, AMO JWT keys, App Store Connect keys and signing keys — with scoped secrets, approval gates and rotation.

Running Extension Tests in Headless CI

Run MV3 extension end-to-end tests on a CI runner — why classic headless mode cannot load extensions, the new headless mode, xvfb fallbacks, caching browsers, and keeping flaky runs visible.

Signing and Publishing to AMO from CI

Release a Firefox extension from CI with web-ext sign — AMO API keys, listed versus unlisted channels, submitting source code for minified builds, and handling the asynchronous review.

Building a GitHub Actions Pipeline for Extensions

A working GitHub Actions workflow for MV3 extensions: matrix builds per browser, headless Playwright with a persistent context, artifact upload, and tag-gated store publishing.

Versioning and Changelogs for Extension Releases

Produce store-legal MV3 version numbers from semver tags, handle the 65535 component ceiling, keep manifest and package versions in step, and generate release notes users can read.