Chapter 08Lesson 02~225 minutes

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.

Disposable labBranch rulesProtected tagsPolicy rejectionglabREST 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.
Availability baseline (verified 2026-08-21). GitLab Free supports optional merge-request approvals and project-level protected branches and protected tags on GitLab.com, Self-Managed, and Dedicated. Required approval rules, Code Owners/CODEOWNERS, Code Owner approval enforcement, push rules, and group-level protected-branch governance are Premium/Ultimate. The current project UI is moving protected-branch management into Settings → Repository → Branch rules; older Self-Managed releases can expose different labels. Mandatory work in this chapter therefore uses Free protected branch/tag controls, while paid governance is taught with documentation and synthetic fixtures.

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, and governance-v0.0.1.
  • Safety: do not modify main protection 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
Important: “Not configured” and “No one” are not interchangeable. Current GitLab documentation explicitly recommends selecting No one when the goal is to prevent direct pushes.

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.

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 /infrastructure need 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.

  1. Export the after-state of the lab branch/tag rules.
  2. Remove the governance-v* protected-tag rule.
  3. Remove the governance-lab Branch rule.
  4. Close/merge only the synthetic MR as intended.
  5. Delete the remote governance-change and governance-lab branches only after verifying they contain no valuable work.
  6. Delete the local synthetic tag and branches.
Deletion warning: branch and tag deletion are destructive operations. They are acceptable here only because the names are chapter-specific disposable refs and you have verified their commit identities first.
# 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?

The direct push failed, but the source branch push succeeded. What does that prove?

Why use No one instead of leaving Allowed to push and merge unconfigured?

Can the Free lab prove CODEOWNERS approval enforcement?

What must be checked before deleting the lab refs?

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

Next lesson

Design governance without creating deadlocks

Lesson 3 compares strictness, ownership metadata, inherited policy, emergency authority, and tier-aware governance as an operating-system design problem.

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.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0Send only Ethereum/ERC-20 compatible assets to this address.