We named the branch. The commit landed on main.
In a GitOps repository, main is production. One night in October, manifests nobody had approved landed there, with an image tag that did not exist, and nobody had typed "main". Two Git settings, each reasonable, and a command that is right everywhere else.
In a GitOps repository there is no “deploy” button. Argo CD reads the main branch of k3s-stack, our (private) infrastructure repository, and makes the cluster converge on what it contains. Pushing to main is shipping to production. That is the whole point of the model, and it is why everything goes through a branch, gets reviewed, then merged.
On 19 October at 01:00, a commit landed directly on main: the manifests of the small service that shows readers the key exchange their browser just negotiated. Nobody had approved them. And their image carried a placeholder tag, written while waiting for CI to build the real one:
images:
- name: ghcr.io/mesa-black/handshake
newTag: TO-BE-SET-AFTER-THE-FIRST-BUILD
Argo CD did exactly what it is told: it deployed. The pod sat in ImagePullBackOff for nine minutes, until CI wrote the real tag at 23:09 UTC. No harm this time: a new service nobody was calling yet. But nobody had typed git push origin main.
The command that was typed
The branch was created in a worktree, from the remote branch, then pushed by naming it explicitly. In substance:
git worktree add -b feat/handshake ../k3s-handshake origin/HEAD
# … commit …
git push -u origin feat/handshake
It was typed by the coding agent we work with, and it is the right command in nearly every configuration. It was not right in ours, because two settings meet.
The first is a Git default. A branch created from a remote branch (origin/main, or origin/HEAD, which points to it) tracks it automatically: that is branch.autoSetupMerge, on by default. Our feat/handshake therefore had main as its upstream, without anyone asking for it.
The second is a choice, in our ~/.gitconfig:
[push]
default = upstream
With upstream, git push sends the current branch to its upstream. It is a legitimate setting, designed for workflows where a local branch has a different name from the branch it feeds. With a branch that tracks main, it pushes to main.
So far, nothing surprising: that is what the documentation says for git push with no arguments. The surprise is elsewhere.
Even when you name the branch
We expected naming the branch, git push -u origin feat/handshake, to override the setting. It does not. We replayed it in a throwaway repository, with Git 2.54, each time creating a branch from origin/main:
push.default |
git push |
git push -u origin my-branch |
|---|---|---|
simple, Git’s default |
refuses, and explains why | creates my-branch |
current |
creates the branch | creates my-branch |
upstream |
pushes to main | pushes to main |
With upstream, a destination missing from the command is replaced by the upstream, even when you wrote the name of the source branch. The name you type says what is sent, not where it lands. Only a destination spelled out in full changes the outcome:
git push -u origin HEAD:refs/heads/feat/handshake # does create the branch
The script, to check it without us:
#!/bin/sh
# A branch created from origin/main, pushed by naming it.
set -e
d=$(mktemp -d) && cd "$d"
git init -q --bare -b main remote.git
git clone -q remote.git clone 2>/dev/null && cd clone
git commit -q --allow-empty -m init && git push -q origin main
git switch -q -c my-branch origin/main
git commit -q --allow-empty -m "my-branch"
git -c push.default=upstream push -q -u origin my-branch
git --git-dir=../remote.git for-each-ref --format='%(refname:short)' refs/heads
# prints "main" alone: the branch does not exist, the commit is on main
Why it is worse in a GitOps repository
In an application repository, this push would have put an unreviewed commit on main. Annoying, fixable, and CI would probably have complained before anything reached production.
In a GitOps repository there is no step in between. The commit is the deployment. No review, no merge, and in our case not even an image: the controller applied a manifest referencing a tag that did not exist yet. The mistake does not show in Git, where everything looks normal. It shows in the cluster.
And it does not show in the terminal either. The push succeeds, with no warning. The output does say -> main, on a line nobody reads after a push that worked.
What we changed
Three rules, now written into the agent’s instructions as well as ours:
- Create branches from local
main, not fromorigin/main. A branch created from a local branch tracks nothing:git worktree add -b feat/x ../x main, or--no-trackif you start from the remote anyway. - Push with an explicit destination:
git push -u origin HEAD:refs/heads/feat/x. It is longer, and it is the only form that gives the same result whateverpush.defaultsays. - Check
origin/mainafter every branch push:git log origin/main -1. If the last commit is ours, it is already in production.
We did not change push.default. The setting is useful elsewhere, and changing it would only have fixed our own machine. The rule also protects the agent, which shares the same configuration.
What is still missing
The real barrier is not on the workstation. It is on the server: a GitHub rule that refuses direct pushes to main and requires a pull request, with an exception for the CI deploy key that writes image tags.
Today, k3s-stack’s main is not protected: no rule on the branch at all. Everything above therefore relies on the discipline of whoever pushes, human or agent. It is the next thing to fix, and we write it down here so it does not stay an intention.
What it came to
- One push to
main, at 01:00, that no command had asked for in those words. - Nine minutes of a pod in
ImagePullBackOffin production, on a service nobody was calling yet. - Two settings involved:
branch.autoSetupMerge, by default, andpush.default = upstream, by choice. - One discovery: with
upstream, naming the branch does not change the destination. - Three working rules, and a branch protection still to set up.
Good practices
- In a repository where
mainis production, protectmainon the server before anything else. A rule written in a guide protects those who know it; a server rule protects everyone. - Spell out the destination of a push in full (
HEAD:refs/heads/…) in scripts and in an agent’s instructions: it is the only form independent of local configuration. - Create working branches from the local branch, so they track nothing by accident.
- After a push, read the line that says where it landed, or check the remote branch.
Watch out for
- A right command is only right in one configuration.
git push -u origin my-branchis correct nearly everywhere. An agent that learnt it elsewhere will type it on your machine, with your~/.gitconfig. - An inherited setting shows up in no review.
push.default = upstreamlives in the global configuration, not in the repository. Nothing flags it until it has had its effect. - A successful push does not say where it went. Git gives no warning when the destination differs from the name typed. The only trace is the
-> mainline. - Whoever answers is whoever owns the configuration. The agent typed the command, but it was our machine that sent it to
main. We wrote it a few days ago: an agent can do anything, it cannot answer for what it did.