How to version Azure Policy releases with EPAC and semantic-release

Your commit messages already know the exact next version

How to version Azure Policy releases with EPAC and semantic-release

For a good part of my current career, I supported the platform engineering members who manage the Azure cloud. However, I switched departments, and I'm not in that seat anymore on use cases that help the platform. Yet I keep coming back to some stuff. Stuff that is satisfying after you build a pipeline that does the boring work for you.

In my previous post, you built a pipeline that lints every Azure Policy definition with the Azure Policy Linter before Enterprise Policy as Code (EPAC) deploys it. It answered the question I was looking for: Is this policy any good?

But it left one open. Which version of your policies is running in production right now? And what might have changed since last Tuesday? Git history has the answer somewhere. Between commits called "Test", "Trigger," and "Update pipeline". Or does it?

I wanted to extend the pipeline with those questions answered. So, in this guide, if you want to follow along, you add semantic-release to the pipeline. Every merge to main that adds or fixes a policy becomes a version number, a changelog entry, a Git tag, and an immutable package in Azure Artifacts. You never pick a version yourself.

In this article, you:

  • Check every commit message in a pull request against Conventional Commits
  • Configure semantic-release for Azure Repos
  • Add a Release stage that versions, tags and packages your policies
  • Give the pipeline the permissions it needs to push and publish
  • Release 1.0.0 and 1.1.0 with a new policy that restricts virtual machine SKUs

How it works

The version comes from your commit messages. You write them in the Conventional Commits format, and the pipeline does the rest:

  1. You commit with a message such as feat(definitions): add policy that restricts virtual machine SKUs.
  2. On the pull request, commitlint checks every commit message, and the Azure Policy Linter checks the policies.
  3. After the merge, semantic-release reads the commits since the last release tag and works out the next version.
  4. It writes CHANGELOG.md, tags the commit and hands the version to the pipeline.
  5. The pipeline renames the run to that version and publishes Definitions/, docs/ and CHANGELOG.md as a Universal Package with the same version.

Figure 1: Release flow

The commit type decides the version:

Type Use it for Next version
feat A new policy, policy set, assignment or optional parameter Minor: 1.4.0 → 1.5.0
fix A policy that doesn't do what it was meant to, such as a wrong alias or condition Patch: 1.4.0 → 1.4.1
docs, ci, chore, refactor, ... Anything that doesn't change what is deployed No release
Any type with !, or a BREAKING CHANGE: footer A change someone has to act on Major: 1.4.0 → 2.0.0

What counts as breaking for a policy? Semantic versioning was made for APIs, not for guardrails. Here's the rule I use: a change is breaking when a deployment that works today could fail tomorrow, or when someone has to update an assignment before it still works. For example:

  • switching an effect to Deny, DeployIfNotExists or Modify
  • removing or renaming a policy. EPAC deletes and recreates it under the new name, which drops its compliance history.
  • removing a parameter or an allowed value, or adding a parameter without a default
  • narrowing what a policy allows, such as removing a location from allowedLocations

Such a sample you can easily keep in CONTRIBUTING.md file, so contributors don't have to guess.

Prerequisites

Now that you know about semantic-release, there are a couple of prerequisites if you want to follow along:

  • The sample from the previous post. Copy the azure-devops/epac-policy-linter-pipeline folder into an Azure Repos repository of its own. This guide assumes that folder is the repository root.
  • An Azure DevOps project with Azure Repos and Azure Artifacts.
  • Permission to edit repository security, branch policies and feed settings.
  • Optional: Node.js 22.22.2 or later to check commit messages on your machine before you push.

If you've them in-place, let's get started with adding the release tooling itself.

Add the release tooling

semantic-release and commitlint are Node.js tools. That doesn't make the repository a Node.js project: npm only installs the two tools, and nothing gets published to npm.

First, let's pin the tools in package.json. Add a `package.json` to the repository root:

