Branch Protection Strategies for Enterprise Teams
Implement comprehensive branch protection policies in Azure DevOps for enterprise teams with automated enforcement and emergency procedures
Branch Protection Strategies for Enterprise Teams
Branch protection is the single most important guardrail you can put around your source code. Without it, a single bad merge to main can take down production, leak secrets, or bypass every quality gate your team spent months building. This article covers how to implement comprehensive branch protection policies in Azure DevOps, automate their enforcement across dozens of repositories, and handle the inevitable emergency that requires bypassing them.
Prerequisites
- Azure DevOps organization with Project Administrator or Project Collection Administrator permissions
- Node.js v14 or later installed
- Basic familiarity with Git branching strategies
- A Personal Access Token (PAT) with Code (Read & Write) and Policy (Read & Write) scopes
- At least one Azure DevOps repository to experiment with
Branch Policy Fundamentals in Azure DevOps
Azure DevOps branch policies are server-side enforcement rules. Unlike Git hooks, which run on the client and can be skipped, branch policies are evaluated by the server before a push or merge completes. No amount of --force flags will override them.
Policies apply to specific branches or branch patterns. The most common target is main (or master), but enterprise teams typically protect release/*, hotfix/*, and sometimes develop as well.
Here is what the policy evaluation flow looks like:
Developer pushes to PR branch
|
v
PR created targeting protected branch
|
v
Azure DevOps evaluates all policies:
- Minimum reviewer count met?
- Required reviewers approved?
- Build validation passed?
- Status checks green?
- Work items linked?
- Comment threads resolved?
|
v
All policies pass --> Merge allowed
Any policy fails --> Merge blocked
Every policy is either required or optional. Required policies block the merge button entirely. Optional policies show warnings but still allow the merge. Getting this distinction right is critical. Start with most policies as required and only relax to optional when the team pushes back with legitimate reasons.
Minimum Reviewer Requirements
The minimum reviewer policy is the foundation of any code review workflow. In Azure DevOps, you configure it per branch and specify how many approvals a pull request needs before merging.
Key settings to consider:
- Minimum number of reviewers: Two is the sweet spot for most teams. One reviewer catches obvious bugs. Two reviewers catch design issues and share knowledge across the team.
- Allow requestors to approve their own changes: Always set this to
falsein enterprise environments. Self-approval defeats the purpose of code review. - Prohibit the most recent pusher from approving: Set this to
true. If a developer pushes new commits to a PR, their previous approval should not count. This prevents the pattern where someone approves, the author pushes changes, and the PR merges without anyone reviewing the new code. - Reset votes on new pushes: Consider enabling this. It forces re-review after every push, which is strict but prevents stale approvals on significantly changed code.
// Minimum reviewer policy configuration object
var minimumReviewerPolicy = {
isEnabled: true,
isBlocking: true,
type: {
id: "fa4e907d-c16b-4a4c-9dfa-4916e5d171ab"
},
settings: {
minimumApproverCount: 2,
creatorVoteCounts: false,
allowDownvotes: false,
resetOnSourcePush: true,
requireVoteOnLastIteration: true,
resetRejectVoteOnSourcePush: true,
blockLastPusherVote: true,
scope: [
{
repositoryId: null,
refName: "refs/heads/main",
matchKind: "Exact"
}
]
}
};
The type.id value fa4e907d-c16b-4a4c-9dfa-4916e5d171ab is the well-known GUID for the minimum reviewer policy type. Azure DevOps uses these GUIDs to identify policy types across all organizations.
Build Validation Policies
Build validation ensures that every pull request compiles, passes tests, and meets quality gates before anyone can merge it. This is non-negotiable for enterprise teams.
// Build validation policy configuration
var buildValidationPolicy = {
isEnabled: true,
isBlocking: true,
type: {
id: "0609b952-1397-4640-95ec-e00a01b2c241"
},
settings: {
buildDefinitionId: 42,
queueOnSourceUpdateOnly: true,
manualQueueOnly: false,
displayName: "PR Validation Build",
validDuration: 720,
scope: [
{
repositoryId: null,
refName: "refs/heads/main",
matchKind: "Exact"
}
]
}
};
The validDuration field (in minutes) controls how long a successful build remains valid. Setting this to 720 minutes (12 hours) means that if the PR build passed this morning, the developer does not need to wait for another build this afternoon. Set it lower (60-120 minutes) for fast-moving repositories where main changes frequently.
The queueOnSourceUpdateOnly flag is important. When set to true, builds only trigger when the source branch changes, not when the target branch changes. Set it to false if you want builds to re-run whenever main gets new commits, ensuring the PR is always validated against the latest code.
For enterprise teams, I recommend at least two build validation policies on main:
- A fast build that runs unit tests (blocks merge, 5-10 minute timeout)
- An integration test build that runs longer tests (blocks merge, 30-60 minute timeout)
Status Checks and External Services
Beyond built-in build validation, Azure DevOps supports external status checks. These let third-party tools like SonarQube, Snyk, or custom compliance scanners report their results back to the PR.
// Status check policy configuration
var statusCheckPolicy = {
isEnabled: true,
isBlocking: true,
type: {
id: "cbdc66da-9728-4af8-aada-9a5a32e4a226"
},
settings: {
statusName: "security-scan/snyk",
statusGenre: "security",
authorId: "d6245f20-2af8-44f4-9451-8107cb2767db",
invalidateOnSourceUpdate: true,
policyApplicability: "conditional",
displayName: "Snyk Security Scan",
scope: [
{
repositoryId: null,
refName: "refs/heads/main",
matchKind: "Exact"
}
]
}
};
The authorId is the identity of the service or service connection that posts the status. The statusGenre and statusName together form the unique key for the status. Your external service must post a status matching these exact values, or the policy will never be satisfied.
Here is a Node.js script that posts a status check result from an external service:
var https = require("https");
function postPullRequestStatus(options, callback) {
var body = JSON.stringify({
state: options.state,
description: options.description,
targetUrl: options.targetUrl,
context: {
name: options.statusName,
genre: options.statusGenre
}
});
var requestOptions = {
hostname: "dev.azure.com",
path: "/" + options.organization + "/" + options.project +
"/_apis/git/repositories/" + options.repositoryId +
"/pullRequests/" + options.pullRequestId +
"/statuses?api-version=7.1",
method: "POST",
headers: {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(body),
"Authorization": "Basic " + Buffer.from(":" + options.pat).toString("base64")
}
};
var req = https.request(requestOptions, function(res) {
var data = "";
res.on("data", function(chunk) { data += chunk; });
res.on("end", function() {
callback(null, JSON.parse(data));
});
});
req.on("error", function(err) { callback(err); });
req.write(body);
req.end();
}
// Usage
postPullRequestStatus({
organization: "myorg",
project: "myproject",
repositoryId: "repo-guid-here",
pullRequestId: 1234,
pat: process.env.AZURE_DEVOPS_PAT,
state: "succeeded",
description: "No vulnerabilities found",
targetUrl: "https://app.snyk.io/org/myorg/project/abc123",
statusName: "security-scan/snyk",
statusGenre: "security"
}, function(err, result) {
if (err) {
console.error("Failed to post status:", err.message);
return;
}
console.log("Status posted:", result.state);
});
Valid states are notSet, pending, succeeded, failed, error, and notApplicable. Only succeeded satisfies a required status check policy.
Path-Based Policies
Large repositories often have different ownership boundaries. The database team owns src/db/, the API team owns src/api/, and the infrastructure team owns terraform/. Path-based policies let you apply different rules to different parts of the codebase.
Azure DevOps does not natively support path-based branch policies in the same way that GitHub does with CODEOWNERS. However, you can achieve similar behavior through automatic reviewers with path filters.
// Automatic reviewer policy with path filter
var pathBasedReviewerPolicy = {
isEnabled: true,
isBlocking: true,
type: {
id: "fd2167ab-b0be-447a-8571-0a4ee25a84f5"
},
settings: {
requiredReviewerIds: [
"a1b2c3d4-e5f6-7890-abcd-ef1234567890"
],
filenamePatterns: [
"/src/db/*",
"/migrations/*"
],
addedFilesOnly: false,
message: "Database changes require DBA team approval",
scope: [
{
repositoryId: null,
refName: "refs/heads/main",
matchKind: "Exact"
}
]
}
};
The filenamePatterns field supports glob patterns. When a PR modifies files matching these patterns, the specified reviewers are automatically added and their approval becomes required (because isBlocking is true).
This is one of the most powerful features for enterprise teams. Common patterns include:
/infrastructure/*requires the platform team*.sqlrequires the DBA team/src/auth/*requires the security teampackage.jsonorpackage-lock.jsonrequires a senior engineer/.pipeline/*or/.azuredevops/*requires the DevOps team
Automatic Reviewers and Code Owners
Beyond path-based reviewers, you can configure automatic reviewers who are added to every PR targeting a protected branch. This is useful for team leads, architects, or compliance officers who need visibility into all changes.
// Automatic reviewer policy (all PRs to main)
var autoReviewerPolicy = {
isEnabled: true,
isBlocking: false,
type: {
id: "fd2167ab-b0be-447a-8571-0a4ee25a84f5"
},
settings: {
requiredReviewerIds: [
"team-lead-guid-here",
"architect-guid-here"
],
filenamePatterns: [],
addedFilesOnly: false,
message: "Auto-added for awareness. Approval optional.",
scope: [
{
repositoryId: null,
refName: "refs/heads/main",
matchKind: "Exact"
}
]
}
};
Notice that isBlocking is false here. The reviewers are added for awareness, but their approval is not required for the merge. This is a good middle ground for architects who want to see changes but do not want to become a bottleneck.
For a more robust code ownership model, many enterprise teams maintain a CODEOWNERS file in their repository and use a custom service hook or Azure DevOps extension to parse it and add reviewers programmatically. The built-in automatic reviewer feature handles most cases, but the CODEOWNERS approach scales better when ownership changes frequently.
Merge Strategies and Squash Policies
Azure DevOps lets you restrict which merge strategies are allowed on protected branches. This is more impactful than most teams realize. Inconsistent merge strategies create messy Git history, make bisecting difficult, and cause confusion about what was actually deployed.
The available merge strategies are:
| Strategy | Git History | Traceability | Best For |
|---|---|---|---|
| Merge commit | Preserves all commits | High | Open source, auditable history |
| Squash merge | Single commit per PR | Medium | Feature branches, clean history |
| Rebase | Linear history | Low | Small teams, simple workflows |
| Semi-linear | Linear + merge commits | High | Enterprise teams who want both |
For enterprise teams, I strongly recommend enforcing squash merge on main. Here is why:
- Every commit on
mainmaps to exactly one PR, which maps to exactly one work item - Reverting a bad change is a single
git revertinstead of reverting a chain of commits - The Git log on
mainreads like a changelog - Developers can make messy intermediate commits on feature branches without polluting
main
// Merge strategy restriction policy
var mergeStrategyPolicy = {
isEnabled: true,
isBlocking: true,
type: {
id: "fa4e907d-c16b-4a4c-9dfa-4916e5d171ab"
},
settings: {
allowSquash: true,
allowNoFastForward: false,
allowRebase: false,
allowRebaseMerge: false,
scope: [
{
repositoryId: null,
refName: "refs/heads/main",
matchKind: "Exact"
}
]
}
};
Branch Permissions and Security
Branch policies govern what happens during the PR process. Branch permissions govern who can do what to the branch itself. They are complementary and both are necessary.
Key permissions to configure on protected branches:
- Contribute: Allow for most developers (they can push to feature branches that target this branch)
- Force push: Deny for everyone, including admins. Force pushing to
mainshould never happen - Create branch: Allow for developers, but consider restricting who can create
release/*branches - Delete branch: Deny on
mainandrelease/*. Allow on feature branches - Bypass policies when pushing: Deny for everyone except a dedicated break-glass identity
- Bypass policies when completing pull requests: Deny for most users, allow for specific emergency roles
You can manage these permissions through the Azure DevOps UI under Project Settings > Repositories > Security, or programmatically through the Security namespace API.
var http = require("https");
function setBranchPermission(options, callback) {
// Git branch security namespace ID
var securityNamespaceId = "2e9eb7ed-3c0a-47d4-87c1-0ffdd275fd87";
var body = JSON.stringify({
token: "repoV2/" + options.projectId + "/" + options.repositoryId +
"/refs/heads/" + options.branchName.replace(/\//g, "/"),
merge: true,
accessControlEntries: [
{
descriptor: options.identityDescriptor,
allow: options.allow,
deny: options.deny,
extendedInfo: {
effectiveAllow: options.allow,
effectiveDeny: options.deny
}
}
]
});
var requestOptions = {
hostname: "dev.azure.com",
path: "/" + options.organization +
"/_apis/accesscontrolentries/" + securityNamespaceId +
"?api-version=7.1",
method: "POST",
headers: {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(body),
"Authorization": "Basic " + Buffer.from(":" + options.pat).toString("base64")
}
};
var req = http.request(requestOptions, function(res) {
var data = "";
res.on("data", function(chunk) { data += chunk; });
res.on("end", function() {
callback(null, JSON.parse(data));
});
});
req.on("error", function(err) { callback(err); });
req.write(body);
req.end();
}
The permission bits for the Git security namespace are:
| Permission | Bit Value | Description |
|---|---|---|
| Contribute | 4 | Push commits to the branch |
| Force push | 8 | Force push (rewrite history) |
| Create branch | 16 | Create new branches |
| Create tag | 32 | Create tags |
| Delete branch | 1024 | Remove the branch |
| Bypass policies on push | 128 | Push directly bypassing policies |
| Bypass policies on PR | 32768 | Complete PRs bypassing policies |
Release Branch Patterns
Enterprise teams typically maintain multiple protected branches. A common pattern:
main (primary development)
|
+-- release/2.1
| +-- hotfix/2.1.1
|
+-- release/2.2
|
+-- release/3.0 (upcoming major)
Each branch type needs different policy configurations:
- main: Strictest policies. Two reviewers, full build validation, security scans, squash merge only.
- release/*: Strict policies. Two reviewers, full build validation including deployment tests, security scans. Only bug fixes allowed.
- hotfix/*: Expedited policies. One reviewer (but from a senior list), fast build validation, security scan. Time-sensitive by nature.
You can apply policies to branch patterns using matchKind: "Prefix":
var releasePolicy = {
isEnabled: true,
isBlocking: true,
type: {
id: "fa4e907d-c16b-4a4c-9dfa-4916e5d171ab"
},
settings: {
minimumApproverCount: 2,
creatorVoteCounts: false,
resetOnSourcePush: true,
scope: [
{
repositoryId: null,
refName: "refs/heads/release/",
matchKind: "Prefix"
}
]
}
};
This single policy definition applies to every branch whose name starts with release/. When release/2.3 is created, it automatically inherits these protections.
Policy Bypass and Emergency Procedures
Every enterprise team needs a break-glass procedure. Production is down, the fix is obvious, and waiting for two reviewers and a 30-minute build is not an option. Plan for this ahead of time instead of scrambling during an incident.
The recommended approach:
- Create a dedicated security group called something like "Emergency Bypass" or "Break Glass"
- Grant this group the "Bypass policies when completing pull requests" permission on protected branches
- Keep this group empty by default
- Document the process for temporarily adding someone to this group during an incident
- Set up alerts that fire whenever someone is added to this group or whenever a policy-bypassed merge occurs
- Require a post-incident review for every bypass, no exceptions
Here is a Node.js script that adds a user to the bypass group and automatically removes them after a timeout:
var https = require("https");
var ORGANIZATION = process.env.AZURE_DEVOPS_ORG;
var PAT = process.env.AZURE_DEVOPS_PAT;
var BYPASS_GROUP_DESCRIPTOR = process.env.BYPASS_GROUP_DESCRIPTOR;
var TIMEOUT_MINUTES = 60;
function makeRequest(method, path, body, callback) {
var options = {
hostname: "vssps.dev.azure.com",
path: "/" + ORGANIZATION + path,
method: method,
headers: {
"Content-Type": "application/json",
"Authorization": "Basic " + Buffer.from(":" + PAT).toString("base64")
}
};
if (body) {
var bodyStr = JSON.stringify(body);
options.headers["Content-Length"] = Buffer.byteLength(bodyStr);
}
var req = https.request(options, function(res) {
var data = "";
res.on("data", function(chunk) { data += chunk; });
res.on("end", function() {
var parsed = data ? JSON.parse(data) : {};
callback(null, res.statusCode, parsed);
});
});
req.on("error", function(err) { callback(err); });
if (body) { req.write(JSON.stringify(body)); }
req.end();
}
function addUserToBypassGroup(userDescriptor, reason, callback) {
var path = "/_apis/graph/memberships/" + userDescriptor +
"/" + BYPASS_GROUP_DESCRIPTOR + "?api-version=7.1-preview.1";
makeRequest("PUT", path, {}, function(err, statusCode, result) {
if (err) return callback(err);
if (statusCode !== 200 && statusCode !== 201) {
return callback(new Error("Failed to add user: HTTP " + statusCode));
}
console.log("[%s] EMERGENCY BYPASS GRANTED", new Date().toISOString());
console.log(" User: %s", userDescriptor);
console.log(" Reason: %s", reason);
console.log(" Expires: %d minutes", TIMEOUT_MINUTES);
// Schedule automatic removal
setTimeout(function() {
removeUserFromBypassGroup(userDescriptor, function(removeErr) {
if (removeErr) {
console.error("[%s] CRITICAL: Failed to remove bypass access: %s",
new Date().toISOString(), removeErr.message);
console.error(" MANUAL REMOVAL REQUIRED for user: %s", userDescriptor);
} else {
console.log("[%s] EMERGENCY BYPASS REVOKED (auto-expiry)",
new Date().toISOString());
console.log(" User: %s", userDescriptor);
}
});
}, TIMEOUT_MINUTES * 60 * 1000);
callback(null, result);
});
}
function removeUserFromBypassGroup(userDescriptor, callback) {
var path = "/_apis/graph/memberships/" + userDescriptor +
"/" + BYPASS_GROUP_DESCRIPTOR + "?api-version=7.1-preview.1";
makeRequest("DELETE", path, null, function(err, statusCode) {
if (err) return callback(err);
if (statusCode !== 200 && statusCode !== 204) {
return callback(new Error("Failed to remove user: HTTP " + statusCode));
}
callback(null);
});
}
// Example usage
var userDescriptor = process.argv[2];
var reason = process.argv[3] || "No reason provided";
if (!userDescriptor) {
console.error("Usage: node emergency-bypass.js <user-descriptor> <reason>");
process.exit(1);
}
addUserToBypassGroup(userDescriptor, reason, function(err) {
if (err) {
console.error("Failed to grant bypass:", err.message);
process.exit(1);
}
console.log("Bypass granted. Process will keep running to auto-revoke.");
});
Output when invoked:
$ node emergency-bypass.js "aad.abc123def456" "INCIDENT-4521: Payment processing down, hotfix PR #892"
[2026-02-13T14:30:00.000Z] EMERGENCY BYPASS GRANTED
User: aad.abc123def456
Reason: INCIDENT-4521: Payment processing down, hotfix PR #892
Expires: 60 minutes
Bypass granted. Process will keep running to auto-revoke.
Cross-Repository Policies
Enterprise organizations often have dozens or hundreds of repositories that should share the same branch protection baseline. Manually configuring policies per repository is tedious and error-prone. The Azure DevOps REST API makes it possible to apply policies across all repositories in a project.
When the repositoryId in a policy scope is set to null, the policy applies to all repositories in the project. This is the easiest way to enforce a baseline:
var projectWideScope = {
repositoryId: null,
refName: "refs/heads/main",
matchKind: "Exact"
};
However, some repositories need exceptions. A documentation-only repository might not need build validation. A prototype repository might only need one reviewer. Handle these by creating repository-specific policies that override the project-wide defaults.
Automating Branch Policies with the REST API
The real power of Azure DevOps branch policies comes from automating them. Here is how to list, create, update, and delete policies programmatically.
var https = require("https");
function AzureDevOpsClient(organization, project, pat) {
this.organization = organization;
this.project = project;
this.auth = "Basic " + Buffer.from(":" + pat).toString("base64");
}
AzureDevOpsClient.prototype.request = function(method, path, body, callback) {
var options = {
hostname: "dev.azure.com",
path: "/" + this.organization + "/" + this.project + path,
method: method,
headers: {
"Content-Type": "application/json",
"Authorization": this.auth
}
};
if (body) {
var bodyStr = JSON.stringify(body);
options.headers["Content-Length"] = Buffer.byteLength(bodyStr);
}
var req = https.request(options, function(res) {
var data = "";
res.on("data", function(chunk) { data += chunk; });
res.on("end", function() {
if (res.statusCode >= 400) {
return callback(new Error("HTTP " + res.statusCode + ": " + data));
}
callback(null, JSON.parse(data));
});
});
req.on("error", function(err) { callback(err); });
if (body) { req.write(JSON.stringify(body)); }
req.end();
};
AzureDevOpsClient.prototype.listPolicies = function(callback) {
this.request("GET", "/_apis/policy/configurations?api-version=7.1", null, callback);
};
AzureDevOpsClient.prototype.createPolicy = function(policy, callback) {
this.request("POST", "/_apis/policy/configurations?api-version=7.1", policy, callback);
};
AzureDevOpsClient.prototype.updatePolicy = function(policyId, policy, callback) {
this.request("PUT", "/_apis/policy/configurations/" + policyId + "?api-version=7.1", policy, callback);
};
AzureDevOpsClient.prototype.deletePolicy = function(policyId, callback) {
this.request("DELETE", "/_apis/policy/configurations/" + policyId + "?api-version=7.1", null, callback);
};
AzureDevOpsClient.prototype.listRepositories = function(callback) {
this.request("GET", "/_apis/git/repositories?api-version=7.1", null, callback);
};
Complete Working Example
Here is a comprehensive Node.js script that configures branch policies across all repositories in an Azure DevOps project. It reads a policy manifest from a JSON file and applies it, handling creates, updates, and deletes to converge on the desired state.
var https = require("https");
var fs = require("fs");
var path = require("path");
// ============================================================
// Configuration
// ============================================================
var CONFIG = {
organization: process.env.AZURE_DEVOPS_ORG,
project: process.env.AZURE_DEVOPS_PROJECT,
pat: process.env.AZURE_DEVOPS_PAT
};
// Well-known policy type GUIDs
var POLICY_TYPES = {
MINIMUM_REVIEWERS: "fa4e907d-c16b-4a4c-9dfa-4916e5d171ab",
BUILD_VALIDATION: "0609b952-1397-4640-95ec-e00a01b2c241",
REQUIRED_REVIEWERS: "fd2167ab-b0be-447a-8571-0a4ee25a84f5",
WORK_ITEM_LINKING: "40e92b44-2fe1-4dd6-b3d8-74a9c21d0c6e",
COMMENT_RESOLUTION: "c6a1889d-b943-4856-b76f-9e46bb6b0df2",
MERGE_STRATEGY: "fa4e907d-c16b-4a4c-9dfa-4916e5d171ab",
STATUS_CHECK: "cbdc66da-9728-4af8-aada-9a5a32e4a226"
};
// ============================================================
// HTTP Client
// ============================================================
function apiRequest(method, urlPath, body, callback) {
var auth = "Basic " + Buffer.from(":" + CONFIG.pat).toString("base64");
var options = {
hostname: "dev.azure.com",
path: "/" + CONFIG.organization + "/" + CONFIG.project + urlPath,
method: method,
headers: {
"Content-Type": "application/json",
"Authorization": auth
}
};
if (body) {
var bodyStr = JSON.stringify(body);
options.headers["Content-Length"] = Buffer.byteLength(bodyStr);
}
var req = https.request(options, function(res) {
var data = "";
res.on("data", function(chunk) { data += chunk; });
res.on("end", function() {
if (res.statusCode >= 400) {
var errMsg = "HTTP " + res.statusCode + " " + method + " " + urlPath;
try {
var errBody = JSON.parse(data);
errMsg += ": " + (errBody.message || data);
} catch (e) {
errMsg += ": " + data;
}
return callback(new Error(errMsg));
}
var parsed = data ? JSON.parse(data) : null;
callback(null, parsed);
});
});
req.on("error", function(err) { callback(err); });
if (body) { req.write(JSON.stringify(body)); }
req.end();
}
// ============================================================
// Policy Definitions
// ============================================================
function buildPolicyManifest(repositories) {
var policies = [];
// Project-wide policies (repositoryId: null applies to all repos)
policies.push({
name: "Minimum 2 reviewers on main",
isEnabled: true,
isBlocking: true,
type: { id: POLICY_TYPES.MINIMUM_REVIEWERS },
settings: {
minimumApproverCount: 2,
creatorVoteCounts: false,
resetOnSourcePush: true,
blockLastPusherVote: true,
scope: [{
repositoryId: null,
refName: "refs/heads/main",
matchKind: "Exact"
}]
}
});
policies.push({
name: "Require linked work items on main",
isEnabled: true,
isBlocking: true,
type: { id: POLICY_TYPES.WORK_ITEM_LINKING },
settings: {
scope: [{
repositoryId: null,
refName: "refs/heads/main",
matchKind: "Exact"
}]
}
});
policies.push({
name: "Require comment resolution on main",
isEnabled: true,
isBlocking: true,
type: { id: POLICY_TYPES.COMMENT_RESOLUTION },
settings: {
scope: [{
repositoryId: null,
refName: "refs/heads/main",
matchKind: "Exact"
}]
}
});
policies.push({
name: "Minimum 1 reviewer on release branches",
isEnabled: true,
isBlocking: true,
type: { id: POLICY_TYPES.MINIMUM_REVIEWERS },
settings: {
minimumApproverCount: 1,
creatorVoteCounts: false,
resetOnSourcePush: true,
blockLastPusherVote: true,
scope: [{
repositoryId: null,
refName: "refs/heads/release/",
matchKind: "Prefix"
}]
}
});
// Per-repository build validation policies
repositories.forEach(function(repo) {
if (repo.defaultBranch !== "refs/heads/main") return;
if (repo.isDisabled) return;
// Skip repos that should not have build validation
var skipRepos = ["docs-only", "archived-project"];
if (skipRepos.indexOf(repo.name) !== -1) return;
policies.push({
name: "Build validation for " + repo.name,
isEnabled: true,
isBlocking: true,
type: { id: POLICY_TYPES.BUILD_VALIDATION },
settings: {
buildDefinitionId: repo.buildDefinitionId || null,
queueOnSourceUpdateOnly: true,
manualQueueOnly: false,
displayName: "PR Build - " + repo.name,
validDuration: 720,
scope: [{
repositoryId: repo.id,
refName: "refs/heads/main",
matchKind: "Exact"
}]
}
});
});
return policies;
}
// ============================================================
// Policy Synchronization
// ============================================================
function getExistingPolicies(callback) {
apiRequest("GET", "/_apis/policy/configurations?api-version=7.1", null,
function(err, result) {
if (err) return callback(err);
callback(null, result.value || []);
}
);
}
function getRepositories(callback) {
apiRequest("GET", "/_apis/git/repositories?api-version=7.1", null,
function(err, result) {
if (err) return callback(err);
callback(null, result.value || []);
}
);
}
function findMatchingPolicy(desired, existing) {
for (var i = 0; i < existing.length; i++) {
var ex = existing[i];
if (ex.type.id !== desired.type.id) continue;
var exScope = ex.settings.scope || [];
var desScope = desired.settings.scope || [];
if (exScope.length !== desScope.length) continue;
var scopeMatch = true;
for (var j = 0; j < exScope.length; j++) {
if (exScope[j].refName !== desScope[j].refName ||
exScope[j].matchKind !== desScope[j].matchKind ||
exScope[j].repositoryId !== desScope[j].repositoryId) {
scopeMatch = false;
break;
}
}
if (scopeMatch) return ex;
}
return null;
}
function applyPolicy(desired, existingPolicy, callback) {
var policyConfig = {
isEnabled: desired.isEnabled,
isBlocking: desired.isBlocking,
type: desired.type,
settings: desired.settings
};
if (existingPolicy) {
console.log(" UPDATE: %s (id: %d)", desired.name, existingPolicy.id);
apiRequest("PUT",
"/_apis/policy/configurations/" + existingPolicy.id + "?api-version=7.1",
policyConfig, callback);
} else {
console.log(" CREATE: %s", desired.name);
apiRequest("POST",
"/_apis/policy/configurations?api-version=7.1",
policyConfig, callback);
}
}
function applyPoliciesSequentially(policies, existing, index, results, callback) {
if (index >= policies.length) {
return callback(null, results);
}
var desired = policies[index];
var match = findMatchingPolicy(desired, existing);
applyPolicy(desired, match, function(err, result) {
if (err) {
console.error(" FAILED: %s - %s", desired.name, err.message);
results.failed.push({ name: desired.name, error: err.message });
} else {
results.succeeded.push(desired.name);
}
applyPoliciesSequentially(policies, existing, index + 1, results, callback);
});
}
// ============================================================
// Main
// ============================================================
function main() {
console.log("=== Branch Policy Configuration Tool ===");
console.log("Organization: %s", CONFIG.organization);
console.log("Project: %s", CONFIG.project);
console.log("");
if (!CONFIG.pat) {
console.error("Error: AZURE_DEVOPS_PAT environment variable is required");
process.exit(1);
}
console.log("Fetching repositories...");
getRepositories(function(err, repositories) {
if (err) {
console.error("Failed to fetch repositories:", err.message);
process.exit(1);
}
console.log("Found %d repositories", repositories.length);
console.log("Fetching existing policies...");
getExistingPolicies(function(err, existing) {
if (err) {
console.error("Failed to fetch policies:", err.message);
process.exit(1);
}
console.log("Found %d existing policies", existing.length);
console.log("");
var desired = buildPolicyManifest(repositories);
console.log("Applying %d policies...", desired.length);
var results = { succeeded: [], failed: [] };
applyPoliciesSequentially(desired, existing, 0, results, function(err, results) {
console.log("");
console.log("=== Results ===");
console.log("Succeeded: %d", results.succeeded.length);
console.log("Failed: %d", results.failed.length);
if (results.failed.length > 0) {
console.log("");
console.log("Failures:");
results.failed.forEach(function(f) {
console.log(" - %s: %s", f.name, f.error);
});
process.exit(1);
}
console.log("");
console.log("All policies applied successfully.");
});
});
});
}
main();
Running this script produces output like:
$ AZURE_DEVOPS_ORG=myorg AZURE_DEVOPS_PROJECT=myproject AZURE_DEVOPS_PAT=xxxx node apply-policies.js
=== Branch Policy Configuration Tool ===
Organization: myorg
Project: myproject
Fetching repositories...
Found 14 repositories
Fetching existing policies...
Found 8 existing policies
Applying 18 policies...
CREATE: Minimum 2 reviewers on main
CREATE: Require linked work items on main
CREATE: Require comment resolution on main
CREATE: Minimum 1 reviewer on release branches
UPDATE: Build validation for payment-service (id: 42)
CREATE: Build validation for user-service
CREATE: Build validation for api-gateway
UPDATE: Build validation for notification-service (id: 55)
CREATE: Build validation for inventory-service
...
=== Results ===
Succeeded: 18
Failed: 0
All policies applied successfully.
Common Issues and Troubleshooting
1. Policy Not Evaluating on Pull Requests
Error message: No policies are shown on the PR, even though they are configured.
Cause: The policy scope does not match the target branch. This commonly happens when the branch ref name is wrong.
// Wrong - missing the refs/heads/ prefix
refName: "main"
// Correct
refName: "refs/heads/main"
Also verify that the matchKind is correct. Use Exact for specific branches and Prefix for branch patterns like release/*.
2. Build Validation Policy Shows "Build Definition Not Found"
Error message: TF401027: The build definition ID 999 does not exist or you do not have permissions to access it.
Cause: The buildDefinitionId in the policy configuration references a build pipeline that has been deleted, or the PAT used to create the policy does not have access to the build definition.
Fix: List available build definitions and update the policy with the correct ID:
apiRequest("GET", "/_apis/build/definitions?api-version=7.1", null,
function(err, result) {
result.value.forEach(function(def) {
console.log("ID: %d, Name: %s", def.id, def.name);
});
}
);
3. "Bypass Policies" Permission Not Working
Error message: TF401289: You do not have permission to bypass policies on this branch.
Cause: The user has the "Bypass policies when pushing" permission but is trying to bypass policies when completing a PR (or vice versa). These are two separate permissions.
Fix: Grant the correct permission. For PR completion bypass, the permission bit is 32768. For push bypass, the permission bit is 128. Check which one the user actually needs.
4. Required Reviewer Policy Not Triggering on File Changes
Error message: No error, but the required reviewer is not added to the PR even though changed files match the path filter.
Cause: The filenamePatterns use incorrect glob syntax. Azure DevOps path filters use forward slashes and expect patterns relative to the repository root.
// Wrong - backslashes and missing leading slash
filenamePatterns: ["src\\db\\*"]
// Wrong - double asterisk not supported in this context
filenamePatterns: ["**/db/*"]
// Correct
filenamePatterns: ["/src/db/*"]
Also check that the reviewer identity GUID is correct. An invalid GUID will silently fail to add the reviewer.
5. Policy API Returns 403 Forbidden
Error message: VS403742: The user does not have permission to create or update policies for this project.
Cause: The PAT or identity used does not have Project Administrator permissions, or the PAT scope does not include the Policy (Read & Write) permission.
Fix: Regenerate the PAT with the correct scope. The minimum required scopes are:
Code (Read)to list repositoriesPolicy (Read & Write)to manage policiesBuild (Read)if configuring build validation policies
Best Practices
Apply policies at the project level first, then override per repository. Setting
repositoryId: nullin the policy scope gives you a baseline that covers every new repository automatically. Only create repository-specific policies for genuine exceptions.Version control your policy configurations. Store your policy manifest (the desired state) in a Git repository. Run the synchronization script from a CI pipeline. This gives you an audit trail of every policy change and the ability to roll back.
Use separate build validation pipelines for PRs. Do not reuse your main CI pipeline for PR validation. PR builds should be fast (under 10 minutes), run only unit tests and static analysis, and skip deployment steps. A slow PR build kills developer productivity.
Audit policy bypass events proactively. Azure DevOps generates audit events when policies are bypassed. Feed these into your monitoring system and create alerts. Every bypass should trigger a notification to a Slack channel or Teams group. Normalizing bypass is the first step toward having no protection at all.
Require work item linking on all protected branches. This creates traceability from code changes back to requirements, bugs, or tasks. It also prevents "drive-by" changes that nobody asked for and nobody tracked. The overhead is minimal: create a work item, link it in the PR description, done.
Set reset-on-push to true for main but false for development branches. Resetting reviewer votes on every push is the right call for
mainwhere quality matters most. For long-lived feature branches ordevelop, it creates excessive review churn. Be pragmatic about where you enforce strictness.Implement a documented break-glass procedure before you need one. Do not figure out emergency bypass during an outage. Write the runbook, test it quarterly, and make sure at least three people know how to execute it. Time the procedure so you know exactly how long it takes.
Review and prune policies quarterly. Policies accumulate over time. Teams change, repositories get archived, build definitions get replaced. Run a quarterly audit to remove stale policies, update reviewer assignments, and verify that build definitions still exist.
Keep hotfix branch policies lighter than main. A hotfix exists because production is broken. Requiring two reviewers and a 45-minute integration test suite on a hotfix branch is technically correct but operationally dangerous. One senior reviewer and a fast build are sufficient for hotfixes.