❯ 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
🇫🇷 Lire cet article en français

🔄 Update — June 2026. CI no longer runs on GitHub Actions but on Gitea Actions (self-hosted runner on the NAS). The digest-pin pattern below is unchanged in substance; only the final git push now targets Gitea. See Self-hosted CI with Gitea Actions.

The lie of :latest

:latest does not mean what you think it means. It is not “the newest stable version”. It is not “the image I built yesterday”. It is a mutable pointer that anyone can move at any time.

Imagine your registry hosts ghcr.io/yourname/app:latest. You push a new image this morning — :latest moves. Your colleague reboots a node this afternoon — Kubernetes re-pulls :latest, which is now a different image from the one running this morning. No signal, no trace in git log, no ArgoCD event. The cluster has silently diverged, and nobody signed anything.

It is even worse with imagePullPolicy: Always. Without that option, Kubernetes only pulls the image if the node does not have it in its local cache — so at least your pod restarts with whatever it had at the last pull. With imagePullPolicy: Always, every pod restart re-queries the registry. If :latest has moved between two restarts, your pods run different images depending on when they last booted. A permanently staggered cluster.

The promise of GitOps is that Git is the single source of truth. With a floating tag in the manifest, that promise is broken. What is in Git does not reflect what is actually running.

Why an immutable digest

A SHA256 digest is not a human-readable name — it is the cryptographic hash of the image’s content. It is computed by the registry at push time and it is immutable by construction: if the content changes by one byte, the digest changes. It is impossible for a malicious actor, or for a simple automatic update, to substitute a different image behind the same digest.

# Floating tag: what you wrote yesterday is not what is running today
image: ghcr.io/yellowbeanie/homelab-blog:latest

# Immutable digest: what is in Git is exactly what is running
image: ghcr.io/yellowbeanie/homelab-blog@sha256:4525bb37c02127dedd76f36e2deedfefb0fdcc9cda5560fec39f1846a7ca58a8

The bottom line designates a unique blob in the universe. If your registry burns down and you rebuild it, if Bitnami deprecates its images, if someone overwrites the :latest tag — that digest, as long as it exists somewhere, points to exactly that image. It is the only reference you can commit to Git with the certainty that the commit will still mean something in six months.

And that is precisely why we talk about supply chain: knowing, verifiably, which image is running and where it comes from. A digest lets you answer “what was running on the cluster on 3 January?” by simply reading git log.

The complete pattern

The mechanism is simple: after each build, GitHub Actions captures the resulting digest and rewrites it directly into the Kubernetes manifest, then commits that change. ArgoCD detects the commit and deploys. Here is the blog’s full workflow:

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   # required for the final git push
      packages: write   # required to push to 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

Let’s unpack the key step, “Pin new digest in deployment manifest”:

steps.build.outputs.digest is the native output of docker/build-push-action@v6. It exposes the SHA256 digest of the image that was just pushed — format sha256:abc123…. No need to compute it by hand or inspect the registry after the fact.

The sed is deliberately anchored on the full prefix image: ghcr.io/yellowbeanie/homelab-blog@sha256: followed by hexadecimal characters. It only replaces the exact line preceded by that prefix, with no risk of touching other YAML fields if the manifest evolves.

git diff --quiet makes the step idempotent: if the build produces exactly the same digest (cache hit, identical content), no commit is created. CI does not make noise for nothing.

github-actions[bot] as the author clearly marks in the history that this commit comes from CI, not from a human. Handy for git blame and for future searches.

The result in the manifest:

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

And in git log:

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

Every release is signed by a commit. The history is human-readable, script-queryable, revertable in a single command.

The pitfalls that hurt

The infinite loop without [skip ci]

This is pitfall number one. The workflow pushes a “pin image digest” commit to actual-state. GitHub Actions detects that push on actual-state. GitHub Actions re-runs the workflow. The workflow rebuilds, pushes a new commit. GitHub Actions detects it again. The loop runs until the month’s GitHub Actions minutes are exhausted — or until you cut it off by hand.

The solution is the [skip ci] suffix in the commit message. GitHub Actions recognises this convention and does not trigger a new run on that commit. It is non-negotiable. Without it, you have an infinite loop.

git commit -m "chore(blog): pin image digest [skip ci]"
#                                              ^^^^^^^^^
#                            without this, you rebuild your image in a loop

Missing permissions

