From 4b81d1817d32a44fcccdaa53ae88e986894dc0d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Tue, 22 Sep 2026 09:58:52 +0200 Subject: [PATCH 1/2] fix: make release finalization atomic so branches never linger on a release version Follow-up to #3582. That PR moved the release tag onto a commit carrying the released version, which was the right fix, but it split the work across two jobs: finalize-release pushes the "Release vX.Y.Z" commit, and a separate update-working-version job restores the SNAPSHOT version afterwards. Every push to a release branch triggers snapshot-releases.yml, which deploys with -Prelease. So any gap between those two jobs leaves the branch on a non-SNAPSHOT version and the next merge deploys that version to Maven Central: - update-working-version was guarded by `if: !contains(release_tag, 'RC')` while finalize-release was not, so releasing vX.Y.Z-RC1 committed the RC version to the branch and never restored the SNAPSHOT. Before #3582 the release commit was never pushed at all, so this is a regression. - update-working-version also ran `needs: finalize-release`, so a failed tag push left the branch on the release version with nothing to restore it. - Even on the happy path the branch sits on the release version for as long as the two jobs take to hand over. Both commits and the tag are now built locally in one job and pushed in a single `git push --atomic`, so the branch is never observably left on a non-SNAPSHOT version. The branch refspec is deliberately not forced: if something landed while the release was deploying, the push fails rather than clobbering it, and the tag does not move either. Also: - Pre-releases no longer consume a version number. build-helper:parse-version would turn 5.7.0-RC1 into 5.7.1-SNAPSHOT; the branch is now restored to the development version it already had. - Assert the poms really carry the release version before tagging, and that the tag only ever moves onto a descendant of where it already points. A release cut from an unrelated commit now fails instead of silently losing it. - finalize-release used actions/checkout@v4 and actions/setup-java@v4 while every other job used @v7 and @v6; aligned them. - Dropped ad-m/github-push-action. It was pinned to 881a6320 commented as v0.8.0, but that SHA is v1.3.0 (v0.8.0 is d91a4810), which would have misled dependabot. Plain git push does the same thing. - Guard the SNAPSHOT bump against producing an empty commit, add timeouts, and serialize releases with a concurrency group. - Drop a stale comment claiming main carries a 999-SNAPSHOT sentinel version; main is on 5.6.2-SNAPSHOT. --- .github/workflows/release-project-in-dir.yml | 156 ++++++++++++++----- .github/workflows/release.yml | 14 +- 2 files changed, 121 insertions(+), 49 deletions(-) diff --git a/.github/workflows/release-project-in-dir.yml b/.github/workflows/release-project-in-dir.yml index cec102f586..b6f76abe81 100644 --- a/.github/workflows/release-project-in-dir.yml +++ b/.github/workflows/release-project-in-dir.yml @@ -16,10 +16,12 @@ on: env: # set the target pom to use the input directory as root MAVEN_ARGS: -V -ntp -e -f ${{ inputs.project_dir }}/pom.xml + ROOT_POM: ${{ inputs.project_dir }}/pom.xml jobs: publish: runs-on: ubuntu-latest + timeout-minutes: 60 outputs: release_sha: ${{ steps.resolve-sha.outputs.commit }} steps: @@ -58,81 +60,149 @@ jobs: MAVEN_CENTRAL_TOKEN: ${{ secrets.NEXUS_PASSWORD }} MAVEN_GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} + # Deliberately a single job. The release commit carries a non-SNAPSHOT version, + # and every push to a release branch triggers snapshot-releases.yml, which + # deploys with -Prelease. If restoring the SNAPSHOT version were a separate job, + # anything that stops it from running - a failed tag push, a skipped run, a + # cancellation - would leave the branch on a release version and the next merge + # would deploy that version to Maven Central. So both commits and the tag are + # built locally and pushed in one atomic push: the branch is never observably + # left on a non-SNAPSHOT version. finalize-release: runs-on: ubuntu-latest + timeout-minutes: 20 needs: publish permissions: contents: write steps: - name: Checkout exact published commit - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: ref: "${{ needs.publish.outputs.release_sha }}" fetch-depth: 0 + # Needed to check where the release tag currently points. + fetch-tags: true - name: Set up Java and Maven - uses: actions/setup-java@v4 + uses: actions/setup-java@v6 with: java-version: 17 distribution: temurin cache: 'maven' - - name: Change version to release version + - name: Build release and next development commits + id: commits env: RELEASE_TAG: ${{ inputs.release_tag }} + TARGET_BRANCH: ${{ inputs.version_branch }} run: | + set -euo pipefail + + # Reads the version of the root pom directly, rather than through + # help:evaluate, whose banner and log output would have to be filtered + # out of stdout first. + pom_version() { + python3 -c 'import sys, xml.etree.ElementTree as ET; ns = "{http://maven.apache.org/POM/4.0.0}"; root = ET.parse(sys.argv[1]).getroot(); version = root.findtext(ns + "version") or root.findtext(ns + "parent/" + ns + "version"); print(version.strip())' "${ROOT_POM}" + } + RELEASE_VERSION="${RELEASE_TAG#v}" - ./mvnw ${MAVEN_ARGS} versions:set -DnewVersion="${RELEASE_VERSION}" versions:commit -DprocessAllModules + case "${RELEASE_VERSION}" in + *SNAPSHOT*) + echo "Refusing to release a SNAPSHOT version: ${RELEASE_VERSION}" + exit 1 + ;; + esac + + DEVELOPMENT_VERSION="$(pom_version)" + echo "Development version on ${TARGET_BRANCH}: ${DEVELOPMENT_VERSION}" - - name: Commit and push release version - env: - TARGET_BRANCH: ${{ inputs.version_branch }} - RELEASE_TAG: ${{ inputs.release_tag }} - run: | git config --local user.email "action@github.com" git config --local user.name "GitHub Action" + + ./mvnw ${MAVEN_ARGS} versions:set -DnewVersion="${RELEASE_VERSION}" versions:commit -DprocessAllModules + + # The whole point of this job is that the tag ends up on a commit whose + # poms carry the released version, so assert it rather than trusting + # versions:set to have matched every module. + ACTUAL_VERSION="$(pom_version)" + if [ "${ACTUAL_VERSION}" != "${RELEASE_VERSION}" ]; then + echo "Expected version ${RELEASE_VERSION} in ${ROOT_POM} but found ${ACTUAL_VERSION}" + exit 1 + fi + if git diff --quiet; then - echo "No version changes to commit." + echo "Version is already ${RELEASE_VERSION}, no release commit needed." else git commit -am "Release ${RELEASE_TAG}" - git push origin HEAD:"${TARGET_BRANCH}" fi + RELEASE_COMMIT="$(git rev-parse HEAD)" + echo "release_commit=${RELEASE_COMMIT}" >> "$GITHUB_OUTPUT" + + # A pre-release does not consume a version number, so development + # continues on the version the branch was already on. A final release + # does, so the incremental part is bumped. + case "${RELEASE_VERSION}" in + *-RC*|*.RC*) + NEXT_VERSION="${DEVELOPMENT_VERSION}" + echo "Pre-release, restoring development version ${NEXT_VERSION}" + ./mvnw ${MAVEN_ARGS} versions:set -DnewVersion="${NEXT_VERSION}" versions:commit -DprocessAllModules + ;; + *) + ./mvnw ${MAVEN_ARGS} build-helper:parse-version versions:set \ + -DnewVersion='${parsedVersion.majorVersion}.${parsedVersion.minorVersion}.${parsedVersion.nextIncrementalVersion}-SNAPSHOT' \ + versions:commit -DprocessAllModules + ;; + esac + + NEXT_VERSION="$(pom_version)" + case "${NEXT_VERSION}" in + *-SNAPSHOT) ;; + *) + echo "Next development version ${NEXT_VERSION} is not a SNAPSHOT" + exit 1 + ;; + esac - - name: Override release tag to point to release commit + if git diff --quiet; then + echo "Branch would be left on release version ${RELEASE_VERSION}" + exit 1 + fi + git commit -am "Set new SNAPSHOT version into pom files." + echo "Next development version: ${NEXT_VERSION}" + + - name: Move release tag onto the release commit env: RELEASE_TAG: ${{ inputs.release_tag }} + RELEASE_COMMIT: ${{ steps.commits.outputs.release_commit }} run: | - git tag -f -a "${RELEASE_TAG}" -m "Release ${RELEASE_TAG}" - git push -f origin "refs/tags/${RELEASE_TAG}" - - update-working-version: - runs-on: ubuntu-latest - needs: finalize-release - permissions: - contents: write - if: "!contains(inputs.release_tag, 'RC')" - steps: - - name: Checkout "${{ inputs.version_branch }}" branch - uses: actions/checkout@v7 - with: - ref: "${{ inputs.version_branch }}" + set -euo pipefail + + # GitHub created the tag on whatever the branch tip was when the + # release was published, so it is expected to move - but only forward, + # onto a descendant. Anything else means the release was cut from a + # commit this workflow did not build, and silently discarding it would + # lose the tagged state. + if git rev-parse -q --verify "refs/tags/${RELEASE_TAG}^{commit}" >/dev/null; then + CURRENT_TAGGED="$(git rev-parse "refs/tags/${RELEASE_TAG}^{commit}")" + if ! git merge-base --is-ancestor "${CURRENT_TAGGED}" "${RELEASE_COMMIT}"; then + echo "Tag ${RELEASE_TAG} points at ${CURRENT_TAGGED}, which is not an ancestor of ${RELEASE_COMMIT}" + exit 1 + fi + fi - - name: Set up Java and Maven - uses: actions/setup-java@v6 - with: - java-version: 17 - distribution: temurin - cache: 'maven' + git tag -f -a "${RELEASE_TAG}" "${RELEASE_COMMIT}" -m "Release ${RELEASE_TAG}" - - name: Update version to new SNAPSHOT version + - name: Push release commit, next development commit and tag + env: + RELEASE_TAG: ${{ inputs.release_tag }} + TARGET_BRANCH: ${{ inputs.version_branch }} run: | - ./mvnw ${MAVEN_ARGS} build-helper:parse-version versions:set -DnewVersion=\${parsedVersion.majorVersion}.\${parsedVersion.minorVersion}.\${parsedVersion.nextIncrementalVersion}-SNAPSHOT versions:commit -DprocessAllModules - git config --local user.email "action@github.com" - git config --local user.name "GitHub Action" - git commit -m "Set new SNAPSHOT version into pom files." -a - - - name: Push changes to branch - uses: ad-m/github-push-action@881a6320fdb16eb5318c5054f31c218aec2b324c # v0.8.0 - with: - branch: "${{ inputs.version_branch }}" - github_token: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file + set -euo pipefail + + # One atomic push so the branch is never left holding the release + # commit without the SNAPSHOT commit that follows it. The branch + # refspec is not forced: if something landed on the branch while the + # release was being deployed, this fails instead of clobbering it. + git push --atomic origin \ + "HEAD:refs/heads/${TARGET_BRANCH}" \ + "+refs/tags/${RELEASE_TAG}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a867c5adee..b42dd02233 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,6 +5,12 @@ on: release: types: [ released ] +# Releases push commits to the branch they are cut from, so run them one at a +# time rather than letting two overlap on the same branch. +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + permissions: contents: read @@ -39,7 +45,6 @@ jobs: - if: ${{ startsWith(github.event.release.tag_name, 'v5.' ) }} env: GH_TOKEN: ${{ github.token }} - RAW_TAG: ${{ github.event.release.tag_name }} run: | RELEASE_VERSION="${RAW_TAG#v}" RELEASE_MAJOR_MINOR=$(echo "$RELEASE_VERSION" | cut -d. -f1-2) @@ -56,8 +61,7 @@ jobs: # A maintenance branch (e.g. 5.3.x) exists only for streams no longer # developed on main, so its absence means main is the stream being - # released. Main's pom cannot be used to identify the stream: it carries - # the 999-SNAPSHOT sentinel version. + # released. echo "Release tag major.minor: $RELEASE_MAJOR_MINOR" # Only 404 means "no such branch". Any other outcome is a lookup failure @@ -93,8 +97,6 @@ jobs: esac - if: ${{ env.tmp_version_branch == '' }} name: Fail if version_branch is not set - env: - RAW_TAG: ${{ github.event.release.tag_name }} run: | echo "Failed to find appropriate branch to release ${RAW_TAG} from" exit 1 @@ -115,4 +117,4 @@ jobs: with: version_branch: ${{ needs.prepare-release.outputs.version_branch }} release_tag: ${{ needs.prepare-release.outputs.release_tag }} - project_dir: '.' \ No newline at end of file + project_dir: '.' From 734d6278750375bb82fe6c28f7abde80e5780fe7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Tue, 22 Sep 2026 15:01:44 +0200 Subject: [PATCH 2/2] fix: reject non-release tags instead of handling pre-releases The project does not cut RC releases any more - the last one was v5.0.0-RC1 in January 2025, and the one before that was published as a GitHub pre-release, which fires `prereleased` and never triggers this workflow at all. So rather than carry a code path for them, validate the tag shape in the publish job, before anything is deployed: only a plain major.minor.patch tag is accepted. An unexpected tag now fails loudly and is dealt with by hand. Keeps a postcondition in finalize-release that the version about to be tagged is not a SNAPSHOT. That is the exact failure this workflow exists to prevent, so it is worth asserting in the job that promises it, even though the publish guard makes it unreachable. --- .github/workflows/release-project-in-dir.yml | 47 +++++++++----------- 1 file changed, 22 insertions(+), 25 deletions(-) diff --git a/.github/workflows/release-project-in-dir.yml b/.github/workflows/release-project-in-dir.yml index b6f76abe81..54c01f20a5 100644 --- a/.github/workflows/release-project-in-dir.yml +++ b/.github/workflows/release-project-in-dir.yml @@ -50,7 +50,19 @@ jobs: env: RELEASE_TAG: ${{ inputs.release_tag }} run: | + set -euo pipefail RELEASE_VERSION="${RELEASE_TAG#v}" + + # Only plain major.minor.patch releases are supported. Pre-releases + # used to be handled by skipping the SNAPSHOT bump, which left the + # branch pinned to the pre-release version; the project does not cut + # them any more, so fail here - before anything is deployed - rather + # than carry an unused code path through the rest of the workflow. + if ! printf '%s' "${RELEASE_VERSION}" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "Not a plain major.minor.patch release tag: ${RELEASE_TAG}" + exit 1 + fi + ./mvnw ${MAVEN_ARGS} versions:set -DnewVersion="${RELEASE_VERSION}" versions:commit -DprocessAllModules - name: Publish to Apache Maven Central @@ -94,7 +106,6 @@ jobs: id: commits env: RELEASE_TAG: ${{ inputs.release_tag }} - TARGET_BRANCH: ${{ inputs.version_branch }} run: | set -euo pipefail @@ -106,15 +117,6 @@ jobs: } RELEASE_VERSION="${RELEASE_TAG#v}" - case "${RELEASE_VERSION}" in - *SNAPSHOT*) - echo "Refusing to release a SNAPSHOT version: ${RELEASE_VERSION}" - exit 1 - ;; - esac - - DEVELOPMENT_VERSION="$(pom_version)" - echo "Development version on ${TARGET_BRANCH}: ${DEVELOPMENT_VERSION}" git config --local user.email "action@github.com" git config --local user.name "GitHub Action" @@ -129,6 +131,12 @@ jobs: echo "Expected version ${RELEASE_VERSION} in ${ROOT_POM} but found ${ACTUAL_VERSION}" exit 1 fi + case "${ACTUAL_VERSION}" in + *-SNAPSHOT) + echo "Refusing to tag ${RELEASE_TAG} on a SNAPSHOT version: ${ACTUAL_VERSION}" + exit 1 + ;; + esac if git diff --quiet; then echo "Version is already ${RELEASE_VERSION}, no release commit needed." @@ -138,21 +146,10 @@ jobs: RELEASE_COMMIT="$(git rev-parse HEAD)" echo "release_commit=${RELEASE_COMMIT}" >> "$GITHUB_OUTPUT" - # A pre-release does not consume a version number, so development - # continues on the version the branch was already on. A final release - # does, so the incremental part is bumped. - case "${RELEASE_VERSION}" in - *-RC*|*.RC*) - NEXT_VERSION="${DEVELOPMENT_VERSION}" - echo "Pre-release, restoring development version ${NEXT_VERSION}" - ./mvnw ${MAVEN_ARGS} versions:set -DnewVersion="${NEXT_VERSION}" versions:commit -DprocessAllModules - ;; - *) - ./mvnw ${MAVEN_ARGS} build-helper:parse-version versions:set \ - -DnewVersion='${parsedVersion.majorVersion}.${parsedVersion.minorVersion}.${parsedVersion.nextIncrementalVersion}-SNAPSHOT' \ - versions:commit -DprocessAllModules - ;; - esac + # Development continues on the next incremental version. + ./mvnw ${MAVEN_ARGS} build-helper:parse-version versions:set \ + -DnewVersion='${parsedVersion.majorVersion}.${parsedVersion.minorVersion}.${parsedVersion.nextIncrementalVersion}-SNAPSHOT' \ + versions:commit -DprocessAllModules NEXT_VERSION="$(pom_version)" case "${NEXT_VERSION}" in