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
5 changes: 3 additions & 2 deletions .github/workflows/pr-gate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -232,8 +232,9 @@ jobs:
# aussi les fichiers de la racine, ce qui suffit au reste du job.
#
# Perimetre requis, mesure : pr_gate.py n importe que la stdlib plus
# yaml en import paresseux, et son seul chemin est
# DEFAULT_WORKFLOWS_DIR = <repo>/.github/workflows, que
# yaml en import paresseux et scripts/gh_identity.py (#17418 Phase A,
# epinglage du jeton machine — in-come par le cone), et son seul
# chemin est DEFAULT_WORKFLOWS_DIR = <repo>/.github/workflows, que
# derive_always_on_jobs et derive_advisory_jobs globent (canari regle 8).
- uses: actions/checkout@v4
with:
Expand Down
25 changes: 24 additions & 1 deletion scripts/check_adjoint_prevalidation.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,12 +86,19 @@
import argparse
import hashlib
import json
import os
import re
import subprocess
import sys
from dataclasses import dataclass
from typing import Any

try:
import gh_identity
except ImportError: # charge via importlib dans les tests (hors scripts/)
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import gh_identity

REPO = "jsboige/CoursIA"
# The adjoint remains the canonical emitter: `--template` renders its lane, and
# it is the lane the coordinator nudges first. It is no longer the only one.
Expand Down Expand Up @@ -806,6 +813,14 @@ def render_template(snapshot: dict[str, Any], lane: str = ADJOINT_LANE) -> str:


def main() -> int:
# Warn-fort + poursuite (pas d'abort) : l'echec BRUYANT est porte par le
# helper, gh_identity --whoami et detect_shared_login.py ; fermer l'organe
# sur une lane sans compte machine (#17418 Phase B/C) arreterait les
# dossiers pendant la transition.
try:
gh_identity.pin_gh_token()
except gh_identity.GhIdentityError as exc:
print(f"GH-IDENTITY (WARN, poursuite sous compte actif): {exc}", file=sys.stderr)
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("pr", type=int, help="pull request number")
parser.add_argument("--json", action="store_true", help="emit machine-readable output")
Expand Down Expand Up @@ -857,11 +872,19 @@ def main() -> int:
UnicodeError,
json.JSONDecodeError,
) as exc:
errors = [f"UNKNOWN: {exc}"]
# #17418 Phase A : un rc=2 par rate-limit ne doit plus se lire comme
# « pas de dossier » (rc=1). La banniere nomme la cause et la
# remediation — c'est la confusion des deux qui a coute ~3 h de merge.
if gh_identity.is_rate_limit_error(str(exc)):
banner = gh_identity.rate_limit_banner(str(exc))
print(banner, file=sys.stderr)
errors.append(banner)
result = {
"pr": args.pr,
"ready": False,
"verdict": "UNKNOWN",
"errors": [f"UNKNOWN: {exc}"],
"errors": errors,
}
print(json.dumps(result, ensure_ascii=False) if args.json else f"UNKNOWN -- {exc}")
return EXIT_UNKNOWN
Expand Down
13 changes: 13 additions & 0 deletions scripts/check_unaddressed_nits.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,12 +94,19 @@

import argparse
import json
import os
import re
import subprocess
import sys
import unicodedata
from datetime import datetime, timezone

try:
import gh_identity
except ImportError: # charge via importlib dans les tests (hors scripts/)
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import gh_identity

REPO = "jsboige/CoursIA"

BOT_LOGINS = {"github-actions", "codecov", "dependabot", "copilot-pull-request-reviewer"}
Expand Down Expand Up @@ -5218,6 +5225,12 @@ def audit(limit: int, search: str | None = None) -> int:


