❯ gitops · Running

GitOps cross-repo — déployer une app dont la source vit ailleurs Cross-repo GitOps — deploying an app whose source lives elsewhere

Ajouter Tapaz (prsm-studio) au cluster a été la première app dont la source vit dans un repo séparé. Boucler le GitOps sans renoncer à l'immutabilité du digest a demandé un petit step de CI cross-repo qui patche les manifests homelab après chaque build — et au passage, sceller offline un SealedSecret docker-registry en n'utilisant que la clé publique committée dans le repo. Adding Tapaz (prsm-studio) to the cluster was the first app whose source lives in a separate repo. Closing the loop without losing digest immutability meant building a tiny cross-repo CI step that patches the homelab manifests after each build — and along the way, sealing a docker-registry SealedSecret offline using only the public cert committed in the repo.

· 9 min de lecture 9 min read ·
k3s-clustergitopsargocdcighcrsealed-secretsk8s
🇬🇧 Read this article in English

🔄 Mise à jour — juin 2026. Le build cross-repo de Tapaz tourne désormais en Gitea Actions (et prsm-studio est devenu Gitea-primaire) ; le digest est patché dans le k3s-homelab hébergé sur Gitea. La mécanique cross-repo décrite reste valable, seuls la forge et le runner changent. Voir CI self-hosted avec Gitea Actions.

Le point de départ : un repo de source distinct

Les apps précédentes du cluster (blog, silex, n8n…) ont toutes leurs manifests ET leur Dockerfile committés dans le repo k3s-homelab. Très pratique pour une démo, mais pas la norme dès qu’une app a sa vraie vie ailleurs.

tapaz est la première à inverser le modèle :

  • Code source dans YellowBeanie/prsm-studio (un site React/Vite/TypeScript fait à part)
  • Manifests Kubernetes dans YellowBeanie/k3s-homelab/tapaz/
  • Image sur ghcr.io/yellowbeanie/tapaz, buildée par la CI du repo source

La question qui en découle : comment garder un GitOps pur (digest pinné, traçabilité git, rollback git revert) sans transformer chaque release en cha-cha-cha manuel “commit source → noter le digest → editer le manifest → push homelab” ?

Cadre rapide

Le repo source produit l’image. Le repo homelab consomme l’image. ArgoCD watche le repo homelab. Le seul fichier qui relie les deux est k3s-homelab/tapaz/deployment.yaml, ligne :

image: ghcr.io/yellowbeanie/tapaz@sha256:<digest>

Toute la question se résume à : qui réécrit cette ligne après chaque build ?

Trois écoles :

  1. :latest mutable + imagePullPolicy: Always — simple, mais on perd l’historique exact “quelle image tournait quand” et le rollback devient flou.
  2. ArgoCD Image Updater — un controller dans le cluster qui scrute GHCR et patch les manifests tout seul. Élégant, mais ajoute un composant supplémentaire à maintenir, à monitorer et à reconfigurer en cas de pépin.
  3. CI cross-repo — le workflow du repo source clone le repo homelab, patch avec sed, commit, push. Zéro composant cluster, le pipeline fait tout le boulot.

J’ai choisi l’option 3 : tout vit déjà dans GitHub Actions, donc autant pousser un cran plus loin.

Le workflow étendu

Le workflow build-image.yml côté prsm-studio fait trois choses désormais : build multi-arch (le cluster est 100% Raspberry Pi ARM64, leçon retenue d’épisodes précédents), push sur GHCR, et bump du digest dans le repo homelab.

name: build-image
on:
  push:
    branches: [main]

permissions:
  contents: read
  packages: write

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-qemu-action@v3
      - uses: docker/setup-buildx-action@v3

      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.repository_owner }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build & push
        id: push
        uses: docker/build-push-action@v6
        with:
          context: .
          platforms: linux/amd64,linux/arm64
          push: true
          tags: |
            ghcr.io/yellowbeanie/tapaz:latest
            ghcr.io/yellowbeanie/tapaz:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

      - name: Bump digest in k3s-homelab
        env:
          DIGEST: ${{ steps.push.outputs.digest }}
          SRC_SHA: ${{ github.sha }}
          HOMELAB_REPO_TOKEN: ${{ secrets.HOMELAB_REPO_TOKEN }}
        run: |
          git clone --depth 1 --branch actual-state \
            https://x-access-token:${HOMELAB_REPO_TOKEN}@github.com/YellowBeanie/k3s-homelab.git homelab
          cd homelab
          sed -i -E "s|(image: ghcr\.io/yellowbeanie/tapaz@)sha256:[a-f0-9]+|\1${DIGEST}|" tapaz/deployment.yaml
          if git diff --quiet; then
            echo "Digest already up to date — nothing to commit."
            exit 0
          fi
          git config user.name "github-actions[bot]"
          git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
          git add tapaz/deployment.yaml
          git commit -m "tapaz: bump image to ${DIGEST} (prsm-studio@${SRC_SHA})"
          git push origin actual-state

Trois détails qui ont leur importance :

  • git diff --quiet : un rebuild qui produit exactement le même digest (cache GHA hit, contenu identique) ne génère aucun commit. La CI reste idempotente.
  • sed -E "s|(image: ghcr\.io/yellowbeanie/tapaz@)sha256:[a-f0-9]+|\1${DIGEST}|" : remplacement strictement ancré sur le préfixe image: ghcr.io/yellowbeanie/tapaz@ pour éviter de toucher d’autres champs si le YAML évolue.
  • github-actions[bot] comme auteur : l’historique homelab montre clairement qu’un commit vient de la CI cross-repo, pas d’un humain. Important pour le grep et pour les blame futurs.

