Linting Extensions with web-ext lint in CI
Run Mozilla's addons-linter through web-ext lint in CI to catch manifest errors, unsafe code patterns and store-policy problems before submission: configuration, output formats, failing on warnings, suppressions, and combining with ESLint.
Table of Contents
- What addons-linter checks
- Step-by-step: lint in CI
- 1. Install and run locally
- 2. Configure with web-ext-config
- 3. Add a CI job with machine-readable output
- 4. Turn results into annotations and a verdict
- 5. Combine with ESLint for source-level rules
- 6. Lint every browser’s manifest
- 7. Keep the baseline shrinking
- 8. Run the linter before every store upload too
- Common mistakes
- Cross-browser variation
- Verification
- FAQ
- Related
A release reaches AMO and is rejected by the automatic validator: an innerHTML assignment with a variable, a manifest key Firefox doesn’t recognise, a minified file without source. The same validator is available as a command-line tool — web-ext lint, which runs Mozilla’s addons-linter — and it runs in seconds. Running it in CI on every pull request turns store rejections into pull-request comments. It also catches cross-browser manifest mistakes that matter beyond Firefox. This guide adds it to a pipeline, tunes it, and combines it with ESLint for the issues each tool is best at. It belongs to CI and release automation.
What addons-linter checks
addons-linter is the validator AMO runs on every upload. It inspects the packaged extension, not your source: the manifest (schema validity, unknown or deprecated keys, permission problems, browser_specific_settings requirements), JavaScript (dangerous patterns such as eval, new Function, unsanitised innerHTML and document.write, remote script loading, obfuscation and minification flags), HTML and CSS, locale files, and file names and sizes. Results are errors (would block submission), warnings (reviewers may ask about them) and notices. web-ext lint wraps it with convenient options and reads web-ext configuration. Because it validates the built output, run it on dist/, after bundling.
Step-by-step: lint in CI
1. Install and run locally
1npm i -D web-ext
2npm run build:firefox
3npx web-ext lint --source-dir dist/firefox
Execution context: a developer machine. Pin web-ext in devDependencies so local and CI runs use the same linter rules — the bundled addons-linter version determines what is flagged. Run against the Firefox build, since its manifest includes browser_specific_settings; linting the Chrome build produces Firefox-specific complaints you may not care about.
2. Configure with web-ext-config
1// web-ext-config.mjs
2export default {
3 sourceDir: "dist/firefox",
4 artifactsDir: "release",
5 ignoreFiles: ["**/*.map", "**/*.LICENSE.txt"],
6 lint: {
7 selfHosted: false,
8 privileged: false,
9 warningsAsErrors: false,
10 },
11};
Execution context: the repository root; web-ext picks it up automatically. ignoreFiles keeps files out of both linting and packaging, so the lint matches what you ship. selfHosted disables checks for AMO-listed add-ons — leave it false if you publish on AMO.
3. Add a CI job with machine-readable output
1# .github/workflows/ci.yml (excerpt)
2 lint-package:
3 runs-on: ubuntu-latest
4 steps:
5 - uses: actions/checkout@v4
6 - uses: actions/setup-node@v4
7 with: { node-version-file: .nvmrc, cache: npm }
8 - run: npm ci --ignore-scripts
9 - run: npm run build:firefox
10 - run: npx web-ext lint --output json --pretty false > lint.json || true
11 - run: node scripts/report-lint.mjs lint.json
Execution context: GitHub Actions. --output json produces structured results; the || true lets the next step decide pass or fail and print annotations, rather than the linter’s exit code alone.
4. Turn results into annotations and a verdict
1// scripts/report-lint.mjs
2import { readFile } from "node:fs/promises";
3const r = JSON.parse(await readFile(process.argv[2], "utf8"));
4const baseline = new Set(JSON.parse(await readFile("lint-baseline.json", "utf8").catch(() => "[]")));
5const key = (m) => `${m.code}:${m.file ?? ""}`;
6
7for (const m of [...r.errors, ...r.warnings]) {
8 const level = r.errors.includes(m) ? "error" : "warning";
9 console.log(`::${level} file=${m.file ?? "manifest.json"},line=${m.line ?? 1}::${m.code}: ${m.message}`);
10}
11const newWarnings = r.warnings.filter((m) => !baseline.has(key(m)));
12console.log(`addons-linter: ${r.errors.length} errors, ${r.warnings.length} warnings (${newWarnings.length} new)`);
13if (r.errors.length || newWarnings.length) process.exit(1);
Execution context: Node in CI. GitHub’s ::error / ::warning workflow commands show findings inline on the pull request. Errors always fail. Warnings fail only if they are new compared with a committed baseline — so an existing codebase can adopt the linter without fixing everything first, while new problems are blocked. File paths in the report are relative to the build, which map to bundled files; with source maps you can map them back for friendlier annotations.
5. Combine with ESLint for source-level rules
1// eslint.config.js
2import security from "eslint-plugin-no-unsanitized";
3export default [
4 { plugins: { "no-unsanitized": security }, rules: {
5 "no-unsanitized/property": "error", // innerHTML/outerHTML with dynamic values
6 "no-unsanitized/method": "error", // insertAdjacentHTML, document.write
7 "no-implied-eval": "error",
8 "no-new-func": "error",
9 } },
10];
Execution context: the repository. Mozilla’s eslint-plugin-no-unsanitized flags the same patterns as addons-linter, but on your source with exact line numbers, before bundling obscures them. Use ESLint for fast feedback in the editor and web-ext lint as the final check on what actually ships — third-party code in the bundle is only visible to the latter. See preventing XSS in extension pages.
6. Lint every browser’s manifest
addons-linter targets Firefox, but many of its manifest checks — unknown keys, invalid match patterns, missing icons, malformed locales — apply everywhere. For Chrome-specific checks, add a small JSON-schema validation of dist/chrome/manifest.json in the same job. See snapshot testing the generated manifest.
7. Keep the baseline shrinking
Review lint-baseline.json periodically and remove fixed entries; regenerate it only deliberately. A baseline that only grows hides real problems.
8. Run the linter before every store upload too
1// scripts/release.mjs (excerpt)
2import { execFileSync } from "node:child_process";
3const out = execFileSync("npx", ["web-ext", "lint", "--output", "json", "--pretty", "false"], { encoding: "utf8" });
4const { errors } = JSON.parse(out);
5if (errors.length) {
6 console.error(errors.map((e) => `${e.code}: ${e.message}`).join("\n"));
7 process.exit(1);
8}
Execution context: the release script that runs on tags. Pull-request linting can be skipped by a direct push or a hotfix branch; repeating the error-only check in the release job guarantees no package reaches a store with blocking findings. It costs seconds and saves a rejected upload, a version bump and a second review cycle. Keep the warning baseline check in the pull-request job, where developers can act on it, and only the hard error check here.
Common mistakes
- Linting source instead of the build. Misses bundled third-party code.
- Ignoring warnings forever. Reviewers ask about them; use a baseline.
- Unpinned
web-ext. New linter rules fail CI unexpectedly. - Packaging files the linter ignored. Keep
ignoreFilesshared. - Only ESLint. It cannot see what ends up in the package.
Cross-browser variation
- Chrome / Edge: no official CLI validator;
addons-linterstill catches most manifest and code-safety issues. - Firefox:
addons-linteris the AMO validator; passing it locally predicts the automatic validation result. - Safari: Xcode validates the app bundle at archive time; web-extension issues are caught by the same linting of
dist/safari.
Verification
- Add
el.innerHTML = someVarin a branch and confirm CI fails with an annotation. - Remove
browser_specific_settingsfrom the Firefox manifest and confirm an error. - Confirm the job passes on main with the current baseline.
- Bump
web-extand review any new findings before merging.
FAQ
Does web-ext lint need a Firefox install?
No. It is pure Node.
Can I lint a ZIP?
Yes — addons-linter path/to/file.zip validates the archive directly, which is exactly what you upload.
Should warnings fail CI?
New ones, yes. Existing ones belong in a baseline you work down.
Will passing lint guarantee AMO approval?
No. It predicts the automatic validation; human review can still raise policy questions such as data collection disclosures or unclear permissions. Lint removes the mechanical rejections so review focuses on the rest.
Can I suppress a specific finding?
Not per line in addons-linter. Fix the pattern, move the code into a reviewed sanitiser function, or record the finding in the baseline with a comment in your review notes explaining why it is safe.
How long does the lint take?
Usually a few seconds even for large bundles, so it is cheap to run on every pull request alongside unit tests.
Related
- Signing and publishing to AMO from CI — what follows a clean lint.
- Building a GitHub Actions pipeline for extensions — the pipeline.
- Preventing XSS in extension pages — fixing unsafe assignments.
- CI and release automation — the parent topic.