def main() -> int:
# Warn-fort + poursuite : le FAIL bruyant est porte par gh_identity
# --whoami et detect_shared_login.py (#17418 Phase A, transition B/C).
try:
gh_identity.pin_gh_token()
except gh_identity.GhIdentityError as exc:
print(f"GH-IDENTITY (WARN, poursuite sous compte actif): {exc}", file=sys.stderr)
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("pr", nargs="?", type=int, help="numero de PR (mode gate)")
Expand Down
131 changes: 131 additions & 0 deletions scripts/detect_shared_login.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
"""Detecteur de lane tournant sous le login GitHub partage (#17418 Phase A).

Un detecteur muet se lit comme « tout va bien » : le controle positif est la
moitie du livrable. Ce detecteur fait l'inverse d'un auto-bilan — il sonde ce
qu'une lane SANS epinglage ferait reellement (``gh api user`` dans un env ou
``GH_TOKEN`` est retire), puis nomme la lane et le login qu'elle utiliserait.

Verdicts :

- ``SHARED`` (rc=1) : la sonde non-epinglee sort sous un login qui n'est pas
le compte machine — le seau commun se vide, remediation affichee ;
- ``MACHINE-EVEN-UNPINNED`` (rc=0) : meme sans epinglage, le compte actif du
trousseau est deja le compte machine (etag de session) ;
- ``UNRESOLVABLE`` (rc=2) : la machine n'a pas de compte machine connu —
exactement l'etat que la Phase B/C de #17418 doit fermer ;
- ``PINNED-OK`` avec ``--self`` : le chemin epingle rend le compte machine.

Le test ``scripts/tests/test_detect_shared_login.py`` porte le controle
positif : il declenche volontairement un appel non epingle et ECHOUE si la
detection ne le voit pas.
"""

from __future__ import annotations

import argparse
import os
import subprocess
import sys

import gh_identity


def probe_unpinned_login() -> str | None:
"""Login qu'un appel GH volontairement NON epingle utiliserait.

Simule la lane sans discipline : ``GH_TOKEN``/``GITHUB_TOKEN`` retires,
gh resout alors le compte actif du trousseau — typiquement le login
partage sur les machines de la flotte.
"""
env = {
k: v for k, v in os.environ.items()
if k not in ("GH_TOKEN", "GITHUB_TOKEN")
}
try:
proc = subprocess.run(
["gh", "api", "user", "--jq", ".login"],
capture_output=True, text=True, encoding="utf-8",
env=env, timeout=30,
)
except FileNotFoundError:
return None
if proc.returncode != 0:
return None
return proc.stdout.strip() or None


def lane_label() -> str:
workspace = os.environ.get("COURSIA_WORKSPACE", "CoursIA")
return f"{gh_identity.machine_hostname()}:{workspace}"


def _self_check() -> int:
try:
gh_identity.pin_gh_token()
except gh_identity.GhIdentityError as exc:
print(f"UNRESOLVABLE lane {lane_label()} — {exc}")
return 2
proc = subprocess.run(
["gh", "api", "user", "--jq", ".login"],
capture_output=True, text=True, encoding="utf-8", timeout=30,
)
if proc.returncode != 0:
print(f"PROBE-ERROR lane {lane_label()} — gh api user (epingle) : "
f"{proc.stderr.strip()[:200]}")
return 2
login = proc.stdout.strip()
account = gh_identity.machine_account()
if login.lower() != account.lower():
print(f"SHARED lane {lane_label()} — chemin epingle sort sous "
f"'{login}' != compte machine '{account}'")
return 1
print(f"PINNED-OK lane {lane_label()} — login epingle = {login}")
return 0


def classify(probe_login: str | None, account: str) -> tuple[str, int]:
"""Verdict (label, rc) d'une sonde non-epinglee contre le compte machine.

Pure — le controle positif des tests (#17418 Phase A) la consomme : la
sonde live doit rendre SHARED sur une machine ou le compte actif du
trousseau n'est pas le compte machine, sinon la detection est cassee.
"""
if probe_login is None:
return ("PROBE-ERROR", 2)
if probe_login.lower() == account.lower():
return ("MACHINE-EVEN-UNPINNED", 0)
return ("SHARED", 1)


def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument(
"--self", action="store_true",
help="verifie le chemin EPINGLE (complement de la sonde non-epinglee)",
)
args = parser.parse_args(argv)
if args.self:
return _self_check()

account = gh_identity.machine_account()
unpinned = probe_unpinned_login()
label, rc = classify(unpinned, account)
if label == "PROBE-ERROR":
print(f"PROBE-ERROR lane {lane_label()} — sonde non-epinglee "
"(gh absent, non authentifie ou reseau)")
elif label == "MACHINE-EVEN-UNPINNED":
print(f"MACHINE-EVEN-UNPINNED lane {lane_label()} — compte actif du "
f"trousseau deja '{unpinned}'")
else:
print(
f"SHARED lane {lane_label()} — un appel NON epingle sortirait sous "
f"'{unpinned}' (partage) au lieu du compte machine '{account}'. "
"Chaque organe non epingle vide le seau 5000/h commun. Remediation : "
"les organes epinglent via gh_identity.pin_gh_token() ; verifier que "
"COURSIA_GH_PINNING n'est pas a 'off'."
)
return rc


if __name__ == "__main__": # pragma: no cover
sys.exit(main())
Loading
Loading