❯ gitops · Running

Les tags flottants, c'est du poison Floating tags considered harmful

Comment GitHub Actions épingle automatiquement un digest immuable dans le manifest Kubernetes après chaque build, et pourquoi c'est non-négociable pour la reproductibilité et la supply chain. How GitHub Actions automatically pins an immutable digest into the Kubernetes manifest after every build — and why this is non-negotiable for reproducibility and supply chain integrity.

· 11 min de lecture 11 min read ·
k3s-clustergitopsgithub-actionssupply-chaink8sci
🇬🇧 Read this article in English

🔄 Mise à jour — juin 2026. La CI ne tourne plus sur GitHub Actions mais sur Gitea Actions (runner self-hosté sur le NAS). Le pattern de digest-pin ci-dessous reste identique sur le fond ; seul le git push final cible désormais Gitea. Voir CI self-hosted avec Gitea Actions.

Le mensonge de :latest

:latest ne désigne pas ce que vous croyez. Ce n’est pas “la dernière version stable”. Ce n’est pas “l’image que j’ai buildée hier”. C’est un pointeur mutable que n’importe qui peut déplacer à n’importe quel moment.

Imaginez que votre registry héberge ghcr.io/yourname/app:latest. Vous pushez une nouvelle image ce matin — :latest se déplace. Votre collègue reboot un nœud cette après-midi — Kubernetes re-pull :latest, qui est maintenant une image différente de celle qui tournait ce matin. Aucun signal, aucune trace dans git log, aucun événement ArgoCD. Le cluster a divergé silencieusement, et personne n’a rien signé.

C’est encore pire avec imagePullPolicy: Always. Sans cette option, Kubernetes ne pull l’image que si le nœud ne l’a pas en cache local — donc au moins votre pod redémarre avec ce qu’il avait au dernier pull. Avec imagePullPolicy: Always, chaque redémarrage de pod ré-interroge la registry. Si :latest a bougé entre deux redémarrages, vos pods tournent sur des images différentes selon leur date de dernier boot. Un cluster en quinconce permanent.

La promesse du GitOps, c’est que Git est la source de vérité unique. Avec un tag flottant dans le manifest, cette promesse est rompue. Ce qui est dans Git ne reflète pas ce qui tourne réellement.

Pourquoi un digest immuable

Un digest SHA256 n’est pas un nom humain — c’est le hash cryptographique du contenu de l’image. Il est calculé par la registry au moment du push et il est immuable par construction : si le contenu change d’un octet, le digest change. Il est impossible pour un acteur malveillant ou une simple mise à jour automatique de substituer une image différente derrière le même digest.

# Tag flottant : ce que vous avez écrit hier n'est pas ce qui tourne aujourd'hui
image: ghcr.io/yellowbeanie/homelab-blog:latest

# Digest immuable : ce qui est dans Git est exactement ce qui tourne
image: ghcr.io/yellowbeanie/homelab-blog@sha256:4525bb37c02127dedd76f36e2deedfefb0fdcc9cda5560fec39f1846a7ca58a8

La ligne du bas désigne un blob unique dans l’univers. Si votre registry brûle et que vous la reconstruisez, si Bitnami déprécie ses images, si quelqu’un écrase le tag :latest — ce digest-là, tant qu’il existe quelque part, pointe vers exactement cette image. C’est la seule référence que vous pouvez committer dans Git avec la certitude que ce commit signifie quelque chose dans six mois.

Et c’est précisément pour ça qu’on parle de supply chain : savoir, de façon vérifiable, quelle image tourne et d’où elle vient. Un digest vous permet de répondre à “qu’est-ce qui tournait sur le cluster le 3 janvier ?” en lisant simplement git log.

Le pattern complet

La mécanique est simple : après chaque build, GitHub Actions capture le digest produit et le réécrit directement dans le manifest Kubernetes, puis committe ce changement. ArgoCD détecte le commit et déploie. Voici le workflow complet du blog :

name: Build and push blog image

on:
  workflow_dispatch:
  push:
    branches: [actual-state]
    paths:
      - 'blog/src/**'
      - 'blog/public/**'
      - 'blog/package*.json'
      - 'blog/astro.config.mjs'
      - 'blog/Dockerfile'
      - '.github/workflows/blog.yml'

