CoursesAzure DevOps Engineer ProfessionalAzure Repos & branch policies

Azure Repos & branch policies

Pull requests, protected main, hygiene.

Beginner30 min · lesson 4 of 15

A factory press has an interlock on its safety guard. While the guard is open, the machine cannot cycle. Not *should not*. Cannot: the motor never gets current. Branch policies are that interlock for your main branch. Azure Repos, the Git hosting service built into Azure DevOps, speaks ordinary Git, so any client you already use can clone, fetch, and push. Attach one blocking policy to main, though, and code stops being able to land there until a pull request (a formal proposal to merge one branch into another) has collected the approvals you asked for, a green build, and a linked work item. Review stops being a promise people make each other and becomes something the server checks.

What a pull request and a branch policy actually are

A pull request, PR from here on, is the unit of change control. It points at a target branch (nearly always main), shows reviewers the diff, holds the threaded comments, and records the merge when it finally happens. A branch policy is a server-side rule bolted onto one ref (a ref is Git's name for a branch pointer) such as refs/heads/main. Four policies do most of the work: *minimum number of reviewers*, *build validation* (a named pipeline has to pass before the merge can complete), *work-item linking* (every PR ties back to an item in Azure Boards, the work-tracking side of Azure DevOps), and *comment resolution* (no unresolved review threads at merge time). Two facts matter more than the list itself. First, the instant any blocking policy exists on a branch, the server refuses direct pushes to it, so that branch can only move forward through completed PRs. Second, policies scope either to one exact ref or to a prefix, so --branch-match-type prefix guards the whole release/* family at once, and project-level *cross-repo* policies can cover every repository in the project, including the ones nobody has created yet. That is the whole safety mechanism in a sentence: no code reaches main without review and a green build, so the mainline stays releasable every day of the week.

Set the gates from the command line

You can click these policies together in the web portal. Portal clicks do not survive an audit, a new repository, or a second team joining. The azure-devops extension for the Azure CLI (command-line interface) makes the policies reproducible: a script you keep in version control that stamps the same gates onto every repository. Install it once, set your defaults, then create the reviewer gate.

policy-reviewers.sh
# One-time: the extension adds az repos / az pipelines / az boards
az extension add --name azure-devops
az devops configure --defaults \
organization=https://dev.azure.com/contoso project=Payments
REPO_ID=$(az repos show -r checkout-api --query id -o tsv)
# Gate 1 — two reviewers; approvals reset when new commits arrive
az repos policy approver-count create \
--repository-id "$REPO_ID" --branch main \
--minimum-approver-count 2 \
--creator-vote-counts false \
--allow-downvotes false \
--reset-on-source-push true \
--blocking true --enabled true
# => returns the policy as JSON:
# {
# "id": 7,
# "isBlocking": true,
# "isEnabled": true,
# "settings": {
# "minimumApproverCount": 2,
# "creatorVoteCounts": false,
# "resetOnSourcePush": true,
# "scope": [{
# "matchKind": "Exact",
# "refName": "refs/heads/main",
# "repositoryId": "1f6d8a9c-2b3e-4f10-9c7d-5e8a41b0c2d4"
# }]
# },
# "type": { "displayName": "Minimum number of reviewers" }
# }

Read the settings back before you move on. creatorVoteCounts: false means your own approval on your own PR does not count toward the two you asked for. resetOnSourcePush: true throws away every approval the moment new commits arrive, which is the safer setting and also the one that creates re-review churn on a busy PR. Now stack traceability, comment resolution, and the build gate onto the same branch.

policy-gates.sh
# Gate 2 — every PR must link an Azure Boards work item (traceability)
az repos policy work-item-linking create \
--repository-id "$REPO_ID" --branch main \
--blocking true --enabled true
# Gate 3 — all review threads resolved before merge
az repos policy comment-required create \
--repository-id "$REPO_ID" --branch main \
--blocking true --enabled true
# Gate 4 — the continuous integration (CI) pipeline must pass on the merge result
PIPE_ID=$(az pipelines show --name ci-checkout-api --query id -o tsv)
az repos policy build create \
--repository-id "$REPO_ID" --branch main \
--build-definition-id "$PIPE_ID" \
--display-name "CI (build + tests + scan)" \
--queue-on-source-update-only true \
--manual-queue-only false \
--valid-duration 720 \
--blocking true --enabled true
# => "type": { "displayName": "Build" }, "isBlocking": true
# Same gates for every release branch, present and future:
# --branch release --branch-match-type prefix

Build validation builds the merge, not your branch

Here is the detail most people never learn. When build validation queues your pipeline, it does not build the tip of your feature branch. It checks out refs/pull/<id>/merge, a synthetic commit that Azure Repos creates by merging your branch into whatever the *current* target looks like. You are testing what main is about to become, which catches the classic "worked on my branch, broke after the merge" failure before it lands. Second trap: a pipeline is defined in a YAML file, an indented plain-text format, and the pr: trigger keyword you can write in one is ignored for Azure Repos. It works only for repositories hosted on GitHub and Bitbucket Cloud. For Azure Repos, PR validation comes from the build-validation policy you created a moment ago and from nowhere else. Then there is the expiration pair. --queue-on-source-update-only true together with --valid-duration 720 is the portal option worded as *"after 12 hours if main has been updated"*: a green result stays trusted for twelve hours, and after that the next update to main expires it and queues a fresh run. A push to the PR's own branch always requeues immediately. The two flags are coupled, so the CLI insists on --valid-duration 0 whenever --queue-on-source-update-only is false.

azure-pipelines.yml
# CI trigger fires after merge to main.
trigger:
branches:
include: [ main ]
# NOTE: a `pr:` block here is IGNORED for Azure Repos.
# PR runs are queued by the build-validation branch policy instead.
pool:
vmImage: ubuntu-latest
steps:
- script: |
echo "Reason: $(Build.Reason)"
echo "PR: $(System.PullRequest.PullRequestId)"
echo "Built: $(Build.SourceBranch)"
displayName: Show PR context
- script: dotnet test --configuration Release
displayName: Tests
# --- log output when the policy queues a PR run ---
# Reason: PullRequest
# PR: 212
# Built: refs/pull/212/merge <- the merge result, not your branch tip

What the loop feels like from a terminal

Here is enforcement as a developer meets it. A direct push to main dies at the server with TF402455. The sanctioned path is a short-lived branch and a PR set to auto-complete, which merges itself the second the last policy turns green. Nobody sits watching a merge button.

pr-loop.sh
# A direct push to a policied branch is refused server-side:
git push origin main
# To https://dev.azure.com/contoso/Payments/_git/checkout-api
# ! [remote rejected] main -> main (TF402455: Pushes to this branch are
# not permitted; you must use a pull request to update this branch.)
# The sanctioned path — branch, push, PR with auto-complete:
git switch -c feature/idempotency-keys
git push -u origin feature/idempotency-keys
az repos pr create \
-r checkout-api \
--source-branch feature/idempotency-keys --target-branch main \
--title "Add idempotency keys to payment POST" \
--work-items 4821 \
--auto-complete true --squash true --delete-source-branch true
# {
# "pullRequestId": 212,
# "status": "active",
# "mergeStatus": "queued",
# "autoCompleteSetBy": { "uniqueName": "[email protected]" }
# }
# A teammate approves (your own vote doesn't count here):
az repos pr set-vote --id 212 --vote approve

When the second approval lands and the merge build passes, auto-complete squashes the PR into a single commit and deletes the source branch. Every step of that loop (policy creation, PR, votes, merge) is scriptable, and anything scriptable is auditable. az repos policy list diffs cleanly against the baseline you keep in version control.

The only road to main
1Feature branch
git push -u origin feature/*
2Pull request
az repos pr create --auto-complete
3Policy gates
2 approvals · build on refs/pull/N/merge · linked work item
4Protected main
direct push → TF402455 rejected
Blocking policies are enforced on the server. main advances only through a completed PR, never from a laptop, and the build validates the merge result rather than the branch tip.

What the gates cost, and keeping the repo clean

Every gate buys safety with waiting time, so tune it on purpose. Two required reviewers on a three-person team is a traffic jam. Start blocking with one and raise the count as the team grows. The build gate's expiry is the clearest dial you have. The pairing used above, --queue-on-source-update-only true with a twelve-hour --valid-duration, sits in the middle. The strictest choice is --queue-on-source-update-only false with the mandatory --valid-duration 0, which expires the policy status and re-queues the merge build every single time the *target* branch moves. Safest setting there is, and it burns agent minutes on a busy repository. Stretching the validity window the other way saves compute and risks merging against a validation that has gone stale. resetOnSourcePush is the same trade in miniature: safety against re-approval churn. There is also an escape hatch you should know about. The *Bypass policies when completing pull requests* permission is a break-glass tool, and a merge that used it looks perfectly ordinary in Git history afterwards. Give it to a tiny group and alert on every use.

There is a second bypass permission worth knowing by name. *Bypass policies when pushing* lets an identity push straight past a blocking policy without opening a PR at all. Azure DevOps grants neither permission to any security group out of the box, which is why project administrators are not exempt: until somebody hands one out, the same TF402455 rejection greets the person who created the organization. Teams also differ on how the merge itself lands: a squash into one commit, which is what --squash true does above, or a merge commit that keeps the branch history intact. Pick one per repository, write it down, and stop re-arguing it inside every PR.

Some folders deserve heavier gates than the rest of the tree. Azure Repos has a required-reviewers policy that fires on a path filter, the same idea as a CODEOWNERS file on GitHub: touch /pipelines or /infra and a named group is added to the PR automatically and has to approve before it can complete. Those two folders are where a one-line change can hand out cloud credentials or point a deployment at the wrong subscription, so they earn the extra friction even while the rest of the repository stays at one reviewer.

Hygiene is the other half of a healthy repository. Keep branches short-lived: a branch that lives two days merges quietly, a branch that lives two weeks merges like an archaeological dig. Keep PRs small. A reviewer gives a 400-line diff real scrutiny and a 4,000-line diff a shrug, so the small-PR habit is what makes the reviewer gate *mean* something. Link every PR to a work item so an auditor can walk requirement → code → build → deployment without leaving Azure DevOps. And keep secrets out of the repository altogether, which deserves its own warning.

A secret that lands in Git history is already burned
Deleting a leaked credential in a follow-up commit removes nothing. The secret is still in history, still in every clone and fork, and still in the cached PR merge refs Azure DevOps keeps. Rotation is step one: revoke the credential *before* you clean anything up, then rewrite history with git filter-repo, knowing the rewrite will never reach the copies you do not control. Stop the next one at the server. GitHub Advanced Security for Azure DevOps adds secret scanning with push protection, which rejects a push containing a detectable credential before it ever enters history. Runtime secrets belong in Azure Key Vault and reach pipelines through variable groups or service connections, never in the repository, not even in a commit you tell yourself is "temporary".

A required reviewer and a green build sound pedantic right up to the afternoon somebody merges a red build because the release is tonight. Policies turn the polite rule into a physical one: the server says no. Rewriting main history is a separate lever on a separate screen: there is no force-push branch policy, because force push is a repository permission called *Force push (rewrite history, delete branches and tags)*, and you deny it there. If you inherit a repository with nothing configured, the smallest set worth having is one reviewer, one validation build, and work-item linking if your team tracks work in Boards. That is an hour of setup, and it pays for itself the first time it catches something.

Branch policies decide *how* code is allowed into main. The open question is *what* you branch in the first place: how trunk-based development keeps branches short enough to merge every day, and how feature flags let unfinished work ride to production switched off. Branching strategy and feature flags pick up exactly there.

Try this

On a test repository, put policies on main: at least one reviewer, a build validation pipeline, and work-item linking. Open a PR and watch the Complete button stay dead until the checks finish.

terminal
az repos policy list --repository-id <repoId> -o table
# Open a lab PR against the policied branch (repository and IDs vary by org)
az repos pr create --repository app --source-branch feature/lab --target-branch main --title "Lab PR"
az repos pr policy list --id <prId> -o table
output
$ az repos pr policy list --id 42 -o table
Policy Status
-------------------- --------
Minimum reviewers queued
Build validation running
Work item linking passed
# Sample output — Complete merge stays disabled until required policies succeed.

Takeaway

Remember: Azure Repos is ordinary Git with server-side branch policies bolted on. A protected main turns code review and green builds from a courtesy into physics.

Next: add required reviewers for the sensitive paths (pipelines, infrastructure as code) and put stale-branch deletion on a schedule.

Quick check
01Your team decides no pull request should ever merge on a validation result that predates the current main. You are editing the az repos policy build create call from this lesson. Which pair of values gets you there?
Correct — This is the strict pairing. The CLI will not accept anything but zero for the duration once the first flag is false, and every commit that lands on the target branch throws the old result away. You pay for it in agent minutes on a busy repository.
Incorrect — This is the middle setting the lesson actually scripts, shown in the portal as the twelve hour option. A result that old can be validating against a main that has since moved, which is the exact gap your team wants closed.
Incorrect — Zero is not an off switch. The lesson only ever pairs it with --queue-on-source-update-only false, where it is mandatory rather than optional, and its effect is more reruns rather than fewer.
Incorrect — --manual-queue-only decides whether the validation run starts on its own at all, not how long a green run stays trusted. Set it true and the PR sits there until somebody queues the build themselves.
02A developer adds a pr: block to azure-pipelines.yml in a repository hosted in Azure Repos, pushes a feature branch, and opens a PR into main. No validation run ever shows up on the pull request. What fixes it?
Incorrect — The trigger: block is the CI run after a merge to main. Widening it builds your branch tip, which is not the merge result the PR needs checked, and it still gives the PR nothing to wait on.
Incorrect — The syntax was never the problem. For Azure Repos the pr: keyword is ignored no matter which branches you list under it, because it only works on repositories hosted in GitHub and Bitbucket Cloud.
Correct — For Azure Repos this policy is the only thing that queues a run against a PR. Once it exists, the run checks out refs/pull/<id>/merge and the Complete button stays dead until it passes.
Incorrect — Auto-complete only decides what happens after the gates go green. It merges as soon as the required policies pass, so with no build policy in place there is no build for it to wait on.
03The two reviewer gate was created with --creator-vote-counts false and --reset-on-source-push true. You open a PR, approve it yourself, and a teammate approves it too. Then you push one small fix-up commit to the source branch. Where does the approval count stand?
Incorrect — The discount on your own vote is right, the survival is not. --reset-on-source-push true is exactly the setting that refuses to let approvals outlive new commits, and the churn that causes is the price of it.
Incorrect — The merge build does requeue, but it is not the only thing the push touches. Reading the policy back after you create it is how you catch this: resetOnSourcePush: true in the JSON tells you the votes go too.
Incorrect — It works the other way round. Your vote was never in the total in the first place with --creator-vote-counts false, and the reset clears every approval on the PR rather than just the pusher's.
Correct — Both flags bite at once. Your own approval was never part of the two you asked for, and the new commit threw away the one real approval you had, so the PR now needs two fresh approvals from other people.

Related