-
Notifications
You must be signed in to change notification settings - Fork 243
fix: make release finalization atomic so branches never linger on a release version #3631
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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: | ||
|
|
@@ -48,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 | ||
|
|
@@ -58,81 +72,134 @@ 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 }} | ||
| 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 | ||
|
|
||
| - 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 | ||
| 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 "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" | ||
|
|
||
| # 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 | ||
| *-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 | ||
|
Comment on lines
+182
to
+186
|
||
| 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 }} | ||
| 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}" | ||
|
Comment on lines
+203
to
+205
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win Protect the forced tag update with an explicit lease. The ancestry check uses the tag state fetched during checkout. A concurrent actor can update the remote tag after that check. The forced refspec then overwrites the newer target without validating its ancestry. Capture the raw tag ref OID during the check. Use it with Based on learnings, a local tag check does not reliably protect a later forced update from concurrent tag changes. 🤖 Prompt for AI AgentsSource: Learnings
Comment on lines
+203
to
+205
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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 | ||||||||||||||||
|
Comment on lines
+10
to
+12
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: sed -n '1,140p' .github/workflows/release.yml
rg -n 'concurrency:|workflow_call|workflow_dispatch|release-project-in-dir|release-sdk' .github/workflowsRepository: operator-framework/java-operator-sdk Length of output: 5659 🌐 Web query:
💡 Result: <source_evidence> Citations:
Queue pending release runs. With Suggested fix concurrency:
group: ${{ github.workflow }}
+ queue: max
cancel-in-progress: false📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents
Comment on lines
+10
to
+12
|
||||||||||||||||
|
|
||||||||||||||||
| 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: '.' | ||||||||||||||||
| project_dir: '.' | ||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Restore the checked-out development version instead of deriving a new version.
This command assumes that the existing development version is always the next incremental version after
RELEASE_VERSION. For example, a branch on5.1.0-SNAPSHOTreleased as5.0.0will be changed to5.0.1-SNAPSHOT.Read and validate the original POM version before setting the release version. Restore that exact version after creating
RELEASE_COMMIT.Proposed fix
🤖 Prompt for AI Agents