Ctrl K
ComunidadGuías…

deploy.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env python3
"""deploy.py — Déploiement unifié blink2video.

Même esprit que deploy.py de lidar2map/gpxsolar (source unique de version,
un seul point d'entrée pour push + tag + suivi du build), mais sans leur
mécanique de clone temporaire + mapping de fichiers + patch cloud/local
sans rebuild : ces deux-là servent des bundles PyInstaller de plusieurs
centaines de Mo à quelques Go, où reconstruire à chaque correctif coûte
cher. blink2video pèse ~120 Mo et son build complet (3 OS, via
release.yml) prend 15-20 min sur les runners gratuits GitHub - jamais
assez lent cette session pour justifier un second mécanisme de patch à
maintenir en plus. Ce dossier de travail EST le dépôt git (pas une copie
séparée à synchroniser) : deploy.py commit et pousse directement ici.

Usage :
  python deploy.py -m "mon correctif"                # push seul, pas de release
  python deploy.py -m "..." --new-tag                # push + tag = v<VERSION> (runtime.py) + suit le build
  python deploy.py -m "..." --new-tag v0.9.0          # accepté seulement si ça égale v<VERSION>
  python deploy.py -m "..." --dry-run                 # affiche le diff, ne commit ni ne pousse

Prérequis : git, gh (authentifié : gh auth status).
"""

import argparse
import json
import os
import re
import subprocess
import sys
import tempfile
import time
import urllib.parse
from pathlib import Path
from typing import NoReturn

for _stream in (sys.stdout, sys.stderr):
    try:
        _stream.reconfigure(encoding="utf-8")
    except Exception:
        pass

# === CONFIG ===================================================================

REPO = "nico579/blink2video"
VERSION_FILE = "runtime.py"
SRC = Path(__file__).resolve().parent
BRANCHE_RELEASE = "main"
SHA_GIT_RE = re.compile(r"^[0-9a-f]{40}$")
TAG_RELEASE_RE = re.compile(r"^v\d+\.\d+\.\d+$")

# === COLOR / IO HELPERS (repris tels quels de lidar2map/deploy.py) ==========

_USE_COLOR = sys.stdout.isatty() and os.environ.get("NO_COLOR") is None
if os.name == "nt" and _USE_COLOR:
    try:
        import ctypes
        kernel32 = ctypes.windll.kernel32
        h = kernel32.GetStdHandle(-11)
        mode = ctypes.c_ulong()
        kernel32.GetConsoleMode(h, ctypes.byref(mode))
        kernel32.SetConsoleMode(h, mode.value | 0x0004)
    except Exception:
        _USE_COLOR = False

_COLORS = {"cyan": "\033[36m", "yellow": "\033[33m", "red": "\033[31m", "green": "\033[32m"}


def cprint(msg: str, color: str = "") -> None:
    if _USE_COLOR and color in _COLORS:
        print(f"{_COLORS[color]}{msg}\033[0m")
    else:
        print(msg)


def fail(msg: str) -> NoReturn:
    cprint(f"\nERREUR : {msg}", "red")
    sys.exit(1)


# === SHELL HELPERS =============================================================

def run(cmd, check=True, capture=False, timeout=120, env=None):
    try:
        result = subprocess.run(
            cmd, cwd=str(SRC), check=False, text=True,
            stdout=subprocess.PIPE if capture else None,
            stderr=subprocess.PIPE if capture else None,
            timeout=timeout, env=env,
        )
    except subprocess.TimeoutExpired:
        fail(f"{' '.join(cmd)} a dépassé le timeout ({timeout}s).")
    if check and result.returncode != 0:
        err = (result.stderr or result.stdout or "").strip()
        fail(f"{' '.join(cmd)} a échoué (code {result.returncode})" + (f"\n{err}" if err else ""))
    return result


def git(*args, check=True, capture=False):
    return run(["git", *args], check=check, capture=capture)


def gh_json(*args):
    res = run(["gh", *args], capture=True)
    return json.loads(res.stdout)


