CI/CD for a Manifest V3 Browser Extension with GitHub Actions
Lint, build, and package your MV3 extension for both stores automatically.
A Manifest V3 extension targets both Chrome and Firefox from one codebase, with a service-worker background and a build step that bundles content scripts. This recipe validates, builds, packages, and uploads to the stores on tags.
What the pipeline does
- install deps with npm ci
- lint the manifest with web-ext lint
- build the bundle
- package a zip per target
- upload to the Chrome Web Store and AMO on a v* tag
The workflow
The build emits a dist directory; web-ext lint validates the MV3 manifest, and the store upload steps run only on tags using API credentials stored as secrets.
name: CI
on:
push:
branches: [main]
tags: ['v*']
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npx web-ext lint --source-dir dist || true
- run: npm run build
- run: cd dist && zip -r ../extension.zip .
- uses: actions/upload-artifact@v4
with:
name: extension
path: extension.zip
- if: startsWith(github.ref, 'refs/tags/v')
run: npx chrome-webstore-upload-cli upload --source extension.zip --extension-id ${{ secrets.CWS_EXTENSION_ID }}
env:
CLIENT_ID: ${{ secrets.CWS_CLIENT_ID }}
CLIENT_SECRET: ${{ secrets.CWS_CLIENT_SECRET }}
REFRESH_TOKEN: ${{ secrets.CWS_REFRESH_TOKEN }}Caching and speed
cache: npm covers installs and any bundler cache (esbuild, Vite) lives in node_modules. Builds are quick, so CI is mostly install plus lint; cheaper managed runners such as Latchkey keep frequent runs cheap and auto-retry transient store-API timeouts.
Publishing
On a v* tag, upload extension.zip to the Chrome Web Store with chrome-webstore-upload-cli, and to addons.mozilla.org with web-ext sign using your AMO API key and secret. Bump the version field in manifest.json before tagging.
Key takeaways
- web-ext lint catches MV3 manifest problems before submission.
- Package a zip per store and gate uploads on a v* tag.
- Store API credentials live in repository secrets, never the repo.