The Release Is a Content Digest, Not a Mutable Tag
This is the first part of Ship the Proof, a series on building a software delivery pipeline whose claims survive the trip to production. The whole series rests on one practice, so it goes first: identify a release by what it contains, not by the label you hang on it.
A team I worked with chased a production bug for two days. The image tag running in the cluster matched the tag they had tested. The behavior did not. Somewhere between the test environment and production, myservice:release-2026.05 had been rebuilt, and the rebuild pulled a transitive dependency that had shipped a regression that morning. Same tag. Different bytes. Nobody lied. The tag just moved.
That failure has a one-sentence fix. Stop treating the tag as the release identity, and start treating the content digest as the release identity. Everything else in this series depends on getting that one thing right, because every guarantee you will want to make later is a guarantee about a specific set of bytes.
A tag is a label, not an identity
A container image is identified by its content digest, not by its tag. The OCI image specification is precise about this. The digest “acts as a content identifier, enabling content addressability,” and it “uniquely identifies content by taking a collision-resistant hash of the bytes” (OCI image-spec descriptor).
Two properties follow, and they carry all the weight.
- The same bytes always produce the same digest. A digest names exactly one sequence of bytes.
- Different bytes always produce a different digest. You cannot change the content without changing the name.
A tag has neither property. myimage:latest can point at one set of bytes today and a different set tomorrow. The pointer is convenient for people. v2.4, stable, and release-2026.05 are easier to read than sixty-four hex characters. Convenience is the whole of what a tag offers. It carries no promise that the thing behind the label is the thing you verified. Treat a tag as exactly that: a label you hang on a digest, never the identity itself.
Build once, promote many
If the digest is the identity, the artifact has to be built exactly once. One build produces one digest, and that digest is what moves through every environment. Promotion does not rebuild. It moves the same digest forward and re-checks it.
The alternative, rebuilding per environment to feel safe, breaks the model on contact. A rebuild produces new bytes: different timestamps, freshly resolved transitive dependencies, possibly a build tool that was compromised in the hours since the last build. New bytes mean a new digest. The artifact you verified in staging is no longer the artifact you run in production. That is the two-day bug from the top of this post, with the rebuild made explicit instead of accidental.
Build-once-promote-many is faster, but speed is not the point. It is the precondition for any claim about an artifact to still be true by the time that artifact reaches production.
Every claim you make is a claim about a digest
Here is the part that is easy to miss, and it is the reason this practice comes first.
Everything you will want to say about a release is a statement about a specific digest. When you record how an artifact was built, that provenance is bound to the built artifact. The SLSA provenance predicate attaches to the digest it describes (SLSA provenance). When you record what an artifact contains, that bill of materials describes a digest. When you record that a build passed its tests, the in-toto test-result predicate carries a result that is “One of PASSED, WARNED, or FAILED” (in-toto test-result predicate), and that result is a claim about a digest.
Now run the rebuild against those claims. You signed a statement that says “this digest was built this way, by this workflow, and passed these tests.” Then promotion rebuilt the image. Production is running a different digest. Your signed statement is still real. The signature verifies, the log entry exists, and the statement describes bytes that never reached production. You are holding a genuine certificate about the wrong artifact. The certificate is real and worthless at the same time.
Byte-identical promotion closes that gap. Because the digest in production is the digest you built and signed, the provenance, the bill of materials, and the test results apply to the thing that is actually running. The claims travel with the digest precisely because the digest never changes.
What it costs you to adopt
Three habits hold the model in place, and all three are cheap.
Pin by digest wherever it matters. Sign the digest. Generate the bill of materials against the digest. Reference the digest in the deployment manifest rather than a tag:
spec:
template:
spec:
containers:
- name: example
image: registry.example.com/example@sha256:PRIOR_VERIFIED_DIGEST
A tag in that manifest reintroduces drift. A digest does not.
Promote the digest, do not rebuild it. The promotion step is a copy plus a re-check, not a fresh build. Move the exact digest forward and confirm its evidence arrived with it. (Promotion has a trap of its own, because evidence does not travel the way most people assume. A later part in this series, on preserving evidence through promotion, is about that trap.)
Roll back to a digest too. A rollback target is just a prior verified digest. In a GitOps setup that is a revert of the commit that pinned the bad digest, which restores the manifest to the previous one. The cluster reconciles to the prior digest, and that digest passes the same checks as any other image. A rollback target earns no trust simply because it ran before.
The short version
Tags are for people. Digests are for machines, and machines are what move your code to production. The release is not the branch you cut it from and not the tag you stuck on it. The release is the digest. Build it once, verify it once, and promote that exact set of bytes the whole way out. Every signature, every bill of materials, and every passing test you attach to it stays true only because the bytes never change underneath you.
The next part takes this one step further: once the release is a digest rather than a branch, your branching model gets simpler, and the argument about git-flow versus GitHub Flow mostly answers itself.