{
  "name": "epac-policy-linter-pipeline",
  "private": true,
  "description": "Release tooling for the EPAC policy repository. Not a Node.js package: npm only installs semantic-release and commitlint.",
  "engines": {
    "node": "^22.22.2 || >=24.15.0"
  },
  "devDependencies": {
    "@commitlint/cli": "21.2.3",
    "@commitlint/config-conventional": "21.2.3",
    "@semantic-release/changelog": "7.0.0",
    "@semantic-release/commit-analyzer": "13.0.1",
    "@semantic-release/exec": "7.1.0",
    "@semantic-release/git": "11.0.1",
    "@semantic-release/release-notes-generator": "14.1.1",
    "conventional-changelog-conventionalcommits": "9.3.1",
    "semantic-release": "25.0.9"
  }
}

Run npm install once to create package-lock.json, and commit both files. Add node_modules/ to .gitignore. The versions are exact on purpose: the pipeline installs with npm ci, so every run uses the same tools, and a new release of a plugin can't change how your changelog looks overnight.

Time to check out the commit messages against Conventional Commits. Create commitlint.config.mjs:

export default {
    extends: ['@commitlint/config-conventional'],
};

You can now check a message before you commit it:

npm ci
'feat(definitions): add a storage policy' | npx commitlint

You can now configure semantic-release by creating release.config.mjs:

export default {
    branches: ['main'],
    plugins: [
        ['@semantic-release/commit-analyzer', { preset: 'conventionalcommits' }],
        [
            '@semantic-release/release-notes-generator',
            {
                preset: 'conventionalcommits',
                // The default compare link uses GitHub's /compare/a...b, which Azure Repos doesn't serve.
                presetConfig: {
                    compareUrlFormat:
                        '{{host}}/{{owner}}/{{repository}}/branchCompare?baseVersion=GT{{previousTag}}&targetVersion=GT{{currentTag}}',
                },
            },
        ],
        ['@semantic-release/changelog', { changelogFile: 'CHANGELOG.md', changelogTitle: '# Changelog' }],
        [
            '@semantic-release/git',
            {
                assets: ['CHANGELOG.md'],
                // [skip ci] stops this commit from starting another pipeline run.
                message: 'chore(release): ${nextRelease.gitTag} [skip ci]\n\n${nextRelease.notes}',
            },
        ],
        [
            '@semantic-release/exec',
            {
                // Hands the version to the pipeline once the tag is pushed.
                publishCmd: 'printf "##%s[task.setvariable variable=version;isOutput=true]%s\\n" vso "${nextRelease.version}"',
            },
        ],
    ],
};

Each plugin has one job:

  • commit-analyzer reads the commits since the last tag and decides between major, minor, patch or no release.
  • release-notes-generator turns those commits into Markdown. Out of the box, it links each version heading to a GitHub-style /compare/v1.0.0...v1.1.0 page, which returns a 404 on Azure Repos. compareUrlFormat points it at the Azure Repos branchCompare page instead. The commit links work as they are.
  • changelog writes the notes to CHANGELOG.md.
  • git commits CHANGELOG.md back to main. [skip ci] keeps that commit from starting yet another run.
  • exec sets the output variable version for the rest of the pipeline. The logging command is split into ##%s and vso on purpose: semantic-release logs every command it runs, and a complete ##vso[...] string in that log line would set the variable twice.

[!NOTE]
Does your EPAC folder live inside a larger repository? Then extend semantic-release-monorepo with extends: 'semantic-release-monorepo'. It only counts commits that touch the folder, and prefixes tags with the package name, such as epac-policy-linter-pipeline-v1.0.0. Don't use it when the folder is the repository root. It then filters out every commit, and you never get a release.

Check commit messages on pull requests

A bad commit message on main means a wrong version or a missing changelog entry. So check them before they get there. Add a template, pipelines/templates/commit-lint.yml:

