Skip to content
Redgold.AI Blog

· 8 min read · GitOps, Kubernetes, CI, deployment

Keeping Concurrent Deploy Renders From Overwriting Each Other

Our CI renders Kubernetes manifests and commits them to the branch ArgoCD watches, more than 50 times a day, from several jobs at once. Here are the ordering rules we added after renders conflicted, downgraded pins and deleted files they never owned.

Our Kubernetes manifests are generated by Rust code in the monorepo’s CI pipeline crate. After a pull request merges, a publish job builds whatever images changed, runs the generators and commits the rendered YAML into a deploy directory on the same integration branch. ArgoCD watches that directory and syncs the cluster to it. Nobody edits the rendered files by hand in the normal path.

In 26 September to 25 October 2026 (UTC), the branch took 3,159 merged pull requests, 1,494 manifest-only render commits and 177 render commits that re-pinned an image. Several publish jobs run at the same time, each rendering from the commit it started on. Any of them can find that the branch has moved by the time it pushes. This post covers the rules that decide what happens then.

Render commits per day on the integration branch053.9107.8Sep 26Sep 30Oct 4Oct 8Oct 12Oct 16Oct 20Oct 24Manifest-only, week of Sep 26: 58commitsManifest-only, week of Sep 27: 18commitsManifest-only, week of Sep 28: 31commitsManifest-only, week of Sep 29: 28commitsManifest-only, week of Sep 30: 11commitsManifest-only, week of Oct 1: 53commitsManifest-only, week of Oct 2: 48commitsManifest-only, week of Oct 3: 40commitsManifest-only, week of Oct 4: 93commitsManifest-only, week of Oct 5: 44commitsManifest-only, week of Oct 6: 58commitsManifest-only, week of Oct 7: 87commitsManifest-only, week of Oct 8: 58commitsManifest-only, week of Oct 9: 72commitsManifest-only, week of Oct 10: 47commitsManifest-only, week of Oct 11: 97commitsManifest-only, week of Oct 12: 71commitsManifest-only, week of Oct 13: 98commitsManifest-only, week of Oct 14: 51commitsManifest-only, week of Oct 15: 82commitsManifest-only, week of Oct 16: 55commitsManifest-only, week of Oct 17: 26commitsManifest-only, week of Oct 18: 47commitsManifest-only, week of Oct 19: 52commitsManifest-only, week of Oct 20: 15commitsManifest-only, week of Oct 21: 27commitsManifest-only, week of Oct 22: 30commitsManifest-only, week of Oct 23: 24commitsManifest-only, week of Oct 24: 28commitsManifest-only, week of Oct 25: 45commitsManifest-onlyImage pins, week of Sep 26: 0commitsImage pins, week of Sep 27: 0commitsImage pins, week of Sep 28: 0commitsImage pins, week of Sep 29: 2commitsImage pins, week of Sep 30: 0commitsImage pins, week of Oct 1: 2commitsImage pins, week of Oct 2: 7commitsImage pins, week of Oct 3: 8commitsImage pins, week of Oct 4: 8commitsImage pins, week of Oct 5: 3commitsImage pins, week of Oct 6: 4commitsImage pins, week of Oct 7: 9commitsImage pins, week of Oct 8: 2commitsImage pins, week of Oct 9: 10commitsImage pins, week of Oct 10: 2commitsImage pins, week of Oct 11: 4commitsImage pins, week of Oct 12: 1commitsImage pins, week of Oct 13: 6commitsImage pins, week of Oct 14: 4commitsImage pins, week of Oct 15: 2commitsImage pins, week of Oct 16: 0commitsImage pins, week of Oct 17: 1commitsImage pins, week of Oct 18: 1commitsImage pins, week of Oct 19: 3commitsImage pins, week of Oct 20: 13commitsImage pins, week of Oct 21: 8commitsImage pins, week of Oct 22: 21commitsImage pins, week of Oct 23: 16commitsImage pins, week of Oct 24: 16commitsImage pins, week of Oct 25: 24commitsImage pinsManifest-onlyImage pins
Render commits per day on the integration branch. Source: render commits by the CI bot on the integration branch, grouped by UTC commit day, 26 September 00:00 to 26 October 00:00 UTC. “Manifest-only” commits carry the no-image-change subject; “image pins” carry the update-images subject. Counted 26 October 2026 at revision 581466dbce. Merged pull requests in the same window: 3,159 squash commits on the first-parent history.
Show the data
Week ofManifest-only (commits)Image pins (commits)
Sep 26580
Sep 27180
Sep 28310
Sep 29282
Sep 30110
Oct 1532
Oct 2487
Oct 3408
Oct 4938
Oct 5443
Oct 6584
Oct 7879
Oct 8582
Oct 97210
Oct 10472
Oct 11974
Oct 12711
Oct 13986
Oct 14514
Oct 15822
Oct 16550
Oct 17261
Oct 18471
Oct 19523
Oct 201513
Oct 21278
Oct 223021
Oct 232416
Oct 242816
Oct 254524

A render commit should not start another pipeline

A render touches only the deploy directory, which builds no crate and produces no image. Without care, each render push would still start a full CI run that detects no changes, plus the workflows chained to its completion. Render commits carry a skip-CI marker, and each render is pushed on its own, so the marker is on the push’s head commit, which is where the forge checks for it. A merge is a separate push whose head commit has no marker, so real changes are never skipped.

Label the commit by what it changed

The first render commits always read “update images to” a tag. When three renders in a row rewrote other manifest fields without rebuilding anything, the log showed three commits pinning the identical tag, and searching the history for a tag returned manifest churn as if it were duplicate deploys.

The publisher now scans the staged diff for added or removed image references. Only a render that changes one gets the image subject; every other render says “update deploy manifests (no image change)”. In the window above, 89% of render commits were manifest-only. Every manifest-only commit also records the source commit it was rendered from in a trailer line. Most of the rules below depend on that trailer.

ci(dev): update deploy manifests (no image change) [skip ci]

Deploy-Source-SHA: <source commit>
Shape of a manifest-only render commit, from the commit message generator in the deploy module. The source commit is the merge the render was built from.

Losing a race can be a success

A render clones the branch shallowly, generates, commits and pushes. If a merge or another render lands in between, the push is rejected. The publisher then rebases the render onto the new tip and retries. On 11 July it also learned to drop a render that the rebase showed was already present, such as one identical to a concurrent job’s. If the rebase conflicted, it opened a fallback pull request.

That broke when a newer job had already rendered different pins. The older render conflicted with it, opened a conflicted fallback pull request and left a branch behind. By 12 July, 36 orphaned render branches had accumulated. Pushing such a render would also have moved every image pin back to an older tag. The fix checks whether the branch already carries a render for a descendant of this job’s source commit. If it does, the job drops its render and reports success, because a newer render already covers it.

Decide staleness by what the render writes

The harder question is when an older render is still safe to push. The answer changed three times.

  • Gate on render inputs only (August). The first staleness check reused the list of paths that trigger post-deploy work. That list includes high-churn image sources such as the agent CLI, whose commits change no rendered byte. A CLI-only commit landing mid-job refused an otherwise valid render. The gate now covers only generator code and the files generators embed, and a test asserts that every generator module and embedded file sits under one of those roots.
  • Compare Kubernetes resources, not files (October 3). A render scoped to one service may share a YAML file with unrelated templates. Dropping a scoped render now requires every Kubernetes resource it changed to be already present on the branch, or last changed by a render from a strictly newer source. A same-source render, or a newer render of different resources, proves nothing. A failed read or ambiguous history never counts as delivery.
  • Push older renders that overwrite nothing newer (October 11). Refusing every render whose source was behind the tip could never keep up with the merge rate. An older render is now pushed unless a render from a newer source, or an edit with no recorded source, has already changed one of the files it writes. Unrecorded edits count as newer, so a stale render cannot revert a manual fix.

The coverage and overwrite checks read first-parent history, so a merge resolution without a source trailer cannot borrow coverage from a trailer on the merged side branch.

A failed generator should not delete other manifests

A full render clears the deploy directory and regenerates it, which also removes manifests for services that no longer exist. On 26 July four generators failed partway through, and the render staged 146 deletions. None were files those generators write: they included documentation and compose manifests and about 120 rendered pipeline definitions.

The fix restores every deleted path from the checked-out branch when any generator fails. Paths that a working generator re-rendered keep their new content, because they are modifications rather than deletions. Cleanup of stale paths still runs on every clean render and is suspended only for a run with a failure.

Prove the generator matches its source

The push rules above trust the source commit the render claims. In October we noticed that nothing stopped a fetched, older build of the generator from running in a newer checkout and stamping the newer commit on its output. The pipeline’s build script now embeds a fingerprint of its inputs: paths, file modes and object hashes for the workspace code and every embedded render input. The real render path checks that fingerprint against the checkout before rendering and again before publishing. Dirty or untracked inputs fail the check, and an older driver that lacks the proof command fails closed.

What we would reuse

Our inference from this history, rather than measured results:

  • Record the source commit in every generated commit. Each ordering decision above needs to know which merge a render came from.
  • Decide staleness by what a render writes, at the granularity of the deployed resource. Whether the branch moved since the job started is the wrong question at a high merge rate.
  • Make “a newer render covers this one” an explicit success outcome. Treating it as a conflict produced fallback pull requests and orphaned branches.
  • Scope deletions to what the failing step owned. A cleanup that is correct on a clean run can delete unrelated files when one generator fails.
  • Bind the generator binary to its source before trusting the provenance it stamps.

Limits of this account: it is reconstructed from code, comments, plans and commit history, plus the render-commit counts in the chart. We have no count of how many push races occurred, how many pins were downgraded before the July fix, or how often each later rule fired.