Azure Repos & branch policies
Pull requests, protected main, hygiene.
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.
# One-time: the extension adds az repos / az pipelines / az boardsaz extension add --name azure-devopsaz devops configure --defaults \organization=https://dev.azure.com/contoso project=PaymentsREPO_ID=$(az repos show -r checkout-api --query id -o tsv)# Gate 1 — two reviewers; approvals reset when new commits arriveaz 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.
# 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 mergeaz 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 resultPIPE_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.
# 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-lateststeps:- script: |echo "Reason: $(Build.Reason)"echo "PR: $(System.PullRequest.PullRequestId)"echo "Built: $(Build.SourceBranch)"displayName: Show PR context- script: dotnet test --configuration ReleasedisplayName: 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.
# 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-keysgit push -u origin feature/idempotency-keysaz 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.
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.
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.
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
$ az repos pr policy list --id 42 -o tablePolicy Status-------------------- --------Minimum reviewers queuedBuild validation runningWork 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.
main. You are editing the az repos policy build create call from this lesson. Which pair of values gets you there?main that has since moved, which is the exact gap your team wants closed.--queue-on-source-update-only false, where it is mandatory rather than optional, and its effect is more reruns rather than fewer.--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.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?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.pr: keyword is ignored no matter which branches you list under it, because it only works on repositories hosted in GitHub and Bitbucket Cloud.refs/pull/<id>/merge and the Complete button stays dead until it passes.--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?--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.resetOnSourcePush: true in the JSON tells you the votes go too.--creator-vote-counts false, and the reset clears every approval on the PR rather than just the pusher's.