Chrome Extension CI/CD Pipeline

A complete guide to automating Chrome extension builds, testing, and publishing using GitHub Actions, Playwright, and the Chrome Web Store API.

GitHub Actions Workflow for Extension Builds

Basic Build Workflow

.github/workflows/build.yml
name: Build & Test

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

permissions:
  contents: read

jobs:
  build:
    runs-on: ubuntu-latest

    strategy:
      matrix:
        node-version: [20]

    steps:
      - uses: actions/checkout@v4

      - uses: pnpm/action-setup@v4
        with:
          version: 9

      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: pnpm

      - name: Install dependencies
        run: pnpm install --frozen-lockfile

      - name: Lint
        run: pnpm lint

      - name: Type check
        run: pnpm typecheck

      - name: Run unit tests
        run: pnpm test

      - name: Build extension
        run: pnpm build

      - name: Validate manifest
        run: node scripts/validate-manifest.js

      - name: Upload build artifact
        uses: actions/upload-artifact@v4
        with:
          name: extension-build
          path: dist/
          retention-days: 7

Multi-Browser Build Matrix

Build for Chrome, Firefox, and Edge in parallel:

.github/workflows/multi-browser.yml
name: Multi-Browser Build

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest

    strategy:
      fail-fast: false
      matrix:
        browser: [chrome, firefox, edge]

    steps:
      - uses: actions/checkout@v4

      - uses: pnpm/action-setup@v4
        with:
          version: 9

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: pnpm

      - run: pnpm install --frozen-lockfile

      - name: Build for ${{ matrix.browser }}
        run: pnpm build:${{ matrix.browser }}
        env:
          TARGET_BROWSER: ${{ matrix.browser }}

      - name: Upload ${{ matrix.browser }} build
        uses: actions/upload-artifact@v4
        with:
          name: extension-${{ matrix.browser }}
          path: dist/${{ matrix.browser }}/

The build script uses the TARGET_BROWSER env to adjust the manifest and polyfills:

// scripts/build.ts
import { build } from 'vite';
import { readFileSync, writeFileSync } from 'fs';

const browser = process.env.TARGET_BROWSER ?? 'chrome';

// Transform manifest for target browser
function transformManifest(browser: string): Record<string, unknown> {
  const manifest = JSON.parse(readFileSync('manifest.json', 'utf-8'));

  if (browser === 'firefox') {
    // Firefox uses browser_specific_settings instead of some MV3 fields
    manifest.browser_specific_settings = {
      gecko: {
        id: 'extension@yourservice.com',
        strict_min_version: '109.0',
      },
    };
    // Firefox MV3 uses scripts array for background
    if (manifest.background?.service_worker) {
      manifest.background = {
        scripts: [manifest.background.service_worker],
        type: 'module',
      };
    }
  }

  if (browser === 'edge') {
    // Edge uses the same manifest as Chrome with minor tweaks
    // No changes needed for most extensions
  }

  return manifest;
}

async function main() {
  await build({
    define: {
      __BROWSER__: JSON.stringify(browser),
    },
    build: {
      outDir: `dist/${browser}`,
    },
  });

  const manifest = transformManifest(browser);
  writeFileSync(`dist/${browser}/manifest.json`, JSON.stringify(manifest, null, 2));

  console.log(`Built for ${browser}`);
}

main();

Automated Testing with Playwright

Setting Up Extension Testing

Playwright can load unpacked Chrome extensions for end-to-end testing:

// tests/setup.ts
import { chromium, type BrowserContext } from '@playwright/test';
import path from 'path';

