🔄 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 pushfinal 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.
platforms: linux/arm64platforms: linux/arm64
pousse :latest + tag du SHA commit sur GHCRpushes :latest + the commit SHA tag to GHCR
4525bb37…
steps.build.outputs.digeststeps.build.outputs.digest
sha256:4525bb37… capturé nativement par build-push-actionsha256:4525bb37… captured natively by build-push-action
:latest → @sha256:…:latest → @sha256:…
regex ancrée sur le préfixe complet, idempotent via git diff --quietregex anchored on the full prefix, idempotent via git diff --quiet
chore: pin image digest [skip ci]chore: pin image digest [skip ci]
sans [skip ci] → boucle infinie ; github-actions[bot] comme auteurwithout [skip ci] → infinite loop ; github-actions[bot] as the author
brise la bouclebreaks the loopgit push origin actual-stategit push origin actual-state
commit visible dans git log — registre de déploiements sans outil tierscommit visible in git log — a deployment registry with no third-party tool
@sha256:…
image@sha256:… immuableimage@sha256:… immutable
ArgoCD détecte le commit et déploie exactement ce blob, pour toujoursArgoCD detects the commit and deploys exactly that blob, forever
sed remplace :latest (mutable) par @sha256:… (immuable) directement dans le manifest —
le [skip ci] sur le commit de pin casse la boucle de re-trigger.
ArgoCD déploie exactement ce blob, pour toujours.
sed replaces :latest (mutable) with @sha256:… (immutable) directly in the manifest —
the [skip ci] on the pin commit breaks the re-trigger loop.
ArgoCD deploys exactly that blob, forever.
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.