feat(miroirs) — un dépôt personnel n'est pas une organisation

Le rôle gitea_repo ne savait viser qu'un propriétaire : l'organisation, des
deux côtés à la fois. Les dépôts qui vivent sous le compte personnel
`arcodange` ne pouvaient donc pas sortir du homelab — ni être balayés par
gitea_sync, qui n'interroge que /orgs/<org>/repos.

Trois séparations, toutes rétrocompatibles (les défauts reconduisent le
comportement org-vers-org des dix dépôts déjà en miroir) :

- le propriétaire côté Gitea (`gitea_repo_owner`) n'est plus le même objet que
  celui d'en face (`github_owner`, `gitlab_owner`) ;
- un compte personnel n'est pas une organisation : GitHub ne crée pas le dépôt
  au même endroit, d'où `github_owner_is_org` qui route vers POST /user/repos ;
- GitLab devient facultatif (`gitea_mirror_gitlab`). Il ne l'était pas : sa
  création attendait un 201 sans ignore_errors, si bien qu'un échec GitLab
  avortait l'itération — y compris la moitié GitHub, qui n'y était pour rien.

Deux défauts corrigés au passage, tous deux silencieux :

- les trois listages de gitea_sync ne paginaient pas (30 chez GitHub, 20 chez
  GitLab). Sous la taille d'une page tout va bien ; au-delà, la différence
  entre forges désigne de FAUX dépôts manquants et le rôle les « répare » ;
- la migration entrante posait `repo_owner: github_organization` pour désigner
  le propriétaire DANS Gitea.

Et un piège découvert en exécutant : un dépôt GitHub créé vide adopte comme
branche par défaut la PREMIÈRE branche que le miroir lui pousse — `kadans` a
atterri sur `arcodange/adr-ddd-front`. Le rôle réaligne désormais sur la
branche par défaut de Gitea ; le miroir étant asynchrone, l'alignement échoue
au run qui crée le dépôt et réussit au suivant, d'où le failed_when permissif.