The native GITHUB_TOKEN has had restrictive permissions by default since 2023. If you forget to declare permissions: contents: write at the job level, the git push fails — and GitHub Actions sometimes reports a cryptic error (“remote: Permission to repo denied”) rather than “you don’t have write access”. Same thing for packages: write, without which the GHCR push fails silently.

jobs:
  build-push:
    permissions:
      contents: write  # for the git push of the digest commit
      packages: write  # for the docker push to GHCR

Multi-arch and QEMU — a Pi cluster does not forgive

A 100% Raspberry Pi (ARM64) cluster cannot run an image built only for linux/amd64. The pod starts, Kubernetes attempts the pull, and you get:

exec format error

The digest is valid. The image really does exist in the registry. But the architecture does not match. The solution: docker/setup-qemu-action@v3 for emulation, then platforms: linux/arm64 (or linux/amd64,linux/arm64 if you want to support both).

Without QEMU configured, multi-arch buildx fails at cross-compilation. Order matters: QEMU has to be set up before buildx.

The race condition on git push

If two commits land in parallel on actual-state (a human pushes a fix while CI is running), the workflow’s git push origin actual-state can be rejected with rejected (non-fast-forward). The manifest is not updated. ArgoCD stays on the old digest.

The pragmatic solution for a homelab: tolerate the clean failure. The next workflow (triggered by the next push) will fix the situation. If you want more robustness, a git pull --rebase origin actual-state before the push is enough in the vast majority of cases.

The digest lives, the tag dies — a true story

In August 2025, Bitnami carried out its “Bitnami Secure Images” migration: community (non-VMware) images were withdrawn from Docker Hub and old tags progressively deleted. Setups referencing bitnami/kubectl:1.34.3 started hitting ImagePullBackOff — the tag no longer existed.

Those who had pinned a digest and stored the image in their own registry or in a mirror had no problem. The digest still referenced a valid blob. The others had to urgently hunt down a replacement tag, update the manifests, redeploy — all of it under stress.

A digest does not protect you if the entire registry disappears. But it protects you from silent mutation, from tag deprecation, and from accidental overwrites. That is already the majority of incidents observed in practice.

The cross-repo variant

Everything above works when the Dockerfile and the manifests live in the same repo. But some apps have their source code elsewhere — that is the case for tapaz (the prsm-studio site, in YellowBeanie/prsm-studio).

The principle stays identical: after the build, we patch the manifest. The difference: the source repo’s workflow clones the homelab repo, patches with sed, commits, pushes via a PAT with contents: write scope limited to the homelab repo.

- 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

Note the absence of [skip ci] here: the commit is pushed to k3s-homelab, not to prsm-studio — so there is no risk of looping back on the source workflow. But if k3s-homelab has its own workflows on actual-state, check that they do not rebuild the tapaz image on that push (paths: filter to the rescue).

The commit message tapaz: bump image to ${DIGEST} (prsm-studio@${SRC_SHA}) is particularly valuable: it links the image digest to the SHA of the source commit that produced it. End-to-end traceability from source code down to the pod.

This pattern is covered in detail in the article Cross-repo GitOps — deploying an app whose source lives elsewhere.

The real cost

One extra commit per release. That’s it.

That commit is signed by github-actions[bot], suffixed with [skip ci], and appears clearly in git log for anyone who knows how to read it. It is not noise — it is the trace of every deployment. A readable git log --oneline:

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]

Every pin image digest line corresponds to an actual release on the cluster. It is a deployment ledger inside git log, with no extra tooling, no external tracking service. ArgoCD can see exactly which image it deployed and when — because it is literally in the commit.

A rollback comes down to:

git revert c3f7a21          # undo the last digest pin
git push origin actual-state # ArgoCD redeploys the previous image

No kubectl command by hand. No digging through the registry’s logs. Just standard Git.

What it really changes

The difference between :latest and @sha256: is not a matter of style — it is the difference between a deployment you can audit and a deployment you hope to reproduce.

With a digest pinned in Git:

  • You know exactly which image was running at any moment (read the git history)
  • You can reproduce any past state (checkout the commit + ArgoCD sync)
  • You immediately detect any divergence (ArgoCD compares the manifest’s digest with the pod’s digest)
  • You have a verifiable supply chain with no third-party tooling

The CI pattern described here — build, capture digest, sed, commit, push — is about forty lines of YAML. It has no external dependency, no service to maintain, no cluster controller. It slots into any existing Docker workflow in five minutes.

There is no valid reason to deploy a floating tag on a GitOps cluster. None.

k3s-clustergitopsgithub-actionssupply-chaink8sci