def read_code_version() -> str:
    """Lit la constante VERSION de runtime.py : SOURCE UNIQUE de la version.

    Le tag de release en est dérivé (v<VERSION>) plutôt que saisi une
    deuxième fois : sans ça, tag et constante peuvent diverger (c'est
    exactement le bug déjà vécu sur lidar2map, un tag pointant une version
    que la constante n'annonçait pas encore - même précaution ici avant
    qu'il ne se reproduise sur ce projet)."""
    txt = (SRC / VERSION_FILE).read_text(encoding="utf-8")
    m = re.search(r'^VERSION\s*=\s*"([^"]+)"', txt, re.M)
    if not m:
        fail(f"constante VERSION introuvable dans {VERSION_FILE}")
    return m.group(1)


def _sortie_git(*args) -> str:
    return git(*args, capture=True).stdout.strip()


def _remote_officiel(url: str) -> bool:
    """Reconnaît uniquement le dépôt GitHub attendu, sans alias ni userinfo."""
    url = str(url or "").strip()
    chemin_attendu = f"/{REPO}.git"
    if url == f"git@github.com:{REPO}.git":
        return True
    try:
        parsed = urllib.parse.urlparse(url)
        port = parsed.port
    except ValueError:
        return False
    if parsed.scheme == "https":
        return (parsed.hostname == "github.com"
                and parsed.username is None and parsed.password is None
                and port is None and parsed.path == chemin_attendu
                and not parsed.params and not parsed.query and not parsed.fragment)
    if parsed.scheme == "ssh":
        return (parsed.hostname == "github.com" and parsed.username == "git"
                and parsed.password is None and port in (None, 22)
                and parsed.path == chemin_attendu
                and not parsed.params and not parsed.query and not parsed.fragment)
    return False


def _sha_git(valeur: str, contexte: str) -> str:
    valeur = str(valeur or "").strip().lower()
    if not SHA_GIT_RE.fullmatch(valeur):
        fail(f"SHA Git invalide pour {contexte} : {valeur!r}")
    return valeur


def _sha_remote(ref: str, obligatoire: bool = True) -> str:
    """Lit une référence distante sans modifier le dépôt ni ses refs locales."""
    resultat = git("ls-remote", "--exit-code", "origin", ref,
                   check=False, capture=True)
    if resultat.returncode == 2 and not obligatoire:
        return ""
    if resultat.returncode != 0:
        detail = (resultat.stderr or resultat.stdout or "").strip()
        fail(f"impossible de lire {ref} sur origin" + (f"\n{detail}" if detail else ""))
    lignes = [ligne.split() for ligne in resultat.stdout.splitlines() if ligne.strip()]
    if len(lignes) != 1 or len(lignes[0]) != 2 or lignes[0][1] != ref:
        fail(f"réponse ambiguë de origin pour {ref}")
    return _sha_git(lignes[0][0], ref)


def verifier_depot(new_tag: str = "") -> str:
    """Refuse de déployer depuis une branche, un remote ou un HEAD inattendu."""
    branche = _sortie_git("branch", "--show-current")
    if branche != BRANCHE_RELEASE:
        fail(f"branche courante {branche or '(HEAD détaché)'} ; "
             f"le déploiement exige {BRANCHE_RELEASE}.")

    fetch_url = _sortie_git("remote", "get-url", "origin")
    push_url = _sortie_git("remote", "get-url", "--push", "origin")
    if not _remote_officiel(fetch_url) or not _remote_officiel(push_url):
        fail(f"origin doit pointer en lecture et écriture vers le dépôt officiel "
             f"github.com/{REPO}.\nfetch={fetch_url!r}\npush={push_url!r}")

    local = _sha_git(_sortie_git("rev-parse", "HEAD"), "HEAD local")
    distant = _sha_remote(f"refs/heads/{BRANCHE_RELEASE}")
    if local != distant:
        fail(f"HEAD local ({local}) ne correspond pas exactement à "
             f"origin/{BRANCHE_RELEASE} ({distant}). Synchronise le dépôt avant "
             "de déployer.")

    if new_tag:
        existe_localement = git("show-ref", "--verify", "--quiet",
                                f"refs/tags/{new_tag}", check=False)
        if existe_localement.returncode == 0:
            fail(f"le tag {new_tag} existe déjà localement")
        if existe_localement.returncode not in (0, 1):
            fail(f"impossible de vérifier le tag local {new_tag}")
        if _sha_remote(f"refs/tags/{new_tag}", obligatoire=False):
            fail(f"le tag {new_tag} existe déjà sur origin")
    return local


