publier : les releases voyagent jusqu'aux deux forges ; wiki publie sur la forge du site

Le colis du genome ne portait que la branche : les etiquettes de release
n'arrivaient jamais sur la forge du site. Il les porte desormais, le runner les
pousse sans forcer, et la forge est relue par son API. publier.py pousse aussi
les etiquettes vers eregion. Le wiki, jamais publie sur la forge du site, l'est.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-10-07 19:04:31 -04:00
parent e9215a2141
commit e8907285d5
7 changed files with 227 additions and 10 deletions

View file

@ -1,5 +1,50 @@
# CHANGELOG — Set-OPS
## 2026-10-07 (105) — Les releases et le wiki n'allaient que là où on les poussait à la main
**94 preuves.** La forge du site fait autorité (D-81), mais deux choses n'y arrivaient
jamais d'elles-mêmes : les étiquettes de release, et le wiki.
### Les releases manquaient à la forge du site
`v2026.10.07` poussée sur eregion, la question suivante a été : et la forge du site ? Mesure
(`git ls-remote --tags`) : elle portait `v2026.10.04`, pas `v2026.08.21` ni `v2026.10.07`. Le
colis de `make publier` (`genome_colis.py`) ne transporte que `refs/heads/<branche>`, et
`publier.py` ne poussait que la branche vers eregion. `v2026.10.04` y avait été portée par un
geste à part, que rien ne gardait. Les deux étiquettes manquantes ont été poussées par
`origin` (rebond SSH du poste), et relues identiques des deux côtés.
**Fait** :
- `genome_colis.py` : le colis emporte toutes les étiquettes (`bundle create … --tags`) ; le
runner les récupère et les pousse avec la branche, **sans forcer** : une étiquette déjà
présente sous un autre objet est refusée, des deux côtés. Une release ne bouge pas.
- `genome_pousser.yml` : la forge est relue par son API (`git/refs/tags`), et doit porter
toutes les étiquettes du poste.
- `publier.py` pousse aussi les étiquettes vers eregion.
**Option écartée** : faire d'eregion un miroir poussé par la forge du site. Mesuré : eregion
porte deux branches de contribution non fusionnées (`essai_integration`, 4 commits ;
`fix_client_pki`, demande de fusion n° 5 ouverte), qu'un miroir effacerait. L'exploitant
garde les deux forges poussées depuis le poste, par prudence envers les contributions.
**Éprouvé** : `test_genome_colis.py` (dans `make test`), sur de vrais dépôts git jetables :
sans étiquette, la tête passe ; une release annotée arrive sur la forge, même objet ; une
étiquette déplacée est refusée, la forge garde l'ancienne. Témoin : sur l'ancien
`genome_colis.py`, le test échoue dès « la release voyage jusqu'à la forge ». `make
verifier` conforme, **94/94**.
**Pas encore éprouvé** : la relecture par l'API de la forge (`git/refs/tags`). Le premier
`make publier` qui suivra le dira : « N etiquette(s) du poste, toutes sur la forge ».
### Le wiki de la forge du site était vide
`make wiki-publier` publie vers UNE forge (`WIKI_REMOTE`) ; son témoin
(`docs/audit/wiki-publie.yml`) montrait la dernière publication, le 2026-09-29, vers eregion
seulement. Rien n'avait jamais publié vers la forge du site. Sa branche de wiki demandée à
l'API de Forgejo (`wiki_branch: main`), le wiki est publié sur les deux forges ; relus :
même arbre (`b3a70ba`), 96 pages, 8 figures, des deux côtés. Le témoin ne retient qu'une
forge (la dernière publiée, eregion) : P60 ne voit pas l'autre.
## 2026-10-07 (104) — L'historique de supervision traverse la reconstruction : les deux locataires, sur `2cbfa2d`
**94 preuves.** Les deux locataires rasés et reconstruits par le site qui les nomme, sur le

View file

@ -318,6 +318,7 @@ test: ## Lance les tests unitaires (derivation de nomenclature et d'inventaire)
python3 scripts/tests/test_raser.py
python3 scripts/tests/test_raser_resultat.py
python3 scripts/tests/test_temoins.py
python3 scripts/tests/test_genome_colis.py
python3 scripts/tests/test_adressage_derive.py
python3 scripts/tests/test_gui_intrants.py
python3 scripts/tests/test_runbooks.py

View file

@ -1,5 +1,5 @@
---
# Ecrit par `make wiki-publier`, lu par la preuve P60. Ne pas editer a la main.
remote: ssh://git@eregion.chezlepro.ca:2222/Alliance-Boreale/Set-OPS-Public.wiki.git
source: 3fe7e3c
date: 2026-09-29
source: e9215a2
date: 2026-10-07

View file

@ -160,6 +160,7 @@
loop: "{{ genome_a_pousser }}"
loop_control:
label: "{{ item.dest }}"
register: genome_emballe
changed_when: true
- name: Porter les colis jusqu'au runner
@ -255,6 +256,45 @@
label: "{{ item.depot }}"
index_var: idx
# LES RELEASES AUSSI (2026-10-07). Le colis ne portait que la branche : `v2026.08.21` et
# `v2026.10.07` manquaient a la forge du site. Il porte desormais les etiquettes ; on
# demande a la forge si elle a TOUTES celles du poste. 404 = aucune etiquette sur ce
# depot, ce qui est juste quand le poste n'en a pas non plus.
- name: Demander à la forge les étiquettes qu'elle porte
ansible.builtin.uri:
url: >-
https://forge.{{ domaine_interne }}/api/v1/repos/{{ serveur_ops_forge_organisation
| default('genome') }}/{{ item.depot }}/git/refs/tags
validate_certs: false
status_code: [200, 404]
loop: "{{ genome_a_pousser }}"
loop_control:
label: "{{ item.depot }}"
register: genome_forge_etiquettes
retries: 4
delay: 3
until: genome_forge_etiquettes is succeeded
changed_when: false
- name: La forge porte-t-elle toutes les releases du poste ?
vars:
genome_etiquettes_poste: "{{ (genome_emballe.results[idx].stdout | from_json).etiquettes | default([]) }}"
genome_etiquettes_forge: >-
{{ (genome_forge_etiquettes.results[idx].json | default([]) if genome_forge_etiquettes.results[idx].status == 200 else [])
| map(attribute='ref') | map('regex_replace', '^refs/tags/', '') | list }}
ansible.builtin.assert:
that:
- genome_etiquettes_poste | difference(genome_etiquettes_forge) | length == 0
fail_msg: >-
{{ item.depot }} : la forge n'a pas
{{ genome_etiquettes_poste | difference(genome_etiquettes_forge) | join(', ') }}.
success_msg: >-
{{ item.depot }} : {{ genome_etiquettes_poste | length }} etiquette(s) du poste, toutes sur la forge.
loop: "{{ genome_a_pousser }}"
loop_control:
label: "{{ item.depot }}"
index_var: idx
# LE RUNNER DU SITE AVANCE SON PROPRE CLONE, ET SA CONSOLE NE LE SAIT PAS (2026-09-20).
#
# C'est LUI qui pousse, donc il doit d'abord recevoir : ce playbook fait avancer le

View file

@ -28,6 +28,13 @@ DEUX GESTES, DEUX COTES :
genome_colis.py etat <depot>
Des deux cotes. Ce que cette copie porte, pour le confronter a la forge.
LES ETIQUETTES VOYAGENT AVEC LA BRANCHE (2026-10-07). Le colis ne portait que `main` : les
releases n'arrivaient jamais sur la forge du site, qui fait pourtant autorite (D-81).
`v2026.08.21` et `v2026.10.07` y manquaient, et il a fallu les pousser a la main. Le colis
emporte desormais toutes les etiquettes du poste ; le runner les recoit et les pousse. Une
etiquette qui existe deja SOUS UN AUTRE OBJET est refusee, des deux cotes : une release ne
bouge pas.
`--ff-only` est le garde-fou. Si les deux histoires ont diverge, on s'arrete plutot que de
fabriquer une fusion que personne n'a decidee — et le message de git nomme alors exactement
ce qu'il faut reconcilier.
@ -55,13 +62,18 @@ def _git(depot: Path, *args: str, env: dict | None = None) -> str:
return (r.stdout or "").strip()
def etiquettes(depot: Path) -> list[str]:
return sorted(e for e in _git(depot, "tag", "-l").splitlines() if e)
def emballer(depot: Path, colis: Path, branche: str) -> int:
if not (depot / ".git").exists():
raise SystemExit(f"{depot} n'est pas un depot git — rien a emballer.")
colis.parent.mkdir(parents=True, exist_ok=True)
_git(depot, "bundle", "create", str(colis), branche)
_git(depot, "bundle", "create", str(colis), branche, "--tags")
print(json.dumps({"depot": str(depot), "colis": str(colis),
"tete": _git(depot, "log", "-1", "--format=%H")}))
"tete": _git(depot, "log", "-1", "--format=%H"),
"etiquettes": etiquettes(depot)}))
return 0
@ -101,6 +113,10 @@ def recevoir(depot: Path, colis: Path, pushurl: str, cle: str | None,
f"refs/heads/{branche}:refs/remotes/colis/{branche}")
_git(depot, "merge", "--ff-only", f"refs/remotes/colis/{branche}")
apres = _git(depot, "log", "-1", "--format=%H")
# Sans `--force` : une etiquette deja presente sous un autre objet fait echouer la
# recuperation (« would clobber existing tag ») — c'est voulu.
if any("refs/tags/" in l for l in _git(depot, "bundle", "list-heads", str(colis)).splitlines()):
_git(depot, "fetch", str(colis), "refs/tags/*:refs/tags/*")
env = None
if cle:
@ -114,14 +130,15 @@ def recevoir(depot: Path, colis: Path, pushurl: str, cle: str | None,
# seule l'ECRITURE s'authentifie, et seulement le temps de cette commande.
r = subprocess.run(
["git", "-C", str(depot), "-c", f"remote.origin.pushurl={pushurl}",
"push", "origin", branche],
"push", "origin", branche, "refs/tags/*:refs/tags/*"],
capture_output=True, text=True, env=env)
sortie = ((r.stdout or "") + (r.stderr or "")).strip()
if r.returncode != 0:
raise SystemExit(f"la poussee a echoue :\n {sortie}")
print(json.dumps({"avant": avant, "apres": apres,
"pousse": f"{branche} -> {branche}" in sortie, "sortie": sortie[:400]}))
"pousse": f"{branche} -> {branche}" in sortie,
"etiquettes": etiquettes(depot), "sortie": sortie[:400]}))
return 0

View file

@ -16,9 +16,11 @@ liste que `genome-pousser` — lue, pas recopiee) :
demande de fusion acceptee, par exemple) : publier sans les integrer les ecraserait
sur la forge du site ;
2. signaler ce qui n'est pas committe — cela ne part pas ;
3. pousser sur eregion ;
puis `make genome-pousser`, puis `make genome-etat` : la forge du site doit porter la meme
tete que le poste, sinon on le dit.
3. pousser sur eregion, la branche ET les etiquettes (2026-10-07 : les releases n'allaient
nulle part d'elles-memes ; une etiquette deplacee est refusee, une release ne bouge pas) ;
puis `make genome-pousser`, qui porte aussi les etiquettes et verifie que la forge les a,
puis `make genome-etat` : la forge du site doit porter la meme tete que le poste, sinon on
le dit.
make publier tous les depots du genome
make publier DEPOT=ops-chezlepro
@ -81,7 +83,7 @@ def main() -> int:
if sale:
print(f" ~ {d['depot']:<17} {len(sale)} changement(s) NON committe(s) — ils ne partent pas")
avance = git(depot, "rev-list", "--count", f"{distant}..HEAD").stdout.strip() or "?"
r = git(depot, "push", "-q", "eregion", branche)
r = git(depot, "push", "-q", "eregion", branche, "refs/tags/*:refs/tags/*")
if r.returncode != 0:
refus.append(d["depot"])
print(f" !! {d['depot']:<17} push refuse : {r.stderr.strip()[:120]}")

View file

@ -0,0 +1,112 @@
#!/usr/bin/env python3
"""`genome_colis.py` : le colis porte la branche ET les releases jusqu'à la forge du site.
POURQUOI (2026-10-07). Le colis ne portait que `main`. Les étiquettes de release
n'arrivaient donc jamais sur la forge du site, qui fait autorité (D-81) : `v2026.08.21` et
`v2026.10.07` y manquaient, et il a fallu les pousser à la main.
Sur de vrais dépôts git, dans un répertoire jetable (aucun réseau) :
- un poste, une forge nue, un runner cloné depuis la forge ;
- le poste fait un commit et étiquette une release ; `emballer`, puis `recevoir` sur le
runner, avec la forge pour `pushurl` ;
- la forge porte la nouvelle tête ET l'étiquette ; un dépôt sans étiquette passe aussi ;
- une étiquette déplacée sur le poste est REFUSÉE : une release ne bouge pas.
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
import tempfile
from pathlib import Path
RACINE = Path(__file__).resolve().parents[2]
COLIS = RACINE / "scripts" / "genome_colis.py"
ENV = {**os.environ, "GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t", "GIT_COMMITTER_NAME": "t",
"GIT_COMMITTER_EMAIL": "t@t", "GIT_CONFIG_GLOBAL": os.devnull, "GIT_CONFIG_NOSYSTEM": "1"}
ECHECS: list[str] = []
def verifier(cond: bool, msg: str) -> None:
print(("OK " if cond else "ECHEC ") + msg)
if not cond:
ECHECS.append(msg)
def git(*args: str, cwd: Path | None = None) -> str:
r = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True, env=ENV)
if r.returncode != 0:
raise RuntimeError(f"git {' '.join(args)} : {r.stderr.strip()}")
return r.stdout.strip()
def colis(*args: str) -> subprocess.CompletedProcess:
return subprocess.run([sys.executable, str(COLIS), *args], capture_output=True, text=True, env=ENV)
def monter(d: Path) -> tuple[Path, Path, Path]:
forge, poste, runner = d / "forge.git", d / "poste", d / "runner"
git("init", "-q", "--bare", "-b", "main", str(forge))
git("init", "-q", "-b", "main", str(poste))
(poste / "a").write_text("1")
git("add", "a", cwd=poste)
git("commit", "-q", "-m", "un", cwd=poste)
git("push", "-q", str(forge), "main", cwd=poste)
git("clone", "-q", str(forge), str(runner))
return forge, poste, runner
def porter(poste: Path, runner: Path, forge: Path, d: Path) -> subprocess.CompletedProcess:
paquet = d / "x.bundle"
e = colis("emballer", str(poste), str(paquet))
if e.returncode != 0:
return e
return colis("recevoir", str(runner), str(paquet), str(forge))
def main() -> int:
with tempfile.TemporaryDirectory() as d:
d = Path(d)
forge, poste, runner = monter(d)
# Un depot sans etiquette : rien ne change pour lui.
(poste / "a").write_text("2")
git("commit", "-q", "-am", "deux", cwd=poste)
r = porter(poste, runner, forge, d)
verifier(r.returncode == 0 and git("rev-parse", "main", cwd=forge) == git("rev-parse", "HEAD", cwd=poste),
f"sans etiquette : la forge porte la tete du poste ({r.stderr.strip()[-160:]})")
# Une release annotee.
(poste / "a").write_text("3")
git("commit", "-q", "-am", "trois", cwd=poste)
git("tag", "-a", "v1", "-m", "release v1", cwd=poste)
r = porter(poste, runner, forge, d)
recu = json.loads(r.stdout) if r.returncode == 0 else {}
verifier(r.returncode == 0 and "v1" in git("tag", "-l", cwd=forge).split(),
f"la release voyage jusqu'a la forge ({r.stderr.strip()[-160:]})")
verifier(git("rev-parse", "v1", cwd=forge) == git("rev-parse", "v1", cwd=poste),
"la forge porte le MEME objet d'etiquette que le poste (annotee, pas recreee)")
verifier(recu.get("etiquettes") == ["v1"], f"le runner dit les etiquettes qu'il porte ({recu.get('etiquettes')})")
e = json.loads(colis("emballer", str(poste), str(d / "y.bundle")).stdout)
verifier(e.get("etiquettes") == ["v1"], "l'emballage dit les etiquettes du poste")
# Une release deplacee sur le poste : refusee, la forge garde l'ancienne.
ancienne = git("rev-parse", "v1", cwd=forge)
(poste / "a").write_text("4")
git("commit", "-q", "-am", "quatre", cwd=poste)
git("tag", "-f", "-a", "v1", "-m", "v1 deplacee", cwd=poste)
r = porter(poste, runner, forge, d)
verifier(r.returncode != 0 and git("rev-parse", "v1", cwd=forge) == ancienne,
f"une etiquette deplacee est refusee, la forge garde la release ({r.stderr.strip()[-120:]})")
if ECHECS:
print(f"\n{len(ECHECS)} echec(s).")
return 1
print("\nLe colis porte la branche et les releases ; une release ne bouge pas.")
return 0
if __name__ == "__main__":
sys.exit(main())