Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions .github/workflows/docs-transients-guard.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
name: docs-transients-guard

# Lane transiente docs/transients/ + interdiction d'entree de rapports dates
# neufs sous docs/archive/ (#14623, tranche 2).
#
# Doctrine : l'archive est un stock a resorber, pas une destination. La
# destination d'un rapport date (audit, compte rendu, instantané) est
# docs/transients/ -- nom <YYYY-MM-DD>-<slug>.md + en-tete gele
# "> RAPPORT - <date> - perimetre - fige". Deplacer un document EXISTANT vers
# l'archive reste permis (reclasse, rename) ; c'est la creation qui est
# signalee. Convention complete : docs/transients/README.md.
#
# Trois sens verifies en un appel : dans la lane (nom date + en-tete gele
# concordants), hors de la lane (aucun en-tete de rapport en tete de document
# dans l'arbre perenne), entree d'archive (base...HEAD, creations seulement).
# L'echec du check-run EST le signalement, visible sans ouvrir les logs --
# meme modele que prose-counts-guard (#17636).

on:
pull_request:
paths:
- 'docs/**/*.md'
- 'scripts/check_docs_transients_lane.py'

permissions:
contents: read

concurrency:
group: docs-transients-guard-${{ github.ref }}
cancel-in-progress: true

jobs:
transients:
# Route vers la jambe Linux auto-hebergee (#13378 tranche 1, decision ai-01
# 2026-09-01) : garde Python pur, sans secret ni usage du GITHUB_TOKEN. La
# garde same-repo ci-dessous saute les PRs de fork (pr_gate.py compte
# `skipped` comme OK) : aucun code de fork n'atteint le runner.
runs-on: [self-hosted, coursia-ephemeral, coursia-linux]
if: github.event.pull_request.head.repo.full_name == null || github.event.pull_request.head.repo.full_name == github.repository
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with:
# Shallow (fetch-depth: 2) + fetch explicite de la base depth=1,
# comme prose-counts-guard (#11835) : le diff base...HEAD tient
# sans cloner l'historique complet du depot.
fetch-depth: 2

- uses: actions/setup-python@v5
with:
python-version: '3.12'

- name: Lane transiente et entree d'archive
run: |
# Base = HEAD^1 : sur evenement pull_request, checkout@v4 pose le
# merge ref refs/pull/N/merge -- HEAD est le commit de merge, dont
# le premier parent EST le cote base. Un fetch depth=1 de
# origin/<base_ref> ne suffit pas : quand <base_ref> a bouge entre
# la construction du merge ref et ce fetch, les bornes shallow des
# deux fetchs n'ont aucun ancetre commun et le diff three-dot meurt
# -- mesure run 110043387149 : "fatal: origin/main...HEAD: no merge
# base" -> GIT_DIFF_FAILED. HEAD^1 est present par construction
# (fetch-depth: 2) et merge-base se resout en parent direct ; le
# diff rend exactement les fichiers du plateau GitHub (verifie sur
# repro de la topologie : 6 fichiers, identiques a gh pr view).
python scripts/check_docs_transients_lane.py --base "HEAD^1"
4 changes: 2 additions & 2 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -302,11 +302,11 @@ Lane des comptes rendus et audits **datés** : un transient y naît, s'y conserv

