Technical SEO is testable, so test it in CI and block the deploy, instead of discovering the regression weeks later as a ranking drop in Google Search Console.
seo-ci is a collection of reusable, composable GitHub Actions plus a
reusable workflow that gate deployments on technical-SEO correctness. Each
action is a single, sharp deploy gate: it exits non-zero with a clear,
grep-able report when an SEO regression would ship.
robots.txt that de-indexes the site · sitemaps full of 404s and noindex URLs ·
canonical conflicts · 3-hop redirect chains · invalid JSON-LD · Core Web Vitals
drift · production serving something different from the build artifact
Note
Two audiences. If you own the deploy pipeline, jump to Quick start — wire in the reusable workflow or a single action. If you're an SEO specialist who wants to know exactly what each gate asserts, read The gates and each action's linked README.
| Action | What it catches |
|---|---|
validate-robots |
The catch-all Disallow: / de-indexing catastrophe; legacy rules that block business-critical URLs; blocked CSS/JS; Googlebot crawl-delay. Wildcard-aware (*/$). |
audit-sitemap |
Sitemap rot: URLs that 404, redirect, are noindex/robots-blocked, or canonicalize elsewhere; staging-host leaks; lying <lastmod>; 50k/50MB limits; malformed/XXE XML. |
assert-canonicals |
Canonical present, singular, and consistent across the HTML <link> and the HTTP Link: header; points where you expect. |
check-redirect-chains |
Multi-hop chains (http→https→www→slash) that should be one hop; loops; broken termini. Reports the full chain. |
validate-jsonld |
JSON-LD that isn't valid JSON, isn't a valid schema.org shape, or (consistency mode) describes data not on the page. |
lighthouse-budgets |
LCP/INP/CLS/TTFB drift past budget, from a Lighthouse report or a fresh @lhci/cli run. |
post-deploy-smoke |
Build-artifact-vs-production drift: a stripped header, wrong host routing, a noindex leaked from preview — checked against the live site after deploy. |
Gate your pipeline in two phases around your deploy. In your repo's
.github/workflows/deploy.yml:
jobs:
pre-deploy:
uses: nimajafari/seo-ci/.github/workflows/seo-gates.yml@v1
with:
run-post-deploy: false
robots: ./public/robots.txt
sitemap: ./public/sitemap.xml
jsonld-urls: |
./public/products/widget/index.html
lighthouse-report: ./lhr.json
deploy:
needs: pre-deploy
runs-on: ubuntu-latest
steps: [{ run: ./scripts/deploy.sh }]
post-deploy:
needs: deploy
uses: nimajafari/seo-ci/.github/workflows/seo-gates.yml@v1
with:
run-pre-deploy: false
base-url: https://www.example.com
smoke-urls: |
/
/products/widget
redirect-routes: |
http://example.com/ -> https://www.example.com/A complete, annotated version is in
examples/consuming-workflow.yml.
Every action works in isolation:
- uses: nimajafari/seo-ci/actions/validate-robots@v1
with:
robots: https://www.example.com/robots.txt
allowed: |
https://www.example.com/products/widget
blocked: |
https://www.example.com/admin/Each action documents its own inputs, outputs, and finding codes:
- validate-robots
- audit-sitemap
- assert-canonicals
- check-redirect-chains
- validate-jsonld
- lighthouse-budgets
- post-deploy-smoke
Common outputs (every action): passed (true/false), errors,
warnings, notices, and report — a JSON document
{ ok, strict, errors, warnings, notices, findings[] }. Every action accepts
strict (treat warnings as errors) and report-path (write the JSON report to
a file). Failures are emitted as GitHub annotations and as grep-able
SEVERITY [CODE] message :: target lines, and the step exits non-zero.
Warning
These are starting points, not drop-in guarantees. Your URL patterns, canonical rules, and indexable set are specific to your site — tune each gate's inputs to match. A gate is only as good as the assertions you give it.
Don't cloak. Serving crawlers different content (robots.txt, canonicals, JSON-LD, status codes) than you serve users violates Google's policies. These gates check what a crawler sees; keep it the same as what users see.
A gate that can't fail is theater. So the gates are proven against fixtures:
fixtures/good/— clean artifacts. The positive integration job runs every gate against them and asserts success.fixtures/broken/— the same artifacts with deliberately planted defects. The negative integration job runs every gate against them and asserts each one fails (viacontinue-on-error+ an explicit "did it fail?" check). See fixtures/README.md for the defect-to-gate map.
Both jobs, plus unit tests, typecheck, lint, and a bundle-freshness check, run
in .github/workflows/test.yml on every push and
PR. No proprietary credentials are needed to run any of it.
- robots.txt wildcards. Python's
urllib.robotparserdoes not support Google's*/$wildcards (it treats/*?*x=as a literal prefix and silently fails). We use the npmrobots-parser; for the highest fidelity, wrap Google's C++ parser (google/robotstxt) — seevalidate-robots' README for the tradeoff. - XML hardening. Sitemap parsing refuses
DOCTYPE/ENTITYdeclarations and disables entity processing, closing XXE/SSRF vectors; the 50 MB limit is checked against the uncompressed size. - No naive grep. The "no catch-all
Disallow" check uses a real parser, becauseDisallow: /may live under a specific user-agent group rather than*.
npm ci
npm run typecheck # tsc --noEmit (strict)
npm run lint # eslint
npm run test # vitest unit + URL-mode tests
npm run build # bundle each action to dist/index.js (committed)
npm run serve-fixtures # serve fixtures at http://127.0.0.1:8099
npm run verify # typecheck + lint + test + buildThe dist/index.js bundles are committed (GitHub runs them directly); CI fails
if they drift from source. Run npm run build and commit after changing an
action.
Issues and PRs welcome. Please keep the one-sharp-theme bar: every action is an
SEO deploy gate, nothing else. A new gate needs action.yml, bundled dist/,
src/ with the pure check logic factored out, unit tests, a good and a
broken fixture, and a focused README. Run npm run verify before opening a
PR.
MIT.