Le constat : un dépôt d’infra écrit à 29 % par des robots
Le 2 octobre 2026, je compte les auteurs des commits du dépôt d’infrastructure du cluster, celui qu’ArgoCD surveille. Sur 487 commits, 141 ne viennent pas d’un humain : 97 signés par le robot de Gitea Actions, 44 par celui de GitHub Actions, du temps où la CI tournait encore là-bas. Tous disent la même chose, à la formulation près : « pin image digest » ou « bump image ».
C’est le prix du modèle « GitOps cross-repo » décrit dans un article précédent. Le code d’une app vit dans son dépôt, ses manifests vivent dans le dépôt d’infra. Après chaque build, la CI de l’app clone le dépôt d’infra, réécrit la ligne image: avec un sed, commite et pousse. Ça marche. Mais trois choses me gênaient de plus en plus :
- Le bruit. Un
git logdu dépôt d’infra, c’est un relevé de builds entrecoupé de vraies décisions. Retrouver « quand ai-je changé la politique de sauvegarde ? » demande un--authord’exclusion. - Le jeton. La CI de chaque app détient un jeton capable d’écrire dans le dépôt qui décrit tout le cluster. Le principe du moindre privilège dirait plutôt l’inverse.
- La course. Deux builds qui finissent en même temps poussent sur la même branche, et l’un des deux se fait refuser son push. Le pas de CI ne prévoyait aucune reprise : un cas rare, donc jamais traité.
Le cas le plus caricatural était un petit générateur HTML d’une seule page : 454 Ko de HTML recopiés à la main dans une ConfigMap du dépôt d’infra, sans CI, à près de la moitié de la limite d’1 Mio d’etcd par objet.
L’idée : le dépôt d’infra garde l’Application et les secrets, l’app garde le reste
Le nouveau partage est simple :
- le dépôt de l’app porte son code, son Dockerfile et un dossier
deploy/(namespace, Deployment, Service, Ingress). Sa CI y épingle elle-même le digest de l’image qu’elle vient de construire ; - le dépôt d’infra garde la définition de l’Application ArgoCD et ce qui doit rester centralisé : le SealedSecret qui permet de tirer l’image du registre privé.
Pourquoi garder ce secret côté infra ? Parce qu’un seul jeton de registre sert à huit apps. Quand il tourne, il faut resceller huit SealedSecrets, et je préfère le faire en un commit dans un dépôt plutôt qu’en huit commits dans huit dépôts.
Pour qu’ArgoCD lise les deux endroits, il faut une Application multi-source (spec.sources, disponible depuis ArgoCD 2.6) :
spec:
sources:
- repoURL: http://<gitea-interne>/<org>/<app>.git
targetRevision: main
path: deploy
- repoURL: http://<gitea-interne>/<org>/k3s-homelab.git
targetRevision: actual-state
path: <app> # ne contient plus que sealedsecret-ghcr-pull.yaml
destination:
server: https://kubernetes.default.svc
namespace: <app>
ArgoCD rend les deux chemins et applique l’union. Pour lui, c’est un seul état désiré.
Le prérequis : un credential à préfixe plutôt qu’un credential par dépôt
Jusque-là, ArgoCD ne lisait qu’un dépôt privé, celui d’infra, avec un credential dédié. Cinq dépôts de plus voulaient dire cinq Secrets de plus, et un sixième le jour où une nouvelle app arrive.
ArgoCD sait faire mieux : un Secret étiqueté argocd.argoproj.io/secret-type: repo-creds s’applique à tous les dépôts dont l’URL commence par son préfixe. Un seul credential, avec un jeton en lecture seule, pour http://<gitea-interne>/<org>/, et chaque nouveau dépôt de l’organisation est lisible sans rien ajouter.
Un détail d’ordre : ce credential vit dans un dossier de bootstrap qui n’est pas lui-même géré par ArgoCD. C’est voulu. ArgoCD ne peut pas aller chercher le credential qui lui permet de lire un dépôt dans ce même dépôt. Il est donc appliqué à la main, une fois, et noté comme tel dans la doc.
Au passage, j’ai trouvé dans le namespace d’ArgoCD une clé SSH privée vers l’ancien dépôt GitHub, d’avant la migration vers Gitea, qu’aucune Application ne référençait plus. Elle est partie le même jour.
La méthode : copier à l’identique, vérifier à blanc, basculer, puis seulement la CI
La première app, le pilote, a servi à fixer un ordre que j’ai ensuite répété quatre fois :
- Copier les manifests dans
deploy/du dépôt de l’app, octet pour octet. Aucune « amélioration » pendant le déménagement : un diff vide est la seule preuve qu’on n’a rien cassé. - Exclure
deploy/de l’image (.dockerignore). Pour un site statique servi par nginx, oublier cette ligne revient à publier ses manifests à l’adresse/deploy/deployment.yaml. - Vérifier à blanc avec une Application temporaire sans finalizer, qui pointe vers le nouveau dossier. Sans finalizer, la supprimer ne supprime rien dans le cluster. On regarde seulement ce qu’ArgoCD en rend : 5 ressources, aucune
ComparisonError. - Basculer l’Application réelle vers
sources:. Comme les fichiers sont identiques, ArgoCD ne voit aucune différence : lagenerationdu Deployment est restée à 53, aucun pod recréé. - Seulement ensuite, modifier la CI pour qu’elle épingle le digest dans
deploy/au lieu du dépôt d’infra.
Le premier build après la bascule a fait le tour complet : image construite, digest épinglé dans deploy/deployment.yaml par la CI, ArgoCD synchronisé sur cette révision, generation 54, deux nouveaux pods, site en 200, et /deploy/ en 404. Le dépôt d’infra n’a reçu aucun commit.
Le pilote a basculé à 13 h 31, la doc du patron a suivi à 13 h 52, et les quatre autres apps ont suivi dans l’après-midi. La dernière a basculé à 16 h 05.
Le blog lui-même : 125 fichiers et 71 commits qui déménagent
Le blog que vous lisez faisait partie des cinq, et c’était le cas le plus lourd. Son code Astro entier (125 fichiers : composants, articles, Dockerfile, configuration nginx) vivait encore dans un sous-dossier du dépôt d’infra, avec un filtre pour qu’ArgoCD l’ignore.
Pour ne pas perdre l’historique, le déménagement est passé par git subtree split : les 71 commits qui touchaient ce sous-dossier depuis mai 2026 forment l’historique du nouveau dépôt. Le workflow de CI est parti avec le code. Avant la bascule, une vérification de plus : le digest déjà épinglé dans le nouveau deploy/ était celui qui tournait en production. La bascule s’est donc faite sans redémarrage.
Le pas de CI qui épingle le digest n’a presque pas changé. Il ne clone simplement plus rien :
- name: Pin digest in deploy/deployment.yaml
run: |
set -eu
sed -i -E "s|image: ghcr.io/<compte>/blog@sha256:[a-f0-9]+|image: ghcr.io/<compte>/blog@${DIGEST}|" deploy/deployment.yaml
git diff --quiet deploy/deployment.yaml && { echo up-to-date; exit 0; }
git commit -am "chore(deploy): pin image digest [skip ci]"
git push "<url-du-depot-de-l-app>" HEAD:main
Le piège : le commit qui épingle ne doit pas relancer le build
Dès que la CI commite dans son propre dépôt, une boucle devient possible : build, commit du digest, nouveau push, nouveau build, nouveau digest, et ainsi de suite. Deux protections, volontairement redondantes :
- le marqueur
[skip ci]dans le message du commit d’épinglage ; - le filtre
paths:du workflow, qui ne liste quesrc/**,public/**, le Dockerfile, etc.deploy/**n’y figure pas, donc un commit qui ne touche quedeploy/ne déclenche rien, marqueur ou pas.
Et le piège inverse, rencontré le jour même : Gitea cherche le marqueur dans tout le message, corps compris. J’ai écrit un commit de documentation dont le corps expliquait que la CI utilise [skip ci]. Résultat : aucun build. Il a fallu relancer le run à la main. Depuis, la consigne est écrite en tête du workflow : ne jamais citer ce marqueur dans un message de commit, même pour en parler.
Le bilan, deux semaines plus tard
Entre la dernière bascule et le 15 octobre, le dépôt d’infra a reçu 151 commits. Aucun ne vient d’un robot de CI. Côté blog, 8 des 16 commits de la même période sont des épinglages de digest, là où ils doivent être : dans l’historique de l’app qu’ils concernent.
Le générateur HTML est devenu une vraie image nginx construite par la CI de son dépôt. La ConfigMap de 454 Ko a disparu, avec elle le copier-coller à la main.
Ce que le patron coûte, pour être honnête :
- Deux endroits à regarder. L’état d’une app dépend de deux branches dans deux dépôts. Le commentaire en tête de chaque Application dit lequel contient quoi, et c’est devenu indispensable.
- Le credential de bootstrap reste un geste manuel, à refaire si son jeton tourne.
- Huit SealedSecrets de registre à resceller à chaque rotation. Centralisés, mais huit quand même.
- Une app est restée à l’ancien modèle : sa suite de 43 tests lit les manifests depuis le dépôt d’infra. Son prochain build écrira donc encore dans le dépôt d’infra. La déplacer est un chantier à part.
Ce que je retiens
- Le dépôt qui produit l’artefact écrit sa référence. Une CI qui pousse dans le dépôt d’un autre, c’est un couplage qu’on finit par payer en bruit, en droits trop larges ou en conflits de push.
- Le dépôt d’infra garde ce qui doit rester central : les Applications, les secrets partagés, le bootstrap. Pas les manifests d’une app qui a sa propre vie.
- Un credential
repo-credsà préfixe évite d’ajouter un Secret par dépôt. Il faut juste accepter qu’il vive hors d’ArgoCD. - Migrer sans rien changer. Copie octet pour octet, Application temporaire sans finalizer, bascule, et la CI en dernier. Si la
generationne bouge pas, la bascule est réussie. - Deux garde-fous contre la boucle de build, marqueur et filtre de chemins. Et ne jamais citer le marqueur dans un message de commit.