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.
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.jsonormanifest.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.
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.
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.
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.
Related
- Building a GitHub Actions pipeline for extensions — the workflow file, step by step.
- Versioning and changelogs for extension releases — store-legal numbers and release notes.
- Loading an unpacked extension in Playwright — the suite the gate runs.
- Passing Chrome Web Store review — what the audit step is protecting you from.
- Testing, Debugging & Performance Optimization — the section this guide belongs to.