Building & Deploying a Chrome Extension with CI/CD
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.mdupdated - [ ] 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.
Related Resources
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 testThanks for reading!
Read More Articles