How to version Azure Policy releases with EPAC and semantic-release
Your commit messages already know the exact next version
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.0and1.1.0with 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:
- You commit with a message such as
feat(definitions): add policy that restricts virtual machine SKUs. - On the pull request, commitlint checks every commit message, and the Azure Policy Linter checks the policies.
- After the merge, semantic-release reads the commits since the last release tag and works out the next version.
- It writes
CHANGELOG.md, tags the commit and hands the version to the pipeline. - The pipeline renames the run to that version and publishes
Definitions/,docs/andCHANGELOG.mdas a Universal Package with the same version.

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,DeployIfNotExistsorModify - 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-pipelinefolder 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.0page, which returns a 404 on Azure Repos.compareUrlFormatpoints it at the Azure ReposbranchComparepage instead. The commit links work as they are. - changelog writes the notes to
CHANGELOG.md. - git commits
CHANGELOG.mdback tomain.[skip ci]keeps that commit from starting yet another run. - exec sets the output variable
versionfor the rest of the pipeline. The logging command is split into##%sandvsoon 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 withextends: 'semantic-release-monorepo'. It only counts commits that touch the folder, and prefixes tags with the package name, such asepac-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:
- In your project, select Project settings > Repositories, and then select your repository.
- Select Policies, and under Branch Policies, select
main. - Next to Build Validation, select +.
- Select the EPAC pipeline, set Trigger to Automatic and Policy requirement to Required, and then select Save.

mainWhile 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:
- Under Branch Policies for
main, turn on Limit merge types. - Clear Squash merge, and then select Save.

mainAdd 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: 0andfetchTags: 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 thePublishPackagejob.updatebuildnumberandaddbuildtag. The run is renamed from20261017.1to1.0.0, and taggedv1.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:
- Select Project settings > Repositories, and then select your repository.
- Select Security, and then select <project> Build Service (<organization>).
- Set Contribute, Create tag, Manage notes and Bypass policies when pushing to Allow.

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:
- Select Artifacts, select your feed, and then select the gear icon to open Feed settings.
- Select Permissions > Add users/groups.
- Add both Project Collection Build Service (<organization>) and <project> Build Service (<organization>) with the Feed Publisher (Contributor) role.

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 usessandbox/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.
-
Create a branch:
git switch -c feat/restrict-vm-skus -
Add
Definitions/policyDefinitions/Compute/vm-allowed-skus.jsonc. It checks virtual machines and scale sets against anallowedVmSkusparameter, withAuditas 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.
-
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" -
Assign it in
Definitions/policyAssignments/baseline-assignments.jsoncwith 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.
-
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.

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:

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.

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, plansepac-devfor 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-policiesfrom the feed, plans it and deploys it toepac-dev, then toepac-prodafter 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.0is 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-devandepac-proddeploy 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. WithdesiredState.strategyset tofull, EPAC removes policies that the older version doesn't have, including their compliance history. Plan first, and read the plan before you approveepac-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.