# === PRE-FLIGHT (spécifique à blink2video : pas dans les twins) =============

def preflight() -> None:
    """Suite de tests + cohérence des README, avant tout commit.

    Absent des deploy.py de lidar2map/gpxsolar (pas la même discipline de
    test dans ces projets-là) ; ajouté ici parce que c'est exactement ce
    qui a été fait à la main avant chaque commit tout au long de cette
    session - un oubli ici committerait une régression déjà détectable
    localement en quelques secondes."""
    cprint("==> Suite de tests (python -m unittest discover)", "cyan")
    # Ce dossier est aussi une installation en service (lancée depuis les
    # sources) : un test qui oublierait d'isoler son dossier d'état ne doit
    # jamais atteindre le vrai.
    with tempfile.TemporaryDirectory(prefix="blink-deploy-tests-") as isole:
        res = run([sys.executable, "-m", "unittest", "discover", "-p", "test_*.py"],
                  check=False, capture=True, timeout=300,
                  env=dict(os.environ, BLINK_HOME=isole))
    if res.returncode != 0:
        print((res.stdout or "") + (res.stderr or ""))
        fail("suite de tests en échec - corrige avant de pousser.")
    cprint("    OK", "green")

    cprint("==> Cohérence des README (python docs.py --check)", "cyan")
    res = run([sys.executable, "docs.py", "--check"], check=False, capture=True)
    if res.returncode != 0:
        print((res.stdout or "") + (res.stderr or ""))
        fail("README désynchronisés - lance python docs.py pour les régénérer.")
    cprint("    OK", "green")


# === PUSH + TAG ================================================================

def compute_diff(dry_run: bool = False) -> list:
    cprint("\n==> Modifications :", "cyan")
    # Un dry-run doit être parfaitement observateur : même ``git add`` est une
    # mutation de l'index et peut écraser la sélection de l'utilisateur.
    if not dry_run:
        git("add", "-A")
    status = git("status", "--short", capture=True).stdout.strip()
    if not status:
        cprint("    Aucun changement. Rien à pousser.", "yellow")
        return []
    for line in status.splitlines():
        print(f"    {line}")
    print()
    if dry_run:
        git("diff", "--stat")
        git("diff", "--cached", "--stat")
        return status.splitlines()
    git("diff", "--cached", "--stat")
    changed = _sortie_git("diff", "--cached", "--name-only").splitlines()
    return [c.strip() for c in changed if c.strip()]


def _publier_tag(tag: str, sha: str) -> None:
    cprint(f"\n==> Tag {tag}", "cyan")
    git("tag", "-a", tag, "-m", f"blink2video {tag}", sha)
    git("push", "origin", f"refs/tags/{tag}:refs/tags/{tag}")
    distant = _sha_remote(f"refs/tags/{tag}^{{}}")
    if distant != sha:
        fail(f"le tag distant {tag} pointe vers {distant}, attendu {sha}")


def commit_and_push(message: str, new_tag: str) -> str:
    cprint("\n==> Commit", "cyan")
    git("commit", "-m", message)
    sha = _sha_git(_sortie_git("rev-parse", "HEAD"), "commit créé")
    cprint("\n==> Push origin main", "cyan")
    git("push", "origin", f"HEAD:refs/heads/{BRANCHE_RELEASE}")
    distant = _sha_remote(f"refs/heads/{BRANCHE_RELEASE}")
    if distant != sha:
        fail(f"origin/{BRANCHE_RELEASE} pointe vers {distant}, attendu {sha}")
    if new_tag:
        _publier_tag(new_tag, sha)
    return sha