Ce qui sort du homelab reste un CHOIX : playbooks/07_mirrors.yml parcourt une
liste explicite et relue (`gitea_mirrored_repos`) plutôt que la différence
automatique entre forges, qui recréerait un dépôt supprimé exprès.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
2026-07-27 13:34:26 +02:00
co-authored by Claude Opus 5
parent 726456c5ed
commit e84383ee34
11 changed files with 357 additions and 43 deletions
@@ -9,3 +9,42 @@
# so the secret propagation playbook iterates over this list.
gitea_secret_propagation_users:
- arcodange
# Dépôts mis en miroir vers GitHub (et GitLab) par playbooks/06_mirrors.yml.
# Gitea reste la source ; les forges publiques ne reçoivent qu'une copie poussée.
#
# `gitea_sync` ne balaie qu'UN propriétaire à la fois et déduit les manques en
# comparant les forges : utile pour l'organisation, inadapté ici, où l'on choisit
# dépôt par dépôt ce qui sort du homelab. D'où cette liste, explicite et relue.
#
# owner : propriétaire côté Gitea
# github_owner : propriétaire côté GitHub (un compte personnel n'est pas une
# organisation — voir github_owner_is_org)
# gitlab_namespace : ID numérique du groupe ou du compte GitLab d'accueil
gitea_mirrored_repos:
- name: kadans
owner: arcodange
github_owner: arcodange
github_owner_is_org: false
description: application d'entrainement social de danse
- name: kadans-api
owner: arcodange
github_owner: arcodange
github_owner_is_org: false
- name: kadans-dossier
owner: arcodange
github_owner: arcodange
github_owner_is_org: false
- name: kadans-jobs
owner: arcodange
github_owner: arcodange
github_owner_is_org: false
- name: video_analysis
owner: arcodange
github_owner: arcodange
github_owner_is_org: false
# Espace GitLab qui accueille les dépôts du compte personnel. À renseigner avec
# l'ID du namespace « arcodange » sur gitlab.com (Settings → General) : sans lui,
# la création GitLab retomberait dans le groupe arcodange-org.
gitlab_personal_namespace_id: ~
@@ -0,0 +1,60 @@
---
# Miroirs push Gitea → GitHub (et GitLab).
#
# Gitea est la source ; chaque dépôt listé dans `gitea_mirrored_repos`
# (inventory/group_vars/all/gitea.yml) reçoit un miroir push rafraîchi toutes les
# 8 h ET à chaque commit. Les dépôts créés en face le sont en PRIVÉ.
#
# uv run ansible-playbook -i ansible/arcodange/factory/inventory \
# ansible/arcodange/factory/playbooks/06_mirrors.yml
#
# GitHub seulement (tant que l'espace GitLab personnel n'est pas renseigné) :
# … -e gitea_mirror_gitlab=false
#
# Le jeton Gitea est frappé pour la durée du run puis révoqué en post_tasks.
- name: Mettre les dépôts Gitea en miroir sur GitHub et GitLab
hosts: localhost
gather_facts: true # gitea_token date son jeton avec ansible_date_time
roles:
- role: arcodange.factory.gitea_token
tags:
- gitea_mirrors
tasks:
- name: Poser le miroir de chaque dépôt déclaré
tags: gitea_mirrors
include_role:
name: arcodange.factory.gitea_repo
apply:
tags: gitea_mirrors
vars:
gitea_repo_name: "{{ mirrored_repo.name }}"
gitea_repo_owner: "{{ mirrored_repo.owner }}"
gitea_repo_description: "{{ mirrored_repo.description | default('') }}"
github_owner: "{{ mirrored_repo.github_owner | default(mirrored_repo.owner) }}"
github_owner_is_org: "{{ mirrored_repo.github_owner_is_org | default(true) }}"
gitlab_owner: "{{ mirrored_repo.gitlab_owner | default(mirrored_repo.owner) }}"
gitlab_namespace_id: >-
{{ mirrored_repo.gitlab_namespace
| default(gitlab_personal_namespace_id)
| default(89826881, true) }}
# Ce qui sort du homelab reste privé en face.
github_repo_private: true
gitlab_repo_visibility: private
loop: "{{ gitea_mirrored_repos }}"
loop_control:
loop_var: mirrored_repo
label: "{{ mirrored_repo.owner }}/{{ mirrored_repo.name }}"
post_tasks:
- name: Révoquer le jeton Gitea du run
tags:
- gitea_mirrors
include_role:
name: arcodange.factory.gitea_token
apply:
tags: gitea_mirrors
vars:
gitea_token_delete: true
@@ -6,4 +6,38 @@ gitea_username: arcodange
gitea_organization: arcodange-org
# URL de base du serveur Gitea
gitea_base_url: http://{{ groups.gitea[0] }}:3000
gitea_base_url: http://{{ groups.gitea[0] }}:3000
# Propriétaire du dépôt CÔTÉ GITEA. Par défaut l'organisation, pour ne rien
# changer aux dépôts déjà en miroir ; à surcharger (« arcodange ») pour les
# dépôts qui vivent sous le compte personnel.
gitea_repo_owner: "{{ gitea_organization }}"
# Propriétaires en FACE, forge par forge. Ils suivent le propriétaire Gitea par
# défaut, mais un dépôt personnel peut viser un compte personnel.
github_owner: "{{ github_organization }}"
gitlab_owner: "{{ gitlab_root_group }}"
# Un compte personnel n'est pas une organisation : GitHub ne crée pas un dépôt
# au même endroit (POST /user/repos contre POST /orgs/<org>/repos).
github_owner_is_org: true
# Identifiant du groupe OU de l'utilisateur GitLab qui accueille le projet.
# https://gitlab.com/groups/arcodange-org/-/edit
gitlab_namespace_id: 89826881
# Quelles forges recevoir en miroir. GitLab devient facultatif : sans ça, un
# échec côté GitLab avorte toute l'itération, y compris la partie GitHub.
gitea_mirror_github: true
gitea_mirror_gitlab: true
# Le miroir pousse ; c'est le dépôt d'en face qui doit être privé.
github_repo_private: true
gitlab_repo_visibility: private
# Nom d'utilisateur porté par le miroir push (le mot de passe est le jeton).
github_mirror_username: "{{ gitea_username }}"
gitlab_mirror_username: "{{ gitea_username }}"
# Cadence de rafraîchissement des miroirs, en plus du push à chaque commit.
gitea_mirror_interval: "8h"
@@ -1,6 +1,6 @@
- name: Vérifier si le dépôt existe dans Gitea
uri:
url: "{{ gitea_base_url }}/api/v1/repos/{{ gitea_organization }}/{{ gitea_repo_name }}"
url: "{{ gitea_base_url }}/api/v1/repos/{{ gitea_repo_owner }}/{{ gitea_repo_name }}"
method: GET
headers:
Authorization: "token {{ gitea_api_token }}"
@@ -10,26 +10,36 @@
- name: Vérifier si le dépôt existe sur GitLab
uri:
url: "https://gitlab.com/api/v4/projects/{{ gitlab_root_group }}%2F{{ gitea_repo_name }}"
url: "https://gitlab.com/api/v4/projects/{{ gitlab_owner }}%2F{{ gitea_repo_name }}"
method: GET
headers:
Authorization: "Bearer {{ gitlab_api_token }}"
status_code: 200
register: gitlab_repo_check
ignore_errors: yes
when: gitea_mirror_gitlab | bool
- name: Vérifier si le dépôt existe sur GitHub
uri:
url: "https://api.github.com/repos/{{ github_organization }}/{{ gitea_repo_name }}"
url: "https://api.github.com/repos/{{ github_owner }}/{{ gitea_repo_name }}"
method: GET
headers:
Authorization: "token {{ github_api_token }}"
status_code: 200
register: github_repo_check
ignore_errors: yes
when: gitea_mirror_github | bool
# Une tâche sautée n'enregistre pas de « status » : sans le default(0), la
# condition suivante explose dès qu'une forge est désactivée.
- name: Retenir l'état de chaque forge
set_fact:
gitlab_repo_present: "{{ (gitlab_repo_check.status | default(0)) == 200 }}"
github_repo_present: "{{ (github_repo_check.status | default(0)) == 200 }}"
gitea_repo_present: "{{ (gitea_repo_check.status | default(0)) == 200 }}"
- name: Importer un dépôt GitLab/GitHub vers Gitea
when: gitea_repo_check.status != 200 and (gitlab_repo_check.status == 200 or github_repo_check.status == 200)
when: not gitea_repo_present and (gitlab_repo_present or github_repo_present)
uri:
url: "{{ gitea_base_url }}/api/v1/repos/migrate"
method: POST
@@ -38,16 +48,16 @@
status_code: 201
body_format: json
body:
service: "{{ (gitlab_repo_check.status == 200) | ternary('gitlab','github') }}"
service: "{{ gitlab_repo_present | ternary('gitlab','github') }}"
# URL du dépôt GitHub/GitLab
clone_addr: >-
{{ (gitlab_repo_check.status == 200) | ternary(gitlab_mirror_url,github_mirror_url) }}
{{ gitlab_repo_present | ternary(gitlab_mirror_url,github_mirror_url) }}
auth_username: "{{ gitea_username }}" # Nom d'utilisateur pour l'authentification si nécessaire
# token d'accès
auth_token: >-
{{ (gitlab_repo_check.status == 200) | ternary(gitlab_api_token,github_api_token) }}
{{ gitlab_repo_present | ternary(gitlab_api_token,github_api_token) }}
repo_name: "{{ gitea_repo_name }}" # Nom du dépôt dans Gitea
repo_owner: "{{ github_organization }}" # Propriétaire du dépôt dans Gitea (utilisateur ou organisation
repo_owner: "{{ gitea_repo_owner }}" # Propriétaire du dépôt dans Gitea (utilisateur ou organisation)
mirror: false # Activer le mirroring pour synchroniser les changements
register: migration_result
@@ -66,15 +76,20 @@
body:
name: "{{ gitea_repo_name }}"
path: "{{ gitea_repo_name }}"
namespace_id: "{{ gitlab_namespace_id }}" # Remplacez par l'ID du groupe ou de l'utilisateur où le projet doit être créé
visibility: "{{ gitlab_repo_visibility | default('private') }}" # Définir la visibilité (private, internal, public)
namespace_id: "{{ gitlab_namespace_id }}" # ID du groupe ou de l'utilisateur où le projet doit être créé
visibility: "{{ gitlab_repo_visibility }}" # Définir la visibilité (private, internal, public)
description: "{{ gitea_repo_description | default('') }}"
status_code: 201
when: gitlab_repo_check.status != 200
when: (gitea_mirror_gitlab | bool) and not gitlab_repo_present
# Un compte personnel n'a pas d'endpoint /orgs/<nom>/repos : GitHub crée alors
# le dépôt sous le compte porteur du jeton, via POST /user/repos.
- name: Créer un dépôt sur GitHub si nécessaire
uri:
url: "https://api.github.com/orgs/{{ github_organization }}/repos"
url: >-
{{ (github_owner_is_org | bool)
| ternary('https://api.github.com/orgs/' ~ github_owner ~ '/repos',
'https://api.github.com/user/repos') }}
method: POST
headers:
Authorization: "token {{ github_api_token }}"
@@ -82,13 +97,13 @@
body:
name: "{{ gitea_repo_name }}"
description: "{{ gitea_repo_description | default('') }}"
private: "{{ github_repo_private | default(true) }}" # Définir si le dépôt est privé ou public
private: "{{ github_repo_private | bool }}" # Définir si le dépôt est privé ou public
status_code: 201
when: github_repo_check.status != 200
when: (gitea_mirror_github | bool) and not github_repo_present
- name: Vérifier l'existence des miroirs push sur GitHub et GitLab
uri:
url: "{{ gitea_base_url }}/api/v1/repos/{{ gitea_organization }}/{{ gitea_repo_name }}/push_mirrors"
url: "{{ gitea_base_url }}/api/v1/repos/{{ gitea_repo_owner }}/{{ gitea_repo_name }}/push_mirrors"
method: GET
headers:
Authorization: "token {{ gitea_api_token }}"
@@ -97,32 +112,68 @@
- name: Ajouter un miroir push vers GitHub si nécessaire
uri:
url: "{{ gitea_base_url }}/api/v1/repos/{{ gitea_organization }}/{{ gitea_repo_name }}/push_mirrors"
url: "{{ gitea_base_url }}/api/v1/repos/{{ gitea_repo_owner }}/{{ gitea_repo_name }}/push_mirrors"
method: POST
headers:
Authorization: "token {{ gitea_api_token }}"
body_format: json
body:
interval: "8h"
interval: "{{ gitea_mirror_interval }}"
remote_address: "{{ github_mirror_url }}"
remote_username: "{{ gitea_username }}"
remote_username: "{{ github_mirror_username }}"
remote_password: "{{ github_api_token }}"
sync_on_commit: true
status_code: 200
when: "github_mirror_url not in existing_mirrors.json | map(attribute='remote_address') | list"
when:
- gitea_mirror_github | bool
- github_mirror_url not in existing_mirrors.json | map(attribute='remote_address') | list
- name: Ajouter un miroir push vers GitLab si nécessaire
uri:
url: "{{ gitea_base_url }}/api/v1/repos/{{ gitea_organization }}/{{ gitea_repo_name }}/push_mirrors"
url: "{{ gitea_base_url }}/api/v1/repos/{{ gitea_repo_owner }}/{{ gitea_repo_name }}/push_mirrors"
method: POST
headers:
Authorization: "token {{ gitea_api_token }}"
body_format: json
body:
interval: "8h"
interval: "{{ gitea_mirror_interval }}"
remote_address: "{{ gitlab_mirror_url }}"
remote_username: "{{ gitea_username }}"
remote_username: "{{ gitlab_mirror_username }}"
remote_password: "{{ gitlab_api_token }}"
sync_on_commit: true
status_code: 200
when: "gitlab_mirror_url not in existing_mirrors.json | map(attribute='remote_address') | list"
when:
- gitea_mirror_gitlab | bool
- gitlab_mirror_url not in existing_mirrors.json | map(attribute='remote_address') | list
# Un dépôt GitHub créé vide adopte comme branche par défaut la PREMIÈRE branche
# que le miroir lui pousse — souvent une branche de travail, pas « main ». Le
# miroir étant asynchrone, l'alignement échoue au run qui crée le dépôt et
# réussit au suivant : d'où le failed_when permissif plutôt qu'un blocage.
- name: Aligner la branche par défaut de GitHub sur celle de Gitea
uri:
url: "https://api.github.com/repos/{{ github_owner }}/{{ gitea_repo_name }}"
method: PATCH
headers:
Authorization: "token {{ github_api_token }}"
body_format: json
body:
default_branch: "{{ gitea_repo_check.json.default_branch }}"
status_code: 200
register: github_default_branch
failed_when: false
when:
- gitea_mirror_github | bool
- gitea_repo_present
- (github_repo_check.json.default_branch | default('')) != gitea_repo_check.json.default_branch
- name: Signaler une branche par défaut encore désalignée
debug:
msg: >-
La branche par défaut de github.com/{{ github_owner }}/{{ gitea_repo_name }}
n'a pas pu être alignée sur « {{ gitea_repo_check.json.default_branch }} » :
le miroir ne l'a probablement pas encore poussée. Relancer après la synchro.
when:
- github_default_branch is defined
- github_default_branch is not skipped
- (github_default_branch.status | default(0)) != 200
@@ -3,8 +3,8 @@ gitlab_api_token: '{{ hostvars[groups.gitea[0]].gitea_vault.gitlab_api_token }}'
github_organization: '{{ gitea_organization }}'
gitlab_root_group: '{{ gitea_organization }}'
gitlab_namespace_id: 89826881 # https://gitlab.com/groups/arcodange-org/-/edit
# URLs des miroirs sur GitLab et GitHub
gitlab_mirror_url: "https://gitlab.com/{{ gitlab_root_group | default(gitlab_username | default(gitea_username)) }}/{{ gitea_repo_name }}.git"
github_mirror_url: "https://github.com/{{ github_organization | default(github_username | default(gitea_username)) }}/{{ gitea_repo_name }}.git"
# URLs des miroirs sur GitLab et GitHub — elles suivent le propriétaire visé sur
# chaque forge (cf. github_owner / gitlab_owner dans defaults/).
gitlab_mirror_url: "https://gitlab.com/{{ gitlab_owner }}/{{ gitea_repo_name }}.git"
github_mirror_url: "https://github.com/{{ github_owner }}/{{ gitea_repo_name }}.git"
@@ -4,4 +4,18 @@ gitea_organization: arcodange-org
# URL de base du serveur Gitea
gitea_base_url: http://{{ groups.gitea[0] }}:3000
gitea_token_fact_name: arcodange_factory_gitea_sync_token
gitea_token_fact_name: arcodange_factory_gitea_sync_token
# Propriétaire balayé. Par défaut l'organisation ; mettre « arcodange » et
# gitea_sync_owner_is_org à false pour balayer le compte personnel.
gitea_sync_owner: "{{ gitea_organization }}"
gitea_sync_owner_is_org: true
# Les trois API paginent (30 par défaut chez GitHub, 20 chez GitLab). Sous la
# taille d'une page, la différence entre forges désigne de faux dépôts manquants.
gitea_sync_page_size: 100
# Forges comparées. Balayer une forge qu'on ne veut pas alimenter ferait passer
# tous ses dépôts pour « incomplets ».
gitea_mirror_github: true
gitea_mirror_gitlab: true
@@ -1,40 +1,71 @@
# Un compte personnel n'est pas une organisation : ni GitHub ni GitLab ne
# servent ses dépôts au même endroit.
- name: Lister les dépôts de l'organisation GitHub
uri:
url: "https://api.github.com/orgs/{{ github_organization }}/repos"
url: >-
{{ (gitea_sync_owner_is_org | bool)
| ternary('https://api.github.com/orgs/' ~ github_owner ~ '/repos',
'https://api.github.com/users/' ~ github_owner ~ '/repos')
}}?per_page={{ gitea_sync_page_size }}
method: GET
headers:
Authorization: "token {{ github_api_token }}"
status_code: 200
register: github_repos
when: gitea_mirror_github | bool
- name: Lister les dépôts du groupe GitLab
uri:
url: "https://gitlab.com/api/v4/groups/{{ gitlab_root_group }}/projects"
url: >-
{{ (gitea_sync_owner_is_org | bool)
| ternary('https://gitlab.com/api/v4/groups/' ~ gitlab_owner ~ '/projects',
'https://gitlab.com/api/v4/users/' ~ gitlab_owner ~ '/projects')
}}?per_page={{ gitea_sync_page_size }}
method: GET
headers:
Authorization: "Bearer {{ gitlab_api_token }}"
status_code: 200
register: gitlab_repos
when: gitea_mirror_gitlab | bool
- name: Lister les dépôts de l'organisation Gitea
uri:
url: "{{ gitea_base_url }}/api/v1/orgs/{{ gitea_organization }}/repos"
url: >-
{{ (gitea_sync_owner_is_org | bool)
| ternary(gitea_base_url ~ '/api/v1/orgs/' ~ gitea_sync_owner ~ '/repos',
gitea_base_url ~ '/api/v1/users/' ~ gitea_sync_owner ~ '/repos')
}}?limit={{ gitea_sync_page_size }}
method: GET
headers:
Authorization: "token {{ gitea_api_token }}"
status_code: 200
register: gitea_repos
# Une forge désactivée ne doit pas peser dans la différence : on la remplace par
# la liste Gitea elle-même, qui la rend neutre à l'intersection.
- name: Établir la liste des dépôts incomplets
set_fact:
gitea_repo_names: "{{ gitea_repos.json | map(attribute='name') | list }}"
github_repo_names: >-
{{ (gitea_mirror_github | bool)
| ternary(github_repos.json | default([]) | map(attribute='name') | list,
gitea_repos.json | map(attribute='name') | list) }}
gitlab_repo_names: >-
{{ (gitea_mirror_gitlab | bool)
| ternary(gitlab_repos.json | default([]) | map(attribute='name') | list,
gitea_repos.json | map(attribute='name') | list) }}
- name: Réduire aux dépôts absents d'au moins une forge
set_fact:
repos_incomplete: >-
{{ (github_repo_names | union(gitlab_repo_names) | union(gitea_repo_names))
| difference(github_repo_names | intersect(gitlab_repo_names) | intersect(gitea_repo_names)) }}
- name: Synchroniser
include_role:
name: arcodange.factory.gitea_repo
vars:
github_repo_names: "{{ github_repos.json | map(attribute='name') | list }}"
gitlab_repo_names: "{{ gitlab_repos.json | map(attribute='name') | list }}"
gitea_repo_names: "{{ gitea_repos.json | map(attribute='name') | list }}"
all_repos: "{{ github_repo_names | union(gitlab_repo_names) | union(gitea_repo_names) }}"
repos_common_to_all: "{{ github_repo_names | intersect(gitlab_repo_names) | intersect(gitea_repo_names) }}"
repos_incomplete: "{{ all_repos | difference(repos_common_to_all) }}"
gitea_repo_owner: "{{ gitea_sync_owner }}"
loop: "{{ repos_incomplete }}"
loop_control:
loop_var: gitea_repo_name
@@ -2,4 +2,8 @@ github_api_token: '{{ hostvars[groups.gitea[0]].gitea_vault.github_api_token }}'
gitlab_api_token: '{{ hostvars[groups.gitea[0]].gitea_vault.gitlab_api_token }}'
github_organization: '{{ gitea_organization }}'
gitlab_root_group: '{{ gitea_organization }}'
gitlab_root_group: '{{ gitea_organization }}'
# Les propriétaires en face suivent celui qu'on balaie côté Gitea.
github_owner: '{{ gitea_sync_owner }}'
gitlab_owner: '{{ gitea_sync_owner }}'