DevOps

Building & Deploying a Chrome Extension with CI/CD

Austin H.•July 15, 2026•14 min read
#chrome#cicd#api#ai#automation#github-actions

The Challenge

Chrome extensions start simple but scale messy:

  • Manual version bumps | forget to update manifest.json
  • No tests | push broken code to users
  • Manual store uploads | slow iteration cycle
  • API integration | secrets leaked in code
  • Parsing logic | buggy regex shipped to 10k users

Full CI/CD fixes all of this.

Architecture

GitHub Push
  ||o
GitHub Actions
  |-- Lint (ESLint)
  |-- Type Check (TypeScript)
  |-- Unit Tests
  |-- E2E Tests (Puppeteer)
  |-- Build Extension
  |-- Sign & Package
  ||  Upload to Chrome Web Store
  ||o
Users auto-update (usually within hours)

Project Structure

auto-apply-plugin/
|--  src/
||    |--  content.ts           # Runs on every page
||    |--  background.ts        # Service worker (lifecycle manager)
||    |--  popup.tsx            # Extension UI
||    |--  types.ts             # Shared interfaces
||    ||--  utils/
||        |--  parser.ts        # Parse job postings
||        ||--  api.ts           # Call backend API
|--  tests/
||    |--  parser.test.ts
||    ||--  e2e.test.ts          # Full workflow tests
|--  .github/workflows/
||    |--  lint.yml
||    |--  test.yml
||    ||--  deploy.yml
|--  manifest.json
|--  package.json
|--  tsconfig.json
||--  webpack.config.js        # Bundle extension

Core Files

manifest.json

Defines what your extension can do:

{
  "manifest_version": 3,
  "name": "Auto Apply",
  "version": "1.2.5",
  "permissions": ["activeTab", "scripting", "storage"],
  "host_permissions": ["[linkedin.com](https://linkedin.com/*) "[indeed.com](https://indeed.com/*)],
  "background": {
    "service_worker": "background.js"
  },
  "content_scripts": [
    {
      "matches": ["[linkedin.com](https://linkedin.com/*)],
      "js": ["content.js"]
    }
  ],
  "action": {
    "default_popup": "popup.html",
    "default_title": "Auto Apply"
  }
}

Manifest v3 breaking changes: v2 deprecated. Service workers replace background pages, content_scripts can't inject remote scripts. Use message passing instead.

content.ts

Runs on every matching page:

// content.ts
import { parseJobPosting, extractJobDetails } from './utils/parser';

// Listen for messages from popup or background
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
  if (msg.action === 'parse') {
    try {
      const jobDetails = parseJobPosting(document.body.innerText);
      sendResponse({ success: true, data: jobDetails });
    } catch (error) {
      sendResponse({ success: false, error: error.message });
    }
  }
});

// Auto-detect job postings on page load
if (window.location.hostname.includes('linkedin.com')) {
  const jobDetails = extractJobDetails(document);
  if (jobDetails) {
    chrome.runtime.sendMessage({ action: 'job_found', data: jobDetails });
  }
}

background.ts

Service worker |-- manages extension state and API calls:

// background.ts
import type { JobPosting } from './types';

const API_BASE = process.env.REACT_APP_API_URL || '[localhost:3000](http://localhost:3000)

chrome.runtime.onMessage.addListener(async (msg, sender, sendResponse) => {
  if (msg.action === 'job_found') {
    const job: JobPosting = msg.data;

    try {
      // Send to backend for analysis
      const response = await fetch(`${API_BASE}/api/analyze`, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'Authorization': `Bearer ${process.env.REACT_APP_API_KEY}`,
        },
        body: JSON.stringify(job),
      });

      const result = await response.json();
      
      // Store in extension storage
      chrome.storage.local.set({ [`job_${job.id}`]: result });
      
      sendResponse({ success: true, result });
    } catch (error) {
      console.error('API error:', error);
      sendResponse({ success: false, error: error.message });
    }
  }
});

// Monitor for auto-apply trigger
chrome.alarms.create('check_pending', { periodInMinutes: 5 });
chrome.alarms.onAlarm.addListener((alarm) => {
  if (alarm.name === 'check_pending') {
    checkPendingApplications();
  }
});

async function checkPendingApplications() {
  const jobs = await chrome.storage.local.get(null);
  for (const [key, job] of Object.entries(jobs)) {
    if (key.startsWith('job_') && job.match_score > 0.8) {
      // Auto-apply logic
      console.log(`Applying to ${job.title}`);
    }
  }
}

Parser: The AI Integration

// utils/parser.ts
import { parseWithOllama } from './api';

export interface JobPosting {
  id: string;
  title: string;
  company: string;
  description: string;
  salary?: string;
  location?: string;
}

export async function parseJobPosting(text: string): Promise<JobPosting> {
  // Use Ollama locally for fast parsing (no API key)
  const prompt = `Extract job details from this posting:\n${text}\n\nReturn JSON: {title, company, description, salary, location}`;
  
  const result = await parseWithOllama(prompt);
  return JSON.parse(result);
}

export function extractJobDetails(doc: Document): JobPosting | null {
  // Regex fallback for common patterns
  const titleMatch = doc.querySelector('h1')?.textContent;
  const companyMatch = doc.querySelector('[data-company]')?.textContent;
  
  if (!titleMatch || !companyMatch) return null;
  
  return {
    id: Date.now().toString(),
    title: titleMatch,
    company: companyMatch,
    description: doc.body.innerText,
  };
}

