Vous voulez qu'un déploiement s'arrête net quand une release supprime le <title> côté SSR, casse le <h1> ou injecte un noindex involontaire. Pas un score Lighthouse à 87. Un verdict binaire : pass ou fail, avec un exit 1 qui bloque le merge ou le deploy. Voici comment câbler ce gate SEO dans GitHub Actions, avec trois niveaux de strictness.
Pourquoi Lighthouse CI ne fait pas le job
Lighthouse note une page sur un agrégat (performance, accessibilité, SEO). Un score SEO qui passe de 100 à 92 ne vous dit pas quoi a régressé, et surtout ne fait pas échouer un pipeline de façon déterministe sur le bon critère. Vous voulez l'inverse : un check ciblé qui ignore le bruit et fail uniquement sur des invariants SEO précis.
Le vrai différenciateur, c'est ce que vous comparez. Lighthouse audite le DOM rendu. Or la régression la plus fréquente sur une stack Next.js, Nuxt ou Astro, c'est un écart entre le HTML brut SSR (ce que Googlebot lit sans exécuter de JS) et le DOM rendu côté CSR. Un title injecté par un useEffect apparaît au rendu mais pas dans le HTML brut. Le gate doit lire le HTML brut, pas le DOM hydraté.
Le principe du gate pass/fail
Le gate est un job qui :
- récupère le HTML brut d'une ou plusieurs URLs (preview deploy ou serveur local démarré dans le runner) ;
- assert une liste d'invariants SEO ;
- renvoie
exit 0si tout passe,exit 1au premier invariant violé.
GitHub Actions propage ce code de sortie. Un step qui sort en exit 1 fait échouer le job, et quand un job de workflow référence un environnement, il ne démarre pas tant que toutes les règles de protection ne sont pas validées, et il ne peut pas accéder aux secrets définis dans cet environnement tant que les deployment protection rules ne passent pas. En branchant ce gate comme required status check, un PR ne peut pas merger tant que le SEO régresse.
Étape 1 — Le script de vérification
Créez scripts/seo-gate.mjs. Il prend des URLs et un niveau de strictness via variables d'environnement.
// scripts/seo-gate.mjs
import { parseHTML } from 'linkedom';
const STRICTNESS = process.env.SEO_STRICTNESS || 'standard';
const URLS = process.env.SEO_URLS.split(',').map((u) => u.trim());
// Invariants par niveau
const RULES = {
// niveau 1 : bloque seulement les régressions catastrophiques
blocking: ['hasTitle', 'noUnintendedNoindex', 'has200'],
// niveau 2 : + structure éditoriale
standard: ['hasTitle', 'noUnintendedNoindex', 'has200', 'singleH1', 'hasCanonical'],
// niveau 3 : + qualité fine
strict: [
'hasTitle', 'noUnintendedNoindex', 'has200', 'singleH1',
'hasCanonical', 'titleLength', 'hasMetaDescription', 'hasOgTitle',
],
};
const failures = [];
for (const url of URLS) {
const res = await fetch(url, { redirect: 'manual' });
const html = await res.text(); // HTML BRUT, pas de JS exécuté
const { document } = parseHTML(html);
const checks = {
has200: () => res.status === 200 || fail(url, `status ${res.status}`),
hasTitle: () => {
const t = document.querySelector('title')?.textContent?.trim();
return t || fail(url, 'title absent du HTML brut (régression SSR ?)');
},
titleLength: () => {
const t = document.querySelector('title')?.textContent?.trim() || '';
return (t.length >= 15 && t.length <= 65) || fail(url, `title ${t.length} car.`);
},
singleH1: () => {
const n = document.querySelectorAll('h1').length;
return n === 1 || fail(url, `${n} balises h1`);
},
hasCanonical: () =>
document.querySelector('link[rel="canonical"]') || fail(url, 'canonical absente'),
hasMetaDescription: () =>
document.querySelector('meta[name="description"]') || fail(url, 'meta description absente'),
hasOgTitle: () =>
document.querySelector('meta[property="og:title"]') || fail(url, 'og:title absente'),
noUnintendedNoindex: () => {
const robots = document.querySelector('meta[name="robots"]')?.getAttribute('content') || '';
return !/noindex/i.test(robots) || fail(url, 'noindex détecté dans le HTML brut');
},
};
for (const ruleName of RULES[STRICTNESS]) checks[ruleName]();
}
function fail(url, msg) {
failures.push(`${url} → ${msg}`);
return false;
}
if (failures.length) {
console.error(`SEO GATE FAIL (${STRICTNESS}) :`);
failures.forEach((f) => console.error(' ✗ ' + f));
process.exit(1);
}
console.log(`SEO GATE PASS (${STRICTNESS}) — ${URLS.length} URL(s)`);
Point clé : fetch + linkedom lit le HTML servi, sans hydratation. C'est exactement la vue Googlebot first-pass. Si votre framework injecte le title côté client, le gate fail — c'est le comportement voulu.
Étape 2 — Le workflow
Le gate doit tourner contre l'artefact réellement déployable. Démarrez le serveur de prod dans le runner, ou pointez vers l'URL de preview deploy.
# .github/workflows/seo-gate.yml
name: SEO Gate
on:
pull_request:
branches: [main]
workflow_dispatch:
jobs:
seo-gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npm run build
- run: npm run start & # serveur de prod en arrière-plan
- run: npx wait-on http://localhost:3000
- name: SEO gate
env:
SEO_STRICTNESS: standard
SEO_URLS: "http://localhost:3000/,http://localhost:3000/produits"
run: node scripts/seo-gate.mjs
Le step SEO gate sort en exit 1 au premier invariant violé, ce qui fait échouer le job. Vous pouvez utiliser une GitHub Action de quality gate pour garantir que votre code respecte vos standards en faisant échouer votre workflow. Ici le standard, c'est vos invariants SEO.
Étape 3 — Bloquer réellement le merge
Un job rouge ne suffit pas : il faut le rendre required. Dans Settings → Branches → Branch protection rules, ajoutez seo-gate aux required status checks de main. Attention au piège merge queue : si votre dépôt utilise GitHub Actions pour des checks requis sur les pull requests, vous devez mettre à jour les workflows pour inclure l'événement merge_group comme déclencheur additionnel, sinon les status checks ne se déclencheront pas quand vous ajoutez une PR à une merge queue, et le merge échouera car le check requis ne sera pas remonté.
Ajoutez donc le trigger :
on:
pull_request:
branches: [main]
merge_group:
Les trois niveaux de strictness, et quand les utiliser
| Niveau | Invariants | Cas d'usage |
|---|---|---|
blocking |
title présent, pas de noindex, HTTP 200 | Hotfix, branches rapides : ne bloque que sur catastrophe |
standard |
+ un seul H1, canonical | Flux de PR quotidien |
strict |
+ longueur title, meta description, og:title | Release vers prod, templates critiques |
Pilotez le niveau par environnement : blocking sur les preview branches, strict sur le déploiement prod. Vous évitez de bloquer un dev sur un og:title manquant tout en gardant un garde-fou dur sur la prod.
La limite de ce gate maison, et où Seogard prend le relais
Ce script attrape l'absence d'un invariant. Il n'attrape pas une régression différentielle : un title toujours présent mais qui passe de la valeur métier au fallback générique (le <h1> ou le nom de site). C'est précisément le scénario du first-fallback dont nous parlons pour Contentful où le champ SEO title non synchronisé génère un fallback sur le premier H1. Votre assert hasTitle reste vert alors que le SEO a régressé.
Pour ce delta, il faut comparer le HTML brut SSR au DOM rendu CSR, et comparer la release à un baseline. C'est la règle au cœur du crawler Seogard : il fait échouer le deploy quand le title ou le <h1> diffèrent entre HTML brut et rendu JS, ou quand ils dérivent d'un snapshot de référence — un cas typique étant un title modifié côté CSR pendant que Google voit le default. Le verdict pass/fail arrive en webhook, avec les mêmes trois niveaux de strictness, mais sur une base différentielle que le querySelector ne voit pas. Détails sur seogard.io.
Récap
- Le gate lit le HTML brut, pas le DOM hydraté : c'est la vue Googlebot.
exit 1au premier invariant violé fait échouer le job ; rendez-le required + ajoutez le triggermerge_group.- Trois niveaux de strictness :
blockingen preview,stricten prod. - Un script
querySelectorcouvre les absences. Les régressions différentielles (SSR vs CSR, dérive vs baseline) demandent une comparaison, pas un simple test de présence.
Référence officielle : Events that trigger workflows (GitHub Docs).