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.
Table of Contents
- Root cause: three jobs, not one
- Step 1. Build once per target and upload the result
- Step 2. Run the browser suite against the artifact
- Step 3. Gate publishing on a tag and nothing else
- Step 4. Make failures reproducible locally
- Step 5. Cache what is expensive, not what is cheap
- Cross-browser variation
- Verification
- FAQ
- Related
Extension CI differs from web CI in three ways that break a copied workflow: the browser suite needs a real display and a persistent profile, the build has to run once per target, and the publish step must never fire on an ordinary push because store version numbers cannot be reused. This page is part of CI & Release Automation and gives a workflow that handles all three.
Root cause: three jobs, not one
A single job that builds, tests and publishes is the natural first attempt and the one that hurts. Building per target needs a matrix; the browser suite needs a runner with browsers installed and takes minutes; publishing must be gated on a tag and needs secrets that pull-request builds must never see. Splitting them makes each concern independently cacheable and independently skippable.
Step 1. Build once per target and upload the result
The artifact is what later jobs consume, so nothing is rebuilt after it has been tested — the bytes that pass the suite are the bytes that reach the store.
1# .github/workflows/release.yml
2name: release
3on:
4 push:
5 branches: [main]
6 tags: ["v*"]
7 pull_request:
8
9jobs:
10 build:
11 runs-on: ubuntu-latest
12 strategy:
13 matrix:
14 target: [chrome, firefox]
15 steps:
16 - uses: actions/checkout@v4
17 - uses: actions/setup-node@v4
18 with: { node-version: "20", cache: npm }
19 - run: npm ci
20 - run: node scripts/build.mjs --target=${{ matrix.target }}
21 - run: node scripts/audit.mjs dist/${{ matrix.target }}
22 - uses: actions/upload-artifact@v4
23 with:
24 name: dist-${{ matrix.target }}
25 path: dist/${{ matrix.target }}
26 retention-days: 7
Execution context: GitHub-hosted Ubuntu runner. Running the package audit inside the build job rather than later is deliberate: an unused permission or a stray eval is a build-level defect, and failing here costs seconds instead of the minutes the browser suite takes. The matrix keeps Safari out on purpose — it needs a macOS runner and Apple signing, so it belongs in a separate workflow rather than as a third matrix entry that fails on a missing toolchain.
Step 2. Run the browser suite against the artifact
Playwright needs the browsers installed and, for a persistent context with an extension loaded, a display. The new headless mode removes the need for a virtual framebuffer on Chromium.
1 test:
2 needs: build
3 runs-on: ubuntu-latest
4 steps:
5 - uses: actions/checkout@v4
6 - uses: actions/setup-node@v4
7 with: { node-version: "20", cache: npm }
8 - run: npm ci
9 - uses: actions/download-artifact@v4
10 with: { name: dist-chrome, path: dist/chrome }
11 - run: npx playwright install --with-deps chromium
12 - run: npx playwright test
13 env:
14 EXTENSION_PATH: ${{ github.workspace }}/dist/chrome
15 - uses: actions/upload-artifact@v4
16 if: failure()
17 with:
18 name: playwright-trace
19 path: test-results/
20 retention-days: 7
Execution context: GitHub-hosted Ubuntu runner. --with-deps installs the system libraries the browser needs, which is the step most often missing from a copied workflow. Passing the extension path through the environment keeps the Playwright helper free of repository layout assumptions, and uploading traces only on failure keeps ordinary runs fast while making the failures diagnosable.
Step 3. Gate publishing on a tag and nothing else
The publish job needs both a condition and an environment. The condition keeps it off ordinary pushes; the environment keeps the secrets out of reach of pull requests, including forks.
1 publish:
2 needs: test
3 if: startsWith(github.ref, 'refs/tags/v')
4 runs-on: ubuntu-latest
5 environment: store-release # secrets scoped here, plus optional manual approval
6 steps:
7 - uses: actions/checkout@v4
8 - uses: actions/download-artifact@v4
9 with: { name: dist-chrome, path: dist/chrome }
10 - run: cd dist/chrome && zip -qr ../chrome.zip .
11 - run: bash scripts/upload-chrome.sh
12 env:
13 CWS_CLIENT_ID: ${{ secrets.CWS_CLIENT_ID }}
14 CWS_CLIENT_SECRET: ${{ secrets.CWS_CLIENT_SECRET }}
15 CWS_REFRESH_TOKEN: ${{ secrets.CWS_REFRESH_TOKEN }}
16 CWS_APP_ID: ${{ secrets.CWS_APP_ID }}
17 PUBLISH: "false" # upload as a draft; publish deliberately
Execution context: GitHub-hosted Ubuntu runner, only for tag pushes. The environment key is doing real security work: secrets attached to an environment are unavailable to jobs that do not declare it, so a pull request cannot exfiltrate them even if it modifies the workflow. Keeping PUBLISH false by default means a tag produces a reviewable draft in the store rather than an immediate rollout, which is the right default when the version number is consumed either way.
Step 4. Make failures reproducible locally
A pipeline that only fails in CI is a pipeline nobody can fix. Every step above should be runnable with one command locally, which mostly means keeping logic in scripts rather than in YAML.
1// package.json — the same entry points CI uses
2{
3 "scripts": {
4 "build:chrome": "node scripts/build.mjs --target=chrome",
5 "build:firefox": "node scripts/build.mjs --target=firefox",
6 "audit": "node scripts/audit.mjs dist/chrome",
7 "test:e2e": "playwright test",
8 "ci": "npm run build:chrome && npm run audit && npm run test:e2e"
9 }
10}
Execution context: Node, on a developer machine or a runner. The ci script is the contract: if it passes locally it should pass in CI, and any divergence is a bug in the workflow rather than in the change. Keeping the YAML to uses and run lines with no inline logic is what makes that true — the moment a workflow file contains a conditional expression, local reproduction stops being possible.
Step 5. Cache what is expensive, not what is cheap
Caching node_modules through setup-node is nearly free. Caching browsers is worth more and needs an explicit key, because the browser version is pinned by the Playwright version rather than by the lockfile alone.
1 - name: Cache Playwright browsers
2 uses: actions/cache@v4
3 with:
4 path: ~/.cache/ms-playwright
5 # Key on the resolved Playwright version so a bump invalidates the cache.
6 key: pw-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
7 - run: npx playwright install --with-deps chromium
Execution context: GitHub-hosted runner, before the browser install step. install becomes a no-op on a cache hit while still installing system dependencies, which is the combination you want — the download is the expensive part and the system libraries are not cached anyway. Keying on the lockfile hash rather than on a literal version means the cache invalidates exactly when the pinned browser changes.
Cross-browser variation
- Chromium targets: run on
ubuntu-latestwith the new headless mode; no virtual framebuffer needed. This is the fast path and should carry the bulk of the suite. - Firefox: builds on the same runner. Driving it with an extension loaded is less well supported by Playwright, so many projects run a smaller smoke suite there via
web-extinstead of the full suite. - Safari: needs
macos-latest, Xcode and an Apple certificate. Keep it in a separate workflow with its own trigger so a missing certificate cannot block a Chrome release. - All targets: artifacts are the contract between jobs. Never rebuild in the publish job — upload the bytes that were tested.
Verification
- Push a branch and confirm build and test run while publish is skipped, with the skip visible in the run summary rather than silently absent.
- Open a pull request from a fork and confirm the publish job does not appear and no secret is available to any job that does.
- Push a tag and confirm the store upload creates a draft, then check that re-running the job fails cleanly on the consumed version.
- Break the build deliberately and confirm the failure surfaces in under a minute, before the browser install step.
FAQ
Do I need xvfb for Playwright in CI?
Not for Chromium in the new headless mode, which is what recent Playwright versions use by default. Older setups and some Firefox configurations still need a virtual display, so check the failure message before adding one.
Why upload the built artifact instead of rebuilding in the publish job?
So the bytes that passed the suite are the bytes that ship. A rebuild reintroduces every environmental difference the test job just proved absent, on the one run where you can least afford it.
Can I publish automatically on every tag?
You can, and it removes a safety net that costs very little. Uploading a draft on every tag and publishing deliberately means a mistaken tag costs a version number rather than a rollout to users.
How do I keep secrets away from pull requests?
Attach them to a GitHub environment and declare that environment only on the publish job. Jobs that do not declare it cannot read them, which holds even for a workflow modified by the pull request itself.
Related
- Versioning and changelogs for extension releases — deriving the store-legal version the pipeline uses.
- CI & Release Automation — the guide this page belongs to.
- Loading an unpacked extension in Playwright — the suite this pipeline runs.
- Passing Chrome Web Store review — what the audit step protects.