steps:
  - checkout: self
    fetchDepth: 0

  - task: UseNode@1
    displayName: Use Node.js 24.x
    inputs:
      version: 24.x

  - script: npm ci --no-audit --no-fund
    displayName: Install commitlint

  # The agent builds the pull request's merge commit. Its first parent is the target branch and its second parent
  # is the head of the pull request, so HEAD^1..HEAD^2 is exactly the commits the pull request adds.
  - script: npx --no-install commitlint --from HEAD^1 --to HEAD^2 --verbose
    displayName: Check commit messages

Call it from the Lint stage, only on pull requests:

      - job: CommitMessages
        displayName: Check commit messages
        condition: eq(variables['Build.Reason'], 'PullRequest')
        steps:
          - template: templates/commit-lint.yml

There's a catch, though. That condition only matches when something starts the pipeline for a pull request, and Azure Repos ignores the pr: block in YAML. Pull request runs come from a build validation policy on the target branch. Without one, the CommitMessages job never runs. So add one to main:

  1. In your project, select Project settings > Repositories, and then select your repository.
  2. Select Policies, and under Branch Policies, select main.
  3. Next to Build Validation, select +.
  4. Select the EPAC pipeline, set Trigger to Automatic and Policy requirement to Required, and then select Save.
Figure 2: Adding build validation policy on main

While you're on that policy page, look at the merge types too. They matter more than you'd think. A Squash commit replaces every commit in the pull request with one commit, and Azure Repos titles it Merged PR 21: <pull request title>. semantic-release can't read a type from that message, so your feat silently produces no release.

Use Merge (no fast-forward) instead. The merge commit itself is ignored, and the commits in your pull request reach main as you wrote them. To make sure nobody picks the wrong option, limit the merge types:

  1. Under Branch Policies for main, turn on Limit merge types.
  2. Clear Squash merge, and then select Save.
Figure 3: Squash merge turned offf for main

Add the Release stage

The Release stage only runs on main, after the Lint stage passes. It runs alongside the deployment stages, so a problem with a release never holds up a policy deployment:

  - stage: Release
    displayName: Release
    dependsOn: Lint
    condition: and(succeeded(), ne(variables['Build.Reason'], 'PullRequest'), eq(variables['Build.SourceBranch'], 'refs/heads/main'))
    jobs:
      - job: Release
        displayName: Docs, changelog and version
        steps:
          - template: templates/release.yml
      - job: PublishPackage
        displayName: Publish versioned policy package
        dependsOn: Release
        condition: and(succeeded(), ne(coalesce(dependencies.Release.outputs['SemanticRelease.version'], ''), ''))
        variables:
          policyVersion: $[ dependencies.Release.outputs['SemanticRelease.version'] ]
        steps:
          - template: templates/publish-package.yml
            parameters:
              feed: $(policyPackageFeed)
              packageName: $(policyPackageName)
              version: $(policyVersion)

The Release job does the versioning. The condensed pipelines/templates/release.yml:

steps:
  - checkout: self
    # semantic-release reads the full history and every tag to work out the next version.
    fetchDepth: 0
    fetchTags: true
    # Lets the docs step and semantic-release push with the pipeline's own credentials.
    persistCredentials: true

  # Regenerates docs/ and pushes it to main when it changed.
  - template: policy-docs.yml
    parameters:
      mode: commit

  - task: UseNode@1
    inputs:
      version: 24.x

  - script: npm ci --no-audit --no-fund
    displayName: Install semantic-release

  - script: npx --no-install semantic-release
    name: SemanticRelease
    displayName: Run semantic-release

  - pwsh: |
      $version = '$(SemanticRelease.version)'
      $package = '$(Build.ArtifactStagingDirectory)/epac-policies'
      New-Item -ItemType Directory -Path $package -Force | Out-Null
      Copy-Item -Path Definitions, docs, CHANGELOG.md -Destination $package -Recurse
      Write-Host "##vso[build.updatebuildnumber]$version"
      Write-Host "##vso[build.addbuildtag]v$version"
    displayName: Stage policy package
    condition: and(succeeded(), ne(coalesce(variables['SemanticRelease.version'], ''), ''))

  - publish: $(Build.ArtifactStagingDirectory)/epac-policies
    artifact: epac-policies
    condition: and(succeeded(), ne(coalesce(variables['SemanticRelease.version'], ''), ''))