Le PAT côté secret

Pour autoriser le runner à pousser sur k3s-homelab, il faut un PAT (Personal Access Token) avec scope d’écriture sur ce repo. Le GITHUB_TOKEN natif du workflow ne suffit pas — il est cantonné au repo qui l’exécute (prsm-studio).

Création (à raffiner plus tard en fine-grained si voulu) :

gh secret set HOMELAB_REPO_TOKEN \
  -R YellowBeanie/prsm-studio \
  --body "$PAT_VALUE"

Pour durcir : créer un fine-grained PAT avec uniquement Contents: Read and write sur YellowBeanie/k3s-homelab, et rien d’autre. Le scope minimal protège contre un runner compromis qui tenterait d’aller toucher autre chose.

Cycle complet en pratique

1. git push origin main      (sur prsm-studio)
2. CI build-image se déclenche
   ├─ build amd64 + arm64       (~1m15s avec cache, ~3-4 min sans)
   ├─ push GHCR ghcr.io/yellowbeanie/tapaz:<sha>
   └─ bump digest dans k3s-homelab/tapaz/deployment.yaml
      └─ git push origin actual-state  (sur k3s-homelab)
3. ArgoCD détecte le commit homelab et sync         (~30s)
4. Le pod tapaz pull la nouvelle image et redémarre (~10s)

Du git push initial au pod tournant sur le cluster : environ 2-3 minutes, sans une seule commande à taper en plus.

Bonus : sceller le ghcr-pull SealedSecret hors-ligne

L’image GHCR est privée (et je préfère qu’elle le reste — pas envie d’exposer même les bundles JS d’un site avant qu’il soit publié). Donc le namespace tapaz doit avoir un imagePullSecret, lui-même committé en tant que SealedSecret.

Sauf qu’au moment où j’ai voulu le créer, je n’avais pas accès à kubectl sur le cluster depuis le poste où je travaillais. Le workflow kubeseal classique passe par kubeseal --fetch-cert qui suppose une connexion au cluster.

La parade — astuce sous-documentée : kubeseal accepte --cert <fichier.pem> et chiffre 100 % offline. La clé publique du controller, étant publique, est sans risque à committer dans le repo. Et elle y est déjà : docs/sealed-secrets/sealed-secrets-pub.pem.

# Générer le Secret docker-registry en YAML "à la main"
USER=YellowBeanie
TOKEN=ghp_xxxxxxxx          # PAT avec read:packages
AUTH=$(printf '%s:%s' "$USER" "$TOKEN" | base64)
DOCKERCFG=$(printf '{"auths":{"ghcr.io":{"username":"%s","password":"%s","auth":"%s"}}}' \
  "$USER" "$TOKEN" "$AUTH" | base64 -w0)

cat <<EOF > /tmp/secret.yaml
apiVersion: v1
kind: Secret
metadata:
  name: ghcr-pull
  namespace: tapaz
type: kubernetes.io/dockerconfigjson
data:
  .dockerconfigjson: ${DOCKERCFG}
EOF

# Sceller offline
kubeseal --cert docs/sealed-secrets/sealed-secrets-pub.pem \
         --format yaml \
         --scope strict \
  < /tmp/secret.yaml \
  > tapaz/sealedsecret-ghcr-pull.yaml

rm /tmp/secret.yaml          # ne jamais committer le secret en clair

Le SealedSecret qui en sort est strictement identique à celui qu’aurait produit le workflow kubeseal --fetch-cert. Scope strict = ne peut être déchiffré que dans le namespace tapaz, pour le nom ghcr-pull. Aucun risque de relecture ailleurs si le manifest est exfiltré.

Pourquoi ça scale

Le pattern décrit ici n’est pas spécifique à Tapaz. Le jour où une autre app vient s’ajouter avec son repo source à part, on copie le step “Bump digest” en changeant le sed cible et le secret PAT, et c’est plié. Pas de nouveau controller à apprendre, pas de webhook à configurer, pas de polling à régler. Juste du git et du sed dans un runner.

C’est aussi très observable : tous les bumps automatiques apparaissent sous github-actions[bot] dans l’historique homelab. Un git log --author github-actions tapaz/ donne en deux secondes la timeline des releases successives. Et un rollback à une version antérieure n’est rien d’autre qu’un git revert <commit>.

Leçons retenues

  • Avant de réfléchir à un controller (Image Updater, Flux Image automation, etc.), se demander si un step de CI suffit. Pour un homelab à 15 apps, la simplicité opérationnelle gagne presque toujours.
  • Committer la clé publique sealed-secrets dans docs/sealed-secrets/sealed-secrets-pub.pem débloque le sealing offline. C’est une micro-décision qui paye dès qu’on a un poste sans kubectl ou un collègue à embarquer sur le workflow secrets.
  • Multi-arch n’est pas optionnel sur un cluster Pi. Le réflexe platforms: linux/amd64,linux/arm64 doit faire partie du workflow par défaut, sans quoi le exec format error est garanti.
  • Author bot != humain dans l’historique. Donner un email github-actions[bot]@users.noreply.github.com aux commits CI évite la confusion six mois plus tard quand on cherche qui a touché à quoi.

Tapaz tourne maintenant en autonomie complète : un push sur prsm-studio devient un pod fraîchement déployé sur le cluster en moins de trois minutes. Et le repo k3s-homelab reste la seule source de vérité de l’état désiré, comme l’exige le contrat GitOps.

k3s-clustergitopsargocdcighcrsealed-secretsk8s