Enforcing branching strategies in Azure DevOps

Feb 10, 2025 - 5 min read

In software development, keeping your code well-organized is crucial. Azure DevOps is great for continuous integration and delivery, but it lacks features to enforce a branching strategy. This article explains how to use a PowerShell script in a pipeline step to achieve this.

We will use the Gitflow branching strategy as an example to illustrate the concepts. However, by configuring the branch types and merge policies, it can be adapted to suit any branching strategy.

Required Parts

To enforce a branching strategy, we need to define and implement multiple parts. Here’s a breakdown:

  1. Branch Types: Define the branch types in our branching strategy.
  2. Merge Policy: Define which branch types can be merged into others.
  3. Helpers: Create helper functions to reduce repetition.

Branch Types

To define the branch types of our branching strategy, we build a type/regex hashtable along with an accompanying function:

# branch type = regex pattern
$branchTypeMap = @{
    "main"    = "main"
    "develop" = "develop"
    "feature" = "feature/.+"
    "release" = "release/.+"
    "hotfix"  = "hotfix/.+"
}

function Get-BranchType {
    param ([hashtable]$BranchTypeMap, [string]$BranchName)
    foreach ($item in $BranchTypeMap.GetEnumerator()) {
        if ($BranchName -match "^$($item.Value)$") {
            return $item.Key
        }
    }
    return "unknown"
}

Example calls:

Get-BranchType -BranchTypeMap $branchTypeMap -BranchName "feature/my-new-feature"
# output: feature

Get-BranchType -BranchTypeMap $branchTypeMap -BranchName "test"
# output: unknown ("test" branch is not defined in our map)

Merge Policy

To define the merge rules, we create a hashtable containing the permissible branch type merges along with an accompanying function:

# source branch type = target branch types
$mergePolicyMap = @{
    "feature" = @("develop")
    "release" = @("develop", "main")
    "hotfix"  = @("develop", "main")
}

function Test-MergePolicy {
    param ([hashtable]$MergePolicyMap, [string]$SourceBranchType, [string]$TargetBranchType)
    return $MergePolicyMap[$SourceBranchType] -contains $TargetBranchType
}

Example calls:

# can feature be merged into develop?
Test-MergePolicy -MergePolicyMap $mergePolicyMap -SourceBranchType "feature" -TargetBranchType "develop"
# output: True

# can feature be merged into release?
Test-MergePolicy -MergePolicyMap $mergePolicyMap -SourceBranchType "feature" -TargetBranchType "release"
# output: False

Helpers

To keep things DRY, we define helper functions for cleaning up branch names and stopping the pipeline:

# remove "refs/heads/" from branch name
function Get-ShortBranchName {
    param ([string]$BranchName)
    return $BranchName -replace "^refs/heads/", [string]::Empty
}

# log an error and exit
function Stop-Pipeline {
    param ([string]$Message)
    Write-Host "##vso[task.logissue type=error]$Message"
    Exit 1
}

Azure DevOps Specifics

  • The current branch is stored in the $env:BUILD_SOURCEBRANCH and $(Build.SourceBranch) variables
  • Branch names from variables (eg. $(Build.SourceBranch)) have a “refs/heads/“ prefix
  • For Pull Request builds, variables
    • $env:BUILD_REASON/$(Build.Reason) are set to “PullRequest”
    • $env:BUILD_SOURCEBRANCH/$(Build.SourceBranch) are set to “refs/pull/[id]/merge”
    • $env:SYSTEM_PULLREQUEST_SOURCEBRANCH/$(System.PullRequest.SourceBranch) are set to the source branch
    • $env:SYSTEM_PULLREQUEST_TARGETBRANCH/$(System.PullRequest.TargetBranch) are set to the target branch

Warning: Ensure all target branches have a build validation policy, or the Pull Request build and validation won’t be executed.

Complete Example

azure-pipelines.yml:

steps:
  - task: PowerShell@2
    displayName: Validate branching strategy
    inputs:
      pwsh: true
      targetType: filePath
      filePath: Validate-BranchingStrategy.ps1
    # run only for branches and PRs, skipping tags
    condition: and(succeeded(), or(startsWith(variables['Build.SourceBranch'], 'refs/pull/'), startsWith(variables['Build.SourceBranch'], 'refs/heads/')))

Validate-BranchingStrategy.ps1:

param(
    [Parameter(Mandatory = $false)][string] $BuildReason = $env:BUILD_REASON,
    [Parameter(Mandatory = $false)][string] $SourceBranch = $env:SYSTEM_PULLREQUEST_SOURCEBRANCH,
    [Parameter(Mandatory = $false)][string] $TargetBranch = $env:SYSTEM_PULLREQUEST_TARGETBRANCH,
    [Parameter(Mandatory = $false)][string] $Branch = $env:BUILD_SOURCEBRANCH
)

# branch type = regex pattern
$branchTypeMap = @{
    "main"    = "main"
    "develop" = "develop"
    "feature" = "feature/.+"
    "release" = "release/.+"
    "hotfix"  = "hotfix/.+"
}

# source branch type = target branch types
$mergePolicyMap = @{
    "feature" = @("develop")
    "release" = @("develop", "main")
    "hotfix"  = @("develop", "main")
}

function Get-ShortBranchName {
    param ([string]$BranchName)
    return $BranchName -replace "^refs/heads/", [string]::Empty
}

function Get-BranchType {
    param ([hashtable]$BranchTypeMap, [string]$BranchName)
    foreach ($item in $BranchTypeMap.GetEnumerator()) { if ($BranchName -match "^$($item.Value)$") { return $item.Key } }
    return "unknown"
}

function Test-MergePolicy {
    param ([hashtable]$MergePolicyMap, [string]$SourceBranchType, [string]$TargetBranchType)
    return $MergePolicyMap[$SourceBranchType] -contains $TargetBranchType
}

function Stop-Pipeline {
    param ([string]$Message)
    Write-Host "##vso[task.logissue type=error]$Message"
    Exit 1
}

# validation logic

if ($BuildReason -eq "PullRequest") {
    $shortSourceBranchName = Get-ShortBranchName -BranchName $SourceBranch
    $sourceBranchType = Get-BranchType -BranchTypeMap $branchTypeMap -BranchName $shortSourceBranchName
    if ($sourceBranchType -eq "unknown") { Stop-Pipeline -Message "Branch name '$shortSourceBranchName' is not valid" }

    $shortTargetBranchName = Get-ShortBranchName -BranchName $TargetBranch
    $targetBranchType = Get-BranchType -BranchTypeMap $branchTypeMap -BranchName $shortTargetBranchName
    if ($targetBranchType -eq "unknown") { Stop-Pipeline -Message "Branch name '$shortTargetBranchName' is not valid" }

    $isMergePolicyValid = Test-MergePolicy -MergePolicyMap $mergePolicyMap -SourceBranchType $sourceBranchType -TargetBranchType $targetBranchType
    if (!$isMergePolicyValid) { Stop-Pipeline -Message "Merging branch '$shortSourceBranchName' into '$shortTargetBranchName' is not permitted by the current branching strategy" }
}
else {
    $shortBranchName = Get-ShortBranchName -BranchName $Branch
    $branchType = Get-BranchType -BranchTypeMap $branchTypeMap -BranchName $shortBranchName
    if ($branchType -eq "unknown") { Stop-Pipeline -Message "Branch name '$shortBranchName' is not valid" }
}

Write-Host "Validation successful"

Conclusion

The example given is a basic but useful starting point. It’s very flexible and can be modified to work with different branching strategies. Additionally, this solution can be expanded to include specific rules for branch names, lengths, and other custom features.