A few things in there are easy to miss:

  • fetchDepth: 0 and fetchTags: true. New pipelines clone shallowly by default. semantic-release needs every commit and every tag to find the last release.
  • The docs step runs first. The pipeline regenerates the policy documentation with AzurePolicyDocs.Cli and pushes it before semantic-release starts. That order is deliberate: semantic-release refuses to release when the remote branch is ahead of the commit it runs on. Push the docs afterwards, and the next run finds its own docs commit in the way.
  • name: SemanticRelease. The step name is what makes $(SemanticRelease.version) work, both later in this job and in the PublishPackage job.
  • updatebuildnumber and addbuildtag. The run is renamed from 20261017.1 to 1.0.0, and tagged v1.0.0. You can find a release by its version in the list of runs.

The PublishPackage job publishes the staged artifact to Azure Artifacts:

steps:
  - checkout: none

  - download: current
    artifact: epac-policies

  - task: UniversalPackages@1
    displayName: Publish Universal Package
    inputs:
      command: publish
      feed: ${{ parameters.feed }}
      packageName: ${{ parameters.packageName }}
      packageVersion: ${{ parameters.version }}
      directory: $(Pipeline.Workspace)/epac-policies
      packageDescription: EPAC policy definitions, assignments and generated documentation.

It's a separate job for a reason. A Universal Package version can't be overwritten, and a Git tag can't be released twice. If the publish fails, select Rerun failed jobs: it publishes the version that the Release job already produced, without cutting a new release.

Give the pipeline permission to push and publish

The Release job pushes two commits, a tag and a Git note, and publishes a package. The pipeline runs as the project's build service identity, which can do none of that by default. Start with the repository:

  1. Select Project settings > Repositories, and then select your repository.
  2. Select Security, and then select <project> Build Service (<organization>).
  3. Set Contribute, Create tag, Manage notes and Bypass policies when pushing to Allow.
Figure 4: Repository permissions for the build service

Two of those aren't obvious:

  • Manage notes. semantic-release attaches a Git note to every release tag, under refs/notes/semantic-release-<tag>, to record the release channel. It pushes that note right after the tag. Without this permission, the tag reaches the repository but the release fails.
  • Bypass policies when pushing. This one is there because of the build validation policy you added earlier. Without it, the docs and release commits are rejected with TF402455: Pushes to this branch are not permitted. It only applies to this identity. People still need a pull request.

The pipeline can push now, but it can't publish yet. That permission lives on the feed:

  1. Select Artifacts, select your feed, and then select the gear icon to open Feed settings.
  2. Select Permissions > Add users/groups.
  3. Add both Project Collection Build Service (<organization>) and <project> Build Service (<organization>) with the Feed Publisher (Contributor) role.
Figure 5: Feed permission

For a feed that belongs to a project, set policyPackageFeed to <project>/<feed>, such as sandbox/epac. Only organization-scoped feeds use the bare feed name.

[!INFO]
This blog post uses sandbox/epac.

Release version 1.0.0

