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.

Published July 25, 2026 Updated July 25, 2026 8 min read
Table of Contents

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.

The three jobs and what gates each oneBuild runs on every push across a target matrix, test consumes the built artifacts, and publish runs only for tags on the default branch.buildmatrix: chrome, firefoxtestneeds: buildpublishneeds: test, if: tagartifacts carry the build between jobsupload-artifactdist per targetdownload-artifactthe exact bytes testedStore uploadsame bytes again
Secrets are only reachable from the third job, so a pull request from a fork can never touch them.

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.

Where the wall-clock time goesCheckout and dependency install, build, browser install, the suite itself, and the upload, showing that browser install and the suite dominate.job startsjob endsCheckout + npm cicachedBuildsecondsplaywright installbrowser downloadSuitereal browserArtifa…only on…a build break should fail herecache the browser to cut this
Caching npm helps a little; the browser install and the suite are what actually set the pipeline's length.

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.

What each job needs and what it must not haveRunner, inputs, secrets and trigger for the build, test and publish jobs.JobRuns onConsumesSecretsbuildEvery push and PRSourceNonetestEvery push and PRBuild artifactNonepublishTags onlyBuild artifactEnvironment-scopedsafari (separate)Tags, macOSSourceApple certificate
Only the last row has secrets, and only the last row runs on a tag — those two facts travel together.

Cross-browser variation

  • Chromium targets: run on ubuntu-latest with 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-ext instead 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

  1. 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.
  2. 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.
  3. Push a tag and confirm the store upload creates a draft, then check that re-running the job fails cleanly on the consumed version.
  4. 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.

Other Testing, Debugging & Performance Optimization Resources