Stupidly simple branching: trunk-based, but e2e-testable

Every branch is e2e-testable before merge. No environment branches.

Trunk-based development is stupidly simple, and the simplicity is the point. One long-lived branch, short-lived feature branches, fast CI, merge small and merge often. Everyone who has lived through a ten-branch GitFlow repo knows the feeling of coming back to a model where the answer to almost every process question is “merge to main”.

There is one place the simplicity goes quiet: where does a pull request run before it merges? The doctrine handles pre-merge testing with fast unit and integration suites, and it handles environment testing with feature flags and staging after merge. The real environment, the shared one with the real database and the real downstream services, is something the model gives a branch no managed way to touch before it lands. Teams route around this daily with ad hoc deploys to shared dev environments, but routed around is the operative phrase: no ownership, no arbitration, no record.

That leaves a gap, and this article is about filling it without giving up the simplicity. This article calls the mechanism zerv-flow: a label lets a pull request borrow an environment temporarily, and a lock table makes contention over a borrowed environment fail loudly instead of silently. example-zerv-flow is a working repository that runs it on trunk-based: main is the only long-lived branch, and every environment is built from it. The result is the headline: a branch carries a real end-to-end deployment before it merges, and the whole of zerv-flow costs a PR label.

Main is every environment

At rest, the repository has exactly one long-lived branch, and every environment is fed from it. There is no develop branch standing in for the development environment, no staging branch standing in for nonproduction, no per-environment branch at all. Whatever an environment runs, it runs a build of a commit on main.

The familiar trunk-based shop runs its environments at different points of that history. Development tracks HEAD, staging sits parked on a release candidate, production on the last release, and promotion is the act of moving each park point forward. Main is not a menu of candidates to promote from; it is a stack that any later build takes whole. The decision is always how far forward to park, never which commits to take, and the occasional regret has only three outs: a feature flag, a revert, or a cherry-pick.

The example-zerv-flow repository takes the simpler setting: main is production. A merge to main deploys the same fresh commit to development, nonproduction, and production in one pass, every park point at the same place, HEAD. That sounds dangerous, and it would be, in the familiar arrangement. The familiar shop puts its environment gates after merge: promotion steps that walk a commit toward production and catch regret late. This model moves the environment gates to before the merge. A branch auditions in the real environments under a lease while it is still a branch, so by the time a commit lands on main, the environments have already seen it. Teams that still want gradual promotion can gate the matrix, and nothing downstream in this design cares either way.

Through all of this, feature branches stay what they always were: they exist for review and CI, they live for hours, and at rest they hold no claim on any environment. That is the “stupidly simple” part, stated precisely: branches carry code, main carries the environments.

One main branch with tagged releases feeding a deployment area of development, nonproduction, production, and an environment-less target, all idle

Fig. 1. The baseline: main at v1.1.2, feeding all four deployment targets. At rest, every environment is main.

The picture is almost too quiet to be interesting. Main at v1.1.2, four deployment targets, all of them represented by main. The interesting question is what happens when a branch needs more than review and CI, and the canonical model’s answer is “wait for merge”. That answer is the gap.

Copy versus real

The usual fix for “testable branches” already exists, and it deserves its reputation. Preview environments, the Vercel and Netlify and Heroku review-app pattern, give every pull request a deployment years before anything in this article. The idea that solved them: each PR gets a copy of the environment, a fresh container with an empty database and no downstream services.

A copy is exactly the right shape for some testing, and exactly the wrong shape for end-to-end testing. The wrongness shows up twice. Copies are not free: a fresh environment per pull request is running infrastructure, seed data to maintain, glue to keep every copy consistent, and the bill scales with PR volume. And a copy is blind in one direction: with no downstream services, a deploy into it can never show what the deploy does to the services around it, because there are none to affect. A fresh container with an empty schema answers “does it boot”; it cannot answer “does the migration survive contact with the data”, and it cannot answer “what does this change do to the service that calls us”.

What a copy cannot give the branch is the real environment: the nonproduction with the real database holding real-shaped data, the real downstream services, the real configuration that only exists where the real deployment lives. One shared, real, complete environment is what “end-to-end” has always meant, and it is the promise “e2e-testable” makes for the rest of this article.

