Aller au contenu
English
Compte : non connecté, synchronisation possible Mon apprentissage
Selectionner le thème
English

Migrer une organisation vers Forgejo : import et bascule

Par mis à jour le

Améliorer cette page

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.

  • Choisir entre import ponctuel, miroir pull et miroir push, selon que la source reste vivante ou non.
  • Distinguer ce que reprend une migration service: gitea de 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 refus disallowed 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.

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 :

VariableContenu
CIBLEURL de la forge cible
DST_TOKENjeton d'un administrateur de la cible
SRC_TOKENjeton de la source
SRC_LISTEadresse de la source jointe depuis votre poste
SRC_CLONEadresse 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-src
networks:
forgejo:
external: true
name: forgejo-lab_forgejo
volumes:
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:/data

Ce 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) :

Fenêtre de terminal
cd /opt/forgejo-src && docker compose up -d
docker exec -u git forgejo-src forgejo admin user create --admin \
--username srcadmin --email srcadmin@lab.test --random-password \
--must-change-password=false
docker 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.token

Avec 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.

Arbre de décision de la migration : si la source n'est plus utilisée, on fait un import ponctuel puis on vérifie le SHA de main et le nombre de tickets ; si la source reste vivante en lecture seule, on crée un miroir pull à 10 minutes minimum, on fait une dernière synchronisation mirror-sync, puis on convertit le miroir en dépôt classique par l'interface web ; si la cible alimente une autre forge, on crée un miroir push vers un dépôt distant déjà créé, et le parcours s'arrête là. Import et miroir pull mènent au gel de la source (archived true, push refusé 403), puis à git remote set-url et à un git ls-remote identique, puis à la recréation des webhooks, protections, secrets, collaborateurs et membres.non, on quitte la sourceoui, copie en lecture seuleoui, la cible alimente une autreforgeFaut-il garderla source vivante ?Import ponctuelPOST /repos/migrate, mirror falseMiroir pullmirror true, intervalle 10m minimumMiroir pushpush_mirrors, dépôt distant déjà crééVérifier : SHA de main,nombre de ticketsDernière synchromirror-syncConvertir le miroir en dépôt classiqueinterface web, irréversibleGeler la sourcearchived true, push refusé 403git remote set-urlpuis git ls-remote identiqueRecréer ce qui est perdu :webhooks, protections, secrets,collaborateurs, membres
Source · SVG
Échap pour fermer, + et − pour zoomer, défilement pour se déplacer. 100 %

Le tableau donne le mode d'emploi de chaque option, avec le champ de l'API qui la commande.

OptionChamps de l'APIRôle de la sourceEffet sur la cible
Import ponctuelPOST /repos/migrate, service: gitea, mirror absent ou falsegelée après basculedépôt ordinaire, tickets repris
Miroir pullPOST /repos/migrate, service: git, mirror: true, mirror_intervalla référence tant que dure le miroirdépôt en lecture seule, code seul
Miroir pushPOST /repos/{owner}/{repo}/push_mirrorssans objet : un autre serveur reçoit les commitsla 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 cibleConstat
Codeles deux branches (feature/x, main), le tag, les 4 commits
Étiquettes et jalon2 étiquettes, le jalon v1.1 ; le ticket 1 garde étiquette et jalon
Tickets et commentaires2 tickets et le commentaire
Pull requestla PR #3, ouverte, sur la branche feature/x
Release et wikila release v1.0.0, la page Accueil du wiki
Numérotationconservé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.

PerduSourceCible après migration
Webhooks10
Protections de branches10
Secrets d'Actions1[]
Collaborateurs1[]
Auteurs des tickets, du commentaire, de la PRaliceGhost (original_author vide)
Références dans les corps de ticketsacme/infra#1laissé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 :

Fenêtre de terminal
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')"
done

Juste 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 :

Fenêtre de terminal
docker exec -u git forgejo-src forgejo admin user generate-access-token \
--username srcadmin --token-name migration \
--scopes read:repository,read:issue,read:organization --raw

La 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 sourceRésultat
read:repository seul, avec issues: truerefus (HTTP 500) : error while listing issues: token does not have at least one of required scope(s): [read:issue]
read:repository seul, code seulOK (code, branches, tags)
read:repository + read:issueOK : 2 tickets, 1 PR, 2 étiquettes, 1 jalon, 1 release
read:repository + read:issue + read:organizationmê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 jetoncode, 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églageSource forgejo-src (réseau privé)
(défaut)refusée
ALLOW_LOCALNETWORKS = trueacceptée
ALLOWED_DOMAINS = github.com seulrefusée
ALLOWED_DOMAINS = forgejo-src seulacceptée (la liste blanche suffit à autoriser un hôte local)
ALLOWED_DOMAINS = github.com + ALLOW_LOCALNETWORKS = trueacceptée (la liste blanche ne restreint pas un hôte local autorisé)
BLOCKED_DOMAINS = forgejo-src + ALLOW_LOCALNETWORKS = truerefusé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 :

Fenêtre de terminal
docker exec -u git forgejo sed -i '/^\[migrations\]/,/^\[/{/^\[migrations\]/d;/^\[/!d}' /data/gitea/conf/app.ini
docker exec forgejo grep -c migrations /data/gitea/conf/app.ini
docker restart forgejo