| Fichier | Description |
|---------|-------------|
| [transients/README.md](transients/README.md) | Convention de la lane : nom daté `<YYYY-MM-DD>-<slug>.md`, en-tête gelé `> RAPPORT — <date> — <périmètre> — figé`, règle « née ici, sort par distillation », périmètre et hors-périmètre. Lane **vide à dessein** à l'ouverture : la re-vérification des fichiers listés par #14623 a mesuré que la majorité n'est pas transiente |
| [transients/README.md](transients/README.md) | Convention de la lane : nom daté `<YYYY-MM-DD>-<slug>.md`, en-tête gelé `> RAPPORT — <date> — <périmètre> — figé`, règle « née ici, sort par distillation », **critère de sortie de `docs/archive/`** (trois voies + interdiction d'entrée des rapports datés neufs, organe `--base`), périmètre et hors-périmètre. Lane **vide à dessein** à l'ouverture : la re-vérification des fichiers listés par #14623 a mesuré que la majorité n'est pas transiente |

## Archive (docs/archive/)

Documents conservés pour référence mais inactifs. Index complet : [archive/INDEX.md](archive/INDEX.md).
Documents conservés pour référence mais inactifs. Index complet : [archive/INDEX.md](archive/INDEX.md). L'archive est un **stock à résorber, pas une destination** : critère de sortie (distillation / restauration / retrait, preuve exigée) et interdiction d'entrée des rapports datés neufs — [transients/README.md §Sortie d'archive](transients/README.md#sortie-darchive-docsarchive) (#14623).

| Fichier | Description |
|---------|-------------|
Expand Down
28 changes: 24 additions & 4 deletions docs/transients/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,18 +41,38 @@ Le mot `figé` est le marqueur du contrat : un document qui le porte est un inst

Un rapport ne s'accumule pas. Quand sa conclusion est durable, elle est **distillée** dans le document pérenne qui la porte (règle, référence, procédure, README de série), et ce document **cite** le rapport. Une fois la distillation faite, le rapport a fini son office : il peut être archivé ou retiré. La lane est un **transit**, pas un stock.

## Sortie d'archive (`docs/archive/`)

L'archive est un **stock à résorber, pas une destination** (tranche 2 de #14623). Elle ne grandit plus par dépôt de rapports neufs ; elle se résorbe par trois voies, chacune appuyée sur une preuve citée dans la PR :

| Voie | Quand | Preuve exigée dans la PR |
|---|---|---|
| **Distillation** | le contenu durable d'un document archivé sert un document vivant | le diff du document pérenne enrichi, plus le chemin de l'original archivé — qui **reste en place** : l'archive ne réécrit pas l'histoire, elle se résorbe morceau par morceau |
| **Restauration** | un document archivé s'avère mal classé : c'est une référence pérenne | la citation de l'en-tête ou du contenu qui fonde le caractère pérenne — établie sur **lecture complète** du fichier, jamais sur le titre (leçon de la re-vérification #14623 : « état d'une série » est pérenne par la table des catégories elle-même) |
| **Retrait** | contenu intégralement absorbé ou dupliqué ailleurs | preuve de préservation : diff vide contre le document survivant, ou contenu byte-identique cité |

**Entrée : un rapport daté neuf ne rejoint plus `docs/archive/`.** La destination d'un rapport daté est cette lane, `docs/transients/` (nom daté + en-tête gelé). Déplacer un document **existant** vers l'archive reste légitime — c'est une reclasse, pas une création. La distinction est mécanique : l'organe signale toute **création** (statut `A` du diff ; les renames `R` sont des reclasses) sous `docs/archive/` portant la signature transiente — nom daté `<YYYY-MM-DD>-…` ou en-tête gelé en tête :

```bash
python scripts/check_docs_transients_lane.py --base origin/main
```

La garde CI [docs-transients-guard](../../.github/workflows/docs-transients-guard.yml) applique ce contrôle à chaque PR touchant `docs/**/*.md`.

## Vérification

```bash
python scripts/check_docs_transients_lane.py # CONFORME (0) / VIOLATION (1)
python scripts/check_docs_transients_lane.py --json # verdict machine
python scripts/check_docs_transients_lane.py # CONFORME (0) / VIOLATION (1)
python scripts/check_docs_transients_lane.py --json # verdict machine
python scripts/check_docs_transients_lane.py --base origin/main # + contrôle d'entrée d'archive
python -m pytest scripts/tests/test_check_docs_transients_lane.py -q
```

L'organe vérifie les **deux sens** du contrat :
L'organe vérifie les **trois sens** du contrat :

- **dans la lane** — chaque `*.md` (sauf ce README) est daté et porte l'en-tête gelé, dont la date concorde avec celle du nom ;
- **hors de la lane** — aucun fichier de `docs/` (hors `docs/archive/` et cette lane) ne porte l'en-tête `> RAPPORT —` en tête de document : un transient égaré dans l'arbre pérenne est exactement la dérive que cette lane existe pour rendre visible.
- **hors de la lane** — aucun fichier de `docs/` (hors `docs/archive/` et cette lane) ne porte l'en-tête `> RAPPORT —` en tête de document : un transient égaré dans l'arbre pérenne est exactement la dérive que cette lane existe pour rendre visible ;
- **entrée d'archive** (avec `--base <ref>`) — aucune **création** de rapport daté sous `docs/archive/` dans le diff `<ref>...HEAD` : les reclasses (renames) restent permises, les créations portant la signature transiente sont signalées. Un échec du `git diff` sous-jacent est lui-même un finding (`GIT_DIFF_FAILED`), jamais un acquittement silencieux.

## État

Expand Down
137 changes: 131 additions & 6 deletions scripts/check_docs_transients_lane.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
declaration, so this organ is the measurable half. Convention and rationale:
``docs/transients/README.md``.

Two directions, both checked:
Two directions are always checked, a third when ``--base`` is given:

inside the lane every ``*.md`` but the lane README is dated
(``YYYY-MM-DD-slug.md``) and carries the frozen header
Expand All @@ -16,7 +16,14 @@
outside the lane no file under ``docs/`` (outside ``docs/archive/`` and the
lane itself) carries that header in header position -- a
transient parked in the perennial tree is the exact drift
the lane exists to surface.
the lane exists to surface;
archive entry (``--base <ref>`` only, tranche 2 of #14623) no file is
CREATED under ``docs/archive/`` in the ``<ref>...HEAD``
diff carrying the transient signature (dated filename or
frozen header). The archive is stock to absorb, not a
destination; renames INTO it are reclassifications and stay
permitted. A failing ``git diff`` is itself a finding
(``GIT_DIFF_FAILED``), never a silent pass.

Header matching tolerates the dash variant (em dash, en dash, hyphen) and the
accent variant (fige/fige-with-accent) a writer may produce; what it enforces is
Expand All @@ -32,13 +39,15 @@
python scripts/check_docs_transients_lane.py
python scripts/check_docs_transients_lane.py --json
python scripts/check_docs_transients_lane.py --root . --lane-dir docs/transients
python scripts/check_docs_transients_lane.py --base origin/main
"""

from __future__ import annotations

import argparse
import json
import re
import subprocess
import sys
from pathlib import Path

Expand Down Expand Up @@ -158,15 +167,109 @@ def scan_outside(root: Path, lane_dir: Path, archive_dir: Path) -> tuple[list[di
return findings, scanned


def run(root: Path = REPO_ROOT, lane_dir: Path | None = None) -> dict:
def _git_archive_entries(root: Path, base: str) -> list[tuple[str, str, str]] | None:
"""(status, src, dst) of every docs/archive change in ``base...HEAD``.

Rename detection is ON so a move into the archive shows as ``Rxx`` (a
reclassification, permitted) while a creation shows as ``A`` (the gesture
the entry ban targets). Returns ``None`` when git itself fails -- the
caller turns that into a finding rather than a silent pass.
"""
try:
proc = subprocess.run(
[
"git",
"-C",
str(root),
"diff",
"-M",
"--name-status",
f"{base}...HEAD",
"--",
"docs/archive",
],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=60,
)
except OSError:
return None
if proc.returncode != 0:
return None
entries: list[tuple[str, str, str]] = []
for line in proc.stdout.splitlines():
parts = line.split("\t")
if len(parts) < 2:
continue
status, dst = parts[0], parts[-1]
src = parts[1] if len(parts) >= 3 else ""
entries.append((status, src, dst))
return entries


def scan_archive_additions(
root: Path, entries: list[tuple[str, str, str]] | None
) -> list[dict]:
"""Flag dated-report CREATIONS under docs/archive/ (tranche 2, #14623).

``A`` entries carrying the transient signature (dated filename or frozen
header in header position) are findings; ``R`` renames are reclassifications
and pass, as do creations without the signature (an ``INDEX.md`` refresh,
for instance). ``None`` entries mean git failed: that is a finding too.
"""
if entries is None:
return [
{
"file": "docs/archive (diff)",
"reason": (
"GIT_DIFF_FAILED: le git diff sous-jacent a echoue -- "
"le controle d'entree d'archive n'a pas pu etre execute, "
"ce n'est pas un acquittement"
),
}
]
findings: list[dict] = []
for status, _src, dst in entries:
if not status.startswith("A"):
continue
dated_name = DATE_PREFIX_RE.match(Path(dst).name) is not None
frozen_header = _header_match(_header_lines(root / dst)) is not None
if dated_name or frozen_header:
findings.append(
{
"file": dst,
"reason": (
"ARCHIVE_NEW_REPORT: creation d'un rapport date sous "
"docs/archive/ -- l'archive est un stock a resorber, "
"pas une destination ; un rapport date neuf vit dans "
"docs/transients/ (nom <YYYY-MM-DD>-<slug>.md + "
"en-tete gele). Deplacer un document EXISTANT vers "
"l'archive reste permis (reclasse, rename)"
),
}
)
return findings


def run(
root: Path = REPO_ROOT, lane_dir: Path | None = None, base: str | None = None
) -> dict:
root = Path(root).resolve()
lane_dir = Path(lane_dir) if lane_dir else root / LANE_REL
lane_dir = lane_dir.resolve()
archive_dir = (root / ARCHIVE_REL).resolve()

lane_findings = scan_lane(lane_dir)
outside_findings, scanned = scan_outside(root, lane_dir, archive_dir)
findings = lane_findings + outside_findings
if base is not None:
archive_entries = _git_archive_entries(root, base)
archive_findings = scan_archive_additions(root, archive_entries)
else:
archive_entries = None
archive_findings = []
findings = lane_findings + outside_findings + archive_findings
lane_files = (
len([p for p in lane_dir.glob("*.md") if p.name != LANE_README])
if lane_dir.is_dir()
Expand All @@ -178,6 +281,8 @@ def run(root: Path = REPO_ROOT, lane_dir: Path | None = None) -> dict:
"lane_dir": str(lane_dir),
"lane_files": lane_files,
"outside_scanned": scanned,
"archive_base": base,
"archive_entries": None if archive_entries is None else len(archive_entries),
"findings": findings,
}

Expand All @@ -189,6 +294,16 @@ def format_report(report: dict) -> str:
" hors lane : {outside_scanned} fichier(s) 'docs/**/*.md' scanne(s) "
"(hors archive/, hors lane)".format(**report),
]
if report.get("archive_base"):
entries = report.get("archive_entries")
entries_txt = (
"git diff en echec" if entries is None else f"{entries} entree(s) du diff"
)
lines.append(
" archive : base {archive_base}, {e}".format(
e=entries_txt, **report
)
)
if report["findings"]:
lines.append("")
lines.append(" VIOLATION -- {n} finding(s)".format(n=len(report["findings"])))
Expand All @@ -197,7 +312,8 @@ def format_report(report: dict) -> str:
lines.append(" {reason}".format(**finding))
else:
lines.append("")
lines.append(" CONFORME -- les deux sens du contrat tiennent")
senses = 3 if report.get("archive_base") else 2
lines.append(f" CONFORME -- les {senses} sens du contrat tiennent")
return "\n".join(lines)


Expand All @@ -216,9 +332,18 @@ def main(argv: list[str] | None = None) -> int:
help="repertoire de la lane (defaut : <root>/docs/transients)",
)
parser.add_argument("--json", action="store_true", help="sortie machine")
parser.add_argument(
"--base",
default=None,
help=(
"ref de base du diff (ex. origin/main) : ajoute le controle "
"d'entree d'archive -- aucune creation de rapport date sous "
"docs/archive/ dans <base>...HEAD"
),
)
args = parser.parse_args(argv)

report = run(root=args.root, lane_dir=args.lane_dir)
report = run(root=args.root, lane_dir=args.lane_dir, base=args.base)
if args.json:
print(json.dumps(report, ensure_ascii=False, indent=2))
else:
Expand Down
12 changes: 12 additions & 0 deletions scripts/ci/check_self_hosted_runner_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,18 @@
# par Hermes (CONCERNS 19/09) : la PR routait le job self-hosted sans
# toucher cette allowlist fail-closed. Rollback = revert de la PR.
"organ-duplication-advisory.yml",
# #14623 (owner myia-po-2024:CoursIA, PR #18595) : docs-transients
# guard -- detecteur de fichiers transients (rapports/coordination)
# qui doivent vivre sur le dashboard, pas dans le repo (regle
# harness-hygiene, tier Ephemere). Pure-Python stdlib, garde
# same-repo au niveau job (#13874, if head.repo == repository),
# pull_request filtre par paths en auto-couverture des fichiers
# de l'organe, workflow_dispatch pour re-run manuel. runs-on
# STATIQUE jambe Linux containerisee (routage #14283 tranche 3).
# Entree ajoutee au repair du rouge WORKFLOW_NOT_ALLOWED (relecture
# ai-01 c.5917276825) : la PR routait le job self-hosted sans
# toucher cette allowlist fail-closed. Rollback = revert de la PR.
"docs-transients-guard.yml",
}
GITHUB_HOSTED_LABELS = {
"ubuntu-latest",
Expand Down
Loading
Loading