Pour migrer une organisation vers Forgejo 15 LTS, on choisit d'abord entre un
import ponctuel et un miroir, on autorise la forge à joindre la source (réglage
[migrations]), on reprend chaque dépôt par l'API POST /api/v1/repos/migrate,
on vérifie le SHA de main et le nombre de tickets, puis on gèle la source et
on repointe les clones. La leçon s'adresse à l'administrateur qui vient
d'installer sa forge et doit y ramener des équipes. La migration ne reprend ni les webhooks, ni les protections de
branches, ni les secrets d'Actions, ni les collaborateurs, et tous les
auteurs deviennent Ghost : la leçon donne la liste de contrôle pour les
recréer.
Vous repartez avec quatre preuves : le refus exact d'un import vers un
réseau privé et sa matrice de réglages, une organisation de quatre dépôts
migrée par script (dont un dépôt vide qui échoue, et pourquoi), un miroir
resynchronisé à la demande, et une bascule où git ls-remote affiche le
même SHA des deux côtés. Les commandes ont été exécutées le 2026-10-16 sur la
pile de la leçon Installer Forgejo LTS
(Forgejo 15.0.10), avec une seconde instance Forgejo 15.0.10 jetable comme
source. Quelques gestes n'ont pas pu être prouvés, et la leçon les dit à
l'endroit où ils se présentent.
Ce que vous allez apprendre
Section intitulée « Ce que vous allez apprendre »- Choisir entre import ponctuel, miroir pull et miroir push, selon que la source reste vivante ou non.
- Distinguer ce que reprend une migration
service: giteade ce qu'elle perd, et dresser la liste de contrôle d'après-migration. - Créer un jeton source au moindre privilège et reconnaître le refus quand une portée manque.
- Régler
[migrations]pour lever le refusdisallowed hosts, sans laisser le réglage traîner. - Migrer une organisation entière par un script API paginé et la vérifier.
- Basculer les développeurs : convertir le miroir si besoin, geler la source, resynchroniser, repointer
origin.
Quels sont les prérequis ?
Section intitulée « Quels sont les prérequis ? »Il vous faut la forge de la leçon d'installation, une organisation cible déjà créée avec ses équipes (acme2 dans le lab, voir Organisations, équipes et droits), et la maîtrise de la montée de version : une migration se prépare comme une montée, sauvegarde faite avant, selon Sauvegarder et restaurer Forgejo. La source est une forge Gitea ou Forgejo dont vous êtes administrateur, ou dont un administrateur vous donne un jeton. Le lab n'a joué qu'une source Forgejo 15.0.10 : une source Gitea n'a pas été essayée.
Cette leçon copie des dépôts d'une forge vivante vers une autre, par l'API. Remplacer une instance Gitea par Forgejo sur place (même base de données, autre binaire) est une autre opération, limitée aux Gitea jusqu'à la 1.22.x : elle est décrite dans la présentation de Forgejo.
Vous devez savoir lire du JSON avec jq, lancer curl avec un en-tête
Authorization, et manipuler un remote Git
(collaboration-remotes).
Les commandes de la leçon s'appuient sur cinq variables :
| Variable | Contenu |
|---|---|
CIBLE | URL de la forge cible |
DST_TOKEN | jeton d'un administrateur de la cible |
SRC_TOKEN | jeton de la source |
SRC_LISTE | adresse de la source jointe depuis votre poste |
SRC_CLONE | adresse de la source jointe depuis la cible, pour qu'elle clone |
Dans le lab, l'API de la cible se joue via Caddy avec
curl -k --resolve forge.lab.test:443:127.0.0.1 (certificat auto-signé, port
3000 non publié) ; les commandes écrivent $CIBLE et omettent ces deux
options. Les jetons sont des gabarits : lisez-les dans un fichier
(export SRC_TOKEN=$(cat src.token)), ne les collez jamais dans une commande
ni dans une capture.
La source jetable du lab est une seconde instance sur le même réseau Docker que la cible, sans port publié, avec SQLite. Elle reprend l'image épinglée de la pile :
name: forgejo-srcnetworks: forgejo: external: true name: forgejo-lab_forgejovolumes: src-data:services: src: image: codeberg.org/forgejo/forgejo:15.0.10@sha256:7b2f61163ff1d19f2aaf62d37ae4e805e822c2f86d6628b2bd468470929beacb container_name: forgejo-src environment: USER_UID: "1000" USER_GID: "1000" FORGEJO__database__DB_TYPE: sqlite3 FORGEJO__server__ROOT_URL: http://forgejo-src:3000/ FORGEJO__server__DOMAIN: forgejo-src FORGEJO__security__INSTALL_LOCK: "true" FORGEJO__service__DISABLE_REGISTRATION: "true" networks: [forgejo] volumes: - src-data:/dataCe fichier ne sert qu'à avoir une source à migrer : sur une vraie migration, elle existe déjà. Rangez-le dans /opt/forgejo-src/compose.yaml, puis lancez la source, attendez que /api/healthz réponde 200 et créez son administrateur et un jeton d'administration pour préparer le contenu (le mot de passe aléatoire n'est pas utilisé, seul le jeton sert). Le fichier admin.token doit avoir une lecture réservée à son propriétaire (mode 0600) :
cd /opt/forgejo-src && docker compose up -ddocker exec -u git forgejo-src forgejo admin user create --admin \ --username srcadmin --email srcadmin@lab.test --random-password \ --must-change-password=falsedocker exec -u git forgejo-src forgejo admin user generate-access-token \ --username srcadmin --token-name preparation --scopes all --raw \ > /opt/forgejo-src/admin.token && chmod 600 /opt/forgejo-src/admin.tokenAvec ce jeton (lu dans le fichier, via l'IP du conteneur obtenue par docker inspect), l'API de la source crée l'utilisateur alice (forgejo admin user create, comme ci-dessus) puis l'organisation acme et ses dépôts, par les routes habituelles de l'API de Forgejo : POST /orgs, POST /orgs/acme/repos (avec auto_init: true pour app, infra et docs), puis labels, milestones, issues (créés avec le jeton d'alice), pulls, releases, wiki/new, hooks, branch_protections, actions/secrets/{nom} et collaborators/alice. Les commits, la branche et le tag arrivent par git push. Le quatrième dépôt, docs-sauvegarde, est créé sans auto_init : il reste vide et sert deux fois plus bas.
L'organisation acme contient donc quatre dépôts : app, infra, docs et docs-sauvegarde. Le dépôt app porte 4 commits sur main, une branche feature/x, un tag v1.0.0, deux étiquettes, un jalon, deux tickets ouverts par alice, une pull request, une release, une page de wiki, un webhook, une protection de branche, un secret d'Actions et un collaborateur.
Import ponctuel, miroir pull ou miroir push : lequel choisir ?
Section intitulée « Import ponctuel, miroir pull ou miroir push : lequel choisir ? »On choisit l'import ponctuel quand on quitte la source, le miroir pull quand la source reste vivante et que la cible n'en est qu'une copie, le miroir push quand la cible doit alimenter une autre forge. La différence tient à une question : qui reçoit les commits ensuite ? Un miroir pull est une copie en lecture seule que la cible tire de la source à intervalle régulier ; un miroir push fait l'inverse, la cible pousse ses commits vers un dépôt distant.
Après un import, la cible devient la référence et la source est gelée. Après un miroir pull, la source reste la référence et la cible refuse tout push, jusqu'au jour où l'on convertit la copie en dépôt classique pour basculer. Avec un miroir push, la cible reste la référence et pousse ailleurs : il n'y a pas de bascule.
Le schéma suivant résume ce choix et la fin du parcours. Suivez la question en haut : les trois branches en partent. Import et miroir pull rejoignent le même geste de gel de la source (le miroir pull passe d'abord par une dernière synchronisation et une conversion), puis le repointage des clones et la recréation de ce que la migration ne reprend pas. Le miroir push s'arrête à son dépôt distant.
Le tableau donne le mode d'emploi de chaque option, avec le champ de l'API qui la commande.
| Option | Champs de l'API | Rôle de la source | Effet sur la cible |
|---|---|---|---|
| Import ponctuel | POST /repos/migrate, service: gitea, mirror absent ou false | gelée après bascule | dépôt ordinaire, tickets repris |
| Miroir pull | POST /repos/migrate, service: git, mirror: true, mirror_interval | la référence tant que dure le miroir | dépôt en lecture seule, code seul |
| Miroir push | POST /repos/{owner}/{repo}/push_mirrors | sans objet : un autre serveur reçoit les commits | la cible pousse vers le dépôt distant |
Deux limites bornent ce tableau. Un miroir ne reprend que le code : le
lab a relevé qu'une migration en service: git ne ramène ni tickets, ni pull
requests, ni étiquettes, ni releases, même avec un jeton. Et le passage d'un
miroir à un dépôt classique se fait par l'interface web, pas par l'API : la
section sur les miroirs donne le geste vérifié. Ces routes sont des appels
d'API de la forge, pas des commandes Git : pour le seul code, Git sait déjà
copier un dépôt, mais seule l'API de Forgejo reprend aussi les objets de la
forge (tickets, pull requests, releases, wiki).
Que reprend une migration Gitea vers Forgejo, et que perd-elle ?
Section intitulée « Que reprend une migration Gitea vers Forgejo, et que perd-elle ? »Une migration service: gitea reprend le code et tout le contenu « métier »
du dépôt, et perd tout ce qui est de la configuration ou de l'identité. Le
lab a migré acme/app vers acme2/app avec le jeton source et toutes les
cases cochées (issues, labels, milestones, pull_requests, releases,
wiki) ; le corps de la requête est celui du script plus bas. Voici ce que la
cible a repris :
| Repris sur la cible | Constat |
|---|---|
| Code | les deux branches (feature/x, main), le tag, les 4 commits |
| Étiquettes et jalon | 2 étiquettes, le jalon v1.1 ; le ticket 1 garde étiquette et jalon |
| Tickets et commentaires | 2 tickets et le commentaire |
| Pull request | la PR #3, ouverte, sur la branche feature/x |
| Release et wiki | la release v1.0.0, la page Accueil du wiki |
| Numérotation | conservée : tickets 1 et 2, PR 3 |
La requête est synchrone : elle ne répond (201) qu'à la fin de la copie. Le tableau suivant donne ce que le même relevé a trouvé perdu, après coup.
| Perdu | Source | Cible après migration |
|---|---|---|
| Webhooks | 1 | 0 |
| Protections de branches | 1 | 0 |
| Secrets d'Actions | 1 | [] |
| Collaborateurs | 1 | [] |
| Auteurs des tickets, du commentaire, de la PR | alice | Ghost (original_author vide) |
| Références dans les corps de tickets | acme/infra#1 | laissées telles quelles, non réécrites |
L'utilisateur alice n'existe pas sur la cible : ses contributions sont donc attribuées à Ghost, l'utilisateur fantôme de Forgejo. Les références comme acme/infra#1 pointent encore vers l'ancien nom d'organisation, qui n'existe plus ou ne désigne plus le même dépôt. Au niveau de l'organisation, les membres ne sont pas repris non plus (la cible n'avait que fjadmin, la source srcadmin).
On en tire une liste de contrôle d'après-migration, à rejouer pour chaque dépôt avec l'API de la cible. La boucle interroge quatre routes et affiche, pour chacune, le nombre d'éléments :
for ressource in hooks branch_protections actions/secrets collaborators; do echo "$ressource : $(curl -s -H "Authorization: token $DST_TOKEN" \ "$CIBLE/api/v1/repos/acme2/app/$ressource" | jq -c 'if type=="array" then length else . end')"doneJuste après la migration, les quatre lignes sont vides ou à 0 : c'est la
liste de ce qu'il reste à recréer à la main (webhooks, protections de
branches, secrets, collaborateurs, puis membres de l'organisation et équipes).
Le lab n'avait qu'une équipe par défaut (Owners) : la reprise d'équipes à
droits personnalisés n'est donc pas démontrée, comptez qu'il faut les
recréer. Le mappage d'auteurs (faire correspondre alice à un compte
existant) n'a pas été joué : créer l'utilisateur avant de migrer changerait
peut-être l'attribution, mais la leçon ne l'a pas mesuré.
Quel jeton donner à la source, et avec quelles portées ?
Section intitulée « Quel jeton donner à la source, et avec quelles portées ? »Un jeton en lecture avec read:repository et read:issue suffit pour
reprendre le code et les tickets ; sans read:issue, la migration des tickets
est refusée avec le nom exact de la portée manquante. Un jeton d'accès à
portées (scopes) n'ouvre que les routes de l'API que ses portées autorisent.
On applique le moindre privilège : le jeton ne sert qu'à lire la source, et
il se supprime dès la fin de la migration. Sur la source, il se crée avec la
commande d'administration (--scopes accepte une liste séparée par des
virgules). Ajoutez read:organization si vous utilisez le script de
l'organisation plus bas : lister les dépôts d'une organisation l'exige, la copie
d'un dépôt non :
docker exec -u git forgejo-src forgejo admin user generate-access-token \ --username srcadmin --token-name migration \ --scopes read:repository,read:issue,read:organization --rawLa commande affiche le jeton une seule fois. Rangez-le dans un fichier protégé au mode 0600, comme pour le jeton de métriques de la leçon Superviser et dépanner. Le lab a testé les cas suivants contre la même source, avec la portée manquante nommée dans chaque refus :
| Jeton source | Résultat |
|---|---|
read:repository seul, avec issues: true | refus (HTTP 500) : error while listing issues: token does not have at least one of required scope(s): [read:issue] |
read:repository seul, code seul | OK (code, branches, tags) |
read:repository + read:issue | OK : 2 tickets, 1 PR, 2 étiquettes, 1 jalon, 1 release |
read:repository + read:issue + read:organization | même résultat pour un dépôt ; indispensable pour GET /orgs/{org}/repos (sans elle : [read:organization] manquante, et le script s'arrête) |
| aucun jeton, dépôt privé | HTTP 500, The target couldn't be found. |
service: git, même avec jeton | code, branches, tags seulement |
Le dernier cas est le plus trompeur : service: git accepte le jeton et ne signale rien, alors qu'il ne reprend aucun objet de la forge. Si vos tickets manquent, regardez d'abord la valeur de service.
Deux gestes demandent en plus la portée write:repository sur la source : geler un dépôt (archived) et alimenter un miroir push. Avec un jeton de lecture, l'archivage répond 403 ([write:repository] manquante) et le miroir push échoue en remote: Forbidden. Les portées du jeton de la cible (celui qui lance la migration) n'ont pas été mesurées : le lab utilisait un jeton d'administrateur à toutes les portées. Pour une vraie migration, créez le jeton cible de l'administrateur qui possède l'organisation d'arrivée, et supprimez-le ensuite.
Pourquoi Forgejo refuse-t-il d'importer depuis un réseau privé ?
Section intitulée « Pourquoi Forgejo refuse-t-il d'importer depuis un réseau privé ? »Par défaut, Forgejo refuse toute migration vers un hôte local ou privé, et
répond HTTP 422 avec le message You can not import from disallowed hosts.
C'est une protection du type SSRF (falsification de requête côté serveur) :
sans elle, un utilisateur pourrait demander à la forge de lire un service
interne en prétendant importer un dépôt. Le message est le même pour une source
en forgejo-src (adresse privée du réseau Docker) et en 127.0.0.1, quelle
que soit la valeur de service.
{"message":"You can not import from disallowed hosts."}Le fichier app.ini n'a aucune section [migrations] par défaut. Trois clés la commandent : ALLOW_LOCALNETWORKS (autorise les réseaux locaux et privés), ALLOWED_DOMAINS (liste blanche d'hôtes) et BLOCKED_DOMAINS (liste noire). Dans la pile Compose, on les pose par variables d'environnement, dans un fichier de surcharge /opt/forgejo-lab/compose.override.yaml (Compose le lit seul à côté de compose.yaml), comme dans la leçon de maintenance :
services: server: environment: FORGEJO__migrations__ALLOW_LOCALNETWORKS: "true"Après docker compose up -d server, la migration depuis forgejo-src passe. Le lab a rejoué six réglages (section [migrations] supprimée avant chaque essai, puis conteneur recréé) et une migration depuis forgejo-src chaque fois :
| Réglage | Source forgejo-src (réseau privé) |
|---|---|
| (défaut) | refusée |
ALLOW_LOCALNETWORKS = true | acceptée |
ALLOWED_DOMAINS = github.com seul | refusée |
ALLOWED_DOMAINS = forgejo-src seul | acceptée (la liste blanche suffit à autoriser un hôte local) |
ALLOWED_DOMAINS = github.com + ALLOW_LOCALNETWORKS = true | acceptée (la liste blanche ne restreint pas un hôte local autorisé) |
BLOCKED_DOMAINS = forgejo-src + ALLOW_LOCALNETWORKS = true | refusée |
La documentation de la version 15.0 va dans le même sens : une liste ALLOWED_DOMAINS vide autorise tout, BLOCKED_DOMAINS l'emporte quand les deux sont posées, et ALLOW_LOCALNETWORKS (défaut false) est « ignoré » pour les domaines que ALLOWED_DOMAINS permet. Deux autres clés existent : SKIP_TLS_VERIFY (défaut false, saute la vérification du certificat de la source) et MAX_ATTEMPTS / RETRY_BACKOFF (3 tentatives, 3 secondes d'attente par défaut, par requête HTTP).
Le résultat qui surprend est la cinquième ligne : une liste blanche ALLOWED_DOMAINS ne ferme pas l'accès aux hôtes locaux dès que ALLOW_LOCALNETWORKS est vrai. Pour n'autoriser qu'un hôte précis, utilisez ALLOWED_DOMAINS seul, sans ALLOW_LOCALNETWORKS. Le lab n'a testé la liste blanche que face à une source en adresse privée : son effet sur une source à nom DNS public n'est pas mesuré. La leçon de montée de version signale d'ailleurs que Forgejo 16 renforce les contrôles de migration, à relire avant de monter.
Pourquoi le réglage reste-t-il après avoir retiré la variable ?
Section intitulée « Pourquoi le réglage reste-t-il après avoir retiré la variable ? »Les variables FORGEJO__migrations__* sont écrites dans app.ini au
démarrage et y restent, même après avoir retiré la surcharge et recréé le
conteneur. Le lab l'a constaté : après retrait de la surcharge, app.ini
portait encore ALLOW_LOCALNETWORKS = true, ALLOWED_DOMAINS = github.com et
BLOCKED_DOMAINS = forgejo-src, cumulés des essais successifs. C'est le
même piège de persistance que celui des journaux, vu dans la leçon
Superviser et dépanner.
Pour revenir au défaut, supprimez la section [migrations] de app.ini en tant qu'utilisateur git (jamais en root, comme dans le lab), puis redémarrez. La première commande efface la section, la deuxième compte ce qu'il en reste, la troisième redémarre la forge :
docker exec -u git forgejo sed -i '/^\[migrations\]/,/^\[/{/^\[migrations\]/d;/^\[/!d}' /data/gitea/conf/app.inidocker exec forgejo grep -c migrations /data/gitea/conf/app.inidocker restart forgejoLe grep -c doit afficher 0. Laisser ALLOW_LOCALNETWORKS à true après la migration est une dette de sécurité : la forge accepterait ensuite d'aller lire n'importe quel service du réseau interne sur demande d'un utilisateur autorisé à migrer.
Comment migrer une organisation entière par l'API ?
Section intitulée « Comment migrer une organisation entière par l'API ? »On crée l'organisation cible, on liste les dépôts de la source page par page,
et on appelle POST /api/v1/repos/migrate pour chacun en relevant le code
HTTP. L'API pagine ses réponses : elle rend au plus limit éléments par
requête, et l'on demande page=1, page=2, et ainsi de suite jusqu'à une page
vide. Un dépôt qui échoue ne doit pas arrêter la boucle : on le note et on
continue, puis on traite les échecs à part. Le lab a migré acme (source) vers
acme2 (cible) ainsi. Si l'organisation cible n'existe pas encore, créez-la
d'abord ; la réponse est un objet JSON dont le champ username vaut acme2 :
curl -s -X POST -H "Authorization: token $DST_TOKEN" \ -H 'Content-Type: application/json' "$CIBLE/api/v1/orgs" \ -d '{"username":"acme2","visibility":"private"}'Le script suivant est celui du lab, ses variables renommées. Il liste les dépôts avec limit=2 (volontairement petit, pour forcer plusieurs pages ; le serveur plafonne une page à 50 éléments par défaut, réglage MAX_RESPONSE_ITEMS de la section [api], et limit=100 serait donc ramené à 50), s'arrête à la première page vide, et affiche un code HTTP par dépôt. Il utilise les deux adresses de la source définies dans les prérequis : SRC_LISTE pour lister, SRC_CLONE pour que la cible clone (dans le lab, l'IP du conteneur d'un côté, son nom de l'autre).
#!/usr/bin/env bash# usage : migrer-org.sh ORG_SOURCE ORG_CIBLE# variables : SRC_LISTE SRC_CLONE SRC_TOKEN CIBLE DST_TOKENset -euo pipefailorg=$1; cible=$2; page=1; total=0; ko=0while :; do noms=$(curl -s -H "Authorization: token $SRC_TOKEN" \ "$SRC_LISTE/api/v1/orgs/$org/repos?page=$page&limit=2" | jq -r 'if type=="array" then .[].name else error(.message) end') [ -z "$noms" ] && break for depot in $noms; do code=$(curl -s -o /tmp/mig.out -w '%{http_code}' -X POST \ -H "Authorization: token $DST_TOKEN" -H 'Content-Type: application/json' \ "$CIBLE/api/v1/repos/migrate" \ -d "{\"clone_addr\":\"$SRC_CLONE/$org/$depot.git\",\"service\":\"gitea\",\"repo_owner\":\"$cible\",\"repo_name\":\"$depot\",\"auth_token\":\"$SRC_TOKEN\",\"issues\":true,\"labels\":true,\"milestones\":true,\"pull_requests\":true,\"releases\":true,\"wiki\":true,\"private\":true}") echo "page $page : $org/$depot -> HTTP $code" total=$((total+1)) [ "$code" = 201 ] || { ko=$((ko+1)); jq -r .message /tmp/mig.out; } done page=$((page+1))doneecho "migrés : $total, en échec : $ko"Le résultat du lab : quatre dépôts traités en deux pages (app, infra, puis docs, docs-sauvegarde), dont trois en 201. Le quatrième, docs-sauvegarde, était vide et a répondu 500 (error while listing pull requests ... The target couldn't be found.) : voir le piège plus bas. Avant de régler [migrations], le même script répond 422 pour les quatre dépôts. Sans read:organization, la liste elle-même échoue et jq affiche la portée manquante.
Comment vérifier que la migration est fidèle ?
Section intitulée « Comment vérifier que la migration est fidèle ? »On compare, source contre cible, la liste des noms, le nombre de commits et
de tickets, et surtout le SHA de la tête de main : un SHA identique prouve
que l'historique est identique. Le SHA est l'identifiant du dernier commit,
calculé à partir de tout l'historique qui le précède. Un décompte seul ne
suffit pas, parce qu'un dépôt peut avoir le bon nombre de commits et un
historique différent.
Deux commandes par dépôt donnent le SHA de main, une par forge. Elles doivent afficher la même chaîne de 40 caractères :
curl -s -H "Authorization: token $SRC_TOKEN" "$SRC_LISTE/api/v1/repos/acme/app/branches/main" | jq -r .commit.idcurl -s -H "Authorization: token $DST_TOKEN" "$CIBLE/api/v1/repos/acme2/app/branches/main" | jq -r .commit.idPour les tickets, interrogez /repos/{org}/{depot}/issues?state=all&type=issues des deux côtés et comptez avec jq length. Au lab, les listes de noms étaient identiques (app, docs, docs-sauvegarde, infra), les commits aussi (app 4 sur 4, infra 2 sur 2, docs 2 sur 2), les tickets aussi (app 2 sur 2, infra 1 sur 1) et le SHA de main de app strictement identique. Le dépôt vide docs-sauvegarde figure bien dans la liste de la cible : le 500 n'a pas empêché sa création, mais il ne contient rien à comparer.
Comment fonctionnent les miroirs pull et push ?
Section intitulée « Comment fonctionnent les miroirs pull et push ? »Un miroir pull est un dépôt en lecture seule que la cible resynchronise depuis la source, au plus toutes les 10 minutes ; un miroir push est l'inverse, la cible pousse vers un dépôt distant qui doit déjà exister. Le miroir pull sert une migration en douceur : il garde la copie à jour jusqu'au jour de la bascule. Le miroir push sert une autre finalité, alimenter une seconde forge (sauvegarde, réplique) depuis la cible.
Comment créer un miroir pull et le resynchroniser ?
Section intitulée « Comment créer un miroir pull et le resynchroniser ? »On crée un miroir pull avec service: git, mirror: true et un
mirror_interval d'au moins 10m (réglage MIN_INTERVAL de la section [mirror], dont le défaut est 10m ; DEFAULT_INTERVAL vaut 8h) ; on force une resynchronisation par
POST /repos/{owner}/{repo}/mirror-sync. Un intervalle sous le minimum est
refusé : interval 1m0s is set below Minimum Interval of 10m0s. Le propriétaire du miroir doit exister sur la cible (sinon : 422 user does not exist [uid: 0, name: acme]) : créez l'organisation acme comme acme2, avec POST /api/v1/orgs. La création ressemble à ceci :
curl -s -X POST -H "Authorization: token $DST_TOKEN" \ -H 'Content-Type: application/json' "$CIBLE/api/v1/repos/migrate" \ -d "{\"clone_addr\":\"$SRC_CLONE/acme/infra.git\",\"service\":\"git\",\"repo_owner\":\"acme\",\"repo_name\":\"infra-mirror\",\"auth_token\":\"$SRC_TOKEN\",\"mirror\":true,\"mirror_interval\":\"10m\",\"private\":true}"Le dépôt répond mirror: true, mirror_interval: "10m0s" et original_url égal à la source. Attention : le jeton source reste stocké dans l'URL du miroir côté serveur. Donnez-lui donc les seules portées de lecture, et supprimez-le de la source le jour où le miroir disparaît.
Après un nouveau commit sur la source, le miroir restait à 3 commits tant qu'on ne synchronisait pas. L'appel suivant doit afficher 200 ; il a amené le miroir à 4 commits en 4 secondes :
curl -s -o /dev/null -w '%{http_code}\n' -X POST \ -H "Authorization: token $DST_TOKEN" "$CIBLE/api/v1/repos/acme/infra-mirror/mirror-sync"Sur un dépôt qui n'est pas un miroir, la même route répond 400 Repository is not a mirror. Un push vers le miroir est refusé en 403, avec remote: mirror repository is read-only : c'est la garantie que la cible reste une copie fidèle. Cette copie apparaît dans la métrique gitea_mirrors de l'endpoint /metrics.
Comment transformer un miroir en dépôt classique ?
Section intitulée « Comment transformer un miroir en dépôt classique ? »Un miroir se convertit en dépôt classique par l'interface web, dans les réglages du dépôt, en retapant propriétaire/nom ; l'API de Forgejo 15.0.10 n'a aucune route pour cela (la description OpenAPI de l'API ne contient que mirror-sync et push_mirrors*). C'est le geste de la bascule d'un miroir pull : sans lui, les développeurs recevraient un 403 à chaque push.
Dans l'interface, c'est le bouton de conversion en dépôt classique des paramètres du dépôt (libellé anglais « Convert to regular repository » ou proche, selon la traduction), qui demande de retaper acme/infra-mirror. L'opération est irréversible. Avec le nom seul (infra-mirror), la page revient en 200 et le dépôt reste un miroir : la confirmation attend le nom complet affiché dans la fenêtre.
Le lab a prouvé ce geste en rejouant la requête du formulaire, en session web d'un administrateur temporaire (créé par forgejo admin user create, supprimé ensuite). Sous le capot, le formulaire envoie POST /{owner}/{repo}/settings avec action=convert et repo_name valant propriétaire/nom ; la page de réglages de cette version ne contient aucun jeton CSRF. Ce n'est pas un contrat d'API, et la documentation officielle des miroirs ne décrit pas cette conversion. La requête a répondu 303, puis GET /api/v1/repos/acme/infra-mirror a donné mirror: false, mirror-sync a répondu 400 Repository is not a mirror et un push de test a été accepté.
Vérifiez donc toujours le résultat avant d'annoncer la bascule : mirror: false et un push de test accepté. L'import ponctuel (service: gitea) reste la voie qui reprend aussi les tickets, à condition de supprimer d'abord le miroir qui occupe le nom (sinon 409, voir les pièges).
Comment configurer un miroir push ?
Section intitulée « Comment configurer un miroir push ? »Un miroir push se crée sur le dépôt de la cible avec
POST /repos/{owner}/{repo}/push_mirrors ; le dépôt distant doit exister
avant, dans une organisation, et le jeton distant doit pouvoir écrire. Les identifiants du dépôt distant se donnent
dans la requête (remote_username, remote_password, ici un jeton de la
forge distante, à lire dans un fichier). Le dépôt acme2/docs est celui migré plus haut ; le dépôt distant est acme/docs-sauvegarde, le dépôt vide de la source :
curl -s -X POST -H "Authorization: token $DST_TOKEN" \ -H 'Content-Type: application/json' "$CIBLE/api/v1/repos/acme2/docs/push_mirrors" \ -d "{\"remote_address\":\"$SRC_CLONE/acme/docs-sauvegarde.git\",\"remote_username\":\"srcadmin\",\"remote_password\":\"$SRC_TOKEN\",\"interval\":\"10m\",\"sync_on_commit\":true}"La réponse (HTTP 200) porte un remote_name généré, interval à 10m0s et sync_on_commit à true. Avec le dépôt distant créé et un jeton en écriture, push_mirrors-sync répond 200, last_error est vide et le dépôt distant reçoit les commits (2 dans le lab). Deux échecs se lisent dans last_error, et push_mirrors-sync répond alors 500 :
last_error | Cause |
|---|---|
remote: Forbidden (403) | jeton distant en lecture seule : la création a réussi, mais il faut write:repository |
Push to create is not enabled for organizations. (403) | le dépôt distant n'existe pas : créez-le avant |
Quand la cible du push est un hôte local, le miroir push exige aussi ALLOW_LOCALNETWORKS, comme l'import.
Comment basculer les développeurs sur la nouvelle forge ?
Section intitulée « Comment basculer les développeurs sur la nouvelle forge ? »La bascule suit quatre étapes dans cet ordre : geler la source, repointer le
clone, contrôler par git ls-remote, puis recréer ce que la migration a
perdu. Geler avant de repointer empêche qu'un développeur pousse encore sur
l'ancienne forge pendant la transition, et un commit poussé sur la source après
la copie n'existerait pas sur la cible. Si un miroir pull alimente la cible,
lancez d'abord un dernier mirror-sync puis convertissez le miroir (section
précédente).
-
Geler la source : archivez le dépôt, qui passe en lecture seule.
Fenêtre de terminal curl -s -X PATCH -H "Authorization: token $SRC_TOKEN" \-H 'Content-Type: application/json' "$SRC_LISTE/api/v1/repos/acme/app" \-d '{"archived":true}'Il faut un jeton
write:repositorysur la source (403 sinon). La réponse portearchived: true. Un push sur la source archivée répond alors403avecremote: This repo is archived. You can view files and clone it, but cannot push or open issues/pull-requests. -
Repointer le clone existant :
originvise la nouvelle adresse, sans recloner.git remote -vdoit afficher la nouvelle URL pourfetchetpush.Fenêtre de terminal git remote set-url origin https://forge.lab.test/acme2/app.gitgit remote -v -
Contrôler que les deux forges disent la même chose :
git ls-remoteinterroge la cible sans rien télécharger.Fenêtre de terminal git ls-remote --heads originUn dépôt privé demande vos identifiants (jeton ou clé) ; avec le certificat auto-signé du lab, ajoutez
GIT_SSL_NO_VERIFY=1et-c http.curloptResolve=forge.lab.test:443:127.0.0.1. La sortie doit listermain, au même SHA que celui relevé à la source (71be10a9…dans la dernière répétition du lab ; le SHA dépend de votre source), etfeature/x. -
Recréer ce que la migration a perdu : webhooks, protections de branches, secrets d'Actions, collaborateurs, membres et équipes, puis rejouer la liste de contrôle jusqu'à ce que les décomptes correspondent à la source.
Recréez les protections de branches avant d'annoncer la bascule aux équipes : la migration ne les reprend pas (0 sur la cible). Le gel est réversible : on désarchive avec {"archived":false} si la bascule échoue (ce retour n'a pas été rejoué). Pour annuler la bascule côté clone, un second git remote set-url remet l'ancienne adresse.
Quels pièges la migration a-t-elle révélés ?
Section intitulée « Quels pièges la migration a-t-elle révélés ? »Quatre pièges coûtent du temps : un échec laisse un dépôt résiduel, un
dépôt source vide donne un 500, une migration GitHub sans jeton avec tickets
reste bloquée, et le réglage [migrations] persiste. Le dernier est traité
plus haut ; voici les trois autres, tels qu'ils ont été mesurés.
Le dépôt résiduel. Après le refus de portée, acme/m-repo existait sur la cible, code poussé, état incohérent. Après l'échec de mirror_interval: "1m" (HTTP 500), acme/infra-mirror existait, listé avec mirror: true, et rejouer la même migration répondait 409 The repository with the same name already exists. La règle : après toute migration en erreur, listez GET /orgs/{org}/repos, supprimez le résidu, puis rejouez.
Le dépôt source vide. Une migration complète d'un dépôt sans commit a
donné un 500 (error while listing pull requests ... The target couldn't be found.). Le dépôt apparaissait pourtant au décompte de la cible. Migrez un
dépôt vide en code seul, ou recréez-le à la main.
La migration GitHub sans jeton. octocat/Hello-World en service: git a
donné un dépôt ordinaire, et en service: github code seul sans jeton un
résultat en environ 32 secondes (durées mesurées au lab, qui varient avec le réseau et la charge de GitHub). Avec tickets, pull requests, jalons,
releases et wiki, la requête est restée bloquée plus de 4 minutes sans
réponse ni erreur ; il a fallu redémarrer le conteneur Forgejo pour
l'interrompre. labels seul, sans jeton, passe en 20 secondes. issues seul
n'a pas répondu en 75 secondes et a laissé un dépôt résiduel. Le quota de l'API
anonyme de GitHub est de 60 requêtes par heure et par adresse IP
(documentation GitHub, /rate_limit), contre 5 000 avec un jeton : sans
jeton, les tickets GitHub ne se migrent pas. Une migration GitHub avec jeton et
une migration GitLab (service: gitlab) n'ont pas été jouées : n'en attendez
rien de garanti avant de les avoir essayées sur une copie. Sur les différences
entre ces forges, voir
Plateformes Git.
Un dernier point sur LFS (stockage de gros fichiers) : la VM du lab n'avait pas git-lfs, la case lfs: true n'a donc pas été éprouvée. Si vos dépôts utilisent LFS, vérifiez sur une copie que les objets sont présents après migration, avant de geler quoi que ce soit.
Que faire quand la migration ne se passe pas comme prévu ?
Section intitulée « Que faire quand la migration ne se passe pas comme prévu ? »Chaque échec de migration se lit dans un message JSON ou un code HTTP. Le tableau relie le symptôme observé à sa cause et à la correction appliquée en lab.
| Symptôme | Cause | Correction |
|---|---|---|
You can not import from disallowed hosts. (422) | hôte local ou privé, [migrations] absent | ALLOW_LOCALNETWORKS ou ALLOWED_DOMAINS, puis redémarrage |
Même refus avec ALLOWED_DOMAINS = github.com | l'hôte n'est pas dans la liste blanche | ajouter l'hôte, ou autoriser les réseaux locaux |
Même refus avec BLOCKED_DOMAINS | l'hôte est dans la liste noire | le retirer de app.ini |
token does not have at least one of required scope(s): [read:issue] (500) | jeton source sans portée tickets | recréer le jeton avec read:repository,read:issue |
The target couldn't be found. (500) | dépôt privé sans jeton (sur un dépôt vide, voir la ligne « 500 sur un dépôt vide ») | fournir auth_token |
| Tickets absents après migration | service: git ne reprend que le code | refaire en service: gitea |
The repository with the same name already exists. (409) | résidu d'un échec précédent, ou nom déjà pris | supprimer le dépôt, rejouer |
interval 1m0s is set below Minimum Interval of 10m0s (500) | intervalle de miroir trop court | mirror_interval à 10m minimum |
500 sur un dépôt vide | migration complète sans commit | code seul, ou recréer le dépôt |
| Migration GitHub bloquée, aucune réponse | tickets sans jeton, quota anonyme de 60 requêtes par heure | jeton GitHub ; sinon redémarrer le conteneur |
400 Repository is not a mirror | mirror-sync sur un dépôt ordinaire | normal : aucune synchronisation à faire |
403 mirror repository is read-only | push vers un miroir pull | pousser vers la source, ou convertir le miroir (formulaire de réglages, voir plus haut) |
token does not have ... [read:organization] | liste des dépôts d'une organisation avec un jeton sans cette portée | recréer le jeton avec read:organization |
remote: Forbidden dans last_error d'un miroir push | jeton distant en lecture seule | jeton write:repository |
Push to create is not enabled for organizations. | dépôt distant du miroir push absent | créer le dépôt distant avant |
[migrations] toujours dans app.ini après retrait de la variable | persistance des variables FORGEJO__* | supprimer la section en tant que git, redémarrer |
Le diagnostic commence par la même question : l'erreur vient-elle de la
politique de la cible (disallowed hosts), de l'identité utilisée pour
lire la source (portées, jeton) ou de l'état laissé par un essai précédent
(dépôt résiduel) ? La première se corrige dans app.ini, la deuxième dans le
jeton, la troisième en supprimant le dépôt.
Comment nettoyer le lab de migration ?
Section intitulée « Comment nettoyer le lab de migration ? »On supprime les dépôts et organisations de test sur la cible, on retire la
section [migrations], on révoque les jetons de migration, puis on détruit la
source jetable avec son volume. Dans cet ordre, la cible revient à l'état de
la leçon d'installation. Supprimez d'abord les dépôts de chaque organisation
(DELETE /repos/{org}/{depot}) puis l'organisation (DELETE /orgs/{org}),
avec le jeton d'administrateur de la cible. La boucle n'affiche rien : le contrôle est le décompte final plus bas.
for org in acme acme2; do for depot in $(curl -s -H "Authorization: token $DST_TOKEN" \ "$CIBLE/api/v1/orgs/$org/repos?limit=100" | jq -r '.[].name'); do curl -s -X DELETE -H "Authorization: token $DST_TOKEN" \ "$CIBLE/api/v1/repos/$org/$depot" done curl -s -X DELETE -H "Authorization: token $DST_TOKEN" "$CIBLE/api/v1/orgs/$org"doneRetirez ensuite la surcharge et recréez le conteneur (première ligne du bloc ci-dessous), puis supprimez la section [migrations] restée dans app.ini (commandes de la section sur la persistance) et redémarrez : l'inverse laisserait la variable la réécrire au démarrage. Enfin, supprimez le jeton de migration dans les paramètres du compte (le lab l'a supprimé en base, dans la table access_token de forgejo-db, faute de route d'API qui accepte un jeton), détruisez la source avec son volume (deuxième ligne) et contrôlez que la forge répond (troisième ligne, 200 attendu) :
cd /opt/forgejo-lab && rm compose.override.yaml && docker compose up -d servercd /opt/forgejo-src && docker compose down -vcurl -sk https://forge.lab.test/api/healthzContrôles du lab : 0 dépôt restant, 0 occurrence de migrations dans app.ini, /api/healthz en 200, volume src-data retiré, et /opt/forgejo-lab réduit à Caddyfile, compose.yaml et .env.
À retenir
Section intitulée « À retenir »- Import ponctuel quand on quitte la source, miroir pull quand elle reste vivante (intervalle de 10m minimum, push refusé en 403), miroir push quand la cible alimente une autre forge (dépôt distant déjà créé dans une organisation).
service: giteareprend code, branches, tags, étiquettes, jalons, tickets, commentaires, PR, releases, wiki et la numérotation ;service: gitne reprend que le code.- La migration perd les webhooks, protections de branches, secrets d'Actions, collaborateurs et membres ; les auteurs deviennent
Ghostet les référencesacme/infra#1ne sont pas réécrites. Rejouez la liste de contrôle. - Jeton source en lecture :
read:repositoryplusread:issue, etread:organizationpour lister une organisation ;write:repositoryseulement pour archiver ou alimenter un miroir push. You can not import from disallowed hosts.(422) se lève parALLOW_LOCALNETWORKSouALLOWED_DOMAINS; les variablesFORGEJO__migrations__*restent dansapp.ini, à supprimer à la main une fois la migration finie.- Une organisation se migre par un script paginé, vérifié par le SHA de
mainet le nombre de tickets ; un échec laisse un dépôt résiduel à supprimer, un dépôt vide donne un 500. - Bascule : dernière synchro et conversion du miroir (interface web, nom complet
propriétaire/nom, puismirror: false),archived: true(push refusé en 403),git remote set-url,git ls-remoteidentique. - Après la bascule, recréez ce qui a été perdu et révoquez les jetons de migration.
Contrôle de connaissances
Section intitulée « Contrôle de connaissances »Six questions tirées sur huit : ce que reprend une migration, les portées du jeton, le réglage [migrations], les miroirs et la bascule.
Contrôle de connaissances
Validez vos connaissances avec ce quiz interactif
Informations
- Le chronomètre démarre au clic sur Démarrer
- Questions à choix multiples, vrai/faux et réponses courtes
- Vous pouvez naviguer entre les questions
- Les résultats détaillés sont affichés à la fin
Lance le quiz et démarre le chronomètre
Vérification
(0/0)Score par domaine
Un domaine regroupe plusieurs guides et reçoit plusieurs questions : son score est plus fiable qu'un score par thème, qui porte souvent sur une seule question.
Bilan
Points forts
Aucun sujet réussi à 80 % sur au moins deux questions pour l’instant.
Points faibles
Aucun sujet sous le seuil de réussite.
À faire ensuite
Des indices pour retenter votre chance ?
Continuer le cours : Épreuve niveau 2 : sécuriser et exploiter une forge
Nouveau quiz complet avec des questions aléatoires
Retravailler uniquement les questions ratées
Retour à la liste des certifications