← Blog

Versioning Mule applications with the Maven release plugin in Azure Pipelines

Reference: templates/jobs/release.yml · ADR 2 · ADR 3

A released version should be built once, from a tagged commit, and never rebuilt with different code. The maven-release-plugin enforces that, and it removes the most common versioning mistake in Mule teams: people editing <version> by hand. This article shows what the plugin does, how to run it inside Azure Pipelines, and the three problems you will hit doing so.

What the plugin does

Two goals do the work.

release:prepare, starting from 1.0.0-SNAPSHOT:

  1. Checks there are no uncommitted changes and no SNAPSHOT dependencies.
  2. Changes the version to 1.0.0 and commits: prepare release v1.0.0.
  3. Tags that commit v1.0.0.
  4. Changes the version to the next development version, say 1.1.0-SNAPSHOT, and commits: prepare for next development iteration.
  5. Pushes both commits and the tag.

release:perform checks out the tag into target/checkout and runs the configured goals there — here, deploy, which publishes 1.0.0 to Anypoint Exchange. Because it builds from the tag in a clean directory, the released artifact is exactly what the tag contains.

The plugin configuration in the application’s pom.xml:

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-release-plugin</artifactId>
    <version>3.3.1</version>
    <configuration>
        <scmCommentPrefix>[skip ci] [maven-release-plugin] </scmCommentPrefix>
        <tagNameFormat>v@{project.version}</tagNameFormat>
        <goals>deploy</goals>
        <pushChanges>true</pushChanges>
    </configuration>
</plugin>

It also needs a <scm> block pointing at the repository, and distributionManagement pointing at Exchange (https://maven.anypoint.mulesoft.com/api/v3/organizations/${project.groupId}/maven, with the organization ID as groupId).

Running it in the pipeline

The release job runs on main after a merge. The core of it:

current=$(mvn $(MAVEN_ARGS) -q help:evaluate -Dexpression=project.version -DforceStdout)
release="${current%-SNAPSHOT}"
IFS=. read -r major minor patch <<< "$release"
case "$BUMP" in
  major) next="$((major + 1)).0.0" ;;
  minor) next="${major}.$((minor + 1)).0" ;;
  patch) next="${major}.${minor}.$((patch + 1))" ;;
esac

mvn $(MAVEN_ARGS) release:prepare release:perform \
  -DreleaseVersion="$release" \
  -DdevelopmentVersion="$next-SNAPSHOT" \
  -Darguments="-DskipMunitTests -s $(MAVEN_SETTINGS) -Danypoint.orgId=$(ANYPOINT_ORG_ID)"

echo "##vso[task.setvariable variable=appVersion;isOutput=true]$release"

The version released is whatever main carries without -SNAPSHOT; the next version is decided by a pipeline parameter (nextVersionBump), not by whoever runs it. The released version is published as an output variable, so the deployment stages deploy exactly that version. -Darguments passes options to the Maven run that release:perform starts inside target/checkout.

Problem 1: detached HEAD

Azure Pipelines checks out a specific commit, not a branch. The plugin must commit on a branch, so it fails. The job switches to the real branch first:

branch="${BUILD_SOURCEBRANCH#refs/heads/}"
git switch -C "$branch" "origin/$branch"

Problem 2: pushing without a personal key

The plugin needs to push. Many setups — including the one I used for years — clone the repository again over SSH with a key stored as a secure file. It works, but it adds a secret to rotate and ties releases to whoever owns the key.

The pipeline already has a token for the repository. Keeping it after checkout is one line:

- checkout: self
  persistCredentials: true
  fetchDepth: 0

That was not enough. The first real release failed with:

fatal: could not read Username for 'https://github.com': terminal prompts disabled

The checkout stores the token for the exact URL it cloned (https://github.com/brunosouzas/mulesoft-orders-api). The plugin pushes to the URL in <scm>, which ends in .git. Git scopes credentials by URL, so for Git these are different remotes and the token is not sent. The fix scopes the same header to the whole host:

header=$(git config --get-regexp '^http\..*\.extraheader$' | head -1 | cut -d' ' -f2- || true)
host=$(git remote get-url origin | sed -E 's#^(https://[^/]+/).*#\1#')
if [[ -n "$header" && "$host" == https://* ]]; then
  git config "http.${host}.extraheader" "$header"
fi

It shipped as version 1.0.1 of the templates. It works for Azure Repos as well.

Problem 3: the pipeline triggering itself

The plugin’s two commits land on main, and a push to main triggers the pipeline — which would release again. scmCommentPrefix starts with [skip ci], which Azure Pipelines honours for push triggers. After the release, main shows the two plugin commits and no extra runs.

Branch protection versus an automated release

main is protected: only pull requests may change it. The plugin pushes directly. Something has to give, and there are three options:

  1. Let the release identity bypass the rule on main. Narrowest when the identity is the pipeline itself — for example, the Azure Pipelines GitHub App, or the build service on Azure Repos with “Bypass policies when pushing”.
  2. Release on the release/* branch and merge the result through a PR. main stays fully protected, but the tag lives on a branch that is merged later, and a rejected PR leaves an orphan tag.
  3. Push with a personal token or key. Works everywhere, adds a secret and ties releases to a person.

The reference project uses option 1. One caveat, honestly stated: its GitHub connection uses OAuth, so the pipeline acts as my user, and the bypass has to be granted to the repository admin role. With a GitHub App connection, only the app would be in the bypass list, which is the better setup for a team.

After the release

Merge main back into develop with a PR. If develop was bumped when the release branch was cut (see the GitFlow article), both branches carry the same next -SNAPSHOT and the back-merge has no conflicts.

Checklist

  • Does anyone edit <version> by hand? They shouldn’t need to.
  • Is the tag format consistent (v1.2.3) and is the tag the source of truth for what was released?
  • Does the release job run on a branch, push with the pipeline’s identity and skip its own commits?
  • Is the bypass on main limited to the release identity?