jobs:
  build-push:
    runs-on: ubuntu-latest
    permissions:
      contents: write   # nécessaire pour le git push final
      packages: write   # nécessaire pour pusher sur GHCR

    steps:
      - uses: actions/checkout@v4
        with:
          ref: actual-state

      - name: Set up QEMU (for ARM64 emulation)
        uses: docker/setup-qemu-action@v3

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Log in to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and push
        id: build
        uses: docker/build-push-action@v6
        with:
          context: ./blog
          platforms: linux/arm64
          push: true
          tags: |
            ghcr.io/yellowbeanie/homelab-blog:latest
            ghcr.io/yellowbeanie/homelab-blog:${{ github.sha }}
          no-cache: true

      - name: Pin new digest in deployment manifest
        run: |
          set -eu
          DIGEST="${{ steps.build.outputs.digest }}"
          echo "New digest: ${DIGEST}"
          sed -i -E "s|image: ghcr.io/yellowbeanie/homelab-blog@sha256:[a-f0-9]+|image: ghcr.io/yellowbeanie/homelab-blog@${DIGEST}|" blog/deployment.yaml
          if git diff --quiet blog/deployment.yaml; then
            echo "manifest already up to date"
            exit 0
          fi
          git config user.name "github-actions[bot]"
          git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
          git add blog/deployment.yaml
          git commit -m "chore(blog): pin image digest [skip ci]"
          git push origin actual-state

Décortiquons l’étape clé, “Pin new digest in deployment manifest” :

steps.build.outputs.digest est la sortie native de docker/build-push-action@v6. Elle expose le digest SHA256 de l’image qui vient d’être pushée — format sha256:abc123…. Pas besoin de le calculer à la main ou d’inspecter la registry après coup.

Le sed est délibérément ancré sur le préfixe complet image: ghcr.io/yellowbeanie/homelab-blog@sha256: suivi de caractères hexadécimaux. Il ne remplace que la ligne exacte qui le précède, sans risquer de toucher d’autres champs YAML si le manifest évolue.

git diff --quiet rend le step idempotent : si le build produit exactement le même digest (cache hit, contenu identique), aucun commit n’est créé. La CI ne fait pas de bruit pour rien.

github-actions[bot] comme auteur marque clairement dans l’historique que ce commit vient de la CI, pas d’un humain. Pratique pour les git blame et les recherches futures.

Le résultat dans le manifest :

containers:
- name: blog
  image: ghcr.io/yellowbeanie/homelab-blog@sha256:4525bb37c02127dedd76f36e2deedfefb0fdcc9cda5560fec39f1846a7ca58a8

Et dans git log :

c3f7a21  chore(blog): pin image digest [skip ci]
a9d4b88  feat(blog): add article on sealed secrets

Chaque release est signée par un commit. L’historique est lisible par un humain, interrogeable par un script, revertable en une commande.

Les pièges qui font mal

La boucle infinie sans [skip ci]

C’est le piège numéro un. Le workflow push un commit “pin image digest” sur actual-state. GitHub Actions détecte ce push sur actual-state. GitHub Actions relance le workflow. Le workflow rebuilde, pousse un nouveau commit. GitHub Actions redétecte. La boucle tourne jusqu’à épuisement des minutes GitHub Actions du mois — ou jusqu’à ce que vous la coupiez manuellement.

La solution est le suffixe [skip ci] dans le message de commit. GitHub Actions reconnaît cette convention et ne déclenche pas de nouveau run sur ce commit. C’est non négociable. Sans ça, vous avez une boucle infinie.

git commit -m "chore(blog): pin image digest [skip ci]"
#                                              ^^^^^^^^^
#                              sans ça, vous reconstruisez votre image en boucle

Les permissions manquantes

Le GITHUB_TOKEN natif a des permissions restrictives par défaut depuis 2023. Si vous oubliez de déclarer permissions: contents: write au niveau du job, le git push échoue — et GitHub Actions rapporte parfois une erreur cryptique (“remote: Permission to repo denied”) plutôt que “vous n’avez pas les droits en écriture”. Même chose pour packages: write sans lequel le push GHCR échoue silencieusement.

jobs:
  build-push:
    permissions:
      contents: write  # pour git push du commit digest
      packages: write  # pour docker push sur GHCR

Multi-arch et QEMU — le cluster Pi ne pardonne pas

Un cluster 100% Raspberry Pi (ARM64) ne peut pas exécuter une image buildée uniquement pour linux/amd64. Le pod démarre, Kubernetes tente le pull, et vous obtenez :

exec format error

Le digest est valide. L’image existe bien dans la registry. Mais l’architecture ne correspond pas. La solution : docker/setup-qemu-action@v3 pour l’émulation, puis platforms: linux/arm64 (ou linux/amd64,linux/arm64 si vous voulez supporter les deux).

Sans QEMU configuré, le buildx multi-arch échoue à la compilation croisée. L’ordre compte : QEMU doit être setup avant buildx.

La race condition sur git push

