A messy, honest account of setting up a staging → main branch guard, and the deploy.php collision that took three attempts to fully solve.
The last article ended on a clean note: git push origin main and the live site updates itself within fifteen seconds. That felt good. But once the deploy pipeline was working, a natural question followed: what prevents a half-finished change from going straight to production?
Until now, main was the only branch. Every commit landed directly on the live site. For a personal project with one contributor, that is acceptable, until it isn’t. I wanted a staging branch where work could accumulate and be tested before anything touched production. More importantly, I wanted to enforce that rule mechanically, not just by convention.
The goal: main must only ever receive merges from staging. No direct pushes. No PRs from feature branches. Not even from myself.
What followed was several hours of edge cases, one revert, and a lesson about the difference between local git behaviour and what GitHub actually does server-side.
The Easy Part: Restricting the Source Branch
GitHub’s branch protection rules don’t have a native “only allow merges from branch X” setting. The workaround is a GitHub Actions workflow that runs on every pull request targeting main and fails if the source branch is anything other than staging:
name: Restrict merges to staging only
on:
pull_request:
branches:
- main
jobs:
check-source-branch:
runs-on: ubuntu-latest
steps:
- name: Verify PR is from staging branch
run: |
if [ "${{ github.head_ref }}" != "staging" ]; then
echo "❌ PRs to main are only allowed from the 'staging' branch."
exit 1
fi
echo "✅ Source branch is 'staging'. Merge allowed."
Then you add this check as a required status check in GitHub’s branch protection settings, and set enforce_admins: true so the rule applies to everyone, including repo admins. Combined, these two things mean there is literally no mechanism to bypass the gate, not even me pushing directly from the command line.
That part went smoothly. The harder problem was lurking one step ahead.
The deploy.php Problem
The deployment architecture from the previous article relies on a deploy.php file that lives on each server. The main server’s copy targets the main branch, uses the production webhook secret, and logs to deploy.log. The staging server’s copy targets staging, uses a different secret, and logs to deploy-staging.log. They are fundamentally different files that happen to share a name.
The moment you merge staging into main, git has to decide what to do with deploy.php. Left unchecked, staging’s version, with staging’s secret and branch target, would silently overwrite production’s. The production deploy pipeline would start listening for the wrong branch and verify webhooks against the wrong secret.
Three approaches were tried. Two didn’t work. Here is the honest account.
Attempt 1: .gitattributes -merge
Git has a mechanism for this: the -merge attribute in .gitattributes marks a file as non-auto-mergeable, forcing a conflict whenever the file differs between branches. The idea was to add:
# deploy.php -merge
to .gitattributes on both branches and let git handle the rest. Locally, this works perfectly, any git merge that touches deploy.php produces a conflict you have to resolve manually.
The problem: GitHub’s pull request merge button doesn’t use local git. It uses a server-side merge via the API that doesn’t honour .gitattributes merge drivers. When the PR was tested, GitHub reported the merge as clean, no conflict, and would have happily overwritten main’s deploy.php with staging’s. The .gitattributes approach is local-only.
Attempt 2: Untrack deploy.php on staging
If the file isn’t tracked on staging, it can’t be merged. So: git rm --cached deploy.php on the staging branch, add it to .gitignore, done.
This created a worse problem. The commit that untracks a file shows as a deletion in git. When that deletion is merged into main, git faithfully applies it, and deletes main’s deploy.php from the repository entirely. We’d have traded the wrong file for no file. That PR was closed before it could be merged.
Attempt 3: The right mental model
The mistake in both previous attempts was treating deploy.php as a file that both branches own. The correct framing: main owns deploy.php. Staging does not. The staging server has its own copy, but that copy is a server-side operational file, not something git should know about.
The solution is to reset staging’s deploy.php to be identical to main’s (so there is no diff and no merge conflict), and then add deploy.php to .gitignore on staging to prevent any local staging-specific version from ever being accidentally committed:
# .gitignore on staging
# deploy.php holds server-specific secrets and config.
# The staging server manages its own copy independently.
# Never commit staging-specific changes, only main's version is tracked.
deploy.php
This way, staging’s git tree always carries main’s deploy.php verbatim. When the merge happens, there is no diff on that file. GitHub sees nothing to reconcile. The staging server’s actual deploy.php, with staging secrets and config, lives only on disk, managed independently, invisible to git.
The Post-Merge Safety Net
The .gitignore approach prevents accidental commits. But it doesn’t prevent someone from forcibly staging the file anyway, or a future misconfiguration from sneaking through. So a validation workflow runs on every push to main that touches deploy.php:
- name: Check DEPLOY_BRANCH matches current branch
run: |
ACTUAL=$(grep -oP "DEPLOY_BRANCH',\s*'\K\w+" deploy.php)
if [ "$ACTUAL" != "main" ]; then
echo "❌ deploy.php has DEPLOY_BRANCH='$ACTUAL' but this is main."
exit 1
fi
echo "✅ deploy.php config is correct."
If staging’s config ever lands on main, through any mechanism, this check fails loudly on the next push. It’s a post-merge safety net, not a pre-merge guard, but it means the window of silent misconfiguration is essentially zero.
The Final State
After all the iteration, the setup is:
Branch protection on main: requires a passing check-source-branch status check, with enforce_admins: true. No direct pushes. No PRs from anything other than staging. No exceptions, including for the repository owner.
deploy.php ownership: only main tracks deploy.php. Staging gitignores it. Staging’s server manages its own copy directly. Merging staging into main never touches the file.
Validation CI: guards main’s deploy.php on every push and fails if the config doesn’t match the expected branch. Noisy on purpose.
Any change now follows a single path: commit to staging, open a PR, pass the source-branch check, merge. The production site only updates when that sequence completes in full.
What Made This Hard
The core difficulty was a mismatch between local git behaviour and GitHub’s server-side merge. When you run git merge locally, git reads .gitattributes from the working tree and respects every merge driver it finds. When you click “Merge pull request” on GitHub, a different code path runs that ignores those drivers entirely. Documentation for this discrepancy is sparse, it tends to surface only after you’ve built a solution around the local behaviour and watched it fail in production.
The broader lesson: anything that relies on local git tooling to enforce a policy is not enforced. Policies enforced by GitHub’s server-side systems, required status checks, branch protection rules, restricted push access, are the ones that actually hold. If a rule can be bypassed by using a different git client or skipping the UI, it is a convention, not a guard.
The working solution has no clever git tricks. It is branch protection, a shell script in a CI workflow, and a .gitignore entry. Simple things enforced at the right layer.
This article is part of a series on the infrastructure behind alvin.id. The previous post covers building the auto-deploy webhook pipeline that this staging setup builds on top of.