---
paths: "{COURSE_CATALOG.generated.*,**/README*.md,.github/workflows/catalog-*.yml,.github/workflows/translation-sync.yml,scripts/**/catalog*}"
---

# Catalogue & hygiène PR — le catalogue appartient à l'automatisation

S'applique à **tous les agents du cluster CoursIA** (workers `po-*` + coordinateur `ai-01`) qui ouvrent des PR. Source : mandat user 2026-06-06 (« régler définitivement le pb du catalogue et faire faire aux agents workers le travail t'économisant le tien »). Codifie la leçon `stale-catalog-silent-revert` (incidents #2376 / #2383 / #2385). **Détecté par CI** : `catalog-drift.yml` (check `Notebook catalog drift (read-only, advisory)`, non-bloquant) signale toute PR touchant le catalogue ; la régénération est portée par `catalog-cron.yml` (PR permanente). Le garde bloquant `catalog-pr-guard.yml` a été retiré (#11012) : entité fantôme, 0 run `pull_request` sur ~925 runs `push` en échec. See #2632.

## Règle HARD 1 — NE JAMAIS régénérer le catalogue sur une branche feature

`COURSE_CATALOG.generated.json`, `COURSE_CATALOG.generated.md` et les blocs `<!-- CATALOG-STATUS:START -->…:END -->` dans les README **sont des artefacts générés appartenant à l'automatisation**. Un agent ne les régénère **jamais** à la main sur une branche.

**Pourquoi** : le catalogue embarque des champs git-dérivés (`last_validation`, `issue_pr_associee`, …) + une heuristique de maturité qui **dérivent avec le temps** au fil des commits sur `main`. Régénérer sur une branche dont la base est ancienne produit un diff massif (1000+ lignes) qui mélange des entrées **sans rapport** avec le livrable → conflit catalogue à chaque merge, revert silencieux des champs curés des autres entrées, et explosion de tokens côté coordinateur pour démêler (`git merge origin/main` + de-churn manuel, un par PR).

**Qui régénère, alors** :
- `.github/workflows/catalog-cron.yml` — **cron quotidien** (schedule 03:37 UTC) qui régénère `.json` + `.md` + marqueurs + curriculum + health-dashboard, **sur une branche longue `chore/catalog-refresh-pending`**, commit par `github-actions[bot]`, et ouvre/pingue **une PR permanente** vers `main`. Le bot ne pousse **jamais** directement sur `main` (le `PR gate` requis par la protection de branche est incompatible avec un déclencheur `schedule:` — voir #10136, run 31293765051 du 2026-08-09T04:03Z). C'est le backstop canonique.

  **Le bot crée lui-même le véhicule depuis le 2026-08-12** (résolution du 403 de #10331) : le réglage dépôt *Settings → Actions → General → « Allow GitHub Actions to create and approve pull requests »* est actif — première PR véhicule créée par le bot : #10558 (2026-08-12), puis #11195/#11384/#11585/#11717/#11897 mergées, #12287 ouverte. L'ouverture manuelle du véhicule (#10348, par `jsboige` sous le 403) **n'est plus requise** : après chaque merge du véhicule, le cron du jour suivant recrée la PR de remplacement tout seul.

  **Le commit du bot ne porte plus `[skip ci]`** (retiré par #10425, cf. #10421) : il le portait, et c'est précisément ce qui rendait le véhicule permanent **immergeable** — une tête sans check ne satisfait jamais le `PR gate`, donc la PR ne pouvait pas être mergée sans un geste manuel de réveil. Le marqueur avait été mis pour éviter que le cron ne se redéclenche lui-même ; il a été payé au prix d'une PR bloquée. Si une `chore/*-pending` se présente malgré tout avec un rollup quasi vide (≤ 10 checks, **tous CodeQL *default setup*, tous verts**, PR **BLOCKED**), la cause est connue et le geste de réveil est canonique :

  **Cause** : `actions/checkout@v4` pousse avec `GITHUB_TOKEN` (persist-credentials par défaut). **Un push authentifié par `GITHUB_TOKEN` n'émet aucun événement `pull_request`** (garde anti-récursion GitHub : un workflow `on: pull_request` ne peut pas se déclencher sur un push de `GITHUB_TOKEN`). Aucun événement → aucun check (autre que le *default setup* CodeQL, qui passe par un chemin hors-dépôt) → la tête reste sans le `PR gate` → `BLOCKED`. Le `[skip ci]` retiré par #10421 était une cause partielle de l'immergeabilité de la tête ; **la cause structurelle est le `GITHUB_TOKEN`**, et elle survit au retrait du marqueur.

  **Tell** (à vérifier avant le geste) : `gh api repos/<owner>/<repo>/commits/<sha>/check-runs` rendu sur la **tête bot** retourne ~5 checks, exclusivement CodeQL ; rendu sur **le même arbre sous identité non-bot** retourne ~25-27 checks. Si la tête bot a 5 verts-CodeQL et la PR est BLOCKED, c'est ce cas.

  **Geste canonique** : un **commit vide** (arbre identique, parent stable) sous une identité non-bot. À arbre inchangé, le contenu de la PR ne bouge pas — le prochain run du cron re-root la branche sur main et force-push sous bail, ce que ce commit ne gêne pas. Mesure : 5 → 25 checks (#11202 vérif sur #11195, cohérent avec #10348 5 → 27).

  ```bash
  # Sur la branche chore/catalog-refresh-pending (ou translation-sync-pending) :
  TREE=$(git rev-parse origin/chore/catalog-refresh-pending^{tree})
  PARENT=$(git rev-parse origin/chore/catalog-refresh-pending)
  NEW=$(git commit-tree "$TREE" -p "$PARENT" -m "chore(catalog): wake checks (GITHUB_TOKEN no-event workaround)")
  git push origin "$NEW:chore/catalog-refresh-pending"
  ```

  **`gh pr update-branch` est relégué** au seul cas « branche en retard sur main » (mesure : no-op quand la branche est déjà à jour, qui est le cas nominal d'une `chore/*-pending` re-rooted par le cron). Il ne couvre pas le cas GITHUB_TOKEN.
- `.github/workflows/catalog-drift.yml` — **par-PR**, auto-régénère et committe le catalogue sur la branche d'une PR same-repo (préserve les champs curés via `_merge_curated_fields`, #2433).
- `.github/workflows/translation-sync.yml` — variante pour les **traductions dérivées** (CSV + `*_<lang>.ipynb`), même motif longue-durée : `chore/translation-sync-pending` + PR permanente, post-merge delivery (cf. #10133, #10136). **Sur hold user depuis le 2026-08-12** (#10038, PR #10767) : le trigger `on: push` a été retiré par décision user — le moteur ne tourne plus que sur `workflow_dispatch` (« manual maintainer trigger »). Un véhicule `chore/translation-sync-pending` dormant n'est **pas** un bug : le mécanisme d'ouverture est débloqué (même toggle que catalog-cron, cf #10331), le véhicule s'ouvrira de lui-même à la première exécution post-levée du hold.

**Ce que fait l'agent à la place** : laisser le catalogue **byte-identique à `main`**. Si une branche a malgré tout du churn catalogue (régén accidentelle, base stale) :

```bash
git checkout origin/main -- COURSE_CATALOG.generated.json COURSE_CATALOG.generated.md
# puis re-checkout origin/main sur les README dont SEULS les marqueurs CATALOG-STATUS ont bougé
```

Une **nouvelle entrée** notebook (nouveau notebook ajouté) n'est PAS à inscrire à la main : le cron (`<24h`) ou la CI par-PR la crée. Si l'inscription immédiate est nécessaire, la confier à la lane catalog-drift (po-2023), **pas** la régénérer dans une PR de contenu.

**PR permanente (`chore/<name>-pending`) — modèle de livraison du bot (issue #10136)** : ces branches longues sont la propriété de l'automatisation, **pas** des agents. Si tu vois une `chore/catalog-refresh-pending` ou `chore/translation-sync-pending` pointer un commit récent que tu n'as pas écrit, **ne pas la réutiliser comme base d'une PR de contenu** — elle sera re-pushée / re-pinguée par le cron avant que ta PR ne passe le `PR gate`, et ton diff se fera piétiner. Pour modifier le code de `catalog-cron.yml` / `translation-sync.yml` : nouvelle branche `feature/<sujet>` à part, le cron n'y touche pas.

**Garde G.8 (auto-approbation bot)** : le toggle activé pour lever le 403 autorise aussi le bot à *approuver* des PRs. Vérifié le 2026-08-25 (cf #10331) : la protection de `main` n'exige **aucune review** (aucun état `REVIEW_REQUIRED` observé — le seul gate requis est le check `PR gate`, qu'une approval ne satisfait pas), et **zéro approval bot** dans l'historique récent (toutes les reviews sur PRs mergées sont du compte humain `jsboige`). Si une review requise est un jour ajoutée à la protection, vérifier alors que le bot ne peut pas la satisfaire seul (sinon rubber-stamp structurel).

## Règle HARD 2 — Rebase frais avant push

Repartir d'un `origin/main` à jour avant de pousser. Le label `base-stale-14d` (workflow `stale-base-warning.yml`) signale une base de plus de 14 jours en retard → re-baser **avant** de demander un merge. Une branche stale = source n°1 du poison catalogue ci-dessus + de conflits inutiles.

## Règle HARD 3 — Un seul livrable par PR (atomique)

Une PR = **un** sujet vérifiable (cf G.4 / one-subject-per-PR). Pas de composite « 4 notebooks + refactor script + docs » : split. Seuils CHANGES_REQUESTED : > 3000 lignes hors notebooks / > 15 fichiers / > 4 features / > 1 domaine ([pr-review-discipline.md](pr-review-discipline.md) §A).

## Règle HARD 4 — `Closes #X` quand la PR résout entièrement l'issue

- **`Closes #X` / `Fixes #X`** dans le body **uniquement** quand UNE PR résout **entièrement** UNE issue → GitHub la ferme automatiquement au merge (le backlog d'issues diminue tout seul).
- **`See #X` / `refs #X`** pour une **contribution partielle** à une epic (l'epic reste ouverte). Ne PAS utiliser `Closes` sur une sous-tâche d'epic.

Cette discipline est ce qui fait **baisser le compte d'issues** sans intervention manuelle du coordinateur : une PR qui clôt vraiment une issue le déclare, les epics au long cours restent `See`.

## Voir aussi

- [.claude/rules/git-workflow.md](git-workflow.md) — branches `feature/`, no force push, no direct main push
- [.claude/rules/proactive-coordination.md](proactive-coordination.md) — 1 PR/wakeup, atomique
- [.claude/rules/pr-review-discipline.md](pr-review-discipline.md) — §A composites trop larges