Le 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 :

Fenêtre de terminal
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_TOKEN
set -euo pipefail
org=$1; cible=$2; page=1; total=0; ko=0
while :; 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))
done
echo "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.

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 :

Fenêtre de terminal
curl -s -H "Authorization: token $SRC_TOKEN" "$SRC_LISTE/api/v1/repos/acme/app/branches/main" | jq -r .commit.id
curl -s -H "Authorization: token $DST_TOKEN" "$CIBLE/api/v1/repos/acme2/app/branches/main" | jq -r .commit.id

Pour 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.

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 :

Fenêtre de terminal
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 :

Fenêtre de terminal
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).

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 :

Fenêtre de terminal
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_errorCause
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).

  1. 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:repository sur la source (403 sinon). La réponse porte archived: true. Un push sur la source archivée répond alors 403 avec remote: This repo is archived. You can view files and clone it, but cannot push or open issues/pull-requests.

  2. Repointer le clone existant : origin vise la nouvelle adresse, sans recloner. git remote -v doit afficher la nouvelle URL pour fetch et push.

    Fenêtre de terminal
    git remote set-url origin https://forge.lab.test/acme2/app.git
    git remote -v
  3. Contrôler que les deux forges disent la même chose : git ls-remote interroge la cible sans rien télécharger.

    Fenêtre de terminal
    git ls-remote --heads origin

    Un dépôt privé demande vos identifiants (jeton ou clé) ; avec le certificat auto-signé du lab, ajoutez GIT_SSL_NO_VERIFY=1 et -c http.curloptResolve=forge.lab.test:443:127.0.0.1. La sortie doit lister main, 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), et feature/x.

  4. 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.

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ômeCauseCorrection
You can not import from disallowed hosts. (422)hôte local ou privé, [migrations] absentALLOW_LOCALNETWORKS ou ALLOWED_DOMAINS, puis redémarrage
Même refus avec ALLOWED_DOMAINS = github.coml'hôte n'est pas dans la liste blancheajouter l'hôte, ou autoriser les réseaux locaux
Même refus avec BLOCKED_DOMAINSl'hôte est dans la liste noirele retirer de app.ini
token does not have at least one of required scope(s): [read:issue] (500)jeton source sans portée ticketsrecré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 migrationservice: git ne reprend que le coderefaire en service: gitea
The repository with the same name already exists. (409)résidu d'un échec précédent, ou nom déjà prissupprimer le dépôt, rejouer
interval 1m0s is set below Minimum Interval of 10m0s (500)intervalle de miroir trop courtmirror_interval à 10m minimum
500 sur un dépôt videmigration complète sans commitcode seul, ou recréer le dépôt
Migration GitHub bloquée, aucune réponsetickets sans jeton, quota anonyme de 60 requêtes par heurejeton GitHub ; sinon redémarrer le conteneur
400 Repository is not a mirrormirror-sync sur un dépôt ordinairenormal : aucune synchronisation à faire
403 mirror repository is read-onlypush vers un miroir pullpousser 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éerecréer le jeton avec read:organization
remote: Forbidden dans last_error d'un miroir pushjeton distant en lecture seulejeton write:repository
Push to create is not enabled for organizations.dépôt distant du miroir push absentcréer le dépôt distant avant
[migrations] toujours dans app.ini après retrait de la variablepersistance 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.

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.

Fenêtre de terminal
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"
done

Retirez 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) :

Fenêtre de terminal
cd /opt/forgejo-lab && rm compose.override.yaml && docker compose up -d server
cd /opt/forgejo-src && docker compose down -v
curl -sk https://forge.lab.test/api/healthz

Contrô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.

  • 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: gitea reprend code, branches, tags, étiquettes, jalons, tickets, commentaires, PR, releases, wiki et la numérotation ; service: git ne reprend que le code.
  • La migration perd les webhooks, protections de branches, secrets d'Actions, collaborateurs et membres ; les auteurs deviennent Ghost et les références acme/infra#1 ne sont pas réécrites. Rejouez la liste de contrôle.
  • Jeton source en lecture : read:repository plus read:issue, et read:organization pour lister une organisation ; write:repository seulement pour archiver ou alimenter un miroir push.
  • You can not import from disallowed hosts. (422) se lève par ALLOW_LOCALNETWORKS ou ALLOWED_DOMAINS ; les variables FORGEJO__migrations__* restent dans app.ini, à supprimer à la main une fois la migration finie.
  • Une organisation se migre par un script paginé, vérifié par le SHA de main et 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, puis mirror: false), archived: true (push refusé en 403), git remote set-url, git ls-remote identique.
  • Après la bascule, recréez ce qui a été perdu et révoquez les jetons de migration.

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

6 questions
6 min.
70% requis

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

Ce projet vous est utile ?

Les guides, formations, quiz et labs restent gratuits. Si vous les utilisez régulièrement, vous pouvez contribuer à leur maintenance et à leur développement.

Devenir membre de soutien (nouvel onglet) Soutenir une fois (nouvel onglet) En savoir plus

Suivez aussi le projet sur LinkedIn