Time to see it work. The first release adds a policy that restricts virtual machine SKUs, a classic way to keep costs under control.

  1. Create a branch:

    git switch -c feat/restrict-vm-skus
    
  2. Add Definitions/policyDefinitions/Compute/vm-allowed-skus.jsonc. It checks virtual machines and scale sets against an allowedVmSkus parameter, with Audit as the default effect. The interesting part of the rule:

    "anyOf": [
        {
            "allOf": [
                { "field": "type", "equals": "Microsoft.Compute/virtualMachines" },
                { "field": "Microsoft.Compute/virtualMachines/sku.name", "notIn": "[parameters('allowedVmSkus')]" }
            ]
        },
        {
            "allOf": [
                { "field": "type", "equals": "Microsoft.Compute/virtualMachineScaleSets" },
                // A Flexible scale set without a VM profile has no SKU to restrict.
                { "field": "Microsoft.Compute/virtualMachineScaleSets/sku.name", "exists": "true" },
                { "field": "Microsoft.Compute/virtualMachineScaleSets/sku.name", "notIn": "[parameters('allowedVmSkus')]" }
            ]
        }
    ]
    

    Why scale sets? Because otherwise a scale set is the easy way around the restriction.

  3. Lint it, and commit it:

    ./scripts/Invoke-PolicyLinter.ps1
    git add Definitions/policyDefinitions/Compute/vm-allowed-skus.jsonc
    git commit -m "feat(definitions): add policy that restricts virtual machine SKUs"
    
  4. Assign it in Definitions/policyAssignments/baseline-assignments.jsonc with a short list of sizes, and commit that separately:

    git commit -am "feat(assignments): audit virtual machine SKUs in the baseline"
    

    Two commits, two lines in the changelog, each with its own scope.

  5. Check the messages, push and open a pull request:

    npx commitlint --from main --to HEAD
    git push -u origin feat/restrict-vm-skus
    

The pull request run lints the policies and checks both commit messages. It also warns that docs/ is out of date. That's expected: the pipeline regenerates and commits the docs once the change reaches main.

Figure 6: Pull request checks

Complete the pull request with Merge (no fast-forward).

Release version 1.1.0

One release proves the setup. The second one proves the versioning. Allow one more VM size in the baseline:

git switch main
git pull
git switch -c feat/allow-d8s-v5
# Add "Standard_D8s_v5" to allowedVmSkus in baseline-assignments.jsonc
git commit -am "feat(assignments): allow Standard_D8s_v5 virtual machines in the baseline"
git push -u origin feat/allow-d8s-v5

Adding a size widens what the policy allows, so nothing that runs today breaks: a feat, and a minor release. After the merge, semantic-release finds v1.0.0, counts one commit and releases 1.1.0:

The following CHANGELOG.md is generated onto the main branch:

Figure 7: What changed between two releases

The version heading is now a link. Select it, and Azure Repos shows everything that changed between the two releases. That's the compareUrlFormat from earlier at work.

Figure 8: Comparing two releases

So, what's running in production? Look up the version, open the changelog, done.

Troubleshoot

Most of these I hit myself while building this. You don't have to.

Symptom Cause Fix
A pull request shows no pipeline run Azure Repos ignores pr: in YAML Add a build validation policy on main
A merge to main doesn't start a run The trigger has a paths filter for a folder the repository doesn't have Remove the filter, or point it at the right folder
semantic-release logs Found 0 commits for package ... since last release The config extends semantic-release-monorepo while the project is the repository root Remove extends from release.config.mjs
The merge produced no release The pull request was squash-merged with the default Merged PR <n>: message Use Merge (no fast-forward), or edit the squash message to a Conventional Commit
TF402455: Pushes to this branch are not permitted The build service can't bypass the branch policy Allow Bypass policies when pushing for the build service
The tag is pushed, but semantic-release fails when it pushes refs/notes/semantic-release-<tag> The build service can't push Git notes Allow Manage notes for the build service
The local branch main is behind the remote one, therefore a new version won't be published. You re-ran the Release stage after an earlier attempt already released. main now has that release's commits. Nothing to fix. Use Rerun failed jobs instead of rerunning the stage.
Version headings in CHANGELOG.md link to a 404 The default compare link uses GitHub's URL format Set compareUrlFormat as shown above
policylinter exited with code 150 ... You must install or update .NET The linter targets .NET 8, and the job only installed the .NET 10 SDK Use UseDotNet@2 with version: 8.0.x in the Lint job
The input file and the --pattern parameter cannot both be specified. On Linux, PowerShell expands wildcards in unquoted native command arguments, variables included Pass the glob quoted: --pattern "$Pattern"