The trade has a price on the test side, and it is worth naming. A throwaway copy lets a test be destructive: delete every user, fine, the database dies with the container. A real shared environment requires tests that clean up after themselves, create what they need and leave what they found. Good suites are written that way anyway, idempotent by habit, but the model here makes it a requirement rather than a virtue. What the model gives back is a cheap revert: when the lease ends, redeploying main puts the code running in the environment back to a known state, no investigation into what the branch left behind. Idempotency covers the data; the deploy recovers by redeploy.

One honesty note before the mechanism: none of this is new as an ambition. GitHub Flow’s original form deployed from the branch as a final verification step before merge, back in 2011. What those efforts, and the ad hoc deploys of the introduction, lack is any notion of who owns the environment while a branch is using it. They are last-deployer-wins, which is exactly the failure the second mechanism exists to prevent.

The lease: a label borrows an environment

The first of zerv-flow’s two mechanisms makes environment borrowing explicit. A pull request labeled deploy-n temporarily makes that branch represent the nonproduction environment: its deploys land in real nonproduction, its artifact carries its version, and the environment reads from the branch instead of from main. The names are this repository’s shorthand, not the mechanism: any label that names an environment works, so deploy-dev and deploy-staging are equally valid spellings. A label of deploy-d or deploy-p borrows the other environments the same way, and the labels stack: one PR carrying both deploy-d and deploy-n holds two leases at once, each environment with its own lock, so the same commit can audition in two places in parallel.

feature/1 labeled deploy-n, with the nonproduction slot locked and running the branch version

Fig. 2. feature/1 labeled deploy-n: it borrows nonproduction, locks the slot, and runs 1.1.3-alpha.1.post.2 while main stays at v1.1.2.

The word that matters is temporarily. The lease ends on events, not on a timer: remove the label, merge the PR, or close the PR, and the environment is main’s again. Nothing long-lived is created. There is no branch to keep in sync. The lease is just a label on the PR, and when the PR goes, the deploys stop with it.

Three details carry the whole design. The deploy only runs when a deploy-* label is present, so an unlabeled PR costs nothing. The lock records the PR itself as the environment’s owner, which is how the system remembers who is holding it. And the deploy does not surrender the lease when it finishes: the branch keeps the environment until the label goes away or the PR merges. A lease that expired after one deploy would make e2e testing a one-shot event; keeping it is what makes the environment genuinely the branch’s to test against, repeatedly, while the work continues.

The lease ends at merge, and that ending is what makes the borrow worth having. A problem caught on the branch costs one red pipeline. The same problem caught after landing is the familiar trunk-based story: the behavior ships unless you keep it quiet with a flag, or undo it with a revert, and the revert only silences it: the dead commit sits in main’s history forever, and every future build takes it whole. The lease moves the discovery to the cheap side of that line.

The lock: contention fails loudly

A label declares intent. It does not answer what happens when two branches both declare it. Two PRs labeled deploy-n, both deploying into the same real nonproduction: without a referee, that is last-deployer-wins, no owner, no arbitration, no record.

The second mechanism is the referee. An environment lock, one lock per environment key, records who owns d, n, or p right now. The key is just a name, so a monorepo can go finer. The first PR to deploy acquires the lock. The second fails:

Note

In a monorepo, <subproject>-n and <subproject>-p are separate locks, and the subprojects never contend with each other.

feature/2 labeled deploy-n failing to acquire the lock held by feature/1

Fig. 3. feature/2 labels deploy-n too, and its deploy dies at the lock: the environment is genuinely held, and the branch never touches nonproduction.

No queue, no retry, no silent override. The second branch’s pipeline goes red, and red is the truth: the environment is genuinely held, and deploying into it would corrupt exactly the test the first branch is running. The red is also the point: a queue of PRs silently serializing behind one shared environment looks green while shipping nothing, and the queue length, the thing a team needs to see, stays invisible. Contention is signal. Retry is noise.

And if a lock outlives its holder, a CI job that dies mid-run, a PR that closed without ever deploying, recovery is deleting a branch: the lock state is one branch per environment key.

A borrow, start to finish

You have seen the pieces of zerv-flow one at a time. The baseline: main at v1.1.2, four deployment targets, all of them fed from main (fig. 1). feature/1 puts a deploy-n label on itself and borrows nonproduction, which locks the slot and runs the branch’s own version while main stays put (fig. 2). feature/2 wants the same environment, and its deploy fails at the lock, because feature/1 still holds it (fig. 3). Now the whole loop. feature/1 finishes its work and merges. The merge deploys main to every environment as the release version v1.1.3, and the lease ends: main owns nonproduction again, now running exactly what the branch proved.

