-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathdeploy.py
More file actions
636 lines (571 loc) · 28.3 KB
/
Copy pathdeploy.py
File metadata and controls
636 lines (571 loc) · 28.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
#!/usr/bin/env python3
"""deploy.py — Déploiement unifié lidar2map (cross-platform Windows/macOS/Linux).
Un seul script pour tout :
• push des sources vers le repo GitHub
• détection automatique de ce qui a changé
• action :
- docs / meta seulement -> push seul
- lidar2map.py seul -> push + patch des 3 bundles (sans rebuild)
- module Python compilé (_split_*.py) -> push puis rebuild des 3 bundles
- .spec / _loader / build.* / xml -> push puis STOP : rebuild via release.yml
(via --new-tag ; le tag est dérivé
de la constante VERSION du code)
Deux voies pour le patch :
--mode cloud (défaut) déclenche update.yml sur le runner GitHub
-> les ~1,5 Go transitent sur le réseau GitHub
--mode local lance update_app.py --release ici
-> les ~1,5 Go transitent par TA connexion
Usage :
python deploy.py -m "mon correctif" # cloud, dernière release
python deploy.py -m "..." --mode local # patch local
python deploy.py -m "..." --patch-tag v1.3.0 # cibler un tag existant
python deploy.py -m "..." --new-tag # rebuild ; tag = v<VERSION> du code
python deploy.py -m "..." --skip-push # patch direct (pas de push)
python deploy.py -m "..." --dry-run # voir le diff sans push
Prérequis :
python (>=3.8), git, gh (authentifié : gh auth status).
--mode local : GH_TOKEN/GITHUB_TOKEN dans l'env (ou gh auth token disponible).
"""
import argparse
import json
import os
import re
import shutil
import stat
import subprocess
import sys
import tempfile
import time
from pathlib import Path
from typing import NoReturn
# Force UTF-8 sur stdout/stderr : sous Windows, le défaut cp1252 fait planter
# print() dès qu'on écrit un caractère non-Latin1 (flèches →, accents corrompus
# dans certaines variantes, etc.). reconfigure() est dispo dès Python 3.7.
for _stream in (sys.stdout, sys.stderr):
try:
_stream.reconfigure(encoding="utf-8")
except Exception:
pass
# === CONFIG (à adapter par projet) ===========================================
PROJECT = "lidar2map"
APP_PY = "lidar2map.py"
REPO_DEFAULT = "nico579/lidar2map"
SRC = Path(__file__).resolve().parent
CLONE = Path(tempfile.gettempdir()) / f"{PROJECT}_gh"
REPO_URL = f"https://github.com/{REPO_DEFAULT}"
# Mapping fichier local -> chemin dans le repo GitHub
MAP = {
# Source
"lidar2map.py": "lidar2map.py",
"_split_deliverables.py": "_split_deliverables.py",
"_split_manifest.py": "_split_manifest.py",
"_split_planning.py": "_split_planning.py",
"_split_runner.py": "_split_runner.py",
"_split_sliding.py": "_split_sliding.py",
"_raster_formats.py": "_raster_formats.py",
"_mbtiles_wmts.py": "_mbtiles_wmts.py",
"_mbtiles_lidar.py": "_mbtiles_lidar.py",
"_mbtiles_wmts_helpers.py": "_mbtiles_wmts_helpers.py",
"_ombrages_pures.py": "_ombrages_pures.py",
"_ombrages_provider.py": "_ombrages_provider.py",
"_shading_specs.py": "_shading_specs.py",
"_bootstrap_policy.py": "_bootstrap_policy.py",
"_bootstrap_runtime.py": "_bootstrap_runtime.py",
"_bootstrap_tls.py": "_bootstrap_tls.py",
"_smoketest.py": "_smoketest.py",
"_logging_helpers.py": "_logging_helpers.py",
"_tee_logger.py": "_tee_logger.py",
"_log_activation.py": "_log_activation.py",
"_atomic_files.py": "_atomic_files.py",
"_http_helpers.py": "_http_helpers.py",
"_runtime_paths.py": "_runtime_paths.py",
"_disk_guard.py": "_disk_guard.py",
"_wfs_pipeline.py": "_wfs_pipeline.py",
"_bdtopo_bulk.py": "_bdtopo_bulk.py",
"_bdtopo_layers.py": "_bdtopo_layers.py",
"_vector_acquisition.py": "_vector_acquisition.py",
"_vector_outputs.py": "_vector_outputs.py",
"_osm_outputs.py": "_osm_outputs.py",
"_osm_map_pipeline.py": "_osm_map_pipeline.py",
"_osm_policy.py": "_osm_policy.py",
"_osm_runtime.py": "_osm_runtime.py",
"_geojson_geometry.py": "_geojson_geometry.py",
"_geojson_mapsforge.py": "_geojson_mapsforge.py",
"_geojson_merge.py": "_geojson_merge.py",
"_geojson_merge_cli.py": "_geojson_merge_cli.py",
"_geojson_osm_export.py": "_geojson_osm_export.py",
"_geojson_osm_xml.py": "_geojson_osm_xml.py",
"_geojson_raster.py": "_geojson_raster.py",
"_loader.py": "_loader.py",
"update_app.py": "update_app.py",
"tagmapping-min.xml": "tagmapping-min.xml",
"deploy.py": "deploy.py",
"coverage_map.py": "coverage_map.py",
"coverage.geojson": "coverage.geojson",
"coverage.png": "coverage.png",
"coverage.fr.png": "coverage.fr.png",
"lidar2map_icon.png": "lidar2map_icon.png",
# Build Windows / Linux
"lidar2map_win.spec": "lidar2map_win.spec",
"lidar2map_win_launcher.spec": "lidar2map_win_launcher.spec",
"lidar2map_win_build.ps1": "lidar2map_win_build.ps1",
"setup_build_windows.ps1": "setup_build_windows.ps1",
"setup_build_linux.sh": "setup_build_linux.sh",
"lidar2map_linux_build.sh": "lidar2map_linux_build.sh",
# Build macOS
"lidar2map_mac.spec": "lidar2map_mac.spec",
"lidar2map_mac_launcher.spec": "lidar2map_mac_launcher.spec",
"lidar2map_mac_build.sh": "lidar2map_mac_build.sh",
"setup_build_mac.sh": "setup_build_mac.sh",
"macos.entitlements": "macos.entitlements",
# Doc + CI + meta
"README_Github.md": "README.md",
"README_Github.fr.md": "README.fr.md",
"README_LIDAR2MAP.md": "BUILD.md",
"pyproject.toml": "pyproject.toml",
"ci_github.yml": ".github/workflows/ci.yml",
"release_github.yml": ".github/workflows/release.yml",
"update_github.yml": ".github/workflows/update.yml",
"cross_platform_github.yml": ".github/workflows/cross_platform.yml",
"smoke_github.yml": ".github/workflows/smoke.yml",
"LICENSE": "LICENSE",
".gitignore": ".gitignore",
".gitattributes": ".gitattributes",
}
# Dossiers à synchroniser récursivement (mirror local -> remote)
FOLDERS = {
"screenshots": "screenshots",
"providers": "providers", # implémentations LiDAR par pays (fr-ign, nl-ahn, ch-swisstopo, etc.)
"Tests": "tests", # tests de régression (calculs scientifiques, tuilage)
"gui": "gui", # front-end extrait de lancer_gui (index.html + style.css + app.js)
"docs": "docs", # doc de référence (roadmap providers), lié depuis le README
"tools": "tools", # outils (découverte de providers par catalogue CSW)
}
# Outils réellement embarqués par les specs PyInstaller. Un changement de l'un
# d'eux doit déclencher le patch des bundles, comme lidar2map.py/providers/gui.
# Les autres scripts de tools/ restent des utilitaires source et ne justifient
# pas de patch binaire.
PATCHABLE_TOOL_FILES = {
"tools/__init__.py",
"tools/rlidar2map_CLI.py",
"tools/rlidar2map_GUI.py",
"tools/rlidar2map_GUI_vm.sh",
}
# Anciens chemins sur GitHub à supprimer (renommages + scripts PS1 obsolètes)
REMOVE = [
"lidar2map.spec",
"lidar2map_launcher.spec",
"lidar2map_build.ps1",
"build_lidar2map.ps1",
"launcher.spec",
"push_github.ps1", # remplacé par deploy.py
"deploy_update.ps1", # remplacé par deploy.py
"TEST_LINUX_MAC.md", # contenu utile fondu dans BUILD.md (section Dépannage)
]
# Patterns "rebuild requis" : si l'un de ces fichiers a changé, le patch ne
# suffit pas. tagmapping-min.xml = donnée bundlée (PAS patchable par update_app.py
# qui ne touche que _internal/lidar2map.py).
def is_rebuild_file(name: str) -> bool:
return (
name == "_loader.py"
or (name.startswith("_split_") and name.endswith(".py"))
or name == "_raster_formats.py"
or name == "_mbtiles_wmts.py"
or name == "_mbtiles_lidar.py"
or name == "_mbtiles_wmts_helpers.py"
or name == "_ombrages_pures.py"
or name == "_ombrages_provider.py"
or name == "_shading_specs.py"
or (name.startswith("_bootstrap_") and name.endswith(".py"))
or name == "_smoketest.py"
or name == "_logging_helpers.py"
or name == "_tee_logger.py"
or name == "_log_activation.py"
or name == "_atomic_files.py"
or name == "_http_helpers.py"
or name == "_runtime_paths.py"
or name == "_disk_guard.py"
or name == "_wfs_pipeline.py"
or name == "_bdtopo_bulk.py"
or name == "_bdtopo_layers.py"
or name == "_vector_acquisition.py"
or name == "_vector_outputs.py"
or (name.startswith("_osm_") and name.endswith(".py"))
or (name.startswith("_geojson_") and name.endswith(".py"))
or name == "lidar2map_icon.png"
or name == "tagmapping-min.xml"
or name.endswith(".entitlements")
or name.endswith(".spec")
or name.endswith("_build.ps1")
or name.endswith("_build.sh")
or name.startswith("setup_build_")
)
# === COLOR / IO HELPERS ======================================================
_USE_COLOR = sys.stdout.isatty() and os.environ.get("NO_COLOR") is None
if os.name == "nt" and _USE_COLOR:
# Active la séquence ANSI sur les terminaux Windows récents.
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) # ENABLE_VIRTUAL_TERMINAL_PROCESSING
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, cwd=None, check=True, capture=False, env=None, timeout=120):
"""Wrapper subprocess.run. capture=True -> renvoie stdout (texte).
timeout : secondes avant abandon. 120s suffit pour la majorité des git/gh
opérations. Pour les commandes longues par nature (git clone d'un repo
avec gros assets, gh run watch sur update.yml, update_app.py --release
qui upload ~1,5 Go), passer un timeout explicite plus large au call site."""
try:
result = subprocess.run(
cmd, cwd=str(cwd) if cwd else None,
check=False, text=True,
stdout=subprocess.PIPE if capture else None,
stderr=subprocess.PIPE if capture else None,
env=env,
timeout=timeout,
)
except subprocess.TimeoutExpired:
fail(f"{' '.join(cmd)} a dépassé le timeout ({timeout}s) — réseau bloqué ou commande hangée ?")
if check and result.returncode != 0:
cmd_str = " ".join(cmd)
err = (result.stderr or result.stdout or "").strip()
fail(f"{cmd_str} a échoué (code {result.returncode})" + (f"\n{err}" if err else ""))
return result
def git(*args, check=True, capture=False):
"""git -C <CLONE> <args>"""
return run(["git", "-C", str(CLONE), *args], check=check, capture=capture)
def gh_json(*args):
"""gh ... --json X (renvoie le JSON parsé)."""
res = run(["gh", *args], capture=True)
return json.loads(res.stdout)
def get_latest_tag(repo: str) -> str:
res = run(["gh", "release", "view", "--repo", repo, "--json", "tagName"],
capture=True, check=False)
if res.returncode != 0:
fail(f"aucune release sur {repo} (crée-en une via release.yml, ou passe --patch-tag)")
data = json.loads(res.stdout)
tag = data.get("tagName", "")
if not tag:
fail(f"aucune release sur {repo}")
return tag
def read_code_version() -> str:
"""Lit la constante VERSION de lidar2map.py : SOURCE UNIQUE de la version.
Le tag de release en est dérivé (v<VERSION>), au lieu d'être saisi une 2e
fois : sans ça, tag et constante peuvent diverger (vécu à v1.15.0, taguée
alors que la constante était restée à 1.14.0 → bandeau update erroné).
"""
txt = (SRC / APP_PY).read_text(encoding="utf-8")
m = re.search(r'^VERSION\s*=\s*"([^"]+)"', txt, re.M)
if not m:
fail(f"constante VERSION introuvable dans {APP_PY}")
return m.group(1)
def find_python() -> str:
for name in ("python", "python3", "py"):
if shutil.which(name):
return name
fail("python introuvable dans le PATH (requis pour --mode local)")
# === PUSH PHASE ==============================================================
def rmtree_force(path, ignore_errors: bool = False):
"""shutil.rmtree qui survit aux fichiers en lecture seule.
git marque ses objets (.git/objects/**) en lecture seule. Sous Windows,
os.unlink refuse alors de les supprimer et rmtree remonte un
PermissionError [WinError 5] — vécu en supprimant un clone temp corrompu.
Le handler retire le flag puis retente. Sous POSIX il ne sert jamais :
c'est le droit d'écriture du DOSSIER qui gouverne la suppression, pas
celui du fichier.
"""
def _retry(func, p, _exc):
try:
os.chmod(p, stat.S_IWRITE)
func(p)
except OSError:
if not ignore_errors:
raise
# onexc remplace onerror depuis Python 3.12 (onerror déprécié).
kw = {"onexc": _retry} if sys.version_info >= (3, 12) else {"onerror": _retry}
try:
shutil.rmtree(path, **kw)
except OSError:
if not ignore_errors:
raise
def clone_or_pull():
if (CLONE / ".git").exists():
cprint(f"==> Pull du repo existant : {CLONE}", "cyan")
# fetch non-fatal : le clone temp peut être corrompu (nettoyage
# périodique de %TEMP% par Windows, copie interrompue, .git tronqué).
# Dans ce cas on supprime et on re-clone au lieu d'échouer sec.
r = git("fetch", "origin", check=False, capture=True)
if r.returncode == 0:
git("reset", "--hard", "origin/main")
git("clean", "-fd")
return
cprint(f" Clone temp corrompu (fetch code {r.returncode}) — "
f"suppression + re-clone.", "yellow")
rmtree_force(CLONE, ignore_errors=True)
cprint(f"==> Clone {REPO_URL} -> {CLONE}", "cyan")
if CLONE.exists():
rmtree_force(CLONE)
# Clone initial : peut prendre 1-2 min (assets binaires, screenshots).
run(["git", "clone", REPO_URL, str(CLONE)], timeout=300)
def remove_obsolete():
cprint("\n==> Suppression des anciens chemins renommés", "cyan")
for f in REMOVE:
p = CLONE / f
if p.exists():
p.unlink()
print(f" SUPPR {f}")
def copy_files():
cprint("\n==> Copie des fichiers source", "cyan")
for src_name, dst_name in MAP.items():
s = SRC / src_name
d = CLONE / dst_name
if s.exists():
d.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(s, d)
print(f" OK {src_name:<32} -> {dst_name}")
else:
cprint(f" -- {src_name:<32} (introuvable, ignoré)", "yellow")
def mirror_folders():
cprint("\n==> Copie des dossiers (mirror)", "cyan")
# Exclure les artefacts Python : __pycache__/, .pyc, .pyo. Sans ça,
# copytree copierait le bytecode au temp clone et seul le .gitignore
# empêcherait le commit — fragile si la règle change.
ignore = shutil.ignore_patterns("__pycache__", "*.pyc", "*.pyo")
def _is_artefact(p): return "__pycache__" in p.parts or p.suffix in (".pyc", ".pyo")
for src_name, dst_name in FOLDERS.items():
s = SRC / src_name
d = CLONE / dst_name
if s.exists():
if d.exists():
rmtree_force(d)
shutil.copytree(s, d, ignore=ignore)
count = sum(1 for p in s.rglob("*") if p.is_file() and not _is_artefact(p))
print(f" OK {src_name:<32} -> {dst_name} ({count} fichiers)")
else:
cprint(f" -- {src_name:<32} (introuvable, ignoré)", "yellow")
def compute_diff():
cprint("\n==> Modifications :", "cyan")
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()
git("diff", "--cached", "--stat")
changed = git("diff", "--cached", "--name-only", capture=True).stdout.strip().splitlines()
return [c.strip() for c in changed if c.strip()]
def commit_and_push(message: str, new_tag: str = ""):
cprint("\n==> Commit", "cyan")
msg_file = CLONE / "COMMIT_MSG.txt"
# UTF-8 SANS BOM par défaut sur Python.
msg_file.write_text(message, encoding="utf-8")
try:
git("commit", "-F", str(msg_file))
finally:
try:
msg_file.unlink()
except FileNotFoundError:
pass
cprint("\n==> Push origin main", "cyan")
git("push", "origin", "main")
if new_tag:
cprint(f"\n==> Tag {new_tag}", "cyan")
git("tag", "-a", new_tag, "-m", message)
git("push", "origin", new_tag)
# === RELEASE PHASE (patch d'une release existante) ===========================
def invoke_cloud(repo: str, target_tag: str):
cprint(f"\n==> Déclenchement de update.yml sur {target_tag} (voie cloud)", "cyan")
run(["gh", "workflow", "run", "update.yml", "--repo", repo, "-f", f"tag={target_tag}"])
run_id = None
for _ in range(6):
time.sleep(5)
runs = gh_json("run", "list", "--repo", repo, "--workflow", "update.yml",
"--limit", "1", "--json", "databaseId")
if runs:
run_id = runs[0]["databaseId"]
break
if not run_id:
fail("run introuvable (voir l'onglet Actions du repo)")
print(f" Run : https://github.com/{repo}/actions/runs/{run_id}")
cprint("==> Surveillance du run (~6-7 min)", "cyan")
# update.yml dure typiquement 5-7 min ; on tolère jusqu'à 20 min pour rester
# robuste si le runner GitHub est lent ce jour-là.
res = run(["gh", "run", "watch", str(run_id), "--repo", repo,
"--exit-status", "--interval", "20"], check=False, timeout=1200)
if res.returncode != 0:
fail(f"le run update.yml a échoué : gh run view {run_id} --repo {repo} --log-failed")
cprint(f"\n==> OK. Bundles de {target_tag} patchés sans rebuild (cloud).", "green")
print(f" Release : https://github.com/{repo}/releases/tag/{target_tag}")
def invoke_local(target_tag: str):
cprint(f"\n==> Patch local via update_app.py --release sur {target_tag} (voie locale)", "cyan")
cprint(" Les ~1,5 Go d'assets transitent par TA connexion (upload vers GitHub).", "yellow")
py = find_python()
env = os.environ.copy()
if "GH_TOKEN" not in env and "GITHUB_TOKEN" not in env:
# Récupère le token de gh CLI si dispo, pour éviter à update_app.py
# le détour par git credential (qui peut prompter).
tok = run(["gh", "auth", "token"], capture=True, check=False)
if tok.returncode == 0 and tok.stdout.strip():
env["GH_TOKEN"] = tok.stdout.strip()
else:
cprint(" Note : aucun token dans l'env ni via gh ; update_app.py tentera git credential.", "yellow")
update_app = SRC / "update_app.py"
if not update_app.exists():
fail(f"update_app.py introuvable à côté ({update_app})")
# update_app.py --release : download 3 assets + patch + upload ~1,5 Go
# depuis la connexion locale. Tolère jusqu'à 40 min pour les connexions lentes.
run([py, str(update_app), "--release", "--tag", target_tag], env=env, timeout=2400)
cprint(f"\n==> OK. Bundles de {target_tag} patchés sans rebuild (local).", "green")
print(f" Release : https://github.com/{REPO_DEFAULT}/releases/tag/{target_tag}")
def invoke_patch(mode: str, repo: str, target_tag: str):
if mode == "local":
invoke_local(target_tag)
else:
invoke_cloud(repo, target_tag)
# === MAIN ====================================================================
def main():
parser = argparse.ArgumentParser(
prog="deploy.py",
description=f"Déploiement unifié {PROJECT} — push + patch cloud/local + tag.",
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("--mode", choices=["cloud", "local"], default="cloud",
help="voie de patch (défaut: cloud = update.yml sur GitHub)")
parser.add_argument("--patch-tag", default="",
help="tag existant à patcher (défaut: dernière release)")
parser.add_argument("--new-tag", nargs="?", const="AUTO", default="",
help="rebuild complet via un nouveau tag git → déclenche "
"release.yml. Sans valeur : tag dérivé de VERSION "
"(v<VERSION>, source unique). Avec valeur vX.Y.Z : "
"acceptée mais doit égaler v<VERSION>, sinon refus.")
parser.add_argument("--skip-push", action="store_true",
help="sauter push+détection, patcher directement la release")
parser.add_argument("--push-only", action="store_true",
help="pousser les sources sur main SANS patcher les bundles "
"(itération debug/mesure : récup via git pull + run direct)")
parser.add_argument("--dry-run", action="store_true",
help="afficher le diff sans commit ni push")
parser.add_argument("--repo", default=REPO_DEFAULT,
help=f"repo GitHub cible (défaut: {REPO_DEFAULT})")
args = parser.parse_args()
# --- Version : source unique (constante VERSION) -> tag dérivé ---
# Le tag n'est jamais une 2e saisie de la version : soit on le dérive de
# VERSION (--new-tag sans valeur), soit on vérifie que la valeur explicite
# colle. Impossible de tagguer v1.16.0 avec VERSION restée à 1.15.x.
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"{APP_PY}). La version a UNE source : bumpe VERSION, puis "
f"passe --new-tag sans valeur (le tag est dérivé).")
# --- Validation ---
if args.new_tag and args.skip_push:
fail("--new-tag et --skip-push sont contradictoires")
if args.new_tag and args.patch_tag:
fail("--new-tag et --patch-tag sont contradictoires (rebuild vs patch existant)")
if args.dry_run and args.skip_push:
fail("--dry-run et --skip-push sont contradictoires")
if args.push_only and args.skip_push:
fail("--push-only et --skip-push sont contradictoires (pousser sans patcher vs patcher sans pousser)")
if args.push_only and args.new_tag:
fail("--push-only et --new-tag sont contradictoires (push seul vs rebuild via tag)")
# --- Mode --skip-push : patch direct, pas de push ni de détection ---
if args.skip_push:
cprint(f"==> --skip-push : pas de push ni de détection, déclenchement direct ({args.mode}).", "yellow")
tag = args.patch_tag or get_latest_tag(args.repo)
invoke_patch(args.mode, args.repo, tag)
return 0
# --- Phase 1 : push + détection ---
cprint("==> [1/2] Push des sources sur main + détection du diff", "cyan")
clone_or_pull()
remove_obsolete()
copy_files()
mirror_folders()
changed = compute_diff()
if not changed:
# --new-tag sans diff : cas légitime (sources déjà poussées lors d'un
# patch précédent, on veut ensuite un vrai rebuild taggé). L'ancien
# early return court-circuitait la création du tag.
if args.new_tag:
cprint(f"\n==> Aucun changement à pousser ; tag {args.new_tag} sur le HEAD courant.", "cyan")
git("tag", "-a", args.new_tag, "-m", args.message)
git("push", "origin", args.new_tag)
cprint(f"\n==> Tag {args.new_tag} poussé → release.yml va se déclencher (rebuild ~30 min sur 3 OS).", "green")
print(f" Suivi : https://github.com/{args.repo}/actions/workflows/release.yml")
return 0 # message déjà affiché par compute_diff
if args.dry_run:
cprint("\n==> --dry-run : pas de commit ni de push.", "yellow")
return 0
commit_and_push(args.message, args.new_tag)
if args.new_tag:
cprint(f"\n==> Nouveau tag {args.new_tag} poussé → release.yml va se déclencher (rebuild ~30 min sur 3 OS).", "green")
print(f" Suivi : https://github.com/{args.repo}/actions/workflows/release.yml")
return 0
cprint("\n==> Fichiers modifiés et poussés :", "cyan")
for c in changed:
print(f" {c}")
# --- Mode --push-only : sources poussées, on saute le patch des bundles ---
if args.push_only:
cprint("\n==> --push-only : sources sur main, patch des bundles sauté.", "green")
cprint(" Récup : git pull && python lidar2map.py ...", "cyan")
return 0
# --- Phase 2 : catégorisation -> action ---
rebuild = [c for c in changed if is_rebuild_file(c)]
# providers/*.py, gui/* et les contrôleurs remote embarqués comptent comme
# du code : update_app.py les injecte dans les bundles lors du patch. Sans
# ce test, un correctif gui/remote/provider serait poussé sur main mais
# jamais livré aux exécutables existants.
code_changed = (APP_PY in changed
or any(c.startswith(("providers/", "gui/")) for c in changed)
or any(c in PATCHABLE_TOOL_FILES for c in changed))
if rebuild:
cprint("\n==> [2/2] REBUILD requis (fichiers impactant le binaire) :", "yellow")
for f in rebuild:
cprint(f" {f}", "yellow")
print()
print(f" Le patch (cloud ou local) ne change que {APP_PY} et les")
print(" ressources Python/shell explicitement embarquées :")
print(" providers/, gui/ et contrôleurs remote de tools/.")
print(" il ne peut PAS livrer ces changements. Il faut un vrai rebuild")
print(" via release.yml, déclenché par un NOUVEAU tag. La version se")
print(f" bumpe dans la constante VERSION de {APP_PY} (source unique) ;")
print(" le tag en est dérivé :")
cur = get_latest_tag(args.repo)
print()
cprint(f" python deploy.py -m \"{args.message}\" --new-tag # tag = v<VERSION> ; dernière release : {cur}", "cyan")
print()
cprint(" Sources déjà poussées ; il ne reste qu'à tagger pour lancer release.yml.", "yellow")
return 0
if code_changed:
tag = args.patch_tag or get_latest_tag(args.repo)
cprint(f"\n==> [2/2] Code embarqué modifié → patch via --mode {args.mode} sur {tag}", "cyan")
cprint(" Avertissement : si tu as touché au BLOC LAUNCHER ou aux DEPS dans", "yellow")
cprint(f" {APP_PY}, le patch ne suffit pas → rebuild via release.yml.", "yellow")
invoke_patch(args.mode, args.repo, tag)
return 0
cprint("\n==> [2/2] Docs / meta seulement → aucun binaire à patcher, push suffit. Terminé.", "green")
return 0
if __name__ == "__main__":
sys.exit(main())