Gate SEO GitHub Actions : faire échouer un déploiement régressif

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 :

  1. récupère le HTML brut d'une ou plusieurs URLs (preview deploy ou serveur local démarré dans le runner) ;
  2. assert une liste d'invariants SEO ;
  3. renvoie exit 0 si tout passe, exit 1 au 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 1 au premier invariant violé fait échouer le job ; rendez-le required + ajoutez le trigger merge_group.
  • Trois niveaux de strictness : blocking en preview, strict en prod.
  • Un script querySelector couvre 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).

Articles connexes

CI/CD SEO9 avril 2026

Inside Google Discover : 20 pipelines, 42M cards décryptés

Analyse technique des 20 pipelines Google Discover, leurs 42 millions de cards, et les leviers concrets pour maximiser la visibilité éditoriale.

CI/CD SEO8 avril 2026

Checks SEO dans le CI/CD : guide d'intégration complet

Intégrez des validations SEO automatisées dans votre pipeline CI/CD. Code, config et scénarios concrets pour bloquer les régressions avant la prod.

CI/CD SEO6 avril 2026

Déploiement vendredi soir : garde-fous SEO dans le CI/CD

Intégrez des tests SEO automatisés dans votre pipeline CI/CD pour détecter les régressions avant qu'elles n'atteignent la production.

Régressions SEO19 juin 2026

Hreflang vers domaines supprimés : −38% de trafic DE en 6 semaines

Un marché allemand fermé, des hreflang encore générés vers le .de mort. Récit de l'incident, diagnostic technique et fix complet.

SSR / CSR19 juin 2026

Multi-currency dropdown réécrit le title côté CSR : fix

Un sélecteur de devise JS réécrit le title au runtime. Google indexe la version USD sur tous les marchés. Récit, diagnostic et correctif.

Régressions SEO18 juin 2026

Crowdin lang=\"auto\" : signal de langue cassé, −34 % trafic

Crowdin auto-translate injecte lang=\"auto\" sur tout le site. Google confond le marché cible. Récit, diagnostic et fix complet.