Approvals, CODEOWNERS, Protected Branches and Tags, Push Rules, and Merge Governance: Guided Hands-On Workflow and Core Operations
Inspect current governance, then run a Free-compatible disposable branch/tag protection workflow, model CODEOWNERS safely, capture a real policy rejection, and verify every change with Git, UI, glab, and REST evidence.
Learning objectives
- Capture existing protected-branch/tag state before changing a disposable project.
- Create a Free-compatible protected branch rule that forces changes through merge requests by denying direct push.
- Create a protected-tag wildcard that intentionally denies creation and interpret the Git rejection.
- Model CODEOWNERS and required approval/push-rule behavior with honest paid-feature fixtures rather than fake Free enforcement.
- Restore all changed settings and verify no lab refs or governance rules remain.
1. Scenario, assumptions, and safety boundary
Use a disposable project you own, such as
GROUP/governance-sandbox. The mandatory path requires
GitLab Free, Git, and an already-authenticated glab. No
new token, real reviewer, production branch, runner, secret,
package, environment, or cloud resource is needed.
- Role: Maintainer or Owner in the disposable project to configure project branch/tag protection.
-
Refs: only
governance-lab,governance-change, andgovernance-v0.0.1. -
Safety: do not modify
mainprotection in this exercise. Build a new branch specifically for the lab. - Paid features: CODEOWNERS enforcement, required approvals, and push rules remain documentation/fixture exercises unless the learner already has an eligible test namespace.
2. Preflight: prove the starting state
Record the project and ref state before mutation. If a branch/tag rule with the lab names already exists, choose a different synthetic name rather than overwriting an unknown policy.
REPO="GROUP/governance-sandbox"
PROJECT_ID="123456"
BASE="main" # verify from the API; change if your project differs
TARGET="governance-lab"
SOURCE="governance-change"
TAG="governance-v0.0.1"
mkdir -p ch08-evidence
glab auth status
glab api "projects/$PROJECT_ID" --jq '{path_with_namespace,default_branch,visibility}' | tee ch08-evidence/project.json
glab api "projects/$PROJECT_ID/protected_branches" --paginate | tee ch08-evidence/protected-branches-before.json
glab api "projects/$PROJECT_ID/protected_tags" --paginate | tee ch08-evidence/protected-tags-before.json
git status --short
git fetch origin --prune --tags
git show-ref --verify --quiet "refs/remotes/origin/$TARGET" && echo "STOP: remote lab branch already exists"
git show-ref --tags --verify --quiet "refs/tags/$TAG" && echo "STOP: local lab tag already exists"
3. Create the disposable target branch before protecting it
Create the target from the verified default branch. The first push is intentionally performed before protection, so the remote branch exists and can then receive a Branch rule.
git switch "$BASE"
git pull --ff-only origin "$BASE"
git switch -c "$TARGET"
printf 'governance lab base
' > governance-lab.txt
git add governance-lab.txt
git commit -m "ch08: create disposable governance target"
git push -u origin "$TARGET"
TARGET_SHA="$(git rev-parse HEAD)"
printf 'target_sha=%s
' "$TARGET_SHA" | tee ch08-evidence/target-sha.txt
Verify in GitLab that the branch exists. At this moment it is an ordinary branch unless an existing wildcard rule happens to match it—another reason the preflight inventory matters.
4. Protect the branch using the current Branch rules UI
In the disposable project, navigate to
Settings → Repository → Branch rules, add a rule
for governance-lab, and configure:
- Allowed to merge: Maintainers (or the narrowest role that still lets your lab identity merge).
- Allowed to push and merge: No one.
- Allowed to force push: disabled.
This is a Free-compatible “merge-request-only” pattern. It protects the synthetic branch without touching the project default branch.
# Verify the resulting hosted policy read-only.
glab api "projects/$PROJECT_ID/protected_branches/$TARGET" | tee ch08-evidence/protected-branch-after.json
5. Engineer one real branch-policy rejection
Create a separate source branch. Then attempt to send its commit directly to the protected target. The refspec makes the intended target explicit.
git switch -c "$SOURCE" "origin/$TARGET"
printf 'change must travel through MR
' >> governance-lab.txt
git add governance-lab.txt
git commit -m "ch08: propose governed change"
SOURCE_SHA="$(git rev-parse HEAD)"
# EXPECTED TO FAIL because TARGET denies direct pushes.
# This is a disposable-policy test, not a force push.
git push origin "HEAD:$TARGET" 2>&1 | tee ch08-evidence/direct-push-rejection.txt
# Confirm remote target did not move.
git ls-remote origin "refs/heads/$TARGET" | tee ch08-evidence/target-after-rejection.txt
# Publish the source branch normally so it can be reviewed/merged.
git push -u origin "$SOURCE"
The useful evidence is not a memorized error string; servers can
phrase the message differently. The invariant is: the push exits
non-zero, the remote target SHA remains the captured
TARGET_SHA, and the source branch can still be pushed
because it is not the protected target.
6. Use the allowed path and verify causality
Create an MR from governance-change to
governance-lab. Before merging, record source/target
identity and explain why the MR path is allowed even though direct
push is denied.
glab mr create -R "$REPO" --source-branch "$SOURCE" --target-branch "$TARGET" --title "ch08: governed synthetic change" --description "Disposable Chapter 08 governance test." --yes
# Find the synthetic MR and inspect exact identities.
glab mr list -R "$REPO" --source-branch "$SOURCE" --target-branch "$TARGET"
# Then use the displayed IID:
MR_IID="REPLACE_WITH_IID"
glab api "projects/$PROJECT_ID/merge_requests/$MR_IID" --jq '{iid,state,source_branch,target_branch,sha,detailed_merge_status}' | tee ch08-evidence/mr-before-merge.json
Merge only after confirming the target is the disposable branch. The branch rule authorizes the merge role but denies direct pushes, so the MR is the intended integration path. On GitLab Free, do not claim that an approval was required unless another control genuinely made it required.
7. Test a protected-tag denial without creating a release
Still in the disposable project, create a protected-tag wildcard
governance-v* under
Settings → Repository → Protected tags and set
Allowed to create: No one. Then create the tag only
in your local repository and attempt to push it.
git fetch origin "$TARGET"
git tag "$TAG" "origin/$TARGET"
# EXPECTED TO FAIL because the wildcard blocks creation.
git push origin "refs/tags/$TAG" 2>&1 | tee ch08-evidence/tag-rejection.txt
# Remote proof: no matching tag should be returned.
git ls-remote --tags origin "refs/tags/$TAG" | tee ch08-evidence/remote-tag-check.txt
A protected-tag rejection proves the tag authorization rule works. It does not prove anything about branch protection, release approvals, package promotion, or CI permissions. Keep each claim scoped to its enforcement point.
8. CODEOWNERS, required approvals, and push rules: honest simulation path
Do not purchase a plan for this chapter. On Free, store the following as an exercise fixture, not as evidence of active enforcement:
# CODEOWNERS policy fixture (Premium/Ultimate feature)
* @platform-lab/maintainers
/docs/ @platform-lab/docs
/.gitlab-ci.yml @platform-lab/platform
Then reason through this paid-governance fixture:
| Fixture | Expected effect if enabled on eligible tier | Evidence to require |
|---|---|---|
| Required approvals = 2 from platform reviewers | MR cannot merge with 0 or 1 qualifying approvals | Approval widget/API shows rule and approvals remaining |
| Code Owner approval on protected target | Changes matching owned paths require eligible Code Owner approval | Matched owner/rule plus MR approval state |
| Push rule: commit message must start with ticket key | Non-matching incoming push is rejected before repository update | Git stderr plus unchanged remote ref; push-rule configuration |
| Remove all approvals when commits are added | A new source commit invalidates prior approval state | Before/after approval state tied to old/new source SHA |
9. Challenge: choose the right control
For each requirement, choose one primary control before revealing your rationale:
- “No one may update the release branch directly.”
- “Only release managers may create
v*tags.” -
“Changes under
/infrastructureneed platform-owner review.” - “Commit messages must match an organization format.”
The expected mapping is branch protection, protected tags, CODEOWNERS + supported approval enforcement, and push rules respectively. If you choose one control for all four, revisit the enforcement-point model.
10. Cleanup and rollback
Cleanup must remove only the synthetic policy and refs. Do not touch unrelated branch rules or tags discovered during preflight.
- Export the after-state of the lab branch/tag rules.
- Remove the
governance-v*protected-tag rule. - Remove the
governance-labBranch rule. - Close/merge only the synthetic MR as intended.
-
Delete the remote
governance-changeandgovernance-labbranches only after verifying they contain no valuable work. - Delete the local synthetic tag and branches.
# Evidence after removing the two lab protection rules.
glab api "projects/$PROJECT_ID/protected_branches" --paginate > ch08-evidence/protected-branches-final.json
glab api "projects/$PROJECT_ID/protected_tags" --paginate > ch08-evidence/protected-tags-final.json
# Delete ONLY verified disposable refs, and report whether each exists.
if git ls-remote --exit-code --heads origin "$SOURCE" >/dev/null 2>&1; then
git push origin --delete "$SOURCE"
else
printf 'remote source already absent: %s
' "$SOURCE"
fi
if git ls-remote --exit-code --heads origin "$TARGET" >/dev/null 2>&1; then
git push origin --delete "$TARGET"
else
printf 'remote target already absent: %s
' "$TARGET"
fi
if git show-ref --verify --quiet "refs/tags/$TAG"; then
git tag -d "$TAG"
else
printf 'local tag already absent: %s
' "$TAG"
fi
git switch "$BASE"
for branch in "$SOURCE" "$TARGET"; do
if git show-ref --verify --quiet "refs/heads/$branch"; then
git branch -D "$branch"
else
printf 'local branch already absent: %s
' "$branch"
fi
done
Knowledge check
Why was the lab branch created before its Branch rule?
So the rule can be added to an existing disposable ref and the learner can clearly compare before/after behavior without touching the default branch.
The direct push failed, but the source branch push succeeded. What does that prove?
The rejection was scoped to the protected target ref rather than a general authentication or network failure.
Why use No one instead of leaving Allowed to push and merge unconfigured?
Current GitLab semantics distinguish the two; explicitly selecting No one is the documented pattern for forcing changes through merge requests.
Can the Free lab prove CODEOWNERS approval enforcement?
No. The Free path can model the file and ownership logic, but live Code Owners/required approval enforcement is Premium/Ultimate.
What must be checked before deleting the lab refs?
Their names and SHAs, that they are disposable, that the MR/evidence is no longer needed, and that no unrelated policy depends on them.
Summary
You proved real Free enforcement without weakening a production branch: direct push to a protected lab target failed, the MR path remained available, a protected-tag wildcard denied tag creation, and paid controls were modeled honestly rather than fabricated. The next lesson turns these mechanics into policy design decisions.
Official references
- GitLab Docs — Merge request approvals
- GitLab Docs — Merge request approval rules
- GitLab Docs — Merge request approval settings
- GitLab Docs — Code Owners
- GitLab Docs — Branch rules
- GitLab Docs — Protected branches
- GitLab Docs — Protection rules and permissions
- GitLab Docs — Protected tags
- GitLab Docs — Push rules
- GitLab Docs — Protect your repository
- GitLab Docs — Merge requests
- GitLab Docs — Protected branches API
- GitLab Docs — Protected tags API
- GitLab Docs — Project push rules API
- GitLab Docs — REST API pagination
- GitLab Docs — glab api
Keep the academy open
Support free, practical DevOps education.
Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0Send only Ethereum/ERC-20 compatible assets to this
address.