Repos

Advanced Branch Policies: Enforcing Code Quality at Scale

A comprehensive guide to configuring and managing Azure DevOps branch policies for enforcing code quality, including minimum reviewers, build validation, path-based policies, and programmatic management via REST API.

Advanced Branch Policies: Enforcing Code Quality at Scale

Branch policies in Azure DevOps are the single most effective mechanism for enforcing code quality standards across engineering teams. They act as automated gatekeepers on your branches, preventing merges that do not meet defined criteria -- minimum reviewers, passing builds, resolved comments, and linked work items. If you are running a team of any size and not using branch policies, you are relying on discipline alone, and discipline does not scale.

Prerequisites

  • An Azure DevOps organization and project with at least one Git repository
  • Project Administrator or Branch Security permissions to configure policies
  • Node.js v16+ installed for the REST API examples
  • A Personal Access Token (PAT) with Code (Read & Write) and Policy (Read & Write) scopes
  • Basic familiarity with Git branching, pull requests, and CI/CD pipelines

Understanding Branch Policies

Branch policies are server-side rules that Azure DevOps enforces on pull requests targeting protected branches. Unlike Git hooks, which run client-side and can be bypassed, branch policies are enforced at the server level. No one pushes directly to a protected branch. Every change goes through a pull request, and every pull request must satisfy the configured policies before it can complete.

This matters because code quality is not a suggestion. When your main branch feeds a production deployment pipeline, a single unreviewed merge can take down a service. Branch policies remove the human error factor from the equation.

The core policy types available are:

Policy Type Purpose
Minimum number of reviewers Ensures peer review before merge
Build validation Runs CI pipelines against the PR
Comment resolution Requires all PR comments to be resolved
Work item linking Requires associated work items
Merge strategy Enforces specific merge types (squash, rebase, etc.)
Status checks Integrates external validation services
Automatically included reviewers Assigns reviewers based on path patterns
Path-based policies Applies different rules to different directories

Each policy can be configured as required (blocking) or optional (advisory). Required policies prevent PR completion until satisfied. Optional policies show warnings but do not block.

Configuring Minimum Reviewer Requirements

The minimum reviewer policy is the foundation of any code review culture. In Azure DevOps, navigate to Project Settings > Repos > Policies, select your branch, and configure the reviewer count.

But the basic UI settings only scratch the surface. Here is what experienced teams actually configure:

Minimum reviewer count: Two is the standard for production branches. One reviewer is acceptable for development branches or small teams, but for main or release/* branches, two reviewers catches significantly more defects.

Allow requestors to approve their own changes: Disable this. Always. If a developer can approve their own PR, the review process is theater. The only exception is a solo developer on a personal project, and even then it builds bad habits.

Prohibit the most recent pusher from approving: Enable this. If a developer pushes a fix in response to review feedback, the person who pushed that fix should not be the one approving it. Someone else needs to verify the fix.

Reset code reviewer votes when there are new pushes: This is critical. When a reviewer approves a PR and the author subsequently pushes new commits, the approval should reset. Those new commits were never reviewed. Enable "Reset all approval votes" for maximum safety.

{
  "isEnabled": true,
  "isBlocking": true,
  "type": {
    "id": "fa4e907d-c16b-4a4c-9dfa-4916e5d171ab"
  },
  "settings": {
    "minimumApproverCount": 2,
    "creatorVoteCounts": false,
    "allowDownvotes": false,
    "resetOnSourcePush": true,
    "requireVoteOnLastIteration": true,
    "resetRejectVote": true,
    "blockLastPusherVote": true,
    "scope": [
      {
        "refName": "refs/heads/main",
        "matchKind": "Exact",
        "repositoryId": null
      }
    ]
  }
}

The requireVoteOnLastIteration field is one that many teams miss. When enabled, it requires that at least one approval vote exists on the final iteration of the PR -- not just on some earlier version of the code that has since been rewritten.

Build Validation Policies

Build validation connects your CI pipeline directly to the pull request workflow. Every PR triggers a build, and the PR cannot complete unless the build passes. This is non-negotiable for any professional team.

To configure build validation, you need an existing build pipeline. Here is a simple azure-pipelines.yml for a Node.js project:

trigger: none

pr:
  branches:
    include:
      - main
      - release/*

pool:
  vmImage: 'ubuntu-latest'

steps:
  - task: NodeTool@0
    inputs:
      versionSpec: '20.x'
    displayName: 'Install Node.js'

  - script: npm ci
    displayName: 'Install dependencies'

  - script: npm run lint
    displayName: 'Run linter'

  - script: npm test
    displayName: 'Run tests'

  - script: npm run build
    displayName: 'Build project'

Note that trigger: none is intentional. This pipeline is triggered by the PR policy, not by pushes. Setting the trigger to none prevents duplicate builds.

When configuring the build validation policy, pay attention to these settings:

  • Build expiration: Set builds to expire after 12 or 24 hours. If a PR sits idle for days, the build results may no longer be relevant because other changes have merged to the target branch.
  • Trigger: Use "Automatic" for most cases. "Manual" triggers require someone to explicitly queue the build, which defeats the purpose of automation.
  • Policy requirement: Set to "Required" for your primary validation pipeline. You can add secondary pipelines as "Optional" for things like integration tests that may be flaky.
  • Build display name: Use a descriptive name like "CI - Unit Tests & Lint" rather than the pipeline name, so PR authors immediately understand what the check validates.
{
  "isEnabled": true,
  "isBlocking": true,
  "type": {
    "id": "0609b952-1397-4640-95ec-e00a01b2c241"
  },
  "settings": {
    "buildDefinitionId": 42,
    "queueOnSourceUpdateOnly": true,
    "manualQueueOnly": false,
    "displayName": "CI - Unit Tests & Lint",
    "validDuration": 720,
    "scope": [
      {
        "refName": "refs/heads/main",
        "matchKind": "Exact",
        "repositoryId": null
      }
    ]
  }
}

The validDuration is specified in minutes. 720 minutes equals 12 hours. After that window, the build is considered stale and must be re-run.

Status Checks and External Service Integration

Beyond built-in build validation, Azure DevOps supports external status checks. These allow third-party services -- SonarQube, Snyk, Checkmarx, or your own custom services -- to post pass/fail statuses to pull requests.

External services interact with PRs through the Status API. Your service receives a webhook notification when a PR is created or updated, performs its analysis, and posts a status back to Azure DevOps.

Here is a Node.js service that acts as an external status check, validating that PR descriptions meet a minimum length requirement:

var express = require("express");
var axios = require("axios");
var app = express();

app.use(express.json());

var AZURE_DEVOPS_ORG = process.env.AZURE_DEVOPS_ORG;
var AZURE_DEVOPS_PAT = process.env.AZURE_DEVOPS_PAT;
var AZURE_DEVOPS_PROJECT = process.env.AZURE_DEVOPS_PROJECT;

var MIN_DESCRIPTION_LENGTH = 50;

app.post("/webhook/pullrequest", function(req, res) {
  var eventType = req.body.eventType;

  if (eventType !== "git.pullrequest.created" && eventType !== "git.pullrequest.updated") {
    return res.status(200).send("Ignored event type: " + eventType);
  }

  var pullRequest = req.body.resource;
  var repositoryId = pullRequest.repository.id;
  var pullRequestId = pullRequest.pullRequestId;
  var description = pullRequest.description || "";

  var passed = description.length >= MIN_DESCRIPTION_LENGTH;
  var statusDescription = passed
    ? "PR description meets minimum length requirement"
    : "PR description must be at least " + MIN_DESCRIPTION_LENGTH + " characters";

  var statusPayload = {
    state: passed ? "succeeded" : "failed",
    description: statusDescription,
    context: {
      name: "pr-description-check",
      genre: "custom-quality-gates"
    },
    targetUrl: "https://wiki.internal.example.com/pr-standards"
  };

  var url = "https://dev.azure.com/" + AZURE_DEVOPS_ORG + "/" + AZURE_DEVOPS_PROJECT +
    "/_apis/git/repositories/" + repositoryId +
    "/pullRequests/" + pullRequestId +
    "/statuses?api-version=7.1";

  var authHeader = "Basic " + Buffer.from(":" + AZURE_DEVOPS_PAT).toString("base64");

  axios.post(url, statusPayload, {
    headers: {
      "Content-Type": "application/json",
      "Authorization": authHeader
    }
  })
  .then(function(response) {
    console.log("Status posted for PR #" + pullRequestId + ": " + (passed ? "PASSED" : "FAILED"));
    res.status(200).send("Status posted");
  })
  .catch(function(error) {
    console.error("Failed to post status:", error.response ? error.response.data : error.message);
    res.status(500).send("Failed to post status");
  });
});

app.listen(3000, function() {
  console.log("PR status check service running on port 3000");
});

To make this status check a required policy, configure a "Status Check" policy in the branch policy settings, matching on the genre custom-quality-gates and name pr-description-check.

Path-Based Policies

Path-based policies are an underutilized feature that allows you to apply different rules to different parts of your codebase. The principle is simple: changes to your database migration scripts deserve stricter review than changes to a README file.

In Azure DevOps, you configure path-based policies through the "Automatically included reviewers" policy with path filters. But you can also scope minimum reviewer counts and build validations to specific paths.

Real-world examples of path-based policies:

Path Pattern Policy Rationale
/src/db/migrations/* 3 reviewers, DBA team auto-assigned Schema changes have broad impact
/infrastructure/* 2 reviewers, DevOps team auto-assigned Infrastructure changes affect all environments
/docs/* 1 reviewer, optional build Documentation changes are low risk
/src/auth/* 2 reviewers + security team, required security scan Authentication code is security-critical
*.config.js 2 reviewers, config owners auto-assigned Configuration changes can break deployments

Here is the JSON configuration for an automatically included reviewer policy scoped to database migration paths:

{
  "isEnabled": true,
  "isBlocking": true,
  "type": {
    "id": "fd2167ab-b0be-447a-8571-0b55b4f05b6e"
  },
  "settings": {
    "requiredReviewerIds": [
      "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
    ],
    "filenamePatterns": [
      "/src/db/migrations/*"
    ],
    "addedFilesOnly": false,
    "message": "Database migration changes require DBA team approval",
    "scope": [
      {
        "refName": "refs/heads/main",
        "matchKind": "Exact",
        "repositoryId": null
      }
    ]
  }
}

The requiredReviewerIds field takes Azure DevOps user or group GUIDs. Use group GUIDs when possible -- individual IDs create a single point of failure when someone leaves the team.

Branch Policy Bypass and Emergency Procedures

Every team eventually faces a scenario where a critical hotfix needs to bypass the normal review process. Azure DevOps provides a "Bypass policies" permission that can be granted to specific users or groups.

Here is how to handle this responsibly:

Create a dedicated "Emergency Bypass" group. Do not grant bypass permissions to individuals. Add a small number of senior engineers (2-3 maximum) to this group. This creates an audit trail and makes it clear who has this power.

Require bypass justification. When someone uses "Override policies" to complete a PR, Azure DevOps records this action. Set up a service hook to notify a Slack or Teams channel whenever a policy override occurs:

// Service hook handler for policy override notifications
app.post("/webhook/policy-override", function(req, res) {
  var resource = req.body.resource;
  var completedBy = resource.completedBy || resource.closedBy;
  var title = resource.title;
  var pullRequestId = resource.pullRequestId;
  var repositoryName = resource.repository.name;

  var message = "POLICY OVERRIDE ALERT\n" +
    "PR #" + pullRequestId + ": " + title + "\n" +
    "Repository: " + repositoryName + "\n" +
    "Overridden by: " + completedBy.displayName + "\n" +
    "Time: " + new Date().toISOString();

  // Post to your team's alerting channel
  notifyTeamChannel(message);

  res.status(200).send("Notification sent");
});

Follow up on every bypass. After the emergency is resolved, create a follow-up PR with proper review that covers the bypassed changes. Treat every bypass as a minor incident -- not punitive, but worth tracking and learning from.

Programmatic Policy Management via REST API

Managing branch policies through the UI works for a single repository. When you manage 50 or 200 repositories, you need the REST API. The Azure DevOps Policy API allows you to create, read, update, and delete policies programmatically.

The key endpoints are:

GET    https://dev.azure.com/{org}/{project}/_apis/policy/configurations?api-version=7.1
POST   https://dev.azure.com/{org}/{project}/_apis/policy/configurations?api-version=7.1
PUT    https://dev.azure.com/{org}/{project}/_apis/policy/configurations/{id}?api-version=7.1
DELETE https://dev.azure.com/{org}/{project}/_apis/policy/configurations/{id}?api-version=7.1

Each policy type has a unique GUID identifier:

Minimum reviewers:           fa4e907d-c16b-4a4c-9dfa-4916e5d171ab
Build validation:            0609b952-1397-4640-95ec-e00a01b2c241
Comment requirements:        c6a1889d-b943-4856-b76f-9e46bb6b0df2
Work item linking:           40e92b44-2fe1-4dd6-b3d8-74a9c21d0c6e
Merge strategy:              fa4e907d-c16b-4a4c-9dfa-4106e5d171ab
Status check:                cbdc66da-9728-4af8-aada-9a5a32e4a226
Auto reviewers:              fd2167ab-b0be-447a-8571-0b55b4f05b6e

Cross-Repository Policy Patterns

Enterprise teams often need consistent policies across dozens or hundreds of repositories. Azure DevOps supports cross-repository policies at the project level, but they are coarse-grained. For fine-grained control, teams use a "policy-as-code" approach: define policies in a central configuration file and deploy them via a pipeline.

Here is a pattern that works well:

{
  "policyTemplate": "standard-service",
  "repositories": [
    "api-gateway",
    "user-service",
    "payment-service",
    "notification-service"
  ],
  "branches": ["main", "release/*"],
  "policies": {
    "minimumReviewers": {
      "count": 2,
      "creatorVoteCounts": false,
      "resetOnSourcePush": true,
      "blockLastPusherVote": true
    },
    "buildValidation": [
      {
        "pipelineName": "CI",
        "required": true,
        "validDuration": 720
      }
    ],
    "commentResolution": {
      "required": true
    },
    "workItemLinking": {
      "required": true
    }
  }
}

A deployment pipeline reads this configuration and applies it to each repository using the REST API. This ensures consistency and makes policy changes auditable -- they go through the same PR process as code changes.

Complete Working Example

The following Node.js script configures a complete set of branch policies for a repository's main branch. It sets up minimum reviewers, build validation, and comment resolution requirements, all through the Azure DevOps REST API.

var axios = require("axios");

// Configuration
var ORG = process.env.AZURE_DEVOPS_ORG || "my-organization";
var PROJECT = process.env.AZURE_DEVOPS_PROJECT || "my-project";
var REPO_NAME = process.env.AZURE_DEVOPS_REPO || "my-repo";
var PAT = process.env.AZURE_DEVOPS_PAT;
var BUILD_DEFINITION_ID = parseInt(process.env.BUILD_DEFINITION_ID || "1", 10);

if (!PAT) {
  console.error("ERROR: AZURE_DEVOPS_PAT environment variable is required");
  process.exit(1);
}

var BASE_URL = "https://dev.azure.com/" + ORG + "/" + PROJECT;
var AUTH_HEADER = "Basic " + Buffer.from(":" + PAT).toString("base64");
var API_VERSION = "api-version=7.1";

var httpClient = axios.create({
  headers: {
    "Content-Type": "application/json",
    "Authorization": AUTH_HEADER
  }
});

// Policy type GUIDs
var POLICY_TYPES = {
  MINIMUM_REVIEWERS: "fa4e907d-c16b-4a4c-9dfa-4916e5d171ab",
  BUILD_VALIDATION: "0609b952-1397-4640-95ec-e00a01b2c241",
  COMMENT_RESOLUTION: "c6a1889d-b943-4856-b76f-9e46bb6b0df2",
  WORK_ITEM_LINKING: "40e92b44-2fe1-4dd6-b3d8-74a9c21d0c6e"
};

function getRepositoryId() {
  var url = BASE_URL + "/_apis/git/repositories/" + REPO_NAME + "?" + API_VERSION;
  return httpClient.get(url).then(function(response) {
    console.log("Found repository: " + response.data.name + " (ID: " + response.data.id + ")");
    return response.data.id;
  });
}

function getExistingPolicies() {
  var url = BASE_URL + "/_apis/policy/configurations?" + API_VERSION;
  return httpClient.get(url).then(function(response) {
    return response.data.value || [];
  });
}

function createPolicy(policyConfig) {
  var url = BASE_URL + "/_apis/policy/configurations?" + API_VERSION;
  return httpClient.post(url, policyConfig).then(function(response) {
    console.log("  Created policy: " + response.data.type.displayName + " (ID: " + response.data.id + ")");
    return response.data;
  });
}

function deletePolicy(policyId) {
  var url = BASE_URL + "/_apis/policy/configurations/" + policyId + "?" + API_VERSION;
  return httpClient.delete(url).then(function() {
    console.log("  Deleted existing policy ID: " + policyId);
  });
}

function buildScope(repositoryId) {
  return [
    {
      refName: "refs/heads/main",
      matchKind: "Exact",
      repositoryId: repositoryId
    }
  ];
}

function buildMinimumReviewerPolicy(repositoryId) {
  return {
    isEnabled: true,
    isBlocking: true,
    type: { id: POLICY_TYPES.MINIMUM_REVIEWERS },
    settings: {
      minimumApproverCount: 2,
      creatorVoteCounts: false,
      allowDownvotes: false,
      resetOnSourcePush: true,
      requireVoteOnLastIteration: true,
      resetRejectVote: true,
      blockLastPusherVote: true,
      scope: buildScope(repositoryId)
    }
  };
}

function buildBuildValidationPolicy(repositoryId) {
  return {
    isEnabled: true,
    isBlocking: true,
    type: { id: POLICY_TYPES.BUILD_VALIDATION },
    settings: {
      buildDefinitionId: BUILD_DEFINITION_ID,
      queueOnSourceUpdateOnly: true,
      manualQueueOnly: false,
      displayName: "CI - Build, Lint & Test",
      validDuration: 720,
      scope: buildScope(repositoryId)
    }
  };
}

function buildCommentResolutionPolicy(repositoryId) {
  return {
    isEnabled: true,
    isBlocking: true,
    type: { id: POLICY_TYPES.COMMENT_RESOLUTION },
    settings: {
      scope: buildScope(repositoryId)
    }
  };
}

function buildWorkItemLinkingPolicy(repositoryId) {
  return {
    isEnabled: true,
    isBlocking: true,
    type: { id: POLICY_TYPES.WORK_ITEM_LINKING },
    settings: {
      scope: buildScope(repositoryId)
    }
  };
}

function cleanExistingPolicies(existingPolicies, repositoryId) {
  var mainBranchPolicies = existingPolicies.filter(function(policy) {
    if (!policy.settings || !policy.settings.scope) return false;
    return policy.settings.scope.some(function(s) {
      return s.refName === "refs/heads/main" && s.repositoryId === repositoryId;
    });
  });

  if (mainBranchPolicies.length === 0) {
    console.log("  No existing policies found for main branch");
    return Promise.resolve();
  }

  console.log("  Found " + mainBranchPolicies.length + " existing policies to remove");

  var deletions = mainBranchPolicies.map(function(policy) {
    return deletePolicy(policy.id);
  });

  return Promise.all(deletions);
}

function main() {
  var repositoryId;

  console.log("=== Azure DevOps Branch Policy Configuration ===");
  console.log("Organization: " + ORG);
  console.log("Project:      " + PROJECT);
  console.log("Repository:   " + REPO_NAME);
  console.log("");

  console.log("Step 1: Looking up repository...");

  getRepositoryId()
    .then(function(repoId) {
      repositoryId = repoId;

      console.log("\nStep 2: Checking existing policies...");
      return getExistingPolicies();
    })
    .then(function(existingPolicies) {
      return cleanExistingPolicies(existingPolicies, repositoryId);
    })
    .then(function() {
      console.log("\nStep 3: Creating new policies...");

      var policies = [
        { name: "Minimum Reviewers", config: buildMinimumReviewerPolicy(repositoryId) },
        { name: "Build Validation", config: buildBuildValidationPolicy(repositoryId) },
        { name: "Comment Resolution", config: buildCommentResolutionPolicy(repositoryId) },
        { name: "Work Item Linking", config: buildWorkItemLinkingPolicy(repositoryId) }
      ];

      return policies.reduce(function(chain, policy) {
        return chain.then(function() {
          console.log("\n  Configuring: " + policy.name);
          return createPolicy(policy.config);
        });
      }, Promise.resolve());
    })
    .then(function() {
      console.log("\n=== Policy configuration complete ===");
      console.log("The following policies are now active on refs/heads/main:");
      console.log("  - Minimum 2 reviewers (creator vote excluded, reset on push)");
      console.log("  - Build validation (pipeline #" + BUILD_DEFINITION_ID + ", 12hr expiry)");
      console.log("  - All PR comments must be resolved");
      console.log("  - Work items must be linked");
    })
    .catch(function(error) {
      console.error("\nERROR: Policy configuration failed");
      if (error.response) {
        console.error("Status: " + error.response.status);
        console.error("Message: " + JSON.stringify(error.response.data, null, 2));
      } else {
        console.error("Message: " + error.message);
      }
      process.exit(1);
    });
}

main();

Save this as configure-branch-policies.js and run it:

export AZURE_DEVOPS_PAT="your-personal-access-token"
export AZURE_DEVOPS_ORG="my-organization"
export AZURE_DEVOPS_PROJECT="my-project"
export AZURE_DEVOPS_REPO="my-repo"
export BUILD_DEFINITION_ID="42"

node configure-branch-policies.js

Expected output:

=== Azure DevOps Branch Policy Configuration ===
Organization: my-organization
Project:      my-project
Repository:   my-repo

Step 1: Looking up repository...
Found repository: my-repo (ID: a1b2c3d4-e5f6-7890-abcd-ef1234567890)

Step 2: Checking existing policies...
  Found 3 existing policies to remove
  Deleted existing policy ID: 101
  Deleted existing policy ID: 102
  Deleted existing policy ID: 103

Step 3: Creating new policies...

  Configuring: Minimum Reviewers
  Created policy: Minimum number of reviewers (ID: 201)

  Configuring: Build Validation
  Created policy: Build (ID: 202)

  Configuring: Comment Resolution
  Created policy: Comment requirements (ID: 203)

  Configuring: Work Item Linking
  Created policy: Work item linking (ID: 204)

=== Policy configuration complete ===
The following policies are now active on refs/heads/main:
  - Minimum 2 reviewers (creator vote excluded, reset on push)
  - Build validation (pipeline #42, 12hr expiry)
  - All PR comments must be resolved
  - Work items must be linked

Execution time is typically under 3 seconds for the full configuration. The script is idempotent -- running it multiple times produces the same result by cleaning existing policies before creating new ones.

Common Issues and Troubleshooting

1. Policy blocks completion but all checks appear green

TF401027: You need at least 2 approving reviewer(s) for pull request #1234 to
complete. The following reviewers must approve: [Security Team].

This usually means an automatically included reviewer group is configured as "required" but no one from that group has reviewed. The green checkmarks you see are for other policies. Look at the "Reviewers" section specifically -- required reviewer groups show a different status indicator than optional ones. The fix is to have a member of the required group approve, or to adjust the auto-included reviewer policy to be optional rather than required.

2. Build validation keeps re-triggering in a loop

Build #20260208.15 for pull request #567 was canceled because a newer build
#20260208.16 has been queued.

This happens when the queueOnSourceUpdateOnly setting is false and something in your pipeline modifies the PR branch (for example, an auto-formatter that commits changes). Each new commit triggers a new build, which triggers another commit, creating an infinite loop. Set queueOnSourceUpdateOnly to true and handle formatting in a pre-commit hook instead of in the pipeline.

3. REST API returns 403 when creating policies

{
  "id": "0",
  "innerException": null,
  "message": "TF400019: The following service identity does not have sufficient
  permissions to complete the operation: Identity '[email protected]'.
  Verify that the identity has 'Edit policies' permission on the Git repository.",
  "typeName": "Microsoft.TeamFoundation.Framework.Server.UnauthorizedRequestException",
  "typeKey": "UnauthorizedRequestException",
  "errorCode": 0
}

Your PAT needs both Code (Read & Write) and Policy (Read & Write) scopes. Additionally, the user associated with the PAT must have "Edit policies" permission on the repository. Project Administrators have this by default, but custom security groups may not. Navigate to Project Settings > Repos > Security to verify.

4. Build validation policy references a deleted pipeline

VS403543: The build definition with ID 42 could not be found. It may have been
deleted. Update or remove the policy configuration referencing this build definition.

When a pipeline is deleted or renamed, any build validation policies referencing its ID break. The policy remains configured but always shows as "not applicable," effectively blocking PRs forever since it can never succeed. Use the REST API to list all policy configurations and delete or update the stale reference:

# List all policies and find the broken one
curl -s -u ":${AZURE_DEVOPS_PAT}" \
  "https://dev.azure.com/${ORG}/${PROJECT}/_apis/policy/configurations?api-version=7.1" \
  | jq '.value[] | select(.settings.buildDefinitionId == 42) | {id, isEnabled}'

# Delete the broken policy
curl -X DELETE -u ":${AZURE_DEVOPS_PAT}" \
  "https://dev.azure.com/${ORG}/${PROJECT}/_apis/policy/configurations/201?api-version=7.1"

5. Merge strategy policy conflicts with squash merge preference

TF401032: The pull request cannot be completed because the merge strategy
'squash' is not allowed by policy. Allowed strategies: 'noFastForward'.

If a merge strategy policy enforces "No fast-forward merge" but a developer selects "Squash commit" in the PR completion dialog, the merge will be rejected. Review your merge strategy policy and ensure it permits the strategies your team actually uses. Most teams benefit from allowing both squash and no-fast-forward, then leaving the choice to the developer based on the nature of the change.

Best Practices

  • Start with required minimum reviewers and build validation. These two policies provide the highest return on investment. Add comment resolution and work item linking once your team is comfortable with the basic workflow. Applying everything at once creates friction and resistance.

  • Use groups, not individuals, for auto-included reviewers. Individual assignments create bottlenecks and single points of failure. When that person goes on vacation, PRs stall. Azure DevOps groups automatically distribute the review load and remain functional as team membership changes.

  • Set build expiration to 12 hours for active branches. Builds that are more than 12 hours old may not reflect the current state of the target branch. This forces a re-validation before merge, catching integration issues that arise from concurrent development. For release branches with less activity, 24 hours is reasonable.

  • Implement policy-as-code for multi-repository environments. Store your policy configurations in a Git repository and deploy them via pipeline. This gives you version history, code review for policy changes, and the ability to roll back. It also ensures consistency across repositories without manual configuration drift.

  • Restrict bypass permissions aggressively and audit every use. Bypass permissions should be limited to 2-3 senior engineers who understand the implications. Set up automated notifications for every policy override. Track bypass frequency as a metric -- if it is happening more than once a month, your policies may be too restrictive or your emergency process needs improvement.

  • Use optional policies as a training mechanism before making them required. When introducing a new policy (such as requiring linked work items), set it as optional for 2-4 weeks. This gives the team time to adjust their workflow without blocking their work. Monitor compliance during this period, then make it required once adoption is high.

  • Configure different policy sets for different branch patterns. Your main branch should have the strictest policies. Feature branches may need only a single reviewer. Release branches should match main strictness plus additional approvals from release managers. Use the matchKind: "Prefix" scope to apply policies to branch patterns like release/*.

  • Document your bypass procedure explicitly. Write down exactly when a bypass is acceptable, who can authorize it, and what follow-up actions are required. Include this in your team's runbook. When production is down at 2 AM is not the time to debate policy -- the procedure should be clear and rehearsed.

References

Powered by Contentful