In the article “How CI/CD and Flux Split Responsibilities in My DocOps Project”, I described the path from a Markdown change to a running version of the site. The pipeline builds and publishes a container image, updates its version in a separate GitOps repository, and Flux brings Kubernetes to the state recorded there.

Reviewing that chain revealed a separate problem. The GitOps repository had its own CI pipeline, but the documentation pipeline pushed changes directly to the main branch watched by Flux.

The checks existed, but they did not stand before deployment.

This is not an incident report. I found the risk by examining the order of events. There was no need to wait for an invalid commit or a Kubernetes failure to expose it.

One commit started two independent processes

After a new documentation release, the pipeline changed the image tag in the GitOps repository and pushed the commit directly to the main branch.

A single push started two processes:

commit in the main branch of the GitOps repository
├── starts the GitOps CI pipeline
└── becomes visible to Flux

GitLab began running its checks. Independently, Flux retrieved the new Git revision and started reconciliation.

In the Flux model, a GitRepository resource retrieves the configured branch and produces an artifact from its current revision. A Kustomization reacts to that artifact, renders the manifests, and applies them to Kubernetes.

The GitLab pipeline status is not part of this sequence. There was no causal dependency between CI and Flux: neither waited for the result of the other.

A gate is a dependency, not a delay

At first, the risk could appear small. CI usually runs quickly, while Flux retrieves changes on a configured interval. The pipeline might finish first during almost every routine deployment.

A deployment gate cannot depend on the word “usually.”

Increasing the Flux interval does not solve the problem either. A pipeline may occasionally take longer, reconciliation can be triggered manually, and a webhook can deliver a change to Flux almost immediately.

Without an explicit dependency between validation and commit publication, the order remains accidental.

A real admission boundary must enforce this sequence:

configuration change
→ required validation
→ successful result
→ publication to the watched branch
→ Flux reconciliation

The previous sequence was different:

publication to the watched branch
→ validation and reconciliation run in parallel

CI was validating state that had already been published to Flux.

A failed pipeline did not roll anything back

If an automated commit contained an error, the GitOps CI pipeline could detect it and fail.

A failed status did not remove the commit from the main branch. It did not restore the previous image tag, cancel reconciliation that had already started, or tell Flux that the revision must not be applied.

By that time, Flux might already have retrieved the commit and started processing the manifests. If Flux itself or the Kubernetes API rejected the change, that was another protection layer. GitOps CI still had not blocked anything.

The checks remained useful. They could detect an error and notify an engineer. In this design, however, CI acted as a detector after publication rather than as a pre-deployment admission step.

How I moved validation before Flux

The documentation pipeline no longer pushes changes directly to the main branch of the GitOps repository.

After releasing a new version, it runs a separate script that:

  1. Creates a temporary branch in the GitOps repository
  2. Updates the documentation image tag
  3. Pushes the temporary branch to GitLab
  4. Opens a Merge Request into the main branch
  5. Enables automatic merge after a successful pipeline

The branch name contains CI_PIPELINE_ID:

automation/docops/update-image-${CI_PIPELINE_ID}

Different DocOps pipelines therefore do not share one source branch. Retrying the same pipeline keeps the same branch name and reuses the existing branch and Merge Request.

The current sequence is:

DocOps releases a new version
→ creates a temporary branch in the GitOps repository
→ updates the image tag
→ opens a Merge Request
→ starts the GitOps MR pipeline
→ enables auto-merge
→ GitLab waits for a successful pipeline
→ the Merge Request enters the main branch
→ Flux retrieves the new commit

The change now becomes visible to Flux only after the merge.

Auto-merge connects CI to state publication

DocOps creates the Merge Request through the GitLab API and enables merge_when_pipeline_succeeds.

The DocOps pipeline does not wait for the merge itself. Its responsibility ends after the temporary branch has been pushed, the Merge Request has been created, and auto-merge has been enabled successfully.

GitLab controls the remaining order:

GitOps CI succeeds
→ GitLab performs the merge
→ the commit appears in the main branch
→ Flux retrieves the new revision

In an observed run, auto-merge was enabled while the checks were still running. The DocOps pipeline had already finished, GitOps CI continued, and the Merge Request was merged automatically after the pipeline succeeded.

This created the causal dependency that direct pushes did not provide:

successful GitOps CI
→ merge
→ publication of state for Flux

A failure keeps the change outside the main branch

If GitOps CI fails, automatic merge does not happen. The change remains in the temporary branch and the open Merge Request.

Flux continues reading the previous state from the main branch.

A conflict with the main branch also prevents automatic merge. The script does not attempt to resolve the conflict or rewrite Git history. An engineer must intervene before the change can proceed.

This is an important property of the process. Automation failures should not be hidden by automatic Git history repair.

The Merge Request is not for mandatory manual approval

A Merge Request in this chain does not mean that a person must approve every image update manually.

The change is routine and reproducible: the pipeline updates the version of an image that has already been built. It can therefore be merged automatically after the required checks succeed.

The Merge Request acts as a technical boundary:

temporary branch
→ validation
→ main branch

The important change is not the introduction of another GitLab object. It is the moment when desired state is published.

Before:

push to the main branch
→ CI and Flux

Now:

branch
→ CI
→ merge to the main branch
→ Flux

The main branch begins storing state that has passed the standard validation process.

Flux should not be taught to wait for GitLab

Another option is to make Flux query the GitLab pipeline status before reconciliation.

That would couple the delivery mechanism to a specific CI system and introduce another failure point. The design would need to define which component retrieves the status, where credentials are stored, which pipeline is mandatory, and what happens when GitLab is unavailable.

It would also introduce another race: the status of one revision might be retrieved after a newer revision has already appeared.

Changing the publication boundary is simpler. The watched branch should be a source of state that has already passed the intended process.

Flux can then continue doing its normal job: reading Git and bringing Kubernetes to the recorded state.

A team transition takes time

The new design removes direct pushes from the standard DocOps automation. It does not yet prohibit every possible bypass at the GitLab project level.

Users with privileged roles can still change the main branch directly or perform a manual merge. This limitation is known.

A team cannot move from direct pushes to mandatory Merge Requests in a single technical change. The process must be agreed upon, exceptions must be identified, permissions must be updated, and emergency or operational workflows must not be blocked unexpectedly.

The transition is therefore gradual:

automation starts using Merge Requests
→ the process is tested with real releases
→ the team agrees on working rules
→ the remaining bypasses are closed

The deployment gate already operates in the standard DocOps delivery path. Extending the same boundary to manual team workflows is the next stage.

What this gate actually proves

This article is about event order: when a change is validated and when it becomes visible to Flux.

It does not evaluate the completeness of the specific checks inside GitOps CI. Their scope and quality are a separate subject.

Even a perfect check is not a gate when it starts after a direct push to the watched branch. Conversely, the correct event order does not prove that the checks cover every possible error.

These are different tasks:

placement of validation in the process
completeness of validation

This change addressed the first one.

A gate does not confirm deployment success

A correctly placed admission boundary solves only one problem: it prevents a change from the standard delivery process from entering the watched branch before GitOps CI succeeds.

It does not prove that Kubernetes pulled the image, completed the Deployment rollout, and started a working application.

Confirming the deployment result requires separate observation of Flux, Kubernetes, and the application itself. That is the next boundary in the process, not the responsibility of GitOps CI.

Checking your own GitOps design

Four questions are enough to examine the process:

  • Which branch, tag, or other Git reference does Flux watch
  • Who can change that reference, and by which path
  • Which checks must finish before it changes
  • Can the standard automation bypass those checks

It is also useful to identify manual bypasses and determine whether they are temporary parts of a transition or deliberate operational exceptions.

If a successful CI result does not determine whether a change enters the watched reference, that CI is not a deployment gate.

Conclusion

In my DocOps project, a separate CI pipeline in the GitOps repository created the appearance of pre-deployment validation. The event order showed something else: the commit was published to the main branch first, and CI and Flux only then started working with it independently.

I replaced the automation’s direct push with a temporary branch, a Merge Request, and auto-merge after a successful GitOps pipeline.

The standard release path now looks like this:

change
→ GitOps CI
→ merge
→ Flux

A failed pipeline or a conflict keeps the change outside the main branch. The broader team transition to mandatory Merge Requests continues separately and requires gradual changes to working rules and access permissions.

A deployment gate is not defined by the presence of CI or by pipeline speed. It is defined by event order: validation first, publication of state for Flux second.