def watch_release(tag: str, sha: str) -> None:
    """Le tag poussé déclenche déjà release.yml tout seul (on: push: tags:
    v*) : contrairement au patch cloud de lidar2map/gpxsolar, rien à
    invoquer explicitement ici, seulement à retrouver le run et attendre
    qu'il finisse."""
    cprint(f"\n==> {tag} poussé -> release.yml se déclenche (build 3 OS, ~15-20 min)", "cyan")
    run_id = None
    for _ in range(6):
        time.sleep(5)
        runs = gh_json("run", "list", "--repo", REPO, "--workflow", "release.yml",
                       "--event", "push", "--commit", sha,
                       "--limit", "1", "--json", "databaseId,headSha")
        if runs:
            candidat = runs[0]
            if str(candidat.get("headSha") or "").lower() == sha:
                run_id = candidat["databaseId"]
                break
    if not run_id:
        cprint("    Run introuvable automatiquement - vérifie l'onglet Actions.", "yellow")
        return
    print(f"    Run : https://github.com/{REPO}/actions/runs/{run_id}")

    cprint("==> Surveillance du run", "cyan")
    res = run(["gh", "run", "watch", str(run_id), "--repo", REPO,
              "--exit-status", "--interval", "20"], check=False, timeout=1800)
    if res.returncode != 0:
        fail(f"le run release.yml a échoué : gh run view {run_id} --repo {REPO} --log-failed")

    cprint(f"\n==> OK. {tag} publiée.", "green")
    print(f"    Release : https://github.com/{REPO}/releases/tag/{tag}")


# === MAIN ======================================================================

def main() -> int:
    parser = argparse.ArgumentParser(
        prog="deploy.py",
        description="Déploiement unifié blink2video - push + tag + suivi du build.",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="Voir le docstring en tête du fichier pour les exemples.",
    )
    parser.add_argument("-m", "--message", required=True, help="message de commit")
    parser.add_argument("--new-tag", nargs="?", const="AUTO", default="",
                        help="pousse aussi un tag -> déclenche release.yml. Sans "
                             "valeur : dérivé de VERSION (v<VERSION>). Avec une "
                             "valeur vX.Y.Z : acceptée seulement si elle égale "
                             "v<VERSION>, sinon refusée.")
    parser.add_argument("--dry-run", action="store_true",
                        help="affiche le diff sans commit ni push")
    parser.add_argument("--skip-tests", action="store_true",
                        help="saute la suite de tests et docs.py --check (déconseillé)")
    args = parser.parse_args()

    if args.new_tag:
        want = f"v{read_code_version()}"
        if args.new_tag == "AUTO":
            args.new_tag = want
        elif args.new_tag != want:
            fail(f"--new-tag {args.new_tag} != {want} (constante VERSION dans "
                 f"{VERSION_FILE}). Bumpe VERSION, puis repasse --new-tag sans "
                 f"valeur (le tag est dérivé).")
        if not TAG_RELEASE_RE.fullmatch(args.new_tag):
            fail(f"tag de release invalide : {args.new_tag!r} (format attendu vX.Y.Z)")

    sha_initial = verifier_depot(args.new_tag)

    if not args.skip_tests and not args.dry_run:
        preflight()

    changed = compute_diff(args.dry_run)
    # Une simulation ne doit pas non plus publier de tag sur un dépôt propre.
    if args.dry_run:
        cprint("\n==> --dry-run : pas de commit ni de push.", "yellow")
        return 0

    if not changed:
        if args.new_tag:
            cprint(f"\n==> Aucun changement à pousser ; tag {args.new_tag} sur le HEAD courant.", "cyan")
            _publier_tag(args.new_tag, sha_initial)
            watch_release(args.new_tag, sha_initial)
        return 0

    sha_publie = commit_and_push(args.message, args.new_tag)

    if args.new_tag:
        watch_release(args.new_tag, sha_publie)
    else:
        cprint("\n==> Poussé sur main (pas de tag -> pas de release).", "green")

    return 0


if __name__ == "__main__":
    sys.exit(main())