GitHub Actions Workflow

1. Lint & Type Check

# .github/workflows/lint.yml
name: Lint & Type Check
on: [push, pull_request]

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - run: npm run lint
      - run: npm run typecheck

2. Test

# .github/workflows/test.yml
name: Test
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - run: npm run test -- --coverage
      - name: Upload coverage
        uses: codecov/codecov-action@v3
        with:
          file: ./coverage/coverage-final.json

3. Build & Deploy

# .github/workflows/deploy.yml
name: Build & Deploy to Chrome Web Store
on:
  push:
    tags:
      - 'v*'

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - run: npm run build
      
      - name: Create ZIP
        run: |
          cd dist
          zip -r ../extension.zip .
          cd ..
      
      - name: Upload to Chrome Web Store
        run: npm run deploy
        env:
          CHROME_EXTENSION_ID: ${{ secrets.CHROME_EXTENSION_ID }}
          CHROME_CLIENT_ID: ${{ secrets.CHROME_CLIENT_ID }}
          CHROME_CLIENT_SECRET: ${{ secrets.CHROME_CLIENT_SECRET }}
          CHROME_REFRESH_TOKEN: ${{ secrets.CHROME_REFRESH_TOKEN }}
      
      - name: Create Release
        uses: softprops/action-gh-release@v1
        with:
          files: extension.zip

Testing: Unit + E2E

// tests/parser.test.ts
import { parseJobPosting } from '../src/utils/parser';

describe('Parser', () => {
  it('extracts job title', async () => {
    const text = 'Senior Software Engineer - $120k/yr - San Francisco';
    const job = await parseJobPosting(text);
    expect(job.title).toContain('Senior');
    expect(job.salary).toContain('120');
  });

  it('handles malformed input', async () => {
    const text = ';;;; garbage ;;;;';
    expect(async () => parseJobPosting(text)).toThrow();
  });
});
// tests/e2e.test.ts
import puppeteer from 'puppeteer';

describe('Extension E2E', () => {
  let browser, page;

  beforeAll(async () => {
    browser = await puppeteer.launch();
    page = await browser.newPage();
  });

  it('parses LinkedIn job posting', async () => {
    await page.goto('[linkedin.com/jobs/view/123456](https://linkedin.com/jobs/view/123456));
    // Simulate extension parsing
    const result = await page.evaluate(() => {
      return (window as any).jobDetails;
    });
    expect(result.title).toBeDefined();
  });

  afterAll(() => browser.close());
});

Environment & Secrets

# .env.example
REACT_APP_API_URL=[localhost:3000](http://localhost:3000)
REACT_APP_API_KEY=your_key_here

# GitHub Secrets (add via Settings |    Secrets)
CHROME_EXTENSION_ID=
CHROME_CLIENT_ID=
CHROME_CLIENT_SECRET=
CHROME_REFRESH_TOKEN=

Never commit secrets. Use GitHub Secrets. CI/CD injects them at runtime.

Common Gotchas

Content Script Can't Modify Storage

Content scripts run in page context, can't access chrome.* APIs directly.

// [OK]  WRONG
chrome.storage.local.set({ data }); // undefined!

// |-- a RIGHT
chrome.runtime.sendMessage({ action: 'store', data });
// background.ts listens and stores

CORS Fails from Content Script

Content scripts inherit page origin. Use background script for API calls:

// [OK]  WRONG (in content.ts)
fetch('[api.example.com/data](http://api.example.com/data)); // CORS error

// |-- a RIGHT (in background.ts)
fetch('[api.example.com/data](http://api.example.com/data)); // Works

Service Worker Terminates After 5 Minutes

Don't rely on long-lived state. Use chrome.storage instead:

// [OK]  WRONG
let counter = 0;
chrome.alarms.onAlarm.addListener(() => counter++); // Lost on termination

// |-- a RIGHT
chrome.alarms.onAlarm.addListener(async () => {
  const { counter = 0 } = await chrome.storage.local.get('counter');
  await chrome.storage.local.set({ counter: counter + 1 });
});

Deployment Checklist

  • [ ] Version bumped in manifest.json
  • [ ] CHANGELOG.md updated
  • [ ] All tests pass
  • [ ] Coverage > 80%
  • [ ] Linting passes
  • [ ] Screenshots taken (store listing)
  • [ ] Privacy policy link in manifest
  • [ ] Tag pushed: git tag v1.2.5 && git push origin v1.2.5
  • [ ] GitHub Action runs deploy job
  • [ ] Users see update within 24 hours

Example: auto-apply-plugin

github.com/nitsuah/auto-apply-plugin uses this full CI/CD pipeline. Live on Chrome Web Store with 1000+ active users.

Takeaway

Chrome extensions don't have to be manual or risky. Full CI/CD + testing + API integration = shipping fast with confidence. Every push is a potential release; every release is tested, signed, and automated.

Key Takeaways

  • Automated CI/CD eliminates manual version bumps and store uploads
  • Puppeteer E2E tests catch real user workflow regressions
  • GitHub Actions secrets keep API keys out of source code
  • Chrome Web Store API enables programmatic publishing
  • Auto-updates mean users get fixes within hours, not days

Code References

CI/CD Workflow

name: CI
on:
  push:
    branches: [main]
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm run lint
      - run: npm test

Thanks for reading!

Read More Articles