export async function createExtensionContext(): Promise<{
  context: BrowserContext;
  extensionId: string;
}> {
  const extensionPath = path.resolve(__dirname, '../dist/chrome');

  const context = await chromium.launchPersistentContext('', {
    headless: false,  // Extensions require headed mode
    args: [
      `--disable-extensions-except=${extensionPath}`,
      `--load-extension=${extensionPath}`,
      '--no-first-run',
      '--disable-gpu',
    ],
  });

  // Wait for the service worker to register and extract the extension ID
  let extensionId = '';

  // The service worker URL reveals the extension ID
  const serviceWorker = context.serviceWorkers()[0]
    ?? await context.waitForEvent('serviceworker');

  const swUrl = serviceWorker.url();
  const match = swUrl.match(/chrome-extension:\/\/([a-z]+)\//);
  extensionId = match?.[1] ?? '';

  if (!extensionId) {
    throw new Error('Could not determine extension ID from service worker URL');
  }

  return { context, extensionId };
}

Testing the Popup

// tests/popup.spec.ts
import { test, expect, type BrowserContext, type Page } from '@playwright/test';
import { createExtensionContext } from './setup';

let context: BrowserContext;
let extensionId: string;

test.beforeAll(async () => {
  ({ context, extensionId } = await createExtensionContext());
});

test.afterAll(async () => {
  await context.close();
});

test('popup renders correctly', async () => {
  const popupPage = await context.newPage();
  await popupPage.goto(`chrome-extension://${extensionId}/popup.html`);

  // Verify the popup title
  await expect(popupPage.locator('h1')).toHaveText('My Extension');

  // Verify the settings button exists
  await expect(popupPage.getByRole('button', { name: 'Settings' })).toBeVisible();
});

test('popup toggle saves state', async () => {
  const popupPage = await context.newPage();
  await popupPage.goto(`chrome-extension://${extensionId}/popup.html`);

  const toggle = popupPage.getByRole('switch', { name: 'Enable feature' });

  // Toggle on
  await toggle.click();
  await expect(toggle).toBeChecked();

  // Reload and verify persistence
  await popupPage.reload();
  await expect(
    popupPage.getByRole('switch', { name: 'Enable feature' })
  ).toBeChecked();
});

test('popup displays tab count', async () => {
  const popupPage = await context.newPage();
  await popupPage.goto(`chrome-extension://${extensionId}/popup.html`);

  const tabCount = popupPage.getByTestId('tab-count');
  await expect(tabCount).toBeVisible();

  // The count should be a number greater than 0
  const text = await tabCount.textContent();
  expect(Number(text)).toBeGreaterThan(0);
});

Testing Content Scripts

// tests/content-script.spec.ts
import { test, expect, type BrowserContext } from '@playwright/test';
import { createExtensionContext } from './setup';

let context: BrowserContext;

test.beforeAll(async () => {
  ({ context } = await createExtensionContext());
});

test.afterAll(async () => {
  await context.close();
});

test('content script injects overlay on matching pages', async () => {
  const page = await context.newPage();
  await page.goto('https://example.com');

  // Wait for the content script to inject its overlay
  const overlay = page.locator('#my-extension-overlay');
  await expect(overlay).toBeVisible({ timeout: 5000 });

  // Verify overlay content
  await expect(overlay.locator('.overlay-title')).toHaveText('Page Info');
});

test('content script does not inject on non-matching pages', async () => {
  const page = await context.newPage();
  await page.goto('https://google.com');

  // Give it time to potentially inject
  await page.waitForTimeout(2000);

  const overlay = page.locator('#my-extension-overlay');
  await expect(overlay).not.toBeVisible();
});

Playwright CI Configuration

Addition to .github/workflows/build.yml
  e2e:
    runs-on: ubuntu-latest
    needs: build

    steps:
      - uses: actions/checkout@v4

      - uses: pnpm/action-setup@v4
        with:
          version: 9

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: pnpm

      - run: pnpm install --frozen-lockfile

      - name: Download build artifact
        uses: actions/download-artifact@v4
        with:
          name: extension-build
          path: dist/

      - name: Install Playwright browsers
        run: pnpm exec playwright install chromium

      - name: Run E2E tests
        run: pnpm exec playwright test
        env:
          # Extensions need headed mode. use xvfb
          DISPLAY: ':99'

      - name: Upload test results
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: playwright-report
          path: playwright-report/
// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  timeout: 30_000,
  retries: process.env.CI ? 2 : 0,
  workers: 1,  // Extensions require sequential execution
  reporter: process.env.CI ? 'github' : 'html',
  use: {
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
  },
});

