Security

Branch Protection Strategies for Enterprise Teams

Enterprise-grade branch protection strategies for Azure DevOps, covering policy configuration, required reviewers, build validation, merge strategies, cross-repository policies, and automated policy management via REST API.

Branch Protection Strategies for Enterprise Teams

Overview

Branch protection is the first line of defense for your codebase. Without it, any developer can push directly to main, skip code reviews, and deploy broken code to production. In enterprise environments with dozens or hundreds of developers, branch policies are not optional — they are the guardrails that keep the codebase stable. I configure tiered branch protection on every project: strict policies on main and release branches, moderate policies on development branches, and minimal friction on feature branches. This article covers every Azure DevOps branch policy, how to layer them for enterprise workflows, and how to automate policy management via the REST API.

Prerequisites

  • Azure DevOps organization with at least one Git repository
  • Project Administrator permissions for branch policy configuration
  • Understanding of Git branching strategies (GitFlow, trunk-based, or hybrid)
  • Node.js 16 or later for REST API automation scripts
  • Azure DevOps Personal Access Token with Code (Manage) scope for policy automation

Branch Policy Configuration

Azure DevOps branch policies are configured per branch per repository. Policies can target exact branch names or patterns.

Available Policy Types

Policy Purpose Enforcement
Require minimum reviewers Code review before merge Blocks merge without approvals
Check for linked work items Traceability Blocks merge without linked items
Check for comment resolution Review completeness Blocks merge with active comments
Build validation CI checks Blocks merge if build fails
Merge strategy Code history Restricts allowed merge types
Automatically included reviewers Domain expertise Auto-adds reviewers based on paths
Status checks External tools Blocks merge without external approval

Configuring Policies via the UI

  1. Go to Repos > Branches
  2. Find the target branch and click the ... menu
  3. Select Branch policies
  4. Configure each policy type

Policy Scope: Exact vs Pattern

Exact: refs/heads/main
  → Applies only to the main branch

