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.

Published October 2, 2026 Updated October 2, 2026 7 min read
Table of Contents

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.

Where linting runs in the pipelineESLint checks source files on every push; the bundler builds dist; web-ext lint validates the built package as AMO would; results are reported as annotations; errors fail the job and warnings are reviewed against a baseline.Sourcesrc/**/*.tsESLintcode quality, security rulesBuilddist/firefoxvalidate packageweb-ext lintaddons-linterJSON → annotationsPR feedbackErrors fail CIwarnings vs baseline
ESLint on source, addons-linter on the build — different questions.

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.

Common addons-linter findings and fixesTypical addons-linter messages for MV3 extensions, their severity, and the usual fix.FindingSeverityUsual fixUNSAFE_VAR_ASSIGNMENT (innerHTML)WarningtextContent / DOM APIs / sanitiserMANIFEST_FIELD_UNSUPPORTEDWarningRemove or move to per-browser manifestNO_DANGEROUS_EVAL / FunctionWarningRemove dynamic codeMissing gecko idError (MV3)browser_specific_settings.gecko.idMinified/obfuscated fileNotice/WarningSubmit source + build steps
Most findings are cheap to fix before review and expensive after.

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.

A new unsafe assignment caught in reviewA pull request adds an innerHTML assignment with a variable; CI builds and runs web-ext lint; the report script finds a new UNSAFE_VAR_ASSIGNMENT warning not in the baseline, annotates the file and fails the job; the author switches to textContent and the job passes.AuthorCIPull requestpush: el.innerHTML = titlebuild → web-ext lint::warning UNSAFE_VAR_ASSIGNMENT (new) ✗fix: textContent✓ 0 new warnings
The store's validator, moved to the pull request.

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 ignoreFiles shared.
  • Only ESLint. It cannot see what ends up in the package.

Cross-browser variation

  • Chrome / Edge: no official CLI validator; addons-linter still catches most manifest and code-safety issues.
  • Firefox: addons-linter is 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

  1. Add el.innerHTML = someVar in a branch and confirm CI fails with an annotation.
  2. Remove browser_specific_settings from the Firefox manifest and confirm an error.
  3. Confirm the job passes on main with the current baseline.
  4. Bump web-ext and 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.

Other Testing, Debugging & Performance Optimization Resources