Si deux commits arrivent en parallèle sur actual-state (un humain push un fix pendant qu’une CI tourne), le git push origin actual-state du workflow peut être rejeté avec rejected (non-fast-forward). Le manifest n’est pas mis à jour. ArgoCD reste sur l’ancien digest.

La solution pragmatique pour un homelab : tolérer l’échec propre. Le workflow suivant (déclenché par le prochain push) corrigera la situation. Si vous voulez plus de robustesse, un git pull --rebase origin actual-state avant le push suffit dans la majorité des cas.

Le digest vit, le tag meurt — une vraie histoire

En août 2025, Bitnami a opéré sa migration “Bitnami Secure Images” : les images community (non-VMware) ont été retirées de Docker Hub et les tags anciens supprimés progressivement. Des setups qui référençaient bitnami/kubectl:1.34.3 ont commencé à rencontrer des ImagePullBackOff — le tag n’existait plus.

Ceux qui avaient pinné un digest et stocké l’image dans leur propre registry ou dans un mirror n’ont pas eu de problème. Le digest référençait toujours un blob valide. Les autres ont dû chasser en urgence un tag de remplacement, mettre à jour les manifests, redéployer — tout ça sous stress.

Un digest ne vous protège pas si la registry entière disparaît. Mais il vous protège de la mutation silencieuse, de la dépréciation de tag, et de l’écrasement accidentel. C’est déjà la majorité des incidents observés en pratique.

Variante cross-repo

Tout ce qui précède fonctionne quand le Dockerfile et les manifests vivent dans le même repo. Mais certaines apps ont leur code source ailleurs — c’est le cas de tapaz (le site prsm-studio, dans YellowBeanie/prsm-studio).

Le principe reste identique : après le build, on patch le manifest. La différence : le workflow du repo source clone le repo homelab, patch avec sed, commit, push via un PAT avec scope contents: write limité au repo homelab.

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

Notez l’absence de [skip ci] ici : le commit push sur k3s-homelab, pas sur prsm-studio — il n’y a donc pas de risque de reboucler sur le workflow source. Mais si k3s-homelab a ses propres workflows sur actual-state, vérifiez qu’ils ne rebuild pas l’image tapaz sur ce push (filtre paths: à la rescousse).

Le message de commit tapaz: bump image to ${DIGEST} (prsm-studio@${SRC_SHA}) est particulièrement précieux : il relie le digest de l’image au SHA du commit source qui l’a produit. Traçabilité bout en bout depuis le code source jusqu’au pod.

Ce pattern est détaillé dans l’article GitOps cross-repo — déployer une app dont la source vit ailleurs.

Le coût réel

Un commit supplémentaire par release. C’est tout.

Ce commit est signé par github-actions[bot], suffixé [skip ci], et apparaît clairement dans git log pour qui sait lire. Ce n’est pas du bruit — c’est la trace de chaque déploiement. Un git log --oneline lisible :

c3f7a21  chore(blog): pin image digest [skip ci]
a9d4b88  feat(blog): add article on sealed secrets
f2e1c44  chore(silex): pin image digest [skip ci]
9b3a771  fix(silex): correct nginx cache headers
e8d0f23  chore(blog): pin image digest [skip ci]

Chaque ligne pin image digest correspond à une release effective sur le cluster. C’est un registre de déploiements dans git log, sans outil supplémentaire, sans service de tracking externe. ArgoCD peut voir exactement quelle image il a déployée et à quel moment — parce que c’est littéralement dans le commit.

Le rollback se résume à :

git revert c3f7a21          # annule le pin du dernier digest
git push origin actual-state # ArgoCD redéploie l'image précédente

Pas de commande kubectl à la main. Pas de recherche dans les logs de la registry. Juste du Git standard.

Ce que ça change vraiment

La différence entre :latest et @sha256: n’est pas qu’une question de style — c’est la différence entre un déploiement que vous pouvez auditer et un déploiement que vous espérez reproduire.

Avec un digest pinné dans Git :

  • Vous savez exactement quelle image tournait à chaque instant (lisez l’historique git)
  • Vous pouvez reproduire n’importe quel état passé (checkout du commit + ArgoCD sync)
  • Vous détectez immédiatement toute divergence (ArgoCD compare le digest du manifest avec le digest du pod)
  • Vous avez une supply chain vérifiable sans outil tiers

Le pattern CI décrit ici — build, capture digest, sed, commit, push — est une quarantaine de lignes de YAML. Il n’a pas de dépendance externe, pas de service à maintenir, pas de controller cluster. Il s’ajoute à n’importe quel workflow Docker existant en cinq minutes.

Il n’y a aucune raison valable de déployer un tag flottant sur un cluster GitOps. Aucune.

k3s-clustergitopsgithub-actionssupply-chaink8sci