Protect branches and release tags with reviews, signed commits, and bypass rules
For developers and repo admins who need enforceable branch and release protection without blocking delivery. This shows the exact Git commands, provider settings to look for, and verification steps for required reviews, signed commits, protected tags, and tightly scoped bypass access.
TL;DR — Lock down your default branch and release tags with three controls: required pull-request reviews, signed commits/tags, and a very short bypass list limited to automation and release managers. The most common failure is enabling review requirements on the branch but forgetting to protect release tags, which still allows unsigned or unreviewed release refs to be pushed. Reading time: ~5 min
Goal
When you finish, pushes to your main branch and release tags will be blocked unless they meet your review and signature rules, and only explicitly named users or service accounts will be able to bypass those checks.
Prerequisites
- Repository admin or equivalent policy-management access in your Git hosting provider
- A local Git client,
git >= 2.39— check with:
git --version
- GnuPG,
gpg >= 2.2if you use GPG signing — check with:
gpg --version | head -n 1
- Or SSH signing support in Git,
git >= 2.34and an SSH key already loaded in your provider account - The exact branch names to protect, usually
mainandrelease/* - The exact tag pattern to protect, usually
v* - The exact identities allowed to bypass, typically one CI bot account and one release-manager group
- A test clone of the repo where you can create a throwaway branch and tag
Steps
Step 1: Turn on commit signing locally
If you use SSH signing, run:
git config --global gpg.format ssh
git config --global user.signingkey ~/.ssh/id_ed25519.pub
git config --global commit.gpgsign true
git config --global tag.gpgSign true
If you use GPG signing, list keys and pick the long key ID:
gpg --list-secret-keys --keyid-format=long
Then set it in Git:
git config --global user.signingkey YOUR_LONG_KEY_ID
git config --global commit.gpgsign true
git config --global tag.gpgSign true
Success looks like your next signed commit shows Good "git" signature or Good signature from ... in git log --show-signature -1.
Step 2: Register your signing key with your Git hosting provider
In your provider's dashboard, add the same public key you use for signing.
Use one of these exact sources for the public key:
cat ~/.ssh/id_ed25519.pub
or for GPG:
gpg --armor --export YOUR_LONG_KEY_ID
Menu path varies by provider, but it is typically one of:
- Account Settings → SSH and GPG keys → New key
- User Settings → Signing keys → Add key
Success looks like the key appears in your account as a verified signing key, not just an authentication key.
Step 3: Protect the default branch with required reviews and signed commits
In your provider's repository settings, create or edit a branch protection rule for main.
Use these literal values where the UI offers them:
- Branch name pattern:
main - Require pull request before merging:
On - Required approvals:
2 - Dismiss stale approvals when new commits are pushed:
On - Require review from code owners:
Onif you have aCODEOWNERSfile, otherwiseOff - Require status checks to pass before merging:
On - Require signed commits:
On - Restrict direct pushes:
On - Allow force pushes:
Off - Allow deletions:
Off - Include administrators:
Onunless your provider separates admins from bypass actors - Bypass list: add only your CI bot and release-manager group
Success looks like the rule summary showing 2 approvals, signed commits required, and direct pushes blocked for main.
Step 4: Protect release branches separately
Create a second branch protection rule for release branches.
Use these literal values:
- Branch name pattern:
release/* - Require pull request before merging:
On - Required approvals:
1or2to match your release process - Require signed commits:
On - Restrict direct pushes:
On - Allow force pushes:
Off - Allow deletions:
Off - Bypass list: the same CI bot and release-manager group only
Success looks like the rule applying to a branch named like release/1.8 when you view that branch.
Step 5: Protect release tags so unsigned or ad-hoc releases cannot be pushed
Create a tag protection rule.
Use these literal values:
- Tag name pattern:
v* - Creation restriction: only maintainers/release managers or the named release bot
- Deletion restriction:
No oneor admins only, depending on your retention policy - Update/move existing tag:
Blocked
If your provider supports rulesets instead of separate tag protection, create a ruleset targeting tags with pattern v* and apply restrict creation, block updates, and block deletion.
Success looks like a rule summary showing v* protected and existing release tags marked immutable.
⚠️ If you enable
Include administratorsor remove broad bypass rights on an active repo, some existing release jobs may fail immediately. Check your CI identity before saving the rule.
Step 6: Narrow bypass to named automation only
Edit the bypass list on each branch/tag rule and remove broad groups like Developers, Maintainers, or Repository admins if your provider allows narrower exceptions.
Use this target state:
ci-release-bot: allowed to bypass required reviews only if it merges via your release workflowrelease-managers: allowed to createv*tags- Everyone else: no bypass
If your provider supports actor-specific permissions, set:
ci-release-bot→push to matching refs: allowedrelease-managers→create protected tags: allowedforce pushanddelete:not allowedfor both
Success looks like the bypass list containing 1-2 named actors, not whole teams.
Step 7: Test signed commits, blocked direct pushes, and protected tags
Create a test branch, make a signed commit, and push it:
git checkout -b kb/protection-test
echo "test $(date -u +%FT%TZ)" >> protection-test.txt
git add protection-test.txt
git commit -S -m "test signed commit"
git push -u origin kb/protection-test
Check the signature locally:
git log --show-signature -1
Then try a direct push to main and expect rejection:
git checkout main
echo "should fail" >> direct-push-test.txt
git add direct-push-test.txt
git commit -S -m "direct push should be rejected"
git push origin main
Typical failure output shape:
remote: error: GH006: Protected branch update failed for refs/heads/main.
remote: error: Changes must be made through a pull request.
To github.com:ORG/REPO.git
! [remote rejected] main -> main (protected branch hook declined)
error: failed to push some refs to 'github.com:ORG/REPO.git'
Now test protected tags:
git tag -s v0.0.0-test -m "test tag"
git push origin v0.0.0-test
If you are not in the bypass list for tag creation, expect rejection similar to:
remote: error: protected tag 'v0.0.0-test' can only be created by allowed actors
To github.com:ORG/REPO.git
! [remote rejected] v0.0.0-test -> v0.0.0-test (pre-receive hook declined)
error: failed to push some refs to 'github.com:ORG/REPO.git'
Success looks like the feature branch push succeeding, the direct push to main failing, and protected tag creation behaving exactly according to your bypass rule.
Verify it works
Run these checks end to end.
Verify commit signatures locally:
git log --show-signature --max-count=3
Expected shape:
commit 1a2b3c4d...
Good "git" signature for you@example.com with ED25519 key SHA256:...
Author: Your Name <you@example.com>
Date: Tue Jan 14 10:22:11 2026 +0000
Verify direct pushes are blocked:
git push origin HEAD:main
Expected result: non-zero exit code and a remote rejection mentioning protected branch, pull request requirement, signed commits, or missing review.
Verify an unsigned commit is rejected by creating one with signing disabled for a single commit:
git checkout -b kb/unsigned-test
echo unsigned >> unsigned.txt
git add unsigned.txt
git -c commit.gpgsign=false commit -m "unsigned test"
git push -u origin kb/unsigned-test
Then open a pull request to main. Expected result: merge is blocked by a rule stating signed commits are required.
Verify protected tags are immutable:
git tag -f v0.0.0-test
git push --force origin refs/tags/v0.0.0-test
Expected result: non-zero exit code and a rejection mentioning protected tag update or pre-receive hook decline.
Common pitfalls
Signed locally, but provider shows "unverified"
Mistake: you signed commits with an SSH or GPG key that is not registered as a signing key in your account.
Symptom: git log --show-signature is good locally, but the PR or commit page shows Unverified.
Fix: add the exact public key from cat ~/.ssh/id_ed25519.pub or gpg --armor --export YOUR_LONG_KEY_ID to your account's signing keys.
Protected main, but release tags still move
Mistake: you created branch protection only and skipped tag protection for v*.
Symptom: git push --force origin refs/tags/v1.2.3 succeeds even though main is locked down.
Fix: add a tag rule for v* with updates blocked and creation limited to release actors.
CI can no longer cut releases
Mistake: you enabled signed commits and restricted pushes, but your release job pushes with a generic token or a different bot identity than the one in the bypass list.
Symptom: release pipeline fails at git push with protected branch hook declined or not allowed to create protected tag.
Fix: identify the exact actor used by CI and add only that actor to the bypass list for the needed ref pattern.
Admins can still push directly when they should not
Mistake: Include administrators is off, or admins are implicitly exempt in a legacy rule.
Symptom: a repo admin can push to main while everyone else is blocked.
Fix: enable Include administrators or remove admin-wide exemptions and replace them with named bypass actors.
Merge queue or squash merge breaks signature expectations
Mistake: you require signed commits, but your provider's merge method creates a new commit that is not signed by your key or by a trusted server-side signer. Symptom: PR is approved, but merge is blocked or the resulting commit appears unverified. Fix: use the merge method your provider signs correctly in your environment, or require signed commits on incoming commits and let the provider's verified server signature satisfy the final merge commit policy if supported.
Old approvals disappear after a rebase
Mistake: Dismiss stale approvals when new commits are pushed is on, but reviewers expect approvals to survive rebases.
Symptom: PR had 2 approvals, then shows 0 of 2 approvals after force-updating the branch.
Fix: this is expected; ask for re-review after the new commit set is pushed, or disable stale-dismissal only if your risk tolerance allows it.
This article was written by an AI system and published pending human review. Verify anything you intend to act on.
Have a project in mind?
Get an instant AI price estimate for it, or talk directly to our team.
One email a month on what we learn building with AI