Pattern: refs/heads/release/*
  → Applies to all release branches (release/1.0, release/2.0, etc.)

Pattern: refs/heads/feature/*
  → Applies to all feature branches

Required Reviewers and Auto-Completion

Minimum Reviewer Count

For enterprise teams, require at least 2 reviewers on main and 1 on development:

Branch: main
  Minimum number of reviewers: 2
  Allow requestors to approve their own changes: No
  Prohibit the most recent pusher from approving: Yes
  Allow completion even if some reviewers vote "Wait" or "Reject": No
  Reset code reviewer votes when there are new changes: Yes

Branch: develop
  Minimum number of reviewers: 1
  Allow requestors to approve their own changes: No
  Reset code reviewer votes when there are new changes: Yes

The "Prohibit the most recent pusher from approving" setting is critical — it prevents a developer from pushing a commit and immediately approving their own PR. Someone else must review the latest changes.

Automatically Included Reviewers

Set up path-based automatic reviewer assignment so the right experts review changes to their domain:

Path: /src/database/*
  Required reviewer: Database Team
  Policy requirement: Required

Path: /src/auth/*
  Required reviewer: Security Team
  Policy requirement: Required

Path: /infrastructure/*
  Required reviewer: DevOps Team
  Policy requirement: Required

Path: /docs/*
  Required reviewer: None (optional)
  Policy requirement: Optional

This ensures that database schema changes are reviewed by the database team, authentication changes are reviewed by security, and infrastructure changes are reviewed by the DevOps team — automatically, without relying on the PR author to tag the right people.

Auto-Complete Configuration

Auto-complete lets a PR merge automatically once all policies are satisfied:

# When auto-complete is enabled on a PR:
# 1. Reviewers approve
# 2. Build validation passes
# 3. Comments are resolved
# 4. PR merges automatically
# 5. Source branch is deleted (if configured)

Configure at the PR level — the PR author clicks "Set auto-complete" and chooses merge options. Administrators can disable auto-complete for specific branches if needed.

Build Validation Policies

Build validation runs a CI pipeline on every PR and blocks the merge if it fails.

Basic Build Validation

Branch: main
  Build pipeline: CI-Build
  Trigger: Automatic
  Policy requirement: Required
  Build expiration: Immediately when the source branch is updated
  Display name: "CI Build"

Multiple Build Validations

Enterprise repos often need several build validations:

Branch: main
  Build validations:
    1. CI-Build (required) — Compiles and runs unit tests
    2. Security-Scan (required) — Dependency audit and SAST
    3. Integration-Tests (optional) — Runs integration test suite
    4. Performance-Benchmark (optional) — Checks for performance regressions

Required builds block the merge. Optional builds provide information but do not gate.

Build Expiration

Options:
  - Immediately when source branch is updated
    → Most secure: any new push invalidates the build
    → May slow down teams with frequent push-review cycles

  - After X hours if source branch has been updated
    → Balance between security and velocity
    → Set to 12-24 hours for active development branches

  - Never
    → NOT recommended for production branches

For main: use "Immediately when source branch is updated." For develop: use "After 12 hours if source branch has been updated."

Merge Strategy Enforcement

Control how code enters protected branches:

Available Merge Types

Strategy History Use Case
Merge (no fast-forward) Preserves branch history Enterprise default — full traceability
Squash merge Collapses to single commit Clean main history, feature branch detail hidden
Rebase and fast-forward Linear history Small teams, clean log
Rebase with merge commit Linear but with merge marker Combines linearity with merge traceability

Enterprise Recommendations

Branch: main
  Allowed merge types: Squash merge only
  Reason: One commit per feature/fix on main = clean history, easy bisect

Branch: release/*
  Allowed merge types: Merge (no fast-forward) only
  Reason: Preserve merge history for release auditing

Branch: develop
  Allowed merge types: Squash merge OR Merge (no fast-forward)
  Reason: Developer flexibility for the integration branch

Squash Merge Template

Configure a squash commit message template for consistency:

${PullRequest.Title}

${PullRequest.Description}

PR #${PullRequest.PullRequestId}
Reviewers: ${PullRequest.Reviewers}
Work Items: ${PullRequest.WorkItems}

This produces commits like:

Add user authentication middleware

Implements JWT-based authentication with refresh tokens,
middleware for Express.js routes, and role-based access control.

PR #1234
Reviewers: Jane Smith, Bob Johnson
Work Items: #5678, #5679

Bypassing Policies with Audit Trails

Sometimes policies need to be bypassed — hotfixes at 3 AM, emergency deployments, critical security patches. Azure DevOps supports this with full audit logging.

Bypass Permissions

Project Settings > Repositories > [repo] > Security

Permission: "Bypass policies when completing pull requests"
  Grant to: Emergency Responders group
  Effect: Allow

Permission: "Bypass policies when pushing"
  Grant to: NO ONE (or extremely limited)
  Effect: Deny for all except break-glass accounts

The "Bypass policies when pushing" permission allows direct pushes without a PR. This should almost never be granted.

Audit Trail for Policy Bypasses

Every policy bypass is logged in the Azure DevOps audit log:

// scripts/audit-policy-bypasses.js
var https = require("https");

var PAT = process.env.AZURE_PAT;
var ORG = process.env.AZURE_ORG;

function auditRequest(startTime, callback) {
    var auth = Buffer.from(":" + PAT).toString("base64");
    var options = {
        hostname: "auditservice.dev.azure.com",
        path: "/" + ORG + "/_apis/audit/auditlog?startTime=" + encodeURIComponent(startTime) + "&api-version=7.1",
        method: "GET",
        headers: {
            "Authorization": "Basic " + auth,
            "Accept": "application/json"
        }
    };

    var req = https.request(options, function(res) {
        var data = "";
        res.on("data", function(chunk) { data += chunk; });
        res.on("end", function() {
            if (res.statusCode === 200) {
                callback(null, JSON.parse(data));
            } else {
                callback(new Error("Audit error: " + res.statusCode));
            }
        });
    });
    req.on("error", callback);
    req.end();
}

var thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();

auditRequest(thirtyDaysAgo, function(err, data) {
    if (err) {
        console.error("Failed:", err.message);
        process.exit(1);
    }

    var events = data.decoratedAuditLogEntries || [];

    var bypasses = events.filter(function(e) {
        return e.actionId === "Git.RefUpdatePoliciesBypassed" ||
            (e.details && e.details.indexOf("bypass") !== -1);
    });

    console.log("Policy Bypass Audit (Last 30 Days)");
    console.log("===================================\n");
    console.log("Total bypasses: " + bypasses.length + "\n");

    if (bypasses.length === 0) {
        console.log("No policy bypasses detected. Good.");
        return;
    }

    bypasses.forEach(function(e) {
        console.log("[BYPASS] " + e.timestamp);
        console.log("  Actor: " + (e.actorDisplayName || "unknown"));
        console.log("  IP: " + (e.ipAddress || "unknown"));
        console.log("  Details: " + (e.details || "none"));
        console.log("  Area: " + (e.area || "unknown"));
        console.log("");
    });

    if (bypasses.length > 5) {
        console.log("WARNING: High number of policy bypasses. Review whether bypass permissions are too broadly granted.");
    }
});

Emergency Change Process

Define a formal process for policy bypasses:

  1. Developer creates PR and notes the emergency in the description
  2. A member of the Emergency Responders group approves
  3. The approver uses "Override and complete" to bypass remaining policies
  4. A post-mortem is filed within 24 hours explaining why the bypass was necessary
  5. The audit script catches the bypass and alerts the security team

Cross-Repository Branch Policies

Enterprise organizations with many repos need consistent policies across all of them.

Organization-Level Default Policies

Azure DevOps does not natively support organization-wide branch policies. Automate with the REST API:

// scripts/apply-org-policies.js
var https = require("https");

var ORG = process.env.AZURE_ORG;
var PROJECT = process.env.AZURE_PROJECT;
var PAT = process.env.AZURE_PAT;

// Standard policy configuration for all repos
var STANDARD_POLICIES = {
    main: {
        minReviewers: 2,
        creatorVoteCounts: false,
        resetOnPush: true,
        requireWorkItems: true,
        requireCommentResolution: true,
        allowedMergeTypes: { squash: true, noFastForward: false, rebase: false, rebaseMerge: false }
    },
    develop: {
        minReviewers: 1,
        creatorVoteCounts: false,
        resetOnPush: true,
        requireWorkItems: false,
        requireCommentResolution: false,
        allowedMergeTypes: { squash: true, noFastForward: true, rebase: false, rebaseMerge: false }
    }
};

function apiRequest(method, path, body, callback) {
    var auth = Buffer.from(":" + PAT).toString("base64");
    var bodyStr = body ? JSON.stringify(body) : null;

    var options = {
        hostname: "dev.azure.com",
        path: "/" + ORG + "/" + PROJECT + "/_apis" + path,
        method: method,
        headers: {
            "Authorization": "Basic " + auth,
            "Content-Type": "application/json",
            "Accept": "application/json"
        }
    };

    if (bodyStr) {
        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 >= 200 && res.statusCode < 300) {
                try { callback(null, JSON.parse(data)); }
                catch (e) { callback(null, data); }
            } else {
                callback(new Error(method + " " + path + " failed (" + res.statusCode + "): " + data.substring(0, 300)));
            }
        });
    });

    req.on("error", callback);
    if (bodyStr) { req.write(bodyStr); }
    req.end();
}

function createMinReviewerPolicy(repoId, branch, config, callback) {
    var body = {
        isEnabled: true,
        isBlocking: true,
        type: { id: "fa4e907d-c16b-4a4c-9dfa-4916e5d171ab" },
        settings: {
            minimumApproverCount: config.minReviewers,
            creatorVoteCounts: config.creatorVoteCounts,
            allowDownvotes: false,
            resetOnSourcePush: config.resetOnPush,
            scope: [{
                refName: "refs/heads/" + branch,
                matchKind: "Exact",
                repositoryId: repoId
            }]
        }
    };

    apiRequest("POST", "/policy/configurations?api-version=7.1", body, callback);
}

function createMergeStrategyPolicy(repoId, branch, config, callback) {
    var body = {
        isEnabled: true,
        isBlocking: true,
        type: { id: "fa4e907d-c16b-4a4c-9dfa-4b106e5d171c" },
        settings: {
            allowSquash: config.allowedMergeTypes.squash,
            allowNoFastForward: config.allowedMergeTypes.noFastForward,
            allowRebase: config.allowedMergeTypes.rebase,
            allowRebaseMerge: config.allowedMergeTypes.rebaseMerge,
            scope: [{
                refName: "refs/heads/" + branch,
                matchKind: "Exact",
                repositoryId: repoId
            }]
        }
    };

    apiRequest("POST", "/policy/configurations?api-version=7.1", body, callback);
}

function createWorkItemPolicy(repoId, branch, callback) {
    var body = {
        isEnabled: true,
        isBlocking: true,
        type: { id: "40e92b44-2fe1-4dd6-b3d8-74a9c21d0c6e" },
        settings: {
            scope: [{
                refName: "refs/heads/" + branch,
                matchKind: "Exact",
                repositoryId: repoId
            }]
        }
    };

    apiRequest("POST", "/policy/configurations?api-version=7.1", body, callback);
}

function createCommentResolutionPolicy(repoId, branch, callback) {
    var body = {
        isEnabled: true,
        isBlocking: true,
        type: { id: "c6a1889d-b943-4856-b76f-9e46bb6b0df2" },
        settings: {
            scope: [{
                refName: "refs/heads/" + branch,
                matchKind: "Exact",
                repositoryId: repoId
            }]
        }
    };

    apiRequest("POST", "/policy/configurations?api-version=7.1", body, callback);
}

// Get all repos in the project
apiRequest("GET", "/git/repositories?api-version=7.1", null, function(err, data) {
    if (err) {
        console.error("Failed to list repos:", err.message);
        process.exit(1);
    }

    var repos = data.value || [];
    console.log("Applying standard branch policies to " + repos.length + " repositories\n");

    var completed = 0;
    var total = repos.length;

    repos.forEach(function(repo) {
        console.log("Repository: " + repo.name + " (" + repo.id + ")");

        Object.keys(STANDARD_POLICIES).forEach(function(branch) {
            var config = STANDARD_POLICIES[branch];

            createMinReviewerPolicy(repo.id, branch, config, function(err2) {
                if (err2) {
                    console.log("  [SKIP] " + branch + " reviewer policy: " + err2.message.substring(0, 80));
                } else {
                    console.log("  [OK] " + branch + ": min " + config.minReviewers + " reviewer(s)");
                }
            });

            createMergeStrategyPolicy(repo.id, branch, config, function(err3) {
                if (err3) {
                    console.log("  [SKIP] " + branch + " merge policy: " + err3.message.substring(0, 80));
                } else {
                    var types = [];
                    if (config.allowedMergeTypes.squash) types.push("squash");
                    if (config.allowedMergeTypes.noFastForward) types.push("merge");
                    console.log("  [OK] " + branch + ": merge types [" + types.join(", ") + "]");
                }
            });

            if (config.requireWorkItems) {
                createWorkItemPolicy(repo.id, branch, function(err4) {
                    if (err4) {
                        console.log("  [SKIP] " + branch + " work item policy: " + err4.message.substring(0, 80));
                    } else {
                        console.log("  [OK] " + branch + ": require linked work items");
                    }
                });
            }

            if (config.requireCommentResolution) {
                createCommentResolutionPolicy(repo.id, branch, function(err5) {
                    if (err5) {
                        console.log("  [SKIP] " + branch + " comment policy: " + err5.message.substring(0, 80));
                    } else {
                        console.log("  [OK] " + branch + ": require comment resolution");
                    }
                });
            }
        });

        completed++;
        if (completed === total) {
            console.log("\nPolicy application complete.");
        }
    });
});

Output:

Applying standard branch policies to 6 repositories

Repository: api-service (abc123)
  [OK] main: min 2 reviewer(s)
  [OK] main: merge types [squash]
  [OK] main: require linked work items
  [OK] main: require comment resolution
  [OK] develop: min 1 reviewer(s)
  [OK] develop: merge types [squash, merge]

Repository: web-frontend (def456)
  [OK] main: min 2 reviewer(s)
  [OK] main: merge types [squash]
  [OK] main: require linked work items
  [OK] main: require comment resolution
  [OK] develop: min 1 reviewer(s)
  [OK] develop: merge types [squash, merge]

...

Policy application complete.

Protecting Release Branches and Hotfix Workflows

Release branches need special protection because they represent code going to production.

Release Branch Policy Pattern

Branch pattern: refs/heads/release/*
  Minimum reviewers: 2 (including release manager)
  Build validation: Release-CI (required)
  Merge strategy: Merge (no fast-forward) only
  Linked work items: Required
  Comment resolution: Required
  Automatically included reviewers:
    - Release Manager group (required)
    - QA Lead (required)

Hotfix Workflow

Hotfixes bypass the normal development flow but still need protection:

1. Developer creates hotfix/issue-1234 branch from main
2. Developer commits fix and creates PR to main
3. PR requires:
   - 1 reviewer (reduced from 2 for speed)
   - Build validation (must pass)
   - Comment resolution
4. After merge to main:
   - Cherry-pick to active release branches
   - Each cherry-pick PR has its own review

Configure a separate policy for hotfix branches:

Branch pattern: refs/heads/hotfix/*
  Minimum reviewers: 1 (reduced for emergency speed)
  Build validation: CI-Build (required)
  Comment resolution: Required
  Automatically included reviewers:
    - On-Call Engineer group (required)

Complete Working Example

A tiered branch protection setup automated via REST API:

// scripts/setup-tiered-protection.js
var https = require("https");

var ORG = process.env.AZURE_ORG;
var PROJECT = process.env.AZURE_PROJECT;
var PAT = process.env.AZURE_PAT;
var REPO_NAME = process.env.REPO_NAME;
var BUILD_DEFINITION_ID = parseInt(process.env.BUILD_DEFINITION_ID, 10);

function apiRequest(method, path, body, callback) {
    var auth = Buffer.from(":" + PAT).toString("base64");
    var bodyStr = body ? JSON.stringify(body) : null;

    var options = {
        hostname: "dev.azure.com",
        path: "/" + ORG + "/" + PROJECT + "/_apis" + path,
        method: method,
        headers: {
            "Authorization": "Basic " + auth,
            "Content-Type": "application/json",
            "Accept": "application/json"
        }
    };

    if (bodyStr) { 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() {
            try { callback(null, res.statusCode, JSON.parse(data)); }
            catch (e) { callback(null, res.statusCode, data); }
        });
    });
    req.on("error", callback);
    if (bodyStr) { req.write(bodyStr); }
    req.end();
}

function createPolicy(typeId, settings, callback) {
    var body = {
        isEnabled: true,
        isBlocking: true,
        type: { id: typeId },
        settings: settings
    };

    apiRequest("POST", "/policy/configurations?api-version=7.1", body, function(err, status, data) {
        if (err) return callback(err);
        if (status >= 200 && status < 300) {
            callback(null, data);
        } else {
            callback(new Error("Policy creation failed (" + status + "): " + JSON.stringify(data).substring(0, 200)));
        }
    });
}

// Get repo ID
apiRequest("GET", "/git/repositories?api-version=7.1", null, function(err, status, data) {
    if (err) { console.error(err.message); process.exit(1); }

    var repos = data.value || [];
    var repo = repos.filter(function(r) { return r.name === REPO_NAME; })[0];

    if (!repo) {
        console.error("Repository not found: " + REPO_NAME);
        process.exit(1);
    }

    console.log("Setting up tiered branch protection for: " + repo.name);
    console.log("Repository ID: " + repo.id + "\n");

    var repoId = repo.id;

    // TIER 1: Main branch (strictest)
    console.log("=== TIER 1: main (Production) ===");

    // Min reviewers: 2
    createPolicy("fa4e907d-c16b-4a4c-9dfa-4916e5d171ab", {
        minimumApproverCount: 2,
        creatorVoteCounts: false,
        allowDownvotes: false,
        resetOnSourcePush: true,
        blockLastPusherVote: true,
        scope: [{ refName: "refs/heads/main", matchKind: "Exact", repositoryId: repoId }]
    }, function(err2) {
        console.log(err2 ? "  [FAIL] Reviewers: " + err2.message : "  [OK] Min 2 reviewers, reset on push, block self-approval");
    });

    // Build validation
    createPolicy("0609b952-1397-4640-95ec-e00a01b2c241", {
        buildDefinitionId: BUILD_DEFINITION_ID,
        queueOnSourceUpdateOnly: true,
        manualQueueOnly: false,
        displayName: "CI Build Validation",
        validDuration: 0,
        scope: [{ refName: "refs/heads/main", matchKind: "Exact", repositoryId: repoId }]
    }, function(err3) {
        console.log(err3 ? "  [FAIL] Build validation: " + err3.message : "  [OK] Build validation required");
    });

    // Work item linkage
    createPolicy("40e92b44-2fe1-4dd6-b3d8-74a9c21d0c6e", {
        scope: [{ refName: "refs/heads/main", matchKind: "Exact", repositoryId: repoId }]
    }, function(err4) {
        console.log(err4 ? "  [FAIL] Work items: " + err4.message : "  [OK] Linked work items required");
    });

    // Comment resolution
    createPolicy("c6a1889d-b943-4856-b76f-9e46bb6b0df2", {
        scope: [{ refName: "refs/heads/main", matchKind: "Exact", repositoryId: repoId }]
    }, function(err5) {
        console.log(err5 ? "  [FAIL] Comments: " + err5.message : "  [OK] Comment resolution required");
    });

    // Merge strategy: squash only
    createPolicy("fa4e907d-c16b-4a4c-9dfa-4b106e5d171c", {
        allowSquash: true,
        allowNoFastForward: false,
        allowRebase: false,
        allowRebaseMerge: false,
        scope: [{ refName: "refs/heads/main", matchKind: "Exact", repositoryId: repoId }]
    }, function(err6) {
        console.log(err6 ? "  [FAIL] Merge strategy: " + err6.message : "  [OK] Squash merge only");
    });

    // TIER 2: Release branches (strict but different)
    console.log("\n=== TIER 2: release/* (Release) ===");

    createPolicy("fa4e907d-c16b-4a4c-9dfa-4916e5d171ab", {
        minimumApproverCount: 2,
        creatorVoteCounts: false,
        allowDownvotes: false,
        resetOnSourcePush: true,
        scope: [{ refName: "refs/heads/release/", matchKind: "Prefix", repositoryId: repoId }]
    }, function(err7) {
        console.log(err7 ? "  [FAIL] Reviewers: " + err7.message : "  [OK] Min 2 reviewers");
    });

    createPolicy("fa4e907d-c16b-4a4c-9dfa-4b106e5d171c", {
        allowSquash: false,
        allowNoFastForward: true,
        allowRebase: false,
        allowRebaseMerge: false,
        scope: [{ refName: "refs/heads/release/", matchKind: "Prefix", repositoryId: repoId }]
    }, function(err8) {
        console.log(err8 ? "  [FAIL] Merge strategy: " + err8.message : "  [OK] Merge commit only (preserve history)");
    });

    // TIER 3: Develop branch (moderate)
    console.log("\n=== TIER 3: develop (Integration) ===");

    createPolicy("fa4e907d-c16b-4a4c-9dfa-4916e5d171ab", {
        minimumApproverCount: 1,
        creatorVoteCounts: false,
        allowDownvotes: false,
        resetOnSourcePush: false,
        scope: [{ refName: "refs/heads/develop", matchKind: "Exact", repositoryId: repoId }]
    }, function(err9) {
        console.log(err9 ? "  [FAIL] Reviewers: " + err9.message : "  [OK] Min 1 reviewer");
    });

    createPolicy("0609b952-1397-4640-95ec-e00a01b2c241", {
        buildDefinitionId: BUILD_DEFINITION_ID,
        queueOnSourceUpdateOnly: true,
        manualQueueOnly: false,
        displayName: "CI Build",
        validDuration: 720,
        scope: [{ refName: "refs/heads/develop", matchKind: "Exact", repositoryId: repoId }]
    }, function(err10) {
        console.log(err10 ? "  [FAIL] Build validation: " + err10.message : "  [OK] Build validation (12hr expiry)");
    });

    console.log("\n=== TIER 4: feature/* (Minimal) ===");
    console.log("  [INFO] No branch policies on feature branches");
    console.log("  [INFO] Developers have full autonomy on their branches");
    console.log("  [INFO] Policies kick in when they create a PR to develop/main");

    console.log("\nTiered branch protection setup complete.");
});

Run the script:

export AZURE_ORG="your-org"
export AZURE_PROJECT="YourProject"
export AZURE_PAT="your-pat-token"
export REPO_NAME="api-service"
export BUILD_DEFINITION_ID="42"

node scripts/setup-tiered-protection.js

Output:

Setting up tiered branch protection for: api-service
Repository ID: abc12345-def6-7890-abcd-ef1234567890

=== TIER 1: main (Production) ===
  [OK] Min 2 reviewers, reset on push, block self-approval
  [OK] Build validation required
  [OK] Linked work items required
  [OK] Comment resolution required
  [OK] Squash merge only

=== TIER 2: release/* (Release) ===
  [OK] Min 2 reviewers
  [OK] Merge commit only (preserve history)

=== TIER 3: develop (Integration) ===
  [OK] Min 1 reviewer
  [OK] Build validation (12hr expiry)

=== TIER 4: feature/* (Minimal) ===
  [INFO] No branch policies on feature branches
  [INFO] Developers have full autonomy on their branches
  [INFO] Policies kick in when they create a PR to develop/main

Tiered branch protection setup complete.

Common Issues and Troubleshooting

"TF401027: You need the Git 'PullRequestContribute' permission to complete this pull request"

TF401027: You need the Git 'PullRequestContribute' permission to complete this pull request.

The user trying to complete the PR does not have the right repository permissions. Go to Project Settings > Repositories > [repo] > Security and grant "Contribute to pull requests" to the appropriate group. This is separate from the "Contribute" permission which controls pushing.

Branch policies not applying to a pattern branch

You created a policy for refs/heads/release/* but release branches are not protected. Check the matchKind — it should be Prefix for pattern matching, not Exact. In the UI, use the branch selector to pick "Prefix" when configuring the policy. Via the API, set matchKind: "Prefix" and set refName to refs/heads/release/ (with trailing slash).

"The pull request cannot be completed because it is blocked by one or more policies"

The pull request cannot be completed because it is blocked by one or more policies.
Policy: Minimum number of reviewers — Not approved
Policy: Work items — No linked work items

This is the system working correctly. The policies are blocking because the requirements are not met. Either satisfy the policies (get reviews, link work items) or have someone with "Bypass policies" permission override.

Build validation status stuck on "Queued" or "Not applicable"

The build validation trigger may be misconfigured. Check that:

  1. The build definition ID matches an existing pipeline
  2. The pipeline is configured to run on PR triggers
  3. The pipeline has permissions to access the repository
  4. The build agent pool has capacity (no stuck builds)

If the build was previously passing and now shows "Not applicable," the pipeline may have been deleted or renamed. Update the build validation policy to point to the correct pipeline.

Best Practices

  • Use tiered protection: strict on main, moderate on develop, minimal on feature branches. Developers need freedom to experiment on feature branches. The protection should tighten as code moves toward production.

  • Always enable "Reset votes on new push." When a reviewer approves and the author pushes new changes, the approval should reset. The reviewer approved a specific version of the code, not whatever comes next.

  • Block self-approval on production branches. The person who wrote the code should not be the only reviewer. Enable "Prohibit the most recent pusher from approving" on main and release branches.

  • Automate policy configuration across repos. When you have more than 5 repositories, manual policy configuration is error-prone and inconsistent. Use the REST API script to apply standard policies to all repos and run it as part of your repo provisioning process.

  • Require linked work items on main. Every change to production code should be traceable to a requirement, bug report, or task. This is not bureaucracy — it is an audit trail that helps you understand why code changed when you are debugging an incident at 2 AM.

  • Use squash merge on main for clean history. Each feature becomes a single commit on main. This makes git bisect effective, git log readable, and cherry-picks predictable.

  • Audit policy bypasses regularly. Run the audit script weekly. A high bypass rate means either the policies are too restrictive (adjust them) or the team is not following the process (address it).

References

Powered by Contentful