My staging environment wasn't protecting anything
I built a staging environment for this site. Self-hosted, its own CI runner, HTTPS, the full pipeline. Then I used it in a way that gave me exactly none of its benefit, and it took me a while to notice.
The setup
Two environments, two CI systems, one repository.
Staging runs on a server in my house. A self-hosted Gitea instance triggers a runner that builds the site and publishes it behind a reverse proxy. Production runs on Azure Static Web Apps, deployed by GitHub Actions.
The repo has two remotes. origin points at Gitea, github points at GitHub. Both were
tracking main. So my deploy routine was:
git push origin main
git push github main
Two commands, run back to back, usually within the same second.
What that actually does
Both pipelines watch main. Pushing to both remotes means staging and production receive the
same commit at the same moment and start building in parallel.
If a change were broken, production would break at the same time staging did. Staging would warn me about nothing, because there is no window between the two in which a warning could matter.
I had built a gate and installed it beside the door.
The fix is a branch, not a habit
The first correction that comes to mind is discipline. Push to Gitea, check staging, then push to GitHub. That is better, and it costs nothing to start doing.
But it is a control that depends on remembering, and controls that depend on remembering fail eventually. It will be late, the change will be small, and I will push both because the change is "obviously fine."
The structural fix is to stop having both pipelines watch the same branch:
mainis production. Azure's workflow deploys it.stagingis where work lands. Gitea's workflow deploys that.
Now the flow has a gap in it on purpose:
push staging → Gitea builds → staging site
verify here
open a PR: staging → main
merge → Azure builds → production
Promotion became a merge instead of a second push. It is deliberate, it produces a diff I can read before anything ships, and it leaves a record.
The reason main became production rather than the other way around is that Azure was already
wired to main, and Static Web Apps routes non-production branches to preview environments. It
was less risky to adopt the platform's assumption than to fight it. main as the
production-ready branch is the common convention anyway.
The part I haven't fixed
There is a second problem, and the branch change doesn't touch it.
Staging and production build separately. Gitea builds on my self-hosted runner. GitHub builds
again on its own hardware, with its own npm ci, on a different Node version than my server
happens to be running.
So the thing I verify on staging is not the thing that ships to production. It is a rebuild of the same source. Those are not the same claim. A committed lockfile makes them very likely to match, which is exactly why committing it matters, but "very likely" is doing real work in that sentence.
Proper pipelines build an artifact once and promote that artifact through environments. Test the bytes you are going to ship, not a fresh copy of them. I know what the fix looks like and I haven't done it yet, which is at least better than not knowing.
What I took from it
An environment is not a gate because of what you built. It is a gate because something can fail there before it reaches production. If both environments receive the same change at the same time, you own two production environments and one of them has fewer users.
Worth asking of any staging setup: what would have to go wrong for this to stop a release? If there isn't an answer, it isn't stopping anything.