Two pipelines raced and the older commit was deployed last, silently reverting a fix
We merged a hotfix minutes after a feature, and production ended up running the feature without the hotfix. Two pipeline runs deployed in the wrong order because the older one had a slower test stage. How we found it, and the fixes: concurrency groups, deploying immutable digests and refusing to deploy older commits.
At 14:20 we merged a hotfix for a checkout bug that was rejecting some valid postcodes. At 14:31 the pipeline reported "Deployed to production". At 14:45 support said customers were still hitting the bug. The hotfix's code was not running in production.
The production deployment's image tag:
kubectl -n shop get deploy checkout -o jsonpath='{.spec.template.spec.containers[0].image}'
registry.example.com/checkout:main
A mutable tag, main. The pod's actual image digest and the commit label inside the image:
kubectl -n shop get pod -l app=checkout -o jsonpath='{.items[0].status.containerStatuses[0].imageID}'
docker buildx imagetools inspect registry.example.com/checkout@sha256:4be1... --format '{{json .Image.Config.Labels}}'
{"org.opencontainers.image.revision":"a91f3c2", ...}
a91f3c2 was the commit before the hotfix: a feature merged at 14:12. The hotfix was d07e8b4.
The pipeline history
run commit trigger build test deploy finished
#812 a91f3c2 merge 14:12 14:13 14:15 14:33 14:34
#813 d07e8b4 merge 14:20 14:21 14:23 14:30 14:31
Run 813 (hotfix) finished first. Run 812 (feature) finished three minutes later and deployed its image over the hotfix. Run 812 was slower because its changes touched more modules and ran a larger set of integration tests.
Both runs pushed to the same main tag and ran kubectl rollout restart, which pulls whatever main points
to at that moment. Run 812's image push happened after run 813's, so main ended up pointing at the older
commit's image.
Pipelines run concurrently by default
CI systems start a run for every push or merge, and those runs proceed in parallel. Nothing guarantees they finish in the order they started. If each run ends by deploying, the deploy order is decided by which run's tests happen to be faster, not by which commit is newer.
That is fine for build and test. It is not fine for deploy, where the last writer wins.
Mutable tags hide what is deployed
checkout:main means "whatever was pushed to main last". Two consequences:
- The deploy step does not actually decide which code runs; the registry's tag state at pull time does.
- Looking at the Deployment does not tell you which commit is running. We needed the image digest and its labels to find out.
A pod restarted later, for example after a node failure, would pull main again and could get yet another
image, without any deploy.
Environment
| Item | Value |
|---|---|
| CI | GitHub Actions, one workflow for build, test and deploy on push to main |
| Registry | OCI registry, image tagged main and short SHA |
| Deploy | kubectl rollout restart with imagePullPolicy: Always |
| Impact | hotfix reverted for 21 minutes until noticed |
1. Serialise deploys with a concurrency group
GitHub Actions supports concurrency at the workflow or job level. Only one job in a group runs at a time,
and with cancel-in-progress, a newer run cancels an older pending or running one:
jobs:
build-test:
runs-on: ubuntu-latest
steps:
# build, test, push image tagged with the commit SHA
deploy:
needs: build-test
runs-on: ubuntu-latest
environment: production
concurrency:
group: deploy-production-checkout
cancel-in-progress: false
steps:
# deploy by digest (below)
We put the concurrency group on the deploy job only, so builds and tests still run in parallel. We left
cancel-in-progress: false for production: cancelling a deploy halfway can leave a rollout in an odd state.
GitHub Actions keeps at most one pending job per concurrency group, and a newer pending job replaces an
older pending one. So if three runs reach deploy at once, one deploys, the oldest waiting one is dropped, and
the newest deploys next. That is the behaviour we want.
2. Deploy an immutable digest, not a tag
The build job outputs the digest it pushed, and deploy uses exactly that:
build-test:
outputs:
digest: ${{ steps.push.outputs.digest }}
steps:
- id: push
uses: docker/build-push-action@v6
with:
push: true
tags: registry.example.com/checkout:${{ github.sha }}
labels: org.opencontainers.image.revision=${{ github.sha }}
deploy:
steps:
- run: |
kubectl -n shop set image deploy/checkout \
checkout=registry.example.com/checkout@${{ needs.build-test.outputs.digest }}
kubectl -n shop rollout status deploy/checkout --timeout=5m
Now the Deployment spec states exactly which image runs, and changing it is a real change to the spec, which
Kubernetes rolls out properly. A pod restarted later gets the same image. The main tag no longer exists.
3. Refuse to deploy an older commit
Serialisation alone still allows the wrong order in one case: if the newer run reaches the deploy job first and the older one reaches it afterwards (because the older one's tests were slower), the older one waits and then deploys. Concurrency prevents overlap, not reordering. We added a guard at the start of the deploy job:
current=$(kubectl -n shop get deploy checkout \
-o jsonpath='{.metadata.annotations.deploy\.example\.com/revision}')
if [ -n "$current" ] && git merge-base --is-ancestor "$GITHUB_SHA" "$current"; then
echo "Commit $GITHUB_SHA is an ancestor of deployed $current; skipping older deploy."
exit 0
fi
git merge-base --is-ancestor A B succeeds if A is an ancestor of B, that is, if B already contains A. If the
commit we are about to deploy is already included in what is running, deploying it would move backwards, so the
job exits. After a successful rollout, the job writes the deployed SHA to the annotation:
kubectl -n shop annotate deploy/checkout --overwrite deploy.example.com/revision="$GITHUB_SHA"
The job needs the full git history for the ancestry check (fetch-depth: 0 on checkout).
4. Intentional rollbacks still work
Rollbacks are now a separate, manually triggered workflow that takes a specific digest and sets
--force-older explicitly, so going back is a deliberate decision, never an accident of timing.
1. Reproduce the race. On a test repository we merged two commits a minute apart, with an artificial
sleep 180 in the first commit's tests:
| Setup | Final deployed commit |
|---|---|
| Old pipeline | first (older) commit |
| Concurrency group only | first (older) commit, deployed after the newer one |
| Concurrency group + ancestry check | second (newer) commit; older run logged "skipping older deploy" |
The middle row confirmed that serialisation alone was not enough.
2. What is running is always visible. A dashboard panel shows, per service, the deployed revision from the
annotation and the digest from the pods. A check alerts if they ever disagree, or if the deployed revision is
not the head of main for more than 30 minutes during working hours.
3. Hotfix replay. We re-ran the original sequence of merges on staging with the new workflow. The hotfix stayed deployed, and the feature, being an ancestor of the hotfix commit, was not redeployed after it.
4. Security implications. Deploying by digest also closes a supply chain gap: with a mutable tag, anyone who could push to the registry could change what runs in production on the next pod restart, with no deploy recorded. With digests, only the deploy job changes what runs, and the registry is now configured to make release tags immutable.
Takeaways
- Parallel pipeline runs can finish in any order. Never let "last to finish" decide what is deployed.
- Serialise deploy jobs with a concurrency group, but keep build and test parallel.
- Deploy immutable digests, never mutable tags like
mainorlatest. - Refuse to deploy a commit that is already contained in what is running, and make rollbacks explicit.
References: GitHub Actions concurrency, git merge-base, Kubernetes images and image pull policy, OCI image annotations.
Add a label
Embed this post
<iframe src="https://hibug.me/pipeline-deployed-older-commit-race" width="700" height="420" frameborder="0" loading="lazy"></iframe>
I work as a platform engineer supporting product teams who want to ship without becoming infrastructure experts. That means a lot of Dockerfiles, Kubernetes manifests, CI pipelines and the Linux and network details underneath them. The problems I write about are the ones new and experienced engineers both hit: a non-root pod that cannot write its volume, a pipeline that deployed an older commit, a merge that dropped a teammate's fix, files nginx cannot read, large responses that hang over a VPN, a DNS record that broke mail. I explain each one from the error message down to the mechanism, then show the fix and a quick way to check it. Where a topic is a fundamental, like Kubernetes objects, I teach it through one broken deploy rather than a list of definitions.
Comments
0No comments yet. Be the first to comment!
Sign In to post a comment.