Split the pipeline: deploy straight from the feed

Here's the thing. The pipeline now produces a versioned, immutable package. But it still deploys from the commit it checked out. The package is a by-product, not the thing you deploy.

You could turn that around, and split the pipeline in two:

  • The release pipeline runs on pull requests and on main. It lints, plans epac-dev for pull requests, and releases. Its output is a version in the feed.
  • The deploy pipeline never checks out the repository. It downloads one version of epac-policies from the feed, plans it and deploys it to epac-dev, then to epac-prod after approval.

First, give every release a fixed build tag next to the version tag, in the "Stage policy package" step:

Write-Host "##vso[build.addbuildtag]v$version"
Write-Host "##vso[build.addbuildtag]release"

The deploy pipeline triggers on runs with that tag, and reads the version from the run's name, which the Release job set to the version:

# pipelines/epac-policy-deploy.yml
trigger: none
pr: none

parameters:
  - name: version
    displayName: Policy package version to deploy, such as 1.1.0
    type: string
    default: from-trigger

resources:
  pipelines:
    - pipeline: release
      source: epac-policy-release # The name of the release pipeline in Azure DevOps
      trigger:
        branches:
          include:
            - main
        tags:
          - release

variables:
  PAC_DEFINITIONS_FOLDER: $(Pipeline.Workspace)/epac-policies/Definitions
  PAC_OUTPUT_FOLDER: $(Pipeline.Workspace)/Output

Each plan and deploy job then starts with these steps instead of checkout: self:

steps:
  - checkout: none

  - pwsh: |
      $version = '${{ parameters.version }}'
      if ($version -eq 'from-trigger') {
          if ($env:BUILD_REASON -ne 'ResourceTrigger') {
              throw 'Pass the version to deploy, such as 1.1.0.'
          }
          $version = '$(resources.pipeline.release.runName)'
      }
      Write-Host "Deploying epac-policies $version"
      Write-Host "##vso[task.setvariable variable=policyVersion]$version"
    displayName: Resolve the policy version

  - task: UniversalPackages@1
    displayName: Download the policy package
    inputs:
      command: download
      feed: sandbox/epac
      packageName: epac-policies
      packageVersion: $(policyVersion)
      directory: $(Pipeline.Workspace)/epac-policies

  # Install EPAC and run Build-DeploymentPlans or Deploy-PolicyPlan, as before.

What do you get for the extra pipeline?

  • You deploy exactly what you released. A Universal Package version can't change. If 1.1.0 is in production, you know exactly which files that is, and the changelog that came with it is in the same package.
  • You promote, you don't rebuild. epac-dev and epac-prod deploy the same package, not two checkouts of the same branch.
  • You roll back by version. Run the deploy pipeline manually with 1.0.0.
  • Each pipeline gets only the permissions it needs. The release pipeline can push to the repository and publish to the feed, but has no Azure service connections. The deploy pipeline has the service connections, and only reads from the feed.
  • Releases no longer run next to deployments. The release is done before the deployment starts.

[!IMPORTANT]
A rollback is a deployment like any other. With desiredState.strategy set to full, EPAC removes policies that the older version doesn't have, including their compliance history. Plan first, and read the plan before you approve epac-prod.

The pull request plan stays in the release pipeline, by the way. A pull request has no version yet, so its plan can only come from the checked-out branch.

Summary

Your commit messages now carry the release. A feat or a fix merged to main becomes a version, a changelog entry, a tag and a package, without anyone picking a number. The pipeline run is named after the version, so the answer to "what's running in production?" is one click away.

When I worked together with the platform engineering, the questions that cost the most time were never the hard ones. They were "which version is that?" and "what changed?", usually asked by an application team whose deployment just failed on a policy. A platform team builds a product for other teams, and guardrails are part of that product. A product gets a version number, release notes and a way back to the previous release. Your policies now have all three. And nobody on the team had to remember to do it.