feature/1 merging to main as v1.1.3, its lease ended, with every environment redeployed from main

Fig. 4. feature/1 merges as v1.1.3 and the lease ends: the merge deploys main to every environment, and nonproduction is main’s again, now running what the branch proved.

The same merge frees the slot for the next branch. feature/2 has been waiting out the contention, rebasing onto the new main; its next labeled deploy acquires the lock, and the cycle continues, one version behind main, auditioning in the environment main just vacated.

feature/2's updated branch redeploying under its own lease while main stays the only long-lived branch

Fig. 5. The cycle continues: feature/2, rebased onto the new main, acquires the freed lock and redeploys as 1.1.4-alpha.2.post.4 under its own lease.

Nothing in the cycle is state to maintain. Every handoff is an event, a merge or a label, and every version on the way, from v1.1.2 through 1.1.3-alpha.1.post.2 to v1.1.3 and back out to 1.1.4-alpha.2.post.4, tells you exactly which moment of the story each environment is standing in.

Where it fits

zerv-flow is not a branching strategy; the label and the lock do not care what branching strategy feeds them. This article runs them on trunk-based.

Trunk-based / GitHub Flow: the home case. Main plus feature branches, labels borrow environments, merges release.

Trunk-based and GitHub flow: one main branch line with short-lived feature commits branching off and merging back

Fig. 6. Trunk-based / GitHub Flow: one main line, short-lived feature branches. The home case.

GitFlow-adaptive: feature and release branches work unchanged; a release branch can hold a lease on n while it bakes, and every artifact it produces already carries a version.

Gitflow: a main branch line above a develop branch line, with feature, release, and hotfix paths weaving between them

Fig. 7. Gitflow: a release branch can hold a lease on n while it bakes, and every artifact it produces already carries a version.

Release trains: the develop accumulation branch keeps its role, and the train’s release branches borrow environments per departure.

Train release: a develop branch accumulating commits, with release branches departing from it and merging onto main

Fig. 8. Release trains: the develop accumulation branch keeps its role, and each train’s release branch borrows environments per departure.

The one deliberate exclusion is GitLab Flow’s environment branches, where production and nonprod are long-lived branches that promotion merges travel along. That is the pattern this model replaces: environment branches are permanent representations that drift from the code they claim to represent, and promotion between them is manual choreography. The label is the same power with the permanence removed: a branch represents an environment exactly as long as its lease says so, and never drifts because it never outlives its purpose.

GitLab flow: main, nonprod, and develop branch lines stacked, with promotion paths moving commits up between them

Fig. 9. GitLab Flow’s environment branches: the one pattern this model replaces. Promotion is manual choreography between permanent branches that drift.

A version on every deploy

One more thing rides along for free, and it is the reason this repository exists next to zerv. Every artifact in every environment carries a full zerv version, computed from the Git state: 1.1.3-alpha.1.post.2 says the nonproduction deployment descends from v1.1.2, carries a feature branch’s work, and is two commits past its base. “What is running in dev right now” is answered by reading a string, not querying a cluster.

That is the other half of this story, and it is the subject of the previous article: semantic-release versions main, zerv versions everything else, and this pipeline deploys nothing anywhere without one of those two strings attached. A borrowed environment is only worth testing against if you can say exactly what is in it.

“Computed from the Git state” only works if the build is too: the same commit built twice must produce the same artifact, and deploying that artifact twice must land the environment in the same state. Same Git state, same artifact, same environment: builds in this model are idempotent. That is ordinary CI/CD hygiene, usually spoken of as immutable artifacts: a build that reads a timestamp, a floating tag, or a stale cache has no stable artifact to redeploy. It is also what makes the recovery story free: redeploying main after a lease ends lands in the same known state every time. The data side of the bargain was covered earlier (tests leave what they found); this is the deploy side.

Final thoughts

None of zerv-flow costs much to find out about. The machinery is a PR label, a lock that is one branch per environment key, and a handful of readable shared workflows: no platform migration, nothing to install, and removing the label hands the environment back. Clone the repo, put deploy-n on a branch, and the first time you watch the lock turn red for the PR that came second, zerv-flow will have explained itself better than this article ever could.

And the design takes well to partial adoption: run the shared workflows as they are, label, lock, and versioning included, or borrow just the two ideas, implement them in pipelines of your own, and version them however you already version today.