[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"$fdTGGoV01PhDl4oBhBBe1Us4cHfB1xTCtMbsBGNLvAd8":3,"$fT8sJA5DtY1O5pUJZ9BiM4pmikuVUkvDpXTCTzzq8BVg":23,"$fcTFjTaPPT1sVcnh1wWEGoTWMSaOD4bwmwntkMae6w5s":115},{"_id":4,"slug":5,"__v":6,"author":7,"body":8,"canonical":9,"category":10,"createdAt":11,"date":12,"description":13,"htmlContent":14,"image":15,"imageAlt":15,"readingTime":16,"tags":17,"title":21,"updatedAt":22},"6a357992aa6b273b0cf50ebc","gate-seo-github-actions-echouer-deploiement-regressif",0,"Equipe Seogard","Vous voulez qu'un déploiement s'arrête net quand une release supprime le `\u003Ctitle>` côté SSR, casse le `\u003Ch1>` 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.\n\n## Pourquoi Lighthouse CI ne fait pas le job\n\nLighthouse 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.\n\nLe 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é.\n\n## Le principe du gate pass/fail\n\nLe gate est un job qui :\n\n1. récupère le HTML brut d'une ou plusieurs URLs (preview deploy ou serveur local démarré dans le runner) ;\n2. assert une liste d'invariants SEO ;\n3. renvoie `exit 0` si tout passe, `exit 1` au premier invariant violé.\n\nGitHub 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.\n\n## Étape 1 — Le script de vérification\n\nCréez `scripts/seo-gate.mjs`. Il prend des URLs et un niveau de strictness via variables d'environnement.\n\n```javascript\n// scripts/seo-gate.mjs\nimport { parseHTML } from 'linkedom';\n\nconst STRICTNESS = process.env.SEO_STRICTNESS || 'standard';\nconst URLS = process.env.SEO_URLS.split(',').map((u) => u.trim());\n\n// Invariants par niveau\nconst RULES = {\n  // niveau 1 : bloque seulement les régressions catastrophiques\n  blocking: ['hasTitle', 'noUnintendedNoindex', 'has200'],\n  // niveau 2 : + structure éditoriale\n  standard: ['hasTitle', 'noUnintendedNoindex', 'has200', 'singleH1', 'hasCanonical'],\n  // niveau 3 : + qualité fine\n  strict: [\n    'hasTitle', 'noUnintendedNoindex', 'has200', 'singleH1',\n    'hasCanonical', 'titleLength', 'hasMetaDescription', 'hasOgTitle',\n  ],\n};\n\nconst failures = [];\n\nfor (const url of URLS) {\n  const res = await fetch(url, { redirect: 'manual' });\n  const html = await res.text();          // HTML BRUT, pas de JS exécuté\n  const { document } = parseHTML(html);\n\n  const checks = {\n    has200: () => res.status === 200 || fail(url, `status ${res.status}`),\n    hasTitle: () => {\n      const t = document.querySelector('title')?.textContent?.trim();\n      return t || fail(url, 'title absent du HTML brut (régression SSR ?)');\n    },\n    titleLength: () => {\n      const t = document.querySelector('title')?.textContent?.trim() || '';\n      return (t.length >= 15 && t.length \u003C= 65) || fail(url, `title ${t.length} car.`);\n    },\n    singleH1: () => {\n      const n = document.querySelectorAll('h1').length;\n      return n === 1 || fail(url, `${n} balises h1`);\n    },\n    hasCanonical: () =>\n      document.querySelector('link[rel=\"canonical\"]') || fail(url, 'canonical absente'),\n    hasMetaDescription: () =>\n      document.querySelector('meta[name=\"description\"]') || fail(url, 'meta description absente'),\n    hasOgTitle: () =>\n      document.querySelector('meta[property=\"og:title\"]') || fail(url, 'og:title absente'),\n    noUnintendedNoindex: () => {\n      const robots = document.querySelector('meta[name=\"robots\"]')?.getAttribute('content') || '';\n      return !/noindex/i.test(robots) || fail(url, 'noindex détecté dans le HTML brut');\n    },\n  };\n\n  for (const ruleName of RULES[STRICTNESS]) checks[ruleName]();\n}\n\nfunction fail(url, msg) {\n  failures.push(`${url} → ${msg}`);\n  return false;\n}\n\nif (failures.length) {\n  console.error(`SEO GATE FAIL (${STRICTNESS}) :`);\n  failures.forEach((f) => console.error('  ✗ ' + f));\n  process.exit(1);\n}\nconsole.log(`SEO GATE PASS (${STRICTNESS}) — ${URLS.length} URL(s)`);\n```\n\nPoint 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.\n\n## Étape 2 — Le workflow\n\nLe 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.\n\n```yaml\n# .github/workflows/seo-gate.yml\nname: SEO Gate\n\non:\n  pull_request:\n    branches: [main]\n  workflow_dispatch:\n\njobs:\n  seo-gate:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v7\n      - uses: actions/setup-node@v4\n        with:\n          node-version: 20\n      - run: npm ci\n      - run: npm run build\n      - run: npm run start &     # serveur de prod en arrière-plan\n      - run: npx wait-on http://localhost:3000\n      - name: SEO gate\n        env:\n          SEO_STRICTNESS: standard\n          SEO_URLS: \"http://localhost:3000/,http://localhost:3000/produits\"\n        run: node scripts/seo-gate.mjs\n```\n\nLe 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.\n\n## Étape 3 — Bloquer réellement le merge\n\nUn 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é.\n\nAjoutez donc le trigger :\n\n```yaml\non:\n  pull_request:\n    branches: [main]\n  merge_group:\n```\n\n## Les trois niveaux de strictness, et quand les utiliser\n\n| Niveau | Invariants | Cas d'usage |\n|---|---|---|\n| `blocking` | title présent, pas de noindex, HTTP 200 | Hotfix, branches rapides : ne bloque que sur catastrophe |\n| `standard` | + un seul H1, canonical | Flux de PR quotidien |\n| `strict` | + longueur title, meta description, og:title | Release vers prod, templates critiques |\n\nPilotez 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.\n\n## La limite de ce gate maison, et où Seogard prend le relais\n\nCe 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 `\u003Ch1>` 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](/blog/contentful-le-champ-seo-title-non-sync-vers-next-js-fallback-first-h1-genere). Votre assert `hasTitle` reste vert alors que le SEO a régressé.\n\nPour 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 `\u003Ch1>` 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](/blog/multi-currency-dropdown-change-le-title-cote-csr-google-voit-le-default-usd). 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](/).\n\n## Récap\n\n- Le gate lit le **HTML brut**, pas le DOM hydraté : c'est la vue Googlebot.\n- `exit 1` au premier invariant violé fait échouer le job ; rendez-le **required** + ajoutez le trigger `merge_group`.\n- Trois niveaux de strictness : `blocking` en preview, `strict` en prod.\n- 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.\n\nRéférence officielle : [Events that trigger workflows](https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows) (GitHub Docs).\n```","https://seogard.io/blog/gate-seo-github-actions-echouer-deploiement-regressif","CI/CD SEO","2026-06-19T17:17:06.813Z","2026-06-19","Configurez un job GitHub Actions qui renvoie exit 1 sur une régression SEO (title, H1, robots). Verdict pass/fail, 3 niveaux de strictness, YAML prêt à coller.","\u003Cp>Vous voulez qu'un déploiement s'arrête net quand une release supprime le \u003Ccode>&#x3C;title>\u003C/code> côté SSR, casse le \u003Ccode>&#x3C;h1>\u003C/code> ou injecte un \u003Ccode>noindex\u003C/code> involontaire. Pas un score Lighthouse à 87. Un verdict binaire : \u003Ccode>pass\u003C/code> ou \u003Ccode>fail\u003C/code>, avec un \u003Ccode>exit 1\u003C/code> qui bloque le merge ou le deploy. Voici comment câbler ce gate SEO dans GitHub Actions, avec trois niveaux de strictness.\u003C/p>\n\u003Ch2>Pourquoi Lighthouse CI ne fait pas le job\u003C/h2>\n\u003Cp>Lighthouse note une page sur un agrégat (performance, accessibilité, SEO). Un score SEO qui passe de 100 à 92 ne vous dit pas \u003Cem>quoi\u003C/em> 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 \u003Ccode>fail\u003C/code> uniquement sur des invariants SEO précis.\u003C/p>\n\u003Cp>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 \u003Cstrong>HTML brut SSR\u003C/strong> (ce que Googlebot lit sans exécuter de JS) et le \u003Cstrong>DOM rendu côté CSR\u003C/strong>. Un \u003Ccode>title\u003C/code> injecté par un \u003Ccode>useEffect\u003C/code> apparaît au rendu mais pas dans le HTML brut. Le gate doit lire le HTML brut, pas le DOM hydraté.\u003C/p>\n\u003Ch2>Le principe du gate pass/fail\u003C/h2>\n\u003Cp>Le gate est un job qui :\u003C/p>\n\u003Col>\n\u003Cli>récupère le HTML brut d'une ou plusieurs URLs (preview deploy ou serveur local démarré dans le runner) ;\u003C/li>\n\u003Cli>assert une liste d'invariants SEO ;\u003C/li>\n\u003Cli>renvoie \u003Ccode>exit 0\u003C/code> si tout passe, \u003Ccode>exit 1\u003C/code> au premier invariant violé.\u003C/li>\n\u003C/ol>\n\u003Cp>GitHub Actions propage ce code de sortie. Un step qui sort en \u003Ccode>exit 1\u003C/code> 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.\u003C/p>\n\u003Ch2>Étape 1 — Le script de vérification\u003C/h2>\n\u003Cp>Créez \u003Ccode>scripts/seo-gate.mjs\u003C/code>. Il prend des URLs et un niveau de strictness via variables d'environnement.\u003C/p>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\">// scripts/seo-gate.mjs\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">import\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> { parseHTML } \u003C/span>\u003Cspan style=\"color:#F97583\">from\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> 'linkedom'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">;\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">const\u003C/span>\u003Cspan style=\"color:#79B8FF\"> STRICTNESS\u003C/span>\u003Cspan style=\"color:#F97583\"> =\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> process.env.\u003C/span>\u003Cspan style=\"color:#79B8FF\">SEO_STRICTNESS\u003C/span>\u003Cspan style=\"color:#F97583\"> ||\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> 'standard'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">;\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">const\u003C/span>\u003Cspan style=\"color:#79B8FF\"> URLS\u003C/span>\u003Cspan style=\"color:#F97583\"> =\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> process.env.\u003C/span>\u003Cspan style=\"color:#79B8FF\">SEO_URLS\u003C/span>\u003Cspan style=\"color:#E1E4E8\">.\u003C/span>\u003Cspan style=\"color:#B392F0\">split\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#9ECBFF\">','\u003C/span>\u003Cspan style=\"color:#E1E4E8\">).\u003C/span>\u003Cspan style=\"color:#B392F0\">map\u003C/span>\u003Cspan style=\"color:#E1E4E8\">((\u003C/span>\u003Cspan style=\"color:#FFAB70\">u\u003C/span>\u003Cspan style=\"color:#E1E4E8\">) \u003C/span>\u003Cspan style=\"color:#F97583\">=>\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> u.\u003C/span>\u003Cspan style=\"color:#B392F0\">trim\u003C/span>\u003Cspan style=\"color:#E1E4E8\">());\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\">// Invariants par niveau\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">const\u003C/span>\u003Cspan style=\"color:#79B8FF\"> RULES\u003C/span>\u003Cspan style=\"color:#F97583\"> =\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\">  // niveau 1 : bloque seulement les régressions catastrophiques\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  blocking: [\u003C/span>\u003Cspan style=\"color:#9ECBFF\">'hasTitle'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">, \u003C/span>\u003Cspan style=\"color:#9ECBFF\">'noUnintendedNoindex'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">, \u003C/span>\u003Cspan style=\"color:#9ECBFF\">'has200'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">],\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\">  // niveau 2 : + structure éditoriale\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  standard: [\u003C/span>\u003Cspan style=\"color:#9ECBFF\">'hasTitle'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">, \u003C/span>\u003Cspan style=\"color:#9ECBFF\">'noUnintendedNoindex'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">, \u003C/span>\u003Cspan style=\"color:#9ECBFF\">'has200'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">, \u003C/span>\u003Cspan style=\"color:#9ECBFF\">'singleH1'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">, \u003C/span>\u003Cspan style=\"color:#9ECBFF\">'hasCanonical'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">],\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\">  // niveau 3 : + qualité fine\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  strict: [\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#9ECBFF\">    'hasTitle'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">, \u003C/span>\u003Cspan style=\"color:#9ECBFF\">'noUnintendedNoindex'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">, \u003C/span>\u003Cspan style=\"color:#9ECBFF\">'has200'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">, \u003C/span>\u003Cspan style=\"color:#9ECBFF\">'singleH1'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#9ECBFF\">    'hasCanonical'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">, \u003C/span>\u003Cspan style=\"color:#9ECBFF\">'titleLength'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">, \u003C/span>\u003Cspan style=\"color:#9ECBFF\">'hasMetaDescription'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">, \u003C/span>\u003Cspan style=\"color:#9ECBFF\">'hasOgTitle'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  ],\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">};\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">const\u003C/span>\u003Cspan style=\"color:#79B8FF\"> failures\u003C/span>\u003Cspan style=\"color:#F97583\"> =\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> [];\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">for\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> (\u003C/span>\u003Cspan style=\"color:#F97583\">const\u003C/span>\u003Cspan style=\"color:#79B8FF\"> url\u003C/span>\u003Cspan style=\"color:#F97583\"> of\u003C/span>\u003Cspan style=\"color:#79B8FF\"> URLS\u003C/span>\u003Cspan style=\"color:#E1E4E8\">) {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">  const\u003C/span>\u003Cspan style=\"color:#79B8FF\"> res\u003C/span>\u003Cspan style=\"color:#F97583\"> =\u003C/span>\u003Cspan style=\"color:#F97583\"> await\u003C/span>\u003Cspan style=\"color:#B392F0\"> fetch\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(url, { redirect: \u003C/span>\u003Cspan style=\"color:#9ECBFF\">'manual'\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> });\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">  const\u003C/span>\u003Cspan style=\"color:#79B8FF\"> html\u003C/span>\u003Cspan style=\"color:#F97583\"> =\u003C/span>\u003Cspan style=\"color:#F97583\"> await\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> res.\u003C/span>\u003Cspan style=\"color:#B392F0\">text\u003C/span>\u003Cspan style=\"color:#E1E4E8\">();          \u003C/span>\u003Cspan style=\"color:#6A737D\">// HTML BRUT, pas de JS exécuté\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">  const\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> { \u003C/span>\u003Cspan style=\"color:#79B8FF\">document\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> } \u003C/span>\u003Cspan style=\"color:#F97583\">=\u003C/span>\u003Cspan style=\"color:#B392F0\"> parseHTML\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(html);\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">  const\u003C/span>\u003Cspan style=\"color:#79B8FF\"> checks\u003C/span>\u003Cspan style=\"color:#F97583\"> =\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">    has200\u003C/span>\u003Cspan style=\"color:#E1E4E8\">: () \u003C/span>\u003Cspan style=\"color:#F97583\">=>\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> res.status \u003C/span>\u003Cspan style=\"color:#F97583\">===\u003C/span>\u003Cspan style=\"color:#79B8FF\"> 200\u003C/span>\u003Cspan style=\"color:#F97583\"> ||\u003C/span>\u003Cspan style=\"color:#B392F0\"> fail\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(url, \u003C/span>\u003Cspan style=\"color:#9ECBFF\">`status ${\u003C/span>\u003Cspan style=\"color:#E1E4E8\">res\u003C/span>\u003Cspan style=\"color:#9ECBFF\">.\u003C/span>\u003Cspan style=\"color:#E1E4E8\">status\u003C/span>\u003Cspan style=\"color:#9ECBFF\">}`\u003C/span>\u003Cspan style=\"color:#E1E4E8\">),\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">    hasTitle\u003C/span>\u003Cspan style=\"color:#E1E4E8\">: () \u003C/span>\u003Cspan style=\"color:#F97583\">=>\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">      const\u003C/span>\u003Cspan style=\"color:#79B8FF\"> t\u003C/span>\u003Cspan style=\"color:#F97583\"> =\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> document.\u003C/span>\u003Cspan style=\"color:#B392F0\">querySelector\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#9ECBFF\">'title'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">)?.textContent?.\u003C/span>\u003Cspan style=\"color:#B392F0\">trim\u003C/span>\u003Cspan style=\"color:#E1E4E8\">();\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">      return\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> t \u003C/span>\u003Cspan style=\"color:#F97583\">||\u003C/span>\u003Cspan style=\"color:#B392F0\"> fail\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(url, \u003C/span>\u003Cspan style=\"color:#9ECBFF\">'title absent du HTML brut (régression SSR ?)'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">);\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    },\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">    titleLength\u003C/span>\u003Cspan style=\"color:#E1E4E8\">: () \u003C/span>\u003Cspan style=\"color:#F97583\">=>\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">      const\u003C/span>\u003Cspan style=\"color:#79B8FF\"> t\u003C/span>\u003Cspan style=\"color:#F97583\"> =\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> document.\u003C/span>\u003Cspan style=\"color:#B392F0\">querySelector\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#9ECBFF\">'title'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">)?.textContent?.\u003C/span>\u003Cspan style=\"color:#B392F0\">trim\u003C/span>\u003Cspan style=\"color:#E1E4E8\">() \u003C/span>\u003Cspan style=\"color:#F97583\">||\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> ''\u003C/span>\u003Cspan style=\"color:#E1E4E8\">;\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">      return\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> (t.\u003C/span>\u003Cspan style=\"color:#79B8FF\">length\u003C/span>\u003Cspan style=\"color:#F97583\"> >=\u003C/span>\u003Cspan style=\"color:#79B8FF\"> 15\u003C/span>\u003Cspan style=\"color:#F97583\"> &#x26;&#x26;\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> t.\u003C/span>\u003Cspan style=\"color:#79B8FF\">length\u003C/span>\u003Cspan style=\"color:#F97583\"> &#x3C;=\u003C/span>\u003Cspan style=\"color:#79B8FF\"> 65\u003C/span>\u003Cspan style=\"color:#E1E4E8\">) \u003C/span>\u003Cspan style=\"color:#F97583\">||\u003C/span>\u003Cspan style=\"color:#B392F0\"> fail\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(url, \u003C/span>\u003Cspan style=\"color:#9ECBFF\">`title ${\u003C/span>\u003Cspan style=\"color:#E1E4E8\">t\u003C/span>\u003Cspan style=\"color:#9ECBFF\">.\u003C/span>\u003Cspan style=\"color:#79B8FF\">length\u003C/span>\u003Cspan style=\"color:#9ECBFF\">} car.`\u003C/span>\u003Cspan style=\"color:#E1E4E8\">);\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    },\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">    singleH1\u003C/span>\u003Cspan style=\"color:#E1E4E8\">: () \u003C/span>\u003Cspan style=\"color:#F97583\">=>\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">      const\u003C/span>\u003Cspan style=\"color:#79B8FF\"> n\u003C/span>\u003Cspan style=\"color:#F97583\"> =\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> document.\u003C/span>\u003Cspan style=\"color:#B392F0\">querySelectorAll\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#9ECBFF\">'h1'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">).\u003C/span>\u003Cspan style=\"color:#79B8FF\">length\u003C/span>\u003Cspan style=\"color:#E1E4E8\">;\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">      return\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> n \u003C/span>\u003Cspan style=\"color:#F97583\">===\u003C/span>\u003Cspan style=\"color:#79B8FF\"> 1\u003C/span>\u003Cspan style=\"color:#F97583\"> ||\u003C/span>\u003Cspan style=\"color:#B392F0\"> fail\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(url, \u003C/span>\u003Cspan style=\"color:#9ECBFF\">`${\u003C/span>\u003Cspan style=\"color:#E1E4E8\">n\u003C/span>\u003Cspan style=\"color:#9ECBFF\">} balises h1`\u003C/span>\u003Cspan style=\"color:#E1E4E8\">);\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    },\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">    hasCanonical\u003C/span>\u003Cspan style=\"color:#E1E4E8\">: () \u003C/span>\u003Cspan style=\"color:#F97583\">=>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">      document.\u003C/span>\u003Cspan style=\"color:#B392F0\">querySelector\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#9ECBFF\">'link[rel=\"canonical\"]'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">) \u003C/span>\u003Cspan style=\"color:#F97583\">||\u003C/span>\u003Cspan style=\"color:#B392F0\"> fail\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(url, \u003C/span>\u003Cspan style=\"color:#9ECBFF\">'canonical absente'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">),\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">    hasMetaDescription\u003C/span>\u003Cspan style=\"color:#E1E4E8\">: () \u003C/span>\u003Cspan style=\"color:#F97583\">=>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">      document.\u003C/span>\u003Cspan style=\"color:#B392F0\">querySelector\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#9ECBFF\">'meta[name=\"description\"]'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">) \u003C/span>\u003Cspan style=\"color:#F97583\">||\u003C/span>\u003Cspan style=\"color:#B392F0\"> fail\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(url, \u003C/span>\u003Cspan style=\"color:#9ECBFF\">'meta description absente'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">),\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">    hasOgTitle\u003C/span>\u003Cspan style=\"color:#E1E4E8\">: () \u003C/span>\u003Cspan style=\"color:#F97583\">=>\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">      document.\u003C/span>\u003Cspan style=\"color:#B392F0\">querySelector\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#9ECBFF\">'meta[property=\"og:title\"]'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">) \u003C/span>\u003Cspan style=\"color:#F97583\">||\u003C/span>\u003Cspan style=\"color:#B392F0\"> fail\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(url, \u003C/span>\u003Cspan style=\"color:#9ECBFF\">'og:title absente'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">),\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">    noUnintendedNoindex\u003C/span>\u003Cspan style=\"color:#E1E4E8\">: () \u003C/span>\u003Cspan style=\"color:#F97583\">=>\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">      const\u003C/span>\u003Cspan style=\"color:#79B8FF\"> robots\u003C/span>\u003Cspan style=\"color:#F97583\"> =\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> document.\u003C/span>\u003Cspan style=\"color:#B392F0\">querySelector\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#9ECBFF\">'meta[name=\"robots\"]'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">)?.\u003C/span>\u003Cspan style=\"color:#B392F0\">getAttribute\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#9ECBFF\">'content'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">) \u003C/span>\u003Cspan style=\"color:#F97583\">||\u003C/span>\u003Cspan style=\"color:#9ECBFF\"> ''\u003C/span>\u003Cspan style=\"color:#E1E4E8\">;\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">      return\u003C/span>\u003Cspan style=\"color:#F97583\"> !\u003C/span>\u003Cspan style=\"color:#9ECBFF\">/\u003C/span>\u003Cspan style=\"color:#DBEDFF\">noindex\u003C/span>\u003Cspan style=\"color:#9ECBFF\">/\u003C/span>\u003Cspan style=\"color:#F97583\">i\u003C/span>\u003Cspan style=\"color:#E1E4E8\">.\u003C/span>\u003Cspan style=\"color:#B392F0\">test\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(robots) \u003C/span>\u003Cspan style=\"color:#F97583\">||\u003C/span>\u003Cspan style=\"color:#B392F0\"> fail\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(url, \u003C/span>\u003Cspan style=\"color:#9ECBFF\">'noindex détecté dans le HTML brut'\u003C/span>\u003Cspan style=\"color:#E1E4E8\">);\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    },\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  };\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">  for\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> (\u003C/span>\u003Cspan style=\"color:#F97583\">const\u003C/span>\u003Cspan style=\"color:#79B8FF\"> ruleName\u003C/span>\u003Cspan style=\"color:#F97583\"> of\u003C/span>\u003Cspan style=\"color:#79B8FF\"> RULES\u003C/span>\u003Cspan style=\"color:#E1E4E8\">[\u003C/span>\u003Cspan style=\"color:#79B8FF\">STRICTNESS\u003C/span>\u003Cspan style=\"color:#E1E4E8\">]) checks[ruleName]();\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">}\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">function\u003C/span>\u003Cspan style=\"color:#B392F0\"> fail\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#FFAB70\">url\u003C/span>\u003Cspan style=\"color:#E1E4E8\">, \u003C/span>\u003Cspan style=\"color:#FFAB70\">msg\u003C/span>\u003Cspan style=\"color:#E1E4E8\">) {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  failures.\u003C/span>\u003Cspan style=\"color:#B392F0\">push\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#9ECBFF\">`${\u003C/span>\u003Cspan style=\"color:#E1E4E8\">url\u003C/span>\u003Cspan style=\"color:#9ECBFF\">} → ${\u003C/span>\u003Cspan style=\"color:#E1E4E8\">msg\u003C/span>\u003Cspan style=\"color:#9ECBFF\">}`\u003C/span>\u003Cspan style=\"color:#E1E4E8\">);\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">  return\u003C/span>\u003Cspan style=\"color:#79B8FF\"> false\u003C/span>\u003Cspan style=\"color:#E1E4E8\">;\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">}\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">if\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> (failures.\u003C/span>\u003Cspan style=\"color:#79B8FF\">length\u003C/span>\u003Cspan style=\"color:#E1E4E8\">) {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  console.\u003C/span>\u003Cspan style=\"color:#B392F0\">error\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#9ECBFF\">`SEO GATE FAIL (${\u003C/span>\u003Cspan style=\"color:#79B8FF\">STRICTNESS\u003C/span>\u003Cspan style=\"color:#9ECBFF\">}) :`\u003C/span>\u003Cspan style=\"color:#E1E4E8\">);\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  failures.\u003C/span>\u003Cspan style=\"color:#B392F0\">forEach\u003C/span>\u003Cspan style=\"color:#E1E4E8\">((\u003C/span>\u003Cspan style=\"color:#FFAB70\">f\u003C/span>\u003Cspan style=\"color:#E1E4E8\">) \u003C/span>\u003Cspan style=\"color:#F97583\">=>\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> console.\u003C/span>\u003Cspan style=\"color:#B392F0\">error\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#9ECBFF\">'  ✗ '\u003C/span>\u003Cspan style=\"color:#F97583\"> +\u003C/span>\u003Cspan style=\"color:#E1E4E8\"> f));\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  process.\u003C/span>\u003Cspan style=\"color:#B392F0\">exit\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#79B8FF\">1\u003C/span>\u003Cspan style=\"color:#E1E4E8\">);\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">}\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">console.\u003C/span>\u003Cspan style=\"color:#B392F0\">log\u003C/span>\u003Cspan style=\"color:#E1E4E8\">(\u003C/span>\u003Cspan style=\"color:#9ECBFF\">`SEO GATE PASS (${\u003C/span>\u003Cspan style=\"color:#79B8FF\">STRICTNESS\u003C/span>\u003Cspan style=\"color:#9ECBFF\">}) — ${\u003C/span>\u003Cspan style=\"color:#79B8FF\">URLS\u003C/span>\u003Cspan style=\"color:#9ECBFF\">.\u003C/span>\u003Cspan style=\"color:#79B8FF\">length\u003C/span>\u003Cspan style=\"color:#9ECBFF\">} URL(s)`\u003C/span>\u003Cspan style=\"color:#E1E4E8\">);\u003C/span>\u003C/span>\u003C/code>\u003C/pre>\n\u003Cp>Point clé : \u003Ccode>fetch\u003C/code> + \u003Ccode>linkedom\u003C/code> lit le HTML servi, sans hydratation. C'est exactement la vue Googlebot first-pass. Si votre framework injecte le \u003Ccode>title\u003C/code> côté client, le gate \u003Ccode>fail\u003C/code> — c'est le comportement voulu.\u003C/p>\n\u003Ch2>Étape 2 — Le workflow\u003C/h2>\n\u003Cp>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.\u003C/p>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\"># .github/workflows/seo-gate.yml\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#85E89D\">name\u003C/span>\u003Cspan style=\"color:#E1E4E8\">: \u003C/span>\u003Cspan style=\"color:#9ECBFF\">SEO Gate\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#79B8FF\">on\u003C/span>\u003Cspan style=\"color:#E1E4E8\">:\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#85E89D\">  pull_request\u003C/span>\u003Cspan style=\"color:#E1E4E8\">:\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#85E89D\">    branches\u003C/span>\u003Cspan style=\"color:#E1E4E8\">: [\u003C/span>\u003Cspan style=\"color:#9ECBFF\">main\u003C/span>\u003Cspan style=\"color:#E1E4E8\">]\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#85E89D\">  workflow_dispatch\u003C/span>\u003Cspan style=\"color:#E1E4E8\">:\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#85E89D\">jobs\u003C/span>\u003Cspan style=\"color:#E1E4E8\">:\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#85E89D\">  seo-gate\u003C/span>\u003Cspan style=\"color:#E1E4E8\">:\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#85E89D\">    runs-on\u003C/span>\u003Cspan style=\"color:#E1E4E8\">: \u003C/span>\u003Cspan style=\"color:#9ECBFF\">ubuntu-latest\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#85E89D\">    steps\u003C/span>\u003Cspan style=\"color:#E1E4E8\">:\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">      - \u003C/span>\u003Cspan style=\"color:#85E89D\">uses\u003C/span>\u003Cspan style=\"color:#E1E4E8\">: \u003C/span>\u003Cspan style=\"color:#9ECBFF\">actions/checkout@v7\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">      - \u003C/span>\u003Cspan style=\"color:#85E89D\">uses\u003C/span>\u003Cspan style=\"color:#E1E4E8\">: \u003C/span>\u003Cspan style=\"color:#9ECBFF\">actions/setup-node@v4\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#85E89D\">        with\u003C/span>\u003Cspan style=\"color:#E1E4E8\">:\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#85E89D\">          node-version\u003C/span>\u003Cspan style=\"color:#E1E4E8\">: \u003C/span>\u003Cspan style=\"color:#79B8FF\">20\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">      - \u003C/span>\u003Cspan style=\"color:#85E89D\">run\u003C/span>\u003Cspan style=\"color:#E1E4E8\">: \u003C/span>\u003Cspan style=\"color:#9ECBFF\">npm ci\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">      - \u003C/span>\u003Cspan style=\"color:#85E89D\">run\u003C/span>\u003Cspan style=\"color:#E1E4E8\">: \u003C/span>\u003Cspan style=\"color:#9ECBFF\">npm run build\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">      - \u003C/span>\u003Cspan style=\"color:#85E89D\">run\u003C/span>\u003Cspan style=\"color:#E1E4E8\">: \u003C/span>\u003Cspan style=\"color:#9ECBFF\">npm run start &#x26;\u003C/span>\u003Cspan style=\"color:#6A737D\">     # serveur de prod en arrière-plan\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">      - \u003C/span>\u003Cspan style=\"color:#85E89D\">run\u003C/span>\u003Cspan style=\"color:#E1E4E8\">: \u003C/span>\u003Cspan style=\"color:#9ECBFF\">npx wait-on http://localhost:3000\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">      - \u003C/span>\u003Cspan style=\"color:#85E89D\">name\u003C/span>\u003Cspan style=\"color:#E1E4E8\">: \u003C/span>\u003Cspan style=\"color:#9ECBFF\">SEO gate\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#85E89D\">        env\u003C/span>\u003Cspan style=\"color:#E1E4E8\">:\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#85E89D\">          SEO_STRICTNESS\u003C/span>\u003Cspan style=\"color:#E1E4E8\">: \u003C/span>\u003Cspan style=\"color:#9ECBFF\">standard\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#85E89D\">          SEO_URLS\u003C/span>\u003Cspan style=\"color:#E1E4E8\">: \u003C/span>\u003Cspan style=\"color:#9ECBFF\">\"http://localhost:3000/,http://localhost:3000/produits\"\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#85E89D\">        run\u003C/span>\u003Cspan style=\"color:#E1E4E8\">: \u003C/span>\u003Cspan style=\"color:#9ECBFF\">node scripts/seo-gate.mjs\u003C/span>\u003C/span>\u003C/code>\u003C/pre>\n\u003Cp>Le step \u003Ccode>SEO gate\u003C/code> sort en \u003Ccode>exit 1\u003C/code> 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.\u003C/p>\n\u003Ch2>Étape 3 — Bloquer réellement le merge\u003C/h2>\n\u003Cp>Un job rouge ne suffit pas : il faut le rendre \u003Cstrong>required\u003C/strong>. Dans \u003Ccode>Settings → Branches → Branch protection rules\u003C/code>, ajoutez \u003Ccode>seo-gate\u003C/code> aux required status checks de \u003Ccode>main\u003C/code>. 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é.\u003C/p>\n\u003Cp>Ajoutez donc le trigger :\u003C/p>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#79B8FF\">on\u003C/span>\u003Cspan style=\"color:#E1E4E8\">:\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#85E89D\">  pull_request\u003C/span>\u003Cspan style=\"color:#E1E4E8\">:\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#85E89D\">    branches\u003C/span>\u003Cspan style=\"color:#E1E4E8\">: [\u003C/span>\u003Cspan style=\"color:#9ECBFF\">main\u003C/span>\u003Cspan style=\"color:#E1E4E8\">]\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#85E89D\">  merge_group\u003C/span>\u003Cspan style=\"color:#E1E4E8\">:\u003C/span>\u003C/span>\u003C/code>\u003C/pre>\n\u003Ch2>Les trois niveaux de strictness, et quand les utiliser\u003C/h2>\n\u003Ctable>\n\u003Cthead>\n\u003Ctr>\n\u003Cth>Niveau\u003C/th>\n\u003Cth>Invariants\u003C/th>\n\u003Cth>Cas d'usage\u003C/th>\n\u003C/tr>\n\u003C/thead>\n\u003Ctbody>\n\u003Ctr>\n\u003Ctd>\u003Ccode>blocking\u003C/code>\u003C/td>\n\u003Ctd>title présent, pas de noindex, HTTP 200\u003C/td>\n\u003Ctd>Hotfix, branches rapides : ne bloque que sur catastrophe\u003C/td>\n\u003C/tr>\n\u003Ctr>\n\u003Ctd>\u003Ccode>standard\u003C/code>\u003C/td>\n\u003Ctd>+ un seul H1, canonical\u003C/td>\n\u003Ctd>Flux de PR quotidien\u003C/td>\n\u003C/tr>\n\u003Ctr>\n\u003Ctd>\u003Ccode>strict\u003C/code>\u003C/td>\n\u003Ctd>+ longueur title, meta description, og:title\u003C/td>\n\u003Ctd>Release vers prod, templates critiques\u003C/td>\n\u003C/tr>\n\u003C/tbody>\n\u003C/table>\n\u003Cp>Pilotez le niveau par environnement : \u003Ccode>blocking\u003C/code> sur les preview branches, \u003Ccode>strict\u003C/code> 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.\u003C/p>\n\u003Ch2>La limite de ce gate maison, et où Seogard prend le relais\u003C/h2>\n\u003Cp>Ce script attrape l'absence d'un invariant. Il n'attrape pas une \u003Cstrong>régression différentielle\u003C/strong> : un \u003Ccode>title\u003C/code> toujours présent mais qui passe de la valeur métier au fallback générique (le \u003Ccode>&#x3C;h1>\u003C/code> ou le nom de site). C'est précisément le scénario du first-fallback dont nous parlons pour \u003Ca href=\"/blog/contentful-le-champ-seo-title-non-sync-vers-next-js-fallback-first-h1-genere\">Contentful où le champ SEO title non synchronisé génère un fallback sur le premier H1\u003C/a>. Votre assert \u003Ccode>hasTitle\u003C/code> reste vert alors que le SEO a régressé.\u003C/p>\n\u003Cp>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 \u003Ccode>title\u003C/code> ou le \u003Ccode>&#x3C;h1>\u003C/code> diffèrent entre HTML brut et rendu JS, ou quand ils dérivent d'un snapshot de référence — un cas typique étant \u003Ca href=\"/blog/multi-currency-dropdown-change-le-title-cote-csr-google-voit-le-default-usd\">un title modifié côté CSR pendant que Google voit le default\u003C/a>. Le verdict pass/fail arrive en webhook, avec les mêmes trois niveaux de strictness, mais sur une base différentielle que le \u003Ccode>querySelector\u003C/code> ne voit pas. Détails sur \u003Ca href=\"/\">seogard.io\u003C/a>.\u003C/p>\n\u003Ch2>Récap\u003C/h2>\n\u003Cul>\n\u003Cli>Le gate lit le \u003Cstrong>HTML brut\u003C/strong>, pas le DOM hydraté : c'est la vue Googlebot.\u003C/li>\n\u003Cli>\u003Ccode>exit 1\u003C/code> au premier invariant violé fait échouer le job ; rendez-le \u003Cstrong>required\u003C/strong> + ajoutez le trigger \u003Ccode>merge_group\u003C/code>.\u003C/li>\n\u003Cli>Trois niveaux de strictness : \u003Ccode>blocking\u003C/code> en preview, \u003Ccode>strict\u003C/code> en prod.\u003C/li>\n\u003Cli>Un script \u003Ccode>querySelector\u003C/code> 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.\u003C/li>\n\u003C/ul>\n\u003Cp>Référence officielle : \u003Ca href=\"https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows\">Events that trigger workflows\u003C/a> (GitHub Docs).\u003C/p>\n\u003Cpre>\u003Ccode>\u003C/code>\u003C/pre>",null,9,[18,19,20],"github-actions","ci-cd-seo","regression-seo","Gate SEO GitHub Actions : faire échouer un déploiement régressif","Fri Jun 19 2026 17:17:06 GMT+0000 (Coordinated Universal Time)",[24,41,59,73,87,102],{"_id":25,"slug":26,"__v":6,"author":7,"canonical":27,"category":10,"createdAt":28,"date":29,"description":30,"image":15,"imageAlt":15,"readingTime":31,"tags":32,"title":38,"updatedAt":39,"categoryLegacy":40},"69d80609aa6b273b0cac64e3","inside-google-discover-20-pipelines-42-million-cards-and-what-they-mean-for-publishers","https://seogard.io/blog/inside-google-discover-20-pipelines-42-million-cards-and-what-they-mean-for-publishers","2026-04-09T20:03:21.033Z","2026-04-09","Analyse technique des 20 pipelines Google Discover, leurs 42 millions de cards, et les leviers concrets pour maximiser la visibilité éditoriale.",14,[33,34,35,36,37],"google discover","pipelines","feed optimization","structured data","publisher SEO","Inside Google Discover : 20 pipelines, 42M cards décryptés","Thu Apr 09 2026 20:03:21 GMT+0000 (Coordinated Universal Time)","Actualités SEO",{"_id":42,"slug":43,"__v":6,"author":7,"canonical":44,"category":10,"createdAt":45,"date":46,"description":47,"image":15,"imageAlt":15,"readingTime":48,"tags":49,"title":56,"updatedAt":57,"categoryLegacy":58},"69d5ef73fd4d84bed9a4b29a","automatiser-les-checks-seo-dans-le-ci-cd","https://seogard.io/blog/automatiser-les-checks-seo-dans-le-ci-cd","2026-04-08T06:02:27.352Z","2026-04-08","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.",12,[50,51,52,53,54,55],"ci-cd","automatisation","seo","tests","pipeline","régressions","Checks SEO dans le CI/CD : guide d'intégration complet","Wed Apr 08 2026 06:02:27 GMT+0000 (Coordinated Universal Time)","Outils",{"_id":60,"slug":61,"__v":6,"author":7,"canonical":62,"category":10,"createdAt":63,"date":64,"description":65,"image":15,"imageAlt":15,"readingTime":48,"tags":66,"title":70,"updatedAt":71,"categoryLegacy":72},"69d31b9cf4fa19862892a2f6","deploiement-vendredi-soir-comment-eviter-la-catastrophe-seo","https://seogard.io/blog/deploiement-vendredi-soir-comment-eviter-la-catastrophe-seo","2026-04-06T02:34:04.692Z","2026-04-06","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.",[67,50,52,68,69],"déploiement","regression","monitoring","Déploiement vendredi soir : garde-fous SEO dans le CI/CD","Mon Apr 06 2026 04:03:20 GMT+0000 (Coordinated Universal Time)","Monitoring",{"_id":74,"slug":75,"__v":6,"author":7,"canonical":76,"category":77,"createdAt":78,"date":12,"description":79,"image":15,"imageAlt":15,"readingTime":48,"tags":80,"title":85,"updatedAt":86,"categoryLegacy":82},"6a34db4aaa6b273b0c724ac7","hreflang-generated-pointent-vers-des-domaines-supprimes","https://seogard.io/blog/hreflang-generated-pointent-vers-des-domaines-supprimes","Régressions SEO","2026-06-19T06:01:46.165Z","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.",[81,82,83,84],"hreflang","i18n","cleanup","domains","Hreflang vers domaines supprimés : −38% de trafic DE en 6 semaines","Fri Jun 19 2026 06:01:46 GMT+0000 (Coordinated Universal Time)",{"_id":88,"slug":89,"__v":6,"author":7,"canonical":90,"category":91,"createdAt":92,"date":12,"description":93,"image":15,"imageAlt":15,"readingTime":94,"tags":95,"title":100,"updatedAt":101,"categoryLegacy":82},"6a3567e0aa6b273b0ce66e5a","multi-currency-dropdown-change-le-title-cote-csr-google-voit-le-default-usd","https://seogard.io/blog/multi-currency-dropdown-change-le-title-cote-csr-google-voit-le-default-usd","SSR / CSR","2026-06-19T16:01:36.079Z","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.",11,[96,97,98,99,82],"multi currency","csr","title","spa","Multi-currency dropdown réécrit le title côté CSR : fix","Fri Jun 19 2026 16:01:36 GMT+0000 (Coordinated Universal Time)",{"_id":103,"slug":104,"__v":6,"author":7,"canonical":105,"category":77,"createdAt":106,"date":107,"description":108,"image":15,"imageAlt":15,"readingTime":48,"tags":109,"title":113,"updatedAt":114,"categoryLegacy":82},"6a3389c9aa6b273b0c5c95c6","crowdin-auto-translate-injecte-lang-auto-signal-de-langue-casse-sur-tout-le-site","https://seogard.io/blog/crowdin-auto-translate-injecte-lang-auto-signal-de-langue-casse-sur-tout-le-site","2026-06-18T06:01:45.023Z","2026-06-18","Crowdin auto-translate injecte lang=\\\"auto\\\" sur tout le site. Google confond le marché cible. Récit, diagnostic et fix complet.",[110,82,111,112],"crowdin","lang","translation","Crowdin lang=\\\"auto\\\" : signal de langue cassé, −34 % trafic","Thu Jun 18 2026 06:01:45 GMT+0000 (Coordinated Universal Time)",{"categories":116},[117,120,124,128,131,135,137],{"category":77,"slug":118,"count":119},"regressions-seo",137,{"category":121,"slug":122,"count":123},"GEO / IA","geo-ia",98,{"category":125,"slug":126,"count":127},"Indexation & crawl","indexation-crawl",32,{"category":91,"slug":129,"count":130},"ssr-csr",22,{"category":132,"slug":133,"count":134},"Données structurées","donnees-structurees",6,{"category":10,"slug":19,"count":136},4,{"category":138,"slug":139,"count":140},"Monitoring continu","monitoring-continu",3]