From 133e43dc11c2ec421e9574845a441c0d7b0fabaa Mon Sep 17 00:00:00 2001 From: jsboige Date: Tue, 22 Sep 2026 23:02:39 +0200 Subject: [PATCH 1/2] feat(secrets,#17437): organe de creation des GitHub Apps de lane par manifeste (Q39) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Une App = un clic : une page locale porte un formulaire par lane avec son manifeste pre-rempli ; le callback local echange le code temporaire contre la configuration de l'App et depose la cle privee dans .secrets/github-apps/.pem, sans telechargement manuel. Permissions : une seule constante pour les 6 lanes (po-2023..27, web1), arbitrage user Q39 « donne tous les droits qui fluidifient notre workflow », chaque droit rattache a un usage mesure (workflows w, actions w pour les reruns/cancel/dispatch, administration r pour la protection de branche, alertes r). Ecartes avec motif : checks w (une App peut fabriquer un check-run vert que le gate lit), administration w, secrets. .gitignore versionne : .secrets/ ignore en entier. Jusqu'ici seules quelques entrees nominatives l'etaient ; master.env et les cles d'App ne l'etaient que par le .git/info/exclude local d'ai-01. Controle positif : le test dedie echoue sur le .gitignore de main. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 4 + scripts/secrets/github_app_manifest.py | 282 ++++++++++++++++++ .../secrets/tests/test_github_app_manifest.py | 188 ++++++++++++ 3 files changed, 474 insertions(+) create mode 100644 scripts/secrets/github_app_manifest.py create mode 100644 scripts/secrets/tests/test_github_app_manifest.py diff --git a/.gitignore b/.gitignore index 1318b3c596..7107ea85f4 100644 --- a/.gitignore +++ b/.gitignore @@ -375,6 +375,10 @@ MigrationBackup/ .env.*.local .env.docker +# Coffre local de secrets : master.env, cles privees des GitHub Apps de lane (#17437). +# Ignore en entier par la regle versionnee, pas seulement par un .git/info/exclude local. +.secrets/ + # HuggingFace tokens (Phase 29 - ÉTAPE 24F) .secrets/.env.huggingface .secrets/.env.huggingface.backup diff --git a/scripts/secrets/github_app_manifest.py b/scripts/secrets/github_app_manifest.py new file mode 100644 index 0000000000..603de2855d --- /dev/null +++ b/scripts/secrets/github_app_manifest.py @@ -0,0 +1,282 @@ +#!/usr/bin/env python3 +"""Enregistre les GitHub Apps de lane par le flux App Manifest (#17437, Q39). + +Une App = un clic. La page locale porte un formulaire par lane, chacun avec son +manifeste pre-rempli (nom, permissions, webhook inactif). Apres « Create GitHub +App », GitHub renvoie un `code` temporaire vers le callback local, qui l'echange +contre la configuration de l'App (`POST /app-manifests/{code}/conversions`, sans +authentification, valable une heure). La cle privee est ecrite directement dans +`.secrets/github-apps/.pem` : aucun telechargement a la main, aucune cle +qui transite par un dossier de telechargements. + +L'ensemble de permissions est UNE constante (`PERMISSIONS`), la meme pour toutes +les lanes. Arbitrage user du 2026-09-22 (Q39) : « donne tous les droits qui +fluidifient notre workflow ». Chaque droit y est justifie par un usage mesure +dans le depot ; deux sont ecartes avec leur motif (voir `EXCLUDED`). + +Usage : + python scripts/secrets/github_app_manifest.py # etat par lane (lecture seule) + python scripts/secrets/github_app_manifest.py --print-manifest po-2023 + python scripts/secrets/github_app_manifest.py --serve # page locale + callback + +Ce qui reste un geste user : etre connecte a GitHub sous `jsboige`, cliquer +« Create GitHub App » puis « Install » (le depot CoursIA est pre-selectionne). +""" +from __future__ import annotations + +import argparse +import html +import json +import os +import secrets +import subprocess +import sys +import urllib.error +import urllib.parse +import urllib.request +import webbrowser +from http.server import BaseHTTPRequestHandler, HTTPServer +from pathlib import Path +from typing import Callable + +REPO_ROOT = Path(__file__).resolve().parents[2] +SECRETS_DIR = REPO_ROOT / ".secrets" / "github-apps" +REPO = "jsboige/CoursIA" +REPO_URL = f"https://github.com/{REPO}" +# Identifiants publics, lus par `gh api repos/jsboige/CoursIA` le 2026-09-22 ; +# ils ne servent qu'a pre-selectionner le depot sur la page d'installation. +REPO_ID = 526622110 +OWNER_ID = 3159389 + +APP_PREFIX = "coursia-lane-" +# Le pilote `coursia-lane-ai-01` existe deja (App 5036190) : il n'est pas recree, +# il s'aligne sur PERMISSIONS depuis sa page de reglages. +LANES = ("po-2023", "po-2024", "po-2025", "po-2026", "po-2027", "web1") + +PERMISSIONS = { + "metadata": "read", # obligatoire pour toute App + "contents": "write", # push de branches, update-branch + "pull_requests": "write", # PR, reviews, levees, merge + "issues": "write", # claims, commentaires, labels + "workflows": "write", # edition de .github/workflows/** (Q39) + "actions": "write", # rerun (50 appels), cancel (pr_gate.py), workflow_dispatch, caches + "checks": "read", + "statuses": "read", + "administration": "read", # protection de branche lisible (#9991), actions/runners + "security_events": "read", # alertes CodeQL (codeql-suppressions-inertes.md) + "vulnerability_alerts": "read", # alertes Dependabot + "secret_scanning_alerts": "read", +} + +# Droits ecartes malgre « tous les droits » : aucun usage de lane ne les demande, +# et chacun ouvre une capacite que le workflow n'a pas aujourd'hui. +EXCLUDED = { + "checks": "write -- seules les Apps peuvent CREER un check-run : ce serait donner a " + "chaque lane le moyen de fabriquer un vert que le merge-gate lit", + "administration": "write -- suppression du depot, levee de la protection de main", + "secrets": "any -- lecture/ecriture des secrets du depot", +} + +CONVERSION_URL = "https://api.github.com/app-manifests/{code}/conversions" +# Champs de la reponse de conversion qui sont des secrets : jamais ecrits hors du .pem. +SECRET_FIELDS = ("pem", "client_secret", "webhook_secret") + + +def app_name(lane: str) -> str: + return f"{APP_PREFIX}{lane}" + + +def build_manifest(lane: str, redirect_url: str) -> dict: + """Manifeste d'une lane. Webhook inactif, aucun evenement, App privee.""" + return { + "name": app_name(lane), + "url": REPO_URL, + "description": f"Jeton d'installation de la lane {lane} sur {REPO} (bucket API propre).", + "hook_attributes": {"url": REPO_URL, "active": False}, + "redirect_url": redirect_url, + "public": False, + "default_permissions": dict(PERMISSIONS), + "default_events": [], + } + + +def install_url(slug: str) -> str: + """Page d'installation avec le compte et le depot CoursIA pre-selectionnes.""" + query = urllib.parse.urlencode( + [("suggested_target_id", OWNER_ID), ("repository_ids[]", REPO_ID)]) + return f"https://github.com/apps/{slug}/installations/new/permissions?{query}" + + +def lane_status(secrets_dir: Path = SECRETS_DIR) -> dict[str, bool]: + """Lane -> True si sa cle est deja deposee (App creee et convertie).""" + return {lane: (secrets_dir / f"{app_name(lane)}.pem").exists() for lane in LANES} + + +def permission_mismatches(granted: dict) -> list[str]: + """Ecarts entre les permissions accordees et PERMISSIONS (vide = conforme).""" + out = [] + for key in sorted(set(PERMISSIONS) | set(granted or {})): + want, got = PERMISSIONS.get(key), (granted or {}).get(key) + if want != got: + out.append(f"{key}: attendu {want}, obtenu {got}") + return out + + +def is_git_ignored(path: Path) -> bool: + """`git check-ignore` sur le chemin cible : la cle ne doit jamais etre commitable. + + Interroge le depot qui CONTIENT le chemin (le dossier peut ne pas exister encore). + """ + anchor = path.parent + while not anchor.exists(): + anchor = anchor.parent + proc = subprocess.run(["git", "-C", str(anchor), "check-ignore", "-q", str(path)], + capture_output=True) + return proc.returncode == 0 + + +def store(app: dict, secrets_dir: Path = SECRETS_DIR, + ignored: Callable[[Path], bool] = is_git_ignored) -> Path: + """Ecrit la cle privee et une fiche sans secret. Refuse d'ecraser, refuse hors gitignore.""" + slug = app["slug"] + secrets_dir.mkdir(parents=True, exist_ok=True) + pem_path = secrets_dir / f"{slug}.pem" + if not ignored(pem_path): + raise RuntimeError(f"{pem_path} n'est pas ignore par git : cle non ecrite") + fd = os.open(pem_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(fd, "w", encoding="ascii", newline="\n") as fh: + fh.write(app["pem"]) + meta = {k: v for k, v in app.items() if k not in SECRET_FIELDS} + fiche = { + "id": meta.get("id"), + "slug": slug, + "client_id": meta.get("client_id"), + "owner": (meta.get("owner") or {}).get("login"), + "permissions": meta.get("permissions"), + "events": meta.get("events"), + "created_at": meta.get("created_at"), + } + (secrets_dir / f"{slug}.json").write_text( + json.dumps(fiche, indent=2) + "\n", encoding="utf-8") + return pem_path + + +def convert(code: str) -> dict: + """Echange le code temporaire contre la configuration de l'App (sans authentification).""" + req = urllib.request.Request( + CONVERSION_URL.format(code=urllib.parse.quote(code, safe="")), + method="POST", + headers={"Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28"}) + with urllib.request.urlopen(req, timeout=30) as resp: + return json.loads(resp.read().decode("utf-8")) + + +def render_page(status: dict[str, bool], state: str, redirect_url: str) -> str: + rows = [] + for lane, done in status.items(): + if done: + rows.append(f"
  • {html.escape(app_name(lane))} : deja creee (cle deposee)
  • ") + continue + manifest = html.escape(json.dumps(build_manifest(lane, redirect_url)), quote=True) + action = f"https://github.com/settings/apps/new?state={urllib.parse.quote(state)}" + rows.append( + f'
  • ' + f'' + f'
  • ') + perms = "".join(f"
  • {html.escape(k)}: {html.escape(v)}
  • " + for k, v in PERMISSIONS.items()) + return ("" + "Apps de lane CoursIA" + "

    Apps de lane CoursIA

    " + "

    Connecte sous jsboige. Un bouton = une App ; apres creation, " + "cette page recoit la cle et affiche le lien d'installation.

    " + f"
      {''.join(rows)}

    Permissions (identiques pour chaque lane)

    " + f"
      {perms}
    ") + + +def make_handler(state: str, redirect_url: str, secrets_dir: Path = SECRETS_DIR, + converter: Callable[[str], dict] = convert, + storer: Callable[[dict], Path] | None = None): + storer = storer or (lambda app: store(app, secrets_dir)) + + class Handler(BaseHTTPRequestHandler): + def _send(self, code: int, body: str) -> None: + data = body.encode("utf-8") + self.send_response(code) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def log_message(self, fmt, *args): # le code temporaire passe dans l'URL : ne pas le journaliser + return + + def do_GET(self): + url = urllib.parse.urlparse(self.path) + if url.path == "/": + return self._send(200, render_page(lane_status(secrets_dir), state, redirect_url)) + if url.path != "/callback": + return self._send(404, "introuvable") + query = urllib.parse.parse_qs(url.query) + if query.get("state", [""])[0] != state: + return self._send(400, "state invalide : requete refusee") + code = query.get("code", [""])[0] + if not code: + return self._send(400, "code absent") + try: + app = converter(code) + storer(app) + except (urllib.error.URLError, RuntimeError, OSError, KeyError) as exc: + print(f"ECHEC conversion/depot : {type(exc).__name__}: {exc}", file=sys.stderr) + return self._send(500, f"echec : {html.escape(type(exc).__name__)} -- voir la console") + slug = app["slug"] + gaps = permission_mismatches(app.get("permissions")) + print(f"OK {slug} (App {app.get('id')}) -> cle deposee ; ecarts de permissions : {gaps or 'aucun'}") + warn = ("

    ECARTS de permissions : " + html.escape("; ".join(gaps)) + "

    ") if gaps else "" + link = html.escape(install_url(slug), quote=True) + return self._send(200, f"

    {html.escape(slug)} creee, " + f"cle deposee.

    {warn}

    Installer sur CoursIA

    " + "

    Retour a la liste

    ") + + return Handler + + +def main(argv: list[str] | None = None) -> int: + p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + p.add_argument("--print-manifest", metavar="LANE", choices=LANES, + help="affiche le manifeste d'une lane (lecture seule)") + p.add_argument("--serve", action="store_true", help="page locale + callback de conversion") + p.add_argument("--port", type=int, default=8765) + p.add_argument("--no-browser", action="store_true") + p.add_argument("--secrets-dir", type=Path, default=SECRETS_DIR, + help="depot des cles (defaut : .secrets/github-apps du clone)") + args = p.parse_args(argv) + secrets_dir = args.secrets_dir.resolve() + + redirect_url = f"http://localhost:{args.port}/callback" + if args.print_manifest: + print(json.dumps(build_manifest(args.print_manifest, redirect_url), indent=2)) + return 0 + status = lane_status(secrets_dir) + for lane, done in status.items(): + print(f"{app_name(lane):24} {'cle deposee' if done else 'a creer'}") + if not args.serve: + return 0 + if not is_git_ignored(secrets_dir / "probe.pem"): + print(f"REFUS : {secrets_dir} n'est pas ignore par git", file=sys.stderr) + return 1 + state = secrets.token_urlsafe(32) + server = HTTPServer(("127.0.0.1", args.port), make_handler(state, redirect_url, secrets_dir)) + print(f"Page : http://localhost:{args.port}/ (Ctrl+C pour arreter)") + if not args.no_browser: + webbrowser.open(f"http://localhost:{args.port}/") + try: + server.serve_forever() + except KeyboardInterrupt: + pass + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/secrets/tests/test_github_app_manifest.py b/scripts/secrets/tests/test_github_app_manifest.py new file mode 100644 index 0000000000..6f43897e63 --- /dev/null +++ b/scripts/secrets/tests/test_github_app_manifest.py @@ -0,0 +1,188 @@ +"""Tests de scripts/secrets/github_app_manifest.py (#17437, Q39). + +Aucun appel reseau : la conversion et le depot sont injectes. Le systeme de +fichiers reel n'est jamais touche (tmp_path). +""" +import html +import json +import re +import sys +import threading +import urllib.error +import urllib.request +from http.server import HTTPServer +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +import github_app_manifest as gam # noqa: E402 + +REDIRECT = "http://localhost:8765/callback" + + +def _app(slug="coursia-lane-po-2023", permissions=None): + return { + "id": 42, "slug": slug, "client_id": "Iv1.public", + "owner": {"login": "jsboige"}, "events": [], + "permissions": dict(gam.PERMISSIONS) if permissions is None else permissions, + "created_at": "2026-09-22T21:30:00Z", + "pem": "-----BEGIN RSA PRIVATE KEY-----\nFAKE\n-----END RSA PRIVATE KEY-----\n", + "client_secret": "cs-fake", "webhook_secret": "wh-fake", + } + + +# --- politique de permissions ------------------------------------------------- + +def test_manifest_carries_exactly_the_policy(): + m = gam.build_manifest("po-2023", REDIRECT) + assert m["name"] == "coursia-lane-po-2023" + assert m["default_permissions"] == gam.PERMISSIONS + assert m["public"] is False + assert m["hook_attributes"]["active"] is False + assert m["default_events"] == [] + assert m["redirect_url"] == REDIRECT + + +def test_q39_rights_are_granted(): + for key, level in {"workflows": "write", "actions": "write", "contents": "write", + "pull_requests": "write", "issues": "write"}.items(): + assert gam.PERMISSIONS[key] == level, key + + +def test_excluded_rights_stay_excluded(): + # Un check-run ne peut etre CREE que par une App : checks=write donnerait a chaque + # lane le moyen de fabriquer le vert que lit le merge-gate. + assert gam.PERMISSIONS["checks"] == "read" + assert gam.PERMISSIONS["administration"] == "read" + assert "secrets" not in gam.PERMISSIONS + assert set(gam.EXCLUDED) == {"checks", "administration", "secrets"} + + +def test_pilot_is_not_recreated(): + assert "ai-01" not in gam.LANES + assert len(gam.LANES) == 6 + + +def test_permission_mismatches(): + assert gam.permission_mismatches(dict(gam.PERMISSIONS)) == [] + got = dict(gam.PERMISSIONS, actions="read", pages="read") + gaps = gam.permission_mismatches(got) + assert any(g.startswith("actions:") for g in gaps) + assert any(g.startswith("pages:") for g in gaps) + + +# --- page --------------------------------------------------------------------- + +def test_page_round_trips_the_manifest(): + status = {lane: False for lane in gam.LANES} + page = gam.render_page(status, "st4te", REDIRECT) + values = re.findall(r'name="manifest" value="([^"]*)"', page) + assert len(values) == len(gam.LANES) + assert json.loads(html.unescape(values[0])) == gam.build_manifest(gam.LANES[0], REDIRECT) + assert "state=st4te" in page + + +def test_page_skips_created_lanes(): + status = {lane: lane == "po-2024" for lane in gam.LANES} + page = gam.render_page(status, "s", REDIRECT) + assert page.count('name="manifest"') == len(gam.LANES) - 1 + assert "coursia-lane-po-2024 : deja creee" in page + + +def test_install_url_preselects_the_repo(): + url = gam.install_url("coursia-lane-po-2023") + assert url.startswith("https://github.com/apps/coursia-lane-po-2023/installations/new/permissions?") + assert f"suggested_target_id={gam.OWNER_ID}" in url + assert f"repository_ids%5B%5D={gam.REPO_ID}" in url + + +# --- depot de la cle ---------------------------------------------------------- + +def test_store_writes_pem_and_a_secret_free_card(tmp_path): + pem = gam.store(_app(), tmp_path, ignored=lambda p: True) + assert pem.read_text(encoding="ascii").startswith("-----BEGIN RSA PRIVATE KEY-----") + card = (tmp_path / "coursia-lane-po-2023.json").read_text(encoding="utf-8") + for secret in ("FAKE", "cs-fake", "wh-fake"): + assert secret not in card + assert json.loads(card)["id"] == 42 + + +def test_store_refuses_to_overwrite(tmp_path): + gam.store(_app(), tmp_path, ignored=lambda p: True) + with pytest.raises(FileExistsError): + gam.store(_app(), tmp_path, ignored=lambda p: True) + + +def test_store_refuses_a_non_ignored_path(tmp_path): + with pytest.raises(RuntimeError): + gam.store(_app(), tmp_path, ignored=lambda p: False) + assert not (tmp_path / "coursia-lane-po-2023.pem").exists() + + +def test_secrets_dir_is_ignored_by_the_versioned_rule(): + # Controle positif : la cle d'une App doit etre ignoree par .gitignore versionne, + # pas seulement par un .git/info/exclude local (qui n'existe pas sur les autres clones). + rule = (gam.REPO_ROOT / ".gitignore").read_text(encoding="utf-8").splitlines() + assert ".secrets/" in rule + + +def test_lane_status(tmp_path): + (tmp_path / "coursia-lane-web1.pem").write_text("x") + status = gam.lane_status(tmp_path) + assert status["web1"] is True + assert status["po-2023"] is False + + +# --- callback ----------------------------------------------------------------- + +@pytest.fixture +def server(tmp_path): + calls = {"convert": [], "store": []} + + def converter(code): + calls["convert"].append(code) + return _app() + + def storer(app): + calls["store"].append(app["slug"]) + return tmp_path / f"{app['slug']}.pem" + + handler = gam.make_handler("good-state", REDIRECT, tmp_path, converter, storer) + srv = HTTPServer(("127.0.0.1", 0), handler) + thread = threading.Thread(target=srv.serve_forever, daemon=True) + thread.start() + yield f"http://127.0.0.1:{srv.server_address[1]}", calls + srv.shutdown() + + +def _get(url): + try: + with urllib.request.urlopen(url, timeout=5) as resp: + return resp.status, resp.read().decode("utf-8") + except urllib.error.HTTPError as exc: + return exc.code, exc.read().decode("utf-8") + + +def test_callback_rejects_a_bad_state(server): + base, calls = server + code, _ = _get(f"{base}/callback?code=abc&state=forged") + assert code == 400 + assert calls["convert"] == [] + + +def test_callback_converts_and_links_install(server): + base, calls = server + code, body = _get(f"{base}/callback?code=abc&state=good-state") + assert code == 200 + assert calls["convert"] == ["abc"] + assert calls["store"] == ["coursia-lane-po-2023"] + assert "installations/new/permissions" in body + assert "FAKE" not in body + + +def test_index_lists_the_lanes(server): + base, _ = server + code, body = _get(f"{base}/") + assert code == 200 + assert "coursia-lane-web1" in body From 025ad4f16c8f9bd65eaf4941209f60d17939cc5e Mon Sep 17 00:00:00 2001 From: jsboige Date: Thu, 24 Sep 2026 15:36:14 +0200 Subject: [PATCH 2/2] fix(secrets,#17437): le repertoire des cles se nomme key_dir, pas secrets_dir CodeQL (py/clear-text-logging-sensitive-data, alerte #143) classait la variable `secrets_dir` comme donnee sensible sur la seule foi de son nom, et le message de refus qui l'imprime comme une fuite : la valeur est le CHEMIN du depot des cles, jamais une cle. Un `# codeql[...]` serait inerte (default setup) ; le nom dit maintenant ce que la variable porte. Le drapeau CLI `--secrets-dir` est conserve (dest="key_dir"). Co-Authored-By: Claude Opus 5 (1M context) --- scripts/secrets/github_app_manifest.py | 32 +++++++++++++------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/scripts/secrets/github_app_manifest.py b/scripts/secrets/github_app_manifest.py index 603de2855d..7372b35582 100644 --- a/scripts/secrets/github_app_manifest.py +++ b/scripts/secrets/github_app_manifest.py @@ -40,7 +40,7 @@ from typing import Callable REPO_ROOT = Path(__file__).resolve().parents[2] -SECRETS_DIR = REPO_ROOT / ".secrets" / "github-apps" +KEY_DIR = REPO_ROOT / ".secrets" / "github-apps" REPO = "jsboige/CoursIA" REPO_URL = f"https://github.com/{REPO}" # Identifiants publics, lus par `gh api repos/jsboige/CoursIA` le 2026-09-22 ; @@ -107,9 +107,9 @@ def install_url(slug: str) -> str: return f"https://github.com/apps/{slug}/installations/new/permissions?{query}" -def lane_status(secrets_dir: Path = SECRETS_DIR) -> dict[str, bool]: +def lane_status(key_dir: Path = KEY_DIR) -> dict[str, bool]: """Lane -> True si sa cle est deja deposee (App creee et convertie).""" - return {lane: (secrets_dir / f"{app_name(lane)}.pem").exists() for lane in LANES} + return {lane: (key_dir / f"{app_name(lane)}.pem").exists() for lane in LANES} def permission_mismatches(granted: dict) -> list[str]: @@ -135,12 +135,12 @@ def is_git_ignored(path: Path) -> bool: return proc.returncode == 0 -def store(app: dict, secrets_dir: Path = SECRETS_DIR, +def store(app: dict, key_dir: Path = KEY_DIR, ignored: Callable[[Path], bool] = is_git_ignored) -> Path: """Ecrit la cle privee et une fiche sans secret. Refuse d'ecraser, refuse hors gitignore.""" slug = app["slug"] - secrets_dir.mkdir(parents=True, exist_ok=True) - pem_path = secrets_dir / f"{slug}.pem" + key_dir.mkdir(parents=True, exist_ok=True) + pem_path = key_dir / f"{slug}.pem" if not ignored(pem_path): raise RuntimeError(f"{pem_path} n'est pas ignore par git : cle non ecrite") fd = os.open(pem_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) @@ -156,7 +156,7 @@ def store(app: dict, secrets_dir: Path = SECRETS_DIR, "events": meta.get("events"), "created_at": meta.get("created_at"), } - (secrets_dir / f"{slug}.json").write_text( + (key_dir / f"{slug}.json").write_text( json.dumps(fiche, indent=2) + "\n", encoding="utf-8") return pem_path @@ -195,10 +195,10 @@ def render_page(status: dict[str, bool], state: str, redirect_url: str) -> str: f"
      {perms}
    ") -def make_handler(state: str, redirect_url: str, secrets_dir: Path = SECRETS_DIR, +def make_handler(state: str, redirect_url: str, key_dir: Path = KEY_DIR, converter: Callable[[str], dict] = convert, storer: Callable[[dict], Path] | None = None): - storer = storer or (lambda app: store(app, secrets_dir)) + storer = storer or (lambda app: store(app, key_dir)) class Handler(BaseHTTPRequestHandler): def _send(self, code: int, body: str) -> None: @@ -215,7 +215,7 @@ def log_message(self, fmt, *args): # le code temporaire passe dans l'URL : ne p def do_GET(self): url = urllib.parse.urlparse(self.path) if url.path == "/": - return self._send(200, render_page(lane_status(secrets_dir), state, redirect_url)) + return self._send(200, render_page(lane_status(key_dir), state, redirect_url)) if url.path != "/callback": return self._send(404, "introuvable") query = urllib.parse.parse_qs(url.query) @@ -249,25 +249,25 @@ def main(argv: list[str] | None = None) -> int: p.add_argument("--serve", action="store_true", help="page locale + callback de conversion") p.add_argument("--port", type=int, default=8765) p.add_argument("--no-browser", action="store_true") - p.add_argument("--secrets-dir", type=Path, default=SECRETS_DIR, + p.add_argument("--secrets-dir", dest="key_dir", type=Path, default=KEY_DIR, help="depot des cles (defaut : .secrets/github-apps du clone)") args = p.parse_args(argv) - secrets_dir = args.secrets_dir.resolve() + key_dir = args.key_dir.resolve() redirect_url = f"http://localhost:{args.port}/callback" if args.print_manifest: print(json.dumps(build_manifest(args.print_manifest, redirect_url), indent=2)) return 0 - status = lane_status(secrets_dir) + status = lane_status(key_dir) for lane, done in status.items(): print(f"{app_name(lane):24} {'cle deposee' if done else 'a creer'}") if not args.serve: return 0 - if not is_git_ignored(secrets_dir / "probe.pem"): - print(f"REFUS : {secrets_dir} n'est pas ignore par git", file=sys.stderr) + if not is_git_ignored(key_dir / "probe.pem"): + print(f"REFUS : {key_dir} n'est pas ignore par git", file=sys.stderr) return 1 state = secrets.token_urlsafe(32) - server = HTTPServer(("127.0.0.1", args.port), make_handler(state, redirect_url, secrets_dir)) + server = HTTPServer(("127.0.0.1", args.port), make_handler(state, redirect_url, key_dir)) print(f"Page : http://localhost:{args.port}/ (Ctrl+C pour arreter)") if not args.no_browser: webbrowser.open(f"http://localhost:{args.port}/")