Skip to content

Release Identity: What a Build Is Called and What Is Allowed to Ship

Every image my pipelines build gets tagged twice, and only one of those tags is permitted to reach a cluster. This is the reasoning behind that split, why the version comes from a different place in every language, and the specific production failure that made me put both images of a two-image application in a single commit.

Two Tags, Different Jobs

Each build produces a tag that looks like this:

1.4.2-a3f9c1b-87

Three segments: application version, seven characters of the git commit, CI build number. It names exactly one commit, built exactly once, by one identifiable run.

Each build also pushes :latest, to all three registries.

That second fact contradicts a lot of advice, so it is worth being precise about what is actually harmful. latest is useful. Somebody wants to pull the thing and look at it without first going to find a tag. A README example needs a command that does not go stale. Those are real needs and a moving tag serves them well.

The problem was never publishing latest. It is deploying it.

A different latest problem

This post is about the tag a build produces. There is a separate problem with the tag a build consumes: a FROM some-base:latest makes two builds of the same commit non-deterministic. I solved that one by pinning the base image by SHA digest, covered in the DebugBox pipeline write-up. Same tag, opposite end of the pipeline. A moving tag in a deployment manifest means the manifest no longer describes what is running, and a rollback becomes archaeology.

So both tags exist and the boundary is enforced at the handoff rather than at the registry: the deployment repository only ever receives the version tag.

Where the Version Comes From

Three applications, three ecosystems, three different sources of truth. I stopped trying to unify this and let each one be native.

Java reads it from the POM, which is already where a Maven project declares its version:

APP_VERSION=$(mvn help:evaluate -Dexpression=project.version -q -DforceStdout)

Node reads it from package.json, for the same reason:

APP_VERSION=$(node -p "require('./server/package.json').version")

Python has no equivalent convention, so the repository carries a VERSION file at the root, and the pipeline treats a missing or empty one as a build failure rather than a default:

if [ ! -f VERSION ]; then
  echo "❌ VERSION file missing"
  exit 1
fi
APP_VERSION=$(cat VERSION | tr -d '[:space:]')
if [ -z "${APP_VERSION}" ]; then
  echo "❌ VERSION file is empty"
  exit 1
fi

That hard failure is deliberate. The tempting alternative is to fall back to 0.0.0 or to the git SHA alone, which produces a build that succeeds and an artifact nobody can identify. A release with no version is not a release, and the right time to find that out is at the start of the pipeline, not when someone is trying to work out what is deployed.

The composition is identical everywhere regardless of source:

SHORT_SHA=$(git rev-parse --short=7 HEAD)
IMAGE_TAG="${APP_VERSION}-${SHORT_SHA}-${BUILD_NUMBER}"

Each segment answers a different question. The version says what release line this belongs to. The SHA says which commit, exactly, and is the only segment that survives a rebase or a force-push with its meaning intact. The build number disambiguates two builds of the same commit, which happens more than you would expect: a re-run after a flaky test, a rebuild after a registry outage, a manual dispatch to verify a fix.

The Handoff

The last stage of every pipeline writes a file into a separate deployment repository and stops.

cat > ${CD_MANIFEST_PATH} << EOF
IMAGE_TAG=${IMAGE_TAG}
UPDATED_AT=$(date -u +%Y-%m-%dT%H:%M:%SZ)
UPDATED_BY=github-actions-run-${{ github.run_number }}
GIT_COMMIT=${{ github.sha }}
GIT_BRANCH=${{ github.ref_name }}
EOF

git add "${CD_MANIFEST_PATH}"
git diff --cached --quiet \
  && echo "Nothing to commit: image tag unchanged" \
  || { git commit -m "ci: update image tag to ${IMAGE_TAG} [skip ci]" && git push origin HEAD; }

Five fields, and each one exists because I wanted to answer a specific question without opening a CI dashboard: what is deployed, when did it change, which run did it, which commit, which branch.

The git diff --cached --quiet check avoids empty commits when a re-run produces an identical tag. The [skip ci] marker prevents the deployment repository's own automation from triggering on a commit that only a deployment tool should react to.

No pipeline in any of these three repositories holds cluster credentials. The CI system's authority ends at "write a file to a git repository". Whether and when that becomes a running workload is the deployment tool's decision, made from the repository state. That boundary is the whole reason for the split, and it means a compromised CI runner can propose a bad deployment but cannot perform one.

A token detail worth copying

The clone in that step is deliberately two operations:

git clone https://github.com/${CD_REPO}.git cd-repo
cd cd-repo
git remote set-url origin https://x-access-token:${GIT_TOKEN}@github.com/${CD_REPO}.git
# ... commit and push ...
git remote set-url origin https://github.com/${CD_REPO}.git

Cloning with the token embedded in the URL puts the credential in the process arguments, where anything that can read the runner's process list can see it. Cloning publicly first and then setting the authenticated remote writes the token into .git/config instead, which is a file with normal permissions rather than a globally readable process attribute. The final line clears it again so the workspace does not carry the credential into any later step.

Where This Stopped Being Theory

The Node application is a three-tier app that ships two images: an Nginx container serving a React build, and an Express API. Two Dockerfiles, one repository.

When I started writing its pipeline I had to decide whether that was one pipeline or two, and the honest answer took some working out.

Two pipeline files are correct when all of these are true:

  1. Client and server live in separate repositories with independent histories
  2. Their release cycles are genuinely independent, so one can ship without the other
  3. Different teams own them, with different approvals
  4. They have no shared versioning contract

That is the microservices model and it is right for services that meet the bar. None of the four applied here. Both directories sit on the same branch, so a single push touches both and two pipelines would each run a full build on every commit. Both Dockerfiles need the repository root as their build context, so neither is self-contained. SonarQube analyses client/src,server as one project behind one quality gate.

But the condition that actually settles it is the fourth one, and it settles it on a failure mode rather than a preference.

Both images carry the same tag, derived once:

CLIENT_IMAGE_TAG=1.0.0-ab3f12c-42
SERVER_IMAGE_TAG=1.0.0-ab3f12c-42

They are the same because they were built from one commit in one run, and both are written to the deployment repository in a single commit.

Split that across two pipelines and each computes its own tag independently. Different build numbers immediately, and different SHA segments the moment one pipeline runs on a slightly different commit. Two pipelines then race to update the same manifest file, and the deployment tool eventually reads a state like this:

CLIENT_IMAGE_TAG=1.0.0-ab3f12c-42
SERVER_IMAGE_TAG=1.0.0-9d4e77a-41

A frontend from build 42 talking to an API from build 41.

What makes this failure worth designing against is not how bad it is but how quiet it is. Nothing crashes. No pod restarts. No alert fires. The frontend calls an endpoint whose response shape changed, and the user sees a field that renders empty or a form that silently fails to submit. You find out from a support ticket days later, and the deployment that caused it is no longer the most recent one.

One repository, one pipeline, one version source, one atomic commit. Each link is load-bearing.

Multi-Registry Publishing

Each image goes to three registries: GitHub Container Registry, Docker Hub, and a self-hosted Nexus. All three receive both the version tag and latest.

This is a distribution choice rather than a design principle, and the reasons are unremarkable: GHCR because the source is on GitHub and permissions follow the repository, Docker Hub because it is where people look first, Nexus because I run it and wanted a registry whose availability does not depend on anyone else.

The one detail that is not obvious is the Nexus setup. Because it sits behind Nginx and a Cloudflare Tunnel rather than exposing a dedicated port per repository, it uses path-based routing:

docker push nexus.example.com/docker-hosted/java-monolith:1.4.2-a3f9c1b-87

which also requires the Docker Bearer Token realm to be active, or docker login returns 401 with correct credentials.

Concurrency, and the Half-Written Release

Pushing the same build to three registries introduces a failure mode I did not anticipate until I wrote the GitHub Actions version of the pipeline.

Jenkins was straightforward: disableConcurrentBuilds() and move on.

GitHub Actions made me be specific:

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}

Cancel in-progress runs on pull requests, never on main.

A pull request build has no write side effects, so cancelling it when a newer commit arrives saves compute and costs nothing. A main build is in the middle of pushing to three registries. Cancel it at the wrong moment and latest exists in one registry pointing at the new build, in another pointing at the previous one, and is missing from the third. Nothing reports an error, because every individual push that completed succeeded.

The immutable version tag survives this fine, since it is only ever written once. It is precisely the moving tag that is fragile here, which is a reasonable argument for treating latest as a convenience that may occasionally be wrong rather than as anything a system should depend on.

Summary

  • Publish latest if it is useful. Never deploy it.
  • Let each ecosystem provide the version the way it already does. Fail loudly when it cannot.
  • Compose the tag so every segment answers a distinct question: which release, which commit, which run.
  • End the pipeline at a git commit, not a cluster. CI proposes; the deployment tool disposes.
  • Anything that ships together must be versioned together, in one commit, or you will eventually deploy a mismatched pair and not notice.

Source


Series: CI Pipeline Engineering Across Three Applications (Part 8 of 8)