Bundle Size Monitoring

Track your extension size to prevent bloat:

// size-limit.config.ts
import type { SizeLimitConfig } from 'size-limit';

const config: SizeLimitConfig = [
  {
    name: 'Background script',
    path: 'dist/chrome/background.js',
    limit: '50 KB',
  },
  {
    name: 'Content script',
    path: 'dist/chrome/content.js',
    limit: '30 KB',
  },
  {
    name: 'Popup',
    path: 'dist/chrome/popup.js',
    limit: '80 KB',
  },
  {
    name: 'Total extension',
    path: 'dist/chrome//*.{js,css,html}',
    limit: '300 KB',
  },
];

export default config;

Add to your CI workflow:

      - name: Check bundle size
        run: pnpm exec size-limit

For PR comments showing size changes, use the size-limit GitHub Action:

  size:
    runs-on: ubuntu-latest
    if: github.event_name == 'pull_request'

    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
        with:
          version: 9
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: pnpm
      - run: pnpm install --frozen-lockfile

      - uses: andresz1/size-limit-action@v1
        with:
          github_token: ${{ secrets.GITHUB_TOKEN }}
          build_script: build

Manifest Validation in CI

Catch manifest errors before they reach the Web Store:

// scripts/validate-manifest.ts
import { readFileSync } from 'fs';

interface ValidationError {
  field: string;
  message: string;
}

function validateManifest(manifestPath: string): ValidationError[] {
  const errors: ValidationError[] = [];
  const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));

  // Required fields
  const required = ['manifest_version', 'name', 'version', 'description'];
  for (const field of required) {
    if (!manifest[field]) {
      errors.push({ field, message: `Missing required field: ${field}` });
    }
  }

  // Manifest version must be 3
  if (manifest.manifest_version !== 3) {
    errors.push({
      field: 'manifest_version',
      message: `Expected 3, got ${manifest.manifest_version}`,
    });
  }

  // Version must be valid (1-4 dot-separated integers)
  const versionRegex = /^\d+(\.\d+){0,3}$/;
  if (manifest.version && !versionRegex.test(manifest.version)) {
    errors.push({
      field: 'version',
      message: `Invalid version format: ${manifest.version}`,
    });
  }

  // Name length limit
  if (manifest.name && manifest.name.length > 45) {
    errors.push({
      field: 'name',
      message: `Name exceeds 45 characters (${manifest.name.length})`,
    });
  }

  // Description length limit
  if (manifest.description && manifest.description.length > 132) {
    errors.push({
      field: 'description',
      message: `Description exceeds 132 characters (${manifest.description.length})`,
    });
  }

  // Check for dangerous permissions
  const dangerousPerms = ['debugger', 'nativeMessaging', 'proxy'];
  const allPerms = [...(manifest.permissions ?? []), ...(manifest.optional_permissions ?? [])];
  for (const perm of dangerousPerms) {
    if (allPerms.includes(perm)) {
      errors.push({
        field: 'permissions',
        message: `Uses sensitive permission "${perm}". expect extended review`,
      });
    }
  }

  // Icons should include all required sizes
  const requiredIconSizes = ['16', '48', '128'];
  if (manifest.icons) {
    for (const size of requiredIconSizes) {
      if (!manifest.icons[size]) {
        errors.push({
          field: 'icons',
          message: `Missing ${size}x${size} icon`,
        });
      }
    }
  } else {
    errors.push({ field: 'icons', message: 'No icons defined' });
  }

  return errors;
}

// Run validation
const errors = validateManifest('dist/chrome/manifest.json');

if (errors.length > 0) {
  console.error('Manifest validation failed:');
  for (const error of errors) {
    console.error(`  [${error.field}] ${error.message}`);
  }
  process.exit(1);
} else {
  console.log('Manifest validation passed.');
}

Automated Chrome Web Store Publishing

Setting Up chrome-webstore-upload

First, obtain Chrome Web Store API credentials:

  1. Go to the Google Cloud Console and create a project
  2. Enable the Chrome Web Store API
  3. Create OAuth2 credentials (client ID and client secret)
  4. Obtain a refresh token using the OAuth2 flow

Store these as GitHub repository secrets:

Publish Workflow

.github/workflows/publish.yml
name: Publish to Chrome Web Store

on:
  release:
    types: [published]

permissions:
  contents: read

jobs:
  publish:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - uses: pnpm/action-setup@v4
        with:
          version: 9

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: pnpm

      - run: pnpm install --frozen-lockfile

      - name: Build extension
        run: pnpm build

      - name: Package extension
        run: |
          cd dist/chrome
          zip -r ../../extension.zip .

      - name: Upload to Chrome Web Store
        run: node scripts/publish-cws.js
        env:
          CWS_CLIENT_ID: ${{ secrets.CWS_CLIENT_ID }}
          CWS_CLIENT_SECRET: ${{ secrets.CWS_CLIENT_SECRET }}
          CWS_REFRESH_TOKEN: ${{ secrets.CWS_REFRESH_TOKEN }}
          CWS_EXTENSION_ID: ${{ secrets.CWS_EXTENSION_ID }}

Publishing Script

// scripts/publish-cws.ts
import ChromeWebStore from 'chrome-webstore-upload';
import { readFileSync, createReadStream } from 'fs';

async function publish() {
  const {
    CWS_CLIENT_ID,
    CWS_CLIENT_SECRET,
    CWS_REFRESH_TOKEN,
    CWS_EXTENSION_ID,
  } = process.env;

  if (!CWS_CLIENT_ID || !CWS_CLIENT_SECRET || !CWS_REFRESH_TOKEN || !CWS_EXTENSION_ID) {
    throw new Error('Missing Chrome Web Store credentials in environment');
  }

  const store = ChromeWebStore({
    extensionId: CWS_EXTENSION_ID,
    clientId: CWS_CLIENT_ID,
    clientSecret: CWS_CLIENT_SECRET,
    refreshToken: CWS_REFRESH_TOKEN,
  });

  const zipFile = createReadStream('extension.zip');

  console.log('Uploading extension...');
  const uploadResult = await store.uploadExisting(zipFile);

  if (uploadResult.uploadState === 'FAILURE') {
    console.error('Upload failed:', uploadResult.itemError);
    process.exit(1);
  }

  console.log('Upload successful. Publishing...');

  // Publish to the default (production) channel
  const publishResult = await store.publish('default');

  if (publishResult.status.includes('OK')) {
    console.log('Published successfully.');
  } else {
    console.error('Publish failed:', publishResult.statusDetail);
    process.exit(1);
  }
}

publish().catch((err) => {
  console.error('Publish error:', err);
  process.exit(1);
});

Version Bumping Strategies

Semantic Versioning from Conventional Commits

Use standard-version or conventional-changelog to auto-bump versions:

{
  "scripts": {
    "release": "standard-version",
    "release:minor": "standard-version --release-as minor",
    "release:major": "standard-version --release-as major"
  }
}

Chrome Web Store uses a four-part version: major.minor.patch.build. Sync it with your semver:

// scripts/bump-version.ts
import { readFileSync, writeFileSync } from 'fs';

function bumpVersion(type: 'major' | 'minor' | 'patch'): string {
  const pkg = JSON.parse(readFileSync('package.json', 'utf-8'));
  const manifest = JSON.parse(readFileSync('manifest.json', 'utf-8'));

  const [major, minor, patch] = pkg.version.split('.').map(Number);

  let newVersion: string;
  switch (type) {
    case 'major':
      newVersion = `${major + 1}.0.0`;
      break;
    case 'minor':
      newVersion = `${major}.${minor + 1}.0`;
      break;
    case 'patch':
      newVersion = `${major}.${minor}.${patch + 1}`;
      break;
  }

  // Update package.json
  pkg.version = newVersion;
  writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');

  // Update manifest.json (Chrome uses same format)
  manifest.version = newVersion;
  writeFileSync('manifest.json', JSON.stringify(manifest, null, 2) + '\n');

  console.log(`Version bumped to ${newVersion}`);
  return newVersion;
}

const type = (process.argv[2] as 'major' | 'minor' | 'patch') ?? 'patch';
bumpVersion(type);

Auto-Version from Git Tags in CI

      - name: Set version from tag
        run: |
          VERSION=${GITHUB_REF_NAME#v}
          echo "VERSION=$VERSION" >> $GITHUB_ENV
          node -e "
            const fs = require('fs');
            const m = JSON.parse(fs.readFileSync('manifest.json','utf-8'));
            m.version = '$VERSION';
            fs.writeFileSync('manifest.json', JSON.stringify(m, null, 2) + '\n');
          "

Code Signing and Key Management

Extension Key Management

Chrome extensions are identified by their public key. Manage it securely:

// scripts/inject-key.ts
// In development, the key is auto-generated. For production, use a consistent key.
import { readFileSync, writeFileSync } from 'fs';

function injectKey(): void {
  const key = process.env.EXTENSION_PUBLIC_KEY;
  if (!key) {
    console.log('No EXTENSION_PUBLIC_KEY set. skipping key injection (dev mode)');
    return;
  }

  const manifest = JSON.parse(readFileSync('dist/chrome/manifest.json', 'utf-8'));
  manifest.key = key;
  writeFileSync('dist/chrome/manifest.json', JSON.stringify(manifest, null, 2) + '\n');

  console.log('Extension key injected into manifest');
}

injectKey();

Store the private key (.pem file) as an encrypted GitHub secret and never commit it:

      - name: Inject extension key
        run: node scripts/inject-key.js
        env:
          EXTENSION_PUBLIC_KEY: ${{ secrets.EXTENSION_PUBLIC_KEY }}

.gitignore for Key Files

Never commit extension keys
*.pem
*.key
key.json

Release Notes Generation

Conventional Commits to Changelog

// scripts/generate-release-notes.ts
import { execSync } from 'child_process';

interface CommitGroup {
  type: string;
  label: string;
  commits: string[];
}

function generateReleaseNotes(fromTag: string): string {
  const log = execSync(
    `git log ${fromTag}..HEAD --pretty=format:"%s" --no-merges`,
    { encoding: 'utf-8' }
  ).trim();

  if (!log) return 'No changes.';

  const groups: CommitGroup[] = [
    { type: 'feat', label: 'New Features', commits: [] },
    { type: 'fix', label: 'Bug Fixes', commits: [] },
    { type: 'perf', label: 'Performance', commits: [] },
    { type: 'refactor', label: 'Refactoring', commits: [] },
    { type: 'docs', label: 'Documentation', commits: [] },
    { type: 'chore', label: 'Maintenance', commits: [] },
  ];

  for (const line of log.split('\n')) {
    const match = line.match(/^(\w+)(?:\(.+?\))?:\s*(.+)$/);
    if (match) {
      const [, type, description] = match;
      const group = groups.find((g) => g.type === type);
      if (group) {
        group.commits.push(description);
      }
    }
  }

  const sections = groups
    .filter((g) => g.commits.length > 0)
    .map((g) => {
      const items = g.commits.map((c) => `- ${c}`).join('\n');
      return `### ${g.label}\n\n${items}`;
    })
    .join('\n\n');

  return sections || 'Miscellaneous changes.';
}

// Usage: node scripts/generate-release-notes.js v1.0.0
const fromTag = process.argv[2];
if (!fromTag) {
  console.error('Usage: generate-release-notes <from-tag>');
  process.exit(1);
}

console.log(generateReleaseNotes(fromTag));

Automated GitHub Release with Notes

.github/workflows/release.yml
name: Create Release

on:
  push:
    tags: ['v*']

permissions:
  contents: write

jobs:
  release:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0  # Full history for changelog generation

      - uses: pnpm/action-setup@v4
        with:
          version: 9

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: pnpm

      - run: pnpm install --frozen-lockfile
      - run: pnpm build

      - name: Package extension
        run: |
          cd dist/chrome && zip -r ../../extension.zip .
          cd ../firefox && zip -r ../../extension-firefox.zip .

      - name: Generate release notes
        id: notes
        run: |
          PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
          if [ -n "$PREV_TAG" ]; then
            NOTES=$(node scripts/generate-release-notes.js "$PREV_TAG")
          else
            NOTES="Initial release."
          fi
          echo "notes<<EOF" >> $GITHUB_OUTPUT
          echo "$NOTES" >> $GITHUB_OUTPUT
          echo "EOF" >> $GITHUB_OUTPUT

      - name: Create GitHub Release
        uses: softprops/action-gh-release@v2
        with:
          body: ${{ steps.notes.outputs.notes }}
          files: |
            extension.zip
            extension-firefox.zip

Canary and Beta Channel Deployment

Channel-Based Publishing

Chrome Web Store supports publishing to trustedTesters before going public:

// scripts/publish-channel.ts
import ChromeWebStore from 'chrome-webstore-upload';
import { createReadStream } from 'fs';

type Channel = 'default' | 'trustedTesters';

async function publishToChannel(channel: Channel) {
  const store = ChromeWebStore({
    extensionId: process.env.CWS_EXTENSION_ID!,
    clientId: process.env.CWS_CLIENT_ID!,
    clientSecret: process.env.CWS_CLIENT_SECRET!,
    refreshToken: process.env.CWS_REFRESH_TOKEN!,
  });

  const zipFile = createReadStream('extension.zip');

  console.log(`Uploading extension for ${channel} channel...`);
  const uploadResult = await store.uploadExisting(zipFile);

  if (uploadResult.uploadState === 'FAILURE') {
    console.error('Upload failed:', uploadResult.itemError);
    process.exit(1);
  }

  console.log(`Publishing to ${channel}...`);
  const publishResult = await store.publish(channel);

  if (publishResult.status.includes('OK')) {
    console.log(`Published to ${channel} successfully.`);
  } else {
    console.error('Publish failed:', publishResult.statusDetail);
    process.exit(1);
  }
}

const channel = (process.argv[2] as Channel) ?? 'trustedTesters';
publishToChannel(channel);

Staged Rollout Workflow

.github/workflows/staged-rollout.yml
name: Staged Rollout

on:
  workflow_dispatch:
    inputs:
      channel:
        description: 'Publish channel'
        required: true
        type: choice
        options:
          - trustedTesters
          - default

jobs:
  publish:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - uses: pnpm/action-setup@v4
        with:
          version: 9

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: pnpm

      - run: pnpm install --frozen-lockfile
      - run: pnpm build

      - name: Package
        run: cd dist/chrome && zip -r ../../extension.zip .

      - name: Publish to ${{ github.event.inputs.channel }}
        run: node scripts/publish-channel.js ${{ github.event.inputs.channel }}
        env:
          CWS_CLIENT_ID: ${{ secrets.CWS_CLIENT_ID }}
          CWS_CLIENT_SECRET: ${{ secrets.CWS_CLIENT_SECRET }}
          CWS_REFRESH_TOKEN: ${{ secrets.CWS_REFRESH_TOKEN }}
          CWS_EXTENSION_ID: ${{ secrets.CWS_EXTENSION_ID }}

Beta Version Numbering

Use the fourth version segment for beta builds:

// scripts/beta-version.ts
import { readFileSync, writeFileSync } from 'fs';
import { execSync } from 'child_process';

function setBetaVersion(): void {
  const manifest = JSON.parse(readFileSync('manifest.json', 'utf-8'));
  const baseVersion = manifest.version; // e.g., "1.2.0"

  // Use the commit count since the last tag as the build number
  const commitCount = execSync('git rev-list --count HEAD', { encoding: 'utf-8' }).trim();

  const betaVersion = `${baseVersion}.${commitCount}`;
  manifest.version = betaVersion;
  manifest.version_name = `${baseVersion} Beta ${commitCount}`;

  writeFileSync('manifest.json', JSON.stringify(manifest, null, 2) + '\n');

  console.log(`Beta version set to ${betaVersion} (${manifest.version_name})`);
}

setBetaVersion();

Complete Pipeline Example

Here is a full workflow that ties everything together:

.github/workflows/pipeline.yml
name: Extension Pipeline

on:
  push:
    branches: [main]
    tags: ['v*']
  pull_request:
    branches: [main]

permissions:
  contents: write

jobs:
  # 1. Lint, type-check, and unit test
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
        with:
          version: 9
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: pnpm
      - run: pnpm install --frozen-lockfile
      - run: pnpm lint
      - run: pnpm typecheck
      - run: pnpm test

  # 2. Build and validate
  build:
    needs: check
    runs-on: ubuntu-latest
    strategy:
      matrix:
        browser: [chrome, firefox]
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
        with:
          version: 9
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: pnpm
      - run: pnpm install --frozen-lockfile
      - run: pnpm build:${{ matrix.browser }}
      - run: node scripts/validate-manifest.js dist/${{ matrix.browser }}/manifest.json
      - uses: actions/upload-artifact@v4
        with:
          name: extension-${{ matrix.browser }}
          path: dist/${{ matrix.browser }}/

  # 3. E2E tests (Chrome only. Playwright limitation)
  e2e:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
        with:
          version: 9
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: pnpm
      - run: pnpm install --frozen-lockfile
      - uses: actions/download-artifact@v4
        with:
          name: extension-chrome
          path: dist/chrome/
      - run: pnpm exec playwright install chromium
      - run: xvfb-run pnpm exec playwright test

  # 4. Publish on tag push
  publish:
    if: startsWith(github.ref, 'refs/tags/v')
    needs: [build, e2e]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: pnpm/action-setup@v4
        with:
          version: 9
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: pnpm
      - run: pnpm install --frozen-lockfile
      - uses: actions/download-artifact@v4
        with:
          name: extension-chrome
          path: dist/chrome/
      - run: cd dist/chrome && zip -r ../../extension.zip .
      - run: node scripts/publish-cws.js
        env:
          CWS_CLIENT_ID: ${{ secrets.CWS_CLIENT_ID }}
          CWS_CLIENT_SECRET: ${{ secrets.CWS_CLIENT_SECRET }}
          CWS_REFRESH_TOKEN: ${{ secrets.CWS_REFRESH_TOKEN }}
          CWS_EXTENSION_ID: ${{ secrets.CWS_EXTENSION_ID }}

Summary

A mature CI/CD pipeline for Chrome extensions includes:

  1. Build automation. reproducible builds triggered on push and PR
  2. Multi-browser matrix. build for Chrome, Firefox, and Edge in parallel
  3. Playwright E2E testing. load the extension in a real browser and test popup, content scripts, and background behavior
  4. Bundle size monitoring. catch size regressions before they ship
  5. Manifest validation. automated checks for required fields, version format, and dangerous permissions
  6. Chrome Web Store publishing. fully automated upload and publish via API
  7. Version management. semver synced between package.json and manifest.json
  8. Key management. extension keys stored as secrets, never committed
  9. Release notes. auto-generated from conventional commits
  10. Staged rollout. publish to trusted testers before going public

Cross-references:

Part of the Chrome Extension Guide by theluckystrike. Built at zovo.one.