Auto-Deploy on Push vs a Manual Release: What the Agent’s Commit Should Be Allowed to Ship
Quinn Reed
September 30, 2026
The commit landed at 23:41 on a Thursday. The message was tidy: refactor: normalize entries table, rename hours to duration_minutes. The agent had been working through a list of cleanup tasks I had left it that evening, and this was item four. It pushed to main, because I had given it permission to push to main. The host saw the push and deployed it, because I had turned on auto-deploy for main the week I set the project up. The app ran its migrations on boot, because that is how the starter template was wired.
The migration did not rename the column. It dropped hours and added duration_minutes, then ran a backfill that multiplied a column that no longer existed by sixty. Every timesheet entry for the month now had a duration of null. The app was a small time-tracking tool for a three-person design agency, and the month was four days from invoicing.
I found out at 7:10 the next morning from a message that said, in its entirety, “where did my hours go?”
Nothing about that failure was exotic. Auto-deploy worked. The agent’s code ran. The migration did exactly what it said. The failure was a permissions question I had never actually asked: which of the agent’s commits should be allowed to reach production without a human deciding they should?
Auto-deploy was not the mistake
My first reaction was to switch auto-deploy off entirely and go back to deploying by hand. I did that for about two weeks. It was worse.
Manual releases on a small project decay fast. You batch changes because deploying is a chore. Batches get bigger. Bigger batches are harder to reason about, so you deploy less often. After two weeks I had eleven commits sitting on main that had never seen production, three of which I had forgotten the purpose of, and a deploy I was nervous about for reasons I could not articulate. That is not safety. That is risk with a longer fuse.
The useful insight came from looking at what those eleven commits actually were. Seven were harmless: copy changes, a CSS fix, a new test, a README update, a small UI tweak. Four were the kind of change that could hurt: a migration, a new dependency, a change to how sessions were stored, and an edit to the environment configuration. The seven did not need me. The four did.
Auto-deploy was fine. What was wrong was treating every commit as the same kind of thing.

The line I draw now: what the change can break that a redeploy cannot fix
The rule I landed on is simple enough to fit in a sticky note: if rolling back the code does not undo the damage, a human releases it.
A broken CSS rule is fully undone by deploying the previous commit. So is a bad button label, a crashed route, a slow page. Those changes are safe to auto-deploy, even from an agent, because the worst case is a few minutes of something looking wrong and a revert.
A migration is not undone by redeploying old code. The data is already changed. Neither is a change that sends email, charges a card, rotates a key, deletes files in storage, or alters how sessions are signed so everyone gets logged out. Neither is a new dependency whose install script ran on the build machine. Those changes leave marks on the world outside the repository, and a revert only restores the repository.
So in practice, the slow lane covers:
- Database migrations, anything under
migrations/,prisma/,db/, or whatever your framework uses - Environment and infrastructure files:
.env.example,docker-compose.yml,Dockerfile, the host’s config file, CI workflows - Dependency manifests and lockfiles, because a new package can run code at install time and change behaviour in ways nobody reviewed
- Auth, sessions, and payments, by directory
- Anything that talks to the outside world on deploy: seed scripts, email templates tied to scheduled sends, webhook registrations
Everything else rides the fast lane and auto-deploys.
How I wired it without buying anything
The setup is boring on purpose, and it runs on the tools the project already had: a Git host with branch protection, a CI runner, and a hosting platform with a deploy hook.
1. The agent stopped pushing to main. This is the part I should have done on day one. The agent works on branches. main is protected: no direct pushes, merges only through a pull request. That alone would have saved my Thursday, because the migration would have sat on a branch until morning.
2. A path check in CI labels the pull request. A small job looks at which files changed. If any of them match the slow-lane paths, it adds a label, release:manual, and fails a required status check called needs-human-release until someone with the right role approves. If none match, it passes and the pull request can merge on green tests.
3. Auto-deploy only listens to a clean main. I turned off the host’s own auto-deploy and replaced it with a deploy hook that CI calls after merge, but only if the merged commit did not carry the manual label. Commits that did carry it land on main and wait.
4. Manual releases are a tag. When I am ready to ship a slow-lane change, I take a backup, read the migration one more time, and push a tag like release-2026-09-14. CI sees the tag and calls the deploy hook. That is the entire “manual” part. It takes three minutes. It just takes three minutes from me, at a time I chose, with the database snapshot sitting there.
There is one wrinkle. If a slow-lane commit is waiting on main and a fast-lane commit merges after it, the fast-lane deploy would carry the waiting migration with it. I handle that by having the deploy job refuse to run if any undeployed commit since the last release carries the manual label. It posts a message instead: “blocked by pending manual release.” It happens a couple of times a month and it is always the right call.
What changed in how the agent behaves
I expected the agent to be annoyed by this, in whatever way a coding agent can be annoyed. The opposite happened. Once I added a paragraph to the project’s instructions file explaining the two lanes, it started splitting its own work. A task that involved a schema change and a UI change came back as two branches: one small migration branch with a clear description of what would happen to existing rows, and one UI branch that depended on it. The migration branch explicitly said whether the change was reversible.
That second habit is the one I value most. I now ask, in the instructions, for every migration to state in the pull request description:
- What happens to existing rows
- Whether the migration can be reversed without data loss
- Whether old code can run against the new schema for a few minutes during deploy
The Thursday migration would have failed all three questions, and the agent would have told me so if I had asked. It is not that the agent cannot reason about data. It is that nobody asked it to, and the pipeline did not give anyone the chance.

The objections I hear, and what I think of them
“Just have good tests.” I did have tests. They ran against a fresh database, created by the migrations, and they passed, because on a fresh database there are no existing rows to lose. Tests tell you the new code works with the new schema. They rarely tell you what happened to last month’s data on the way there.
“Use preview environments.” Previews are great for looking at UI. Most preview databases are either empty or seeded, for the same reason as the tests. A preview would have shown me a working app with no hours in it and I would have assumed that was the seed data.
“Only let the agent open pull requests, never merge.” That is roughly where I ended up, but it is not sufficient by itself. If I am the one merging and every merge auto-deploys, I will eventually merge a migration at 11 p.m. on a Thursday too. The lanes are there to protect me from my own tiredness as much as from the agent.
“Manual release is just friction.” For the slow lane, the friction is the point. Three minutes of attention, once or twice a week, is cheap insurance against changes that cannot be undone by the thing auto-deploy is best at, which is shipping the next commit.
Getting the hours back
For completeness: I did recover. The host took daily database snapshots, and the most recent one was from 03:00, about three and a half hours after the bad deploy. Nobody had logged time between midnight and three. I restored the snapshot to a separate database, copied the hours column across by entry ID, wrote the conversion by hand, and checked twenty entries against the agency’s own spreadsheet before telling them it was fixed. It took until lunch.
If I had been unlucky about the snapshot timing, or if someone had been logging time overnight, it would have been a much worse week. That is the part that stuck with me. Auto-deploy did not fail. Backups did not fail. The only reason it was an annoying morning instead of a lost month was luck about when a scheduled job ran.
The short version
Let the agent’s commits auto-deploy when a revert fully undoes them. Hold them for a human release when they change data, secrets, dependencies, sessions, or anything outside the repo. Keep the agent off the deploy branch entirely. Make the manual release small and quick enough that you actually do it, and make the pipeline refuse to sneak a waiting slow-lane change out alongside a fast one.
The question was never “should the agent be allowed to ship?” It ships dozens of small things a week for me now, most of them while I am doing something else. The question is which of its commits can touch something a git revert cannot reach. Those are still mine.