release #69
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: release | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| releaseVersion: | |
| description: 'The version to be built and released (e.g. 0.1.0).' | |
| type: string | |
| required: true | |
| nextDevelopmentVersion: | |
| description: 'Next development version (e.g. 0.2.0-SNAPSHOT).' | |
| type: string | |
| required: true | |
| baseBranch: | |
| description: 'Base branch to cut a new release/<version> canary branch from. Mutually exclusive with releaseBranch. If both are empty, defaults to the workflow dispatch ref (typically main).' | |
| type: string | |
| required: false | |
| default: '' | |
| releaseBranch: | |
| description: 'Existing branch to release from (e.g. a maintenance branch for a patch release). Mutually exclusive with baseBranch. No mergeback PR is opened in this mode.' | |
| type: string | |
| required: false | |
| default: '' | |
| dryRun: | |
| description: 'Whether to perform a dry release (no git push, no Maven publish, no canary push, no mergeback PR). Defaults to true.' | |
| type: boolean | |
| default: true | |
| defaults: | |
| run: | |
| shell: bash | |
| # Serialize all release runs. Two releases at once — even on different | |
| # versions or branches — would interleave version commits, tag creation, | |
| # and Central deployment names. Queue them instead of cancelling so a | |
| # legitimate run isn't aborted by an accidental second dispatch. | |
| concurrency: | |
| group: release-${{ github.workflow }} | |
| cancel-in-progress: false | |
| env: | |
| RELEASE_VERSION: ${{ inputs.releaseVersion }} | |
| RELEASE_TAG: ${{ inputs.releaseVersion }} | |
| GH_TOKEN: ${{ github.token }} | |
| jobs: | |
| release: | |
| name: Maven Release | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 60 | |
| permissions: | |
| contents: write | |
| pull-requests: write | |
| # Required for GitHub Models API access (used in "Generate AI release summary" step) | |
| # See: https://docs.github.com/en/github-models/prototyping-with-ai-models | |
| models: read | |
| env: | |
| DEVELOPMENT_VERSION: ${{ inputs.nextDevelopmentVersion }} | |
| PUSH_CHANGES: ${{ !inputs.dryRun }} | |
| SKIP_CENTRAL_RELEASE: ${{ inputs.dryRun }} | |
| SKIP_CAMUNDA_RELEASE: ${{ inputs.dryRun }} | |
| steps: | |
| - name: Output Inputs | |
| run: echo "${{ toJSON(github.event.inputs) }}" | |
| - name: Resolve release branch | |
| id: resolve | |
| env: | |
| BASE_BRANCH_INPUT: ${{ inputs.baseBranch }} | |
| RELEASE_BRANCH_INPUT: ${{ inputs.releaseBranch }} | |
| DEFAULT_BASE: ${{ github.ref_name }} | |
| run: | | |
| if [[ -n "$BASE_BRANCH_INPUT" && -n "$RELEASE_BRANCH_INPUT" ]]; then | |
| echo "::error::baseBranch and releaseBranch are mutually exclusive" | |
| exit 1 | |
| fi | |
| if [[ -n "$RELEASE_BRANCH_INPUT" ]]; then | |
| mode=existing | |
| release_branch="$RELEASE_BRANCH_INPUT" | |
| base_branch="" | |
| checkout_ref="$RELEASE_BRANCH_INPUT" | |
| else | |
| mode=create | |
| base_branch="${BASE_BRANCH_INPUT:-$DEFAULT_BASE}" | |
| release_branch="release/${RELEASE_VERSION}" | |
| checkout_ref="$base_branch" | |
| fi | |
| { | |
| echo "mode=$mode" | |
| echo "releaseBranch=$release_branch" | |
| echo "baseBranch=$base_branch" | |
| echo "checkoutRef=$checkout_ref" | |
| } >> "$GITHUB_OUTPUT" | |
| echo "Mode: $mode" | |
| echo "Release branch: $release_branch" | |
| echo "Base branch: ${base_branch:-(n/a)}" | |
| echo "Checkout ref: $checkout_ref" | |
| - name: Verify canary branch does not exist on origin | |
| if: ${{ steps.resolve.outputs.mode == 'create' && !inputs.dryRun }} | |
| env: | |
| RELEASE_BRANCH: ${{ steps.resolve.outputs.releaseBranch }} | |
| # Authenticate the URL so this works on private repos and so we | |
| # don't share unauthenticated rate limits with the rest of the | |
| # internet. Token is masked by the runner; never echoed. | |
| REMOTE_URL: https://x-access-token:${{ github.token }}@github.com/${{ github.repository }}.git | |
| run: | | |
| # git ls-remote rather than `gh api .../branches/<name>`: the API | |
| # endpoint takes the branch name as a single path segment, so a | |
| # name like `release/0.1.0` would be sent as two segments and | |
| # silently 404 — defeating the safety check. ls-remote handles | |
| # slashes natively and tests the same remote we'll push to. | |
| # | |
| # --exit-code: 0 = ref found, 2 = not found, anything else = | |
| # network/protocol/auth error. Distinguish all three so a transient | |
| # failure doesn't silently bypass the safety check. | |
| set +e | |
| git ls-remote --exit-code --heads "${REMOTE_URL}" "${RELEASE_BRANCH}" >/dev/null 2>&1 | |
| rc=$? | |
| set -e | |
| case "$rc" in | |
| 0) | |
| echo "::error::Release branch ${RELEASE_BRANCH} already exists on origin. Either delete it and re-dispatch, or re-dispatch with releaseBranch=${RELEASE_BRANCH} to release from the existing branch (no mergeback PR will be opened in that mode)." | |
| exit 1 | |
| ;; | |
| 2) | |
| ;; | |
| *) | |
| echo "::error::Could not verify whether ${RELEASE_BRANCH} exists on origin (git ls-remote exit code ${rc}). Aborting to avoid overwriting an existing branch." | |
| exit 1 | |
| ;; | |
| esac | |
| - name: Import Maven Central secrets | |
| id: secrets | |
| uses: hashicorp/vault-action@892a26828f195e65540a40b4768ae4571f51ebfc # v4.0.0 | |
| with: | |
| url: ${{ secrets.VAULT_ADDR }} | |
| method: approle | |
| roleId: ${{ secrets.VAULT_ROLE_ID }} | |
| secretId: ${{ secrets.VAULT_SECRET_ID }} | |
| exportEnv: false | |
| secrets: | | |
| secret/data/github.com/organizations/camunda MAVEN_CENTRAL_GPG_SIGNING_KEY_PASSPHRASE; | |
| secret/data/github.com/organizations/camunda MAVEN_CENTRAL_GPG_SIGNING_KEY_SEC; | |
| secret/data/github.com/organizations/camunda MAVEN_CENTRAL_GPG_SIGNING_KEY_PUB; | |
| secret/data/github.com/organizations/camunda MAVEN_CENTRAL_DEPLOYMENT_USR; | |
| secret/data/github.com/organizations/camunda MAVEN_CENTRAL_DEPLOYMENT_PSW; | |
| - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 | |
| with: | |
| ref: ${{ steps.resolve.outputs.checkoutRef }} | |
| fetch-depth: 0 | |
| token: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Git User Setup | |
| run: | | |
| git config --global user.email "github-actions[release]@users.noreply.github.com" | |
| git config --global user.name "github-actions[release]" | |
| - name: Create canary release branch | |
| if: ${{ steps.resolve.outputs.mode == 'create' }} | |
| env: | |
| RELEASE_BRANCH: ${{ steps.resolve.outputs.releaseBranch }} | |
| run: | | |
| git switch -c "${RELEASE_BRANCH}" | |
| if [[ "${PUSH_CHANGES}" == "true" ]]; then | |
| # Push immediately so release:prepare has an upstream to push version | |
| # commits and tags to, and so the branch is visible for inspection if | |
| # a later step fails. | |
| git push -u origin "${RELEASE_BRANCH}" | |
| fi | |
| - name: Install Maven Central GPG Key | |
| run: | | |
| echo -n "${{ steps.secrets.outputs.MAVEN_CENTRAL_GPG_SIGNING_KEY_SEC }}" \ | |
| | base64 --decode \ | |
| | gpg -q --allow-secret-key-import --import --no-tty --batch --yes | |
| echo -n "${{ steps.secrets.outputs.MAVEN_CENTRAL_GPG_SIGNING_KEY_PUB }}" \ | |
| | base64 --decode \ | |
| | gpg -q --import --no-tty --batch --yes | |
| - name: Set up Java 21 | |
| uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0 | |
| with: | |
| distribution: temurin | |
| java-version: '21' | |
| cache: maven | |
| - name: Create Maven settings.xml | |
| uses: ./.github/actions/create-maven-setting-file | |
| with: | |
| VAULT_ADDR: ${{ secrets.VAULT_ADDR }} | |
| VAULT_ROLE_ID: ${{ secrets.VAULT_ROLE_ID }} | |
| VAULT_SECRET_ID: ${{ secrets.VAULT_SECRET_ID }} | |
| MAVEN_CENTRAL_DEPLOYMENT_USR: ${{ steps.secrets.outputs.MAVEN_CENTRAL_DEPLOYMENT_USR }} | |
| MAVEN_CENTRAL_DEPLOYMENT_PSW: ${{ steps.secrets.outputs.MAVEN_CENTRAL_DEPLOYMENT_PSW }} | |
| - name: Maven Prepare Release | |
| env: | |
| MAVEN_GPG_PASSPHRASE: ${{ steps.secrets.outputs.MAVEN_CENTRAL_GPG_SIGNING_KEY_PASSPHRASE }} | |
| run: | | |
| # The parent maven-release-plugin config interpolates ${arguments}; | |
| # always pass -Darguments (even with no inner args) to avoid a literal | |
| # ${arguments} ending up on the inner Maven command line. | |
| ./mvnw release:prepare -B \ | |
| -Dresume=false \ | |
| -Dtag="${RELEASE_TAG}" \ | |
| -DreleaseVersion="${RELEASE_VERSION}" \ | |
| -DdevelopmentVersion="${DEVELOPMENT_VERSION}" \ | |
| -DpushChanges="${PUSH_CHANGES}" \ | |
| -DremoteTagging="${PUSH_CHANGES}" \ | |
| -DautoVersionSubmodules=true \ | |
| -Darguments="-DskipTests=true -Dgpg.passphrase=${MAVEN_GPG_PASSPHRASE}" | |
| - name: Maven Perform Release | |
| id: maven-perform-release | |
| env: | |
| MAVEN_GPG_PASSPHRASE: ${{ steps.secrets.outputs.MAVEN_CENTRAL_GPG_SIGNING_KEY_PASSPHRASE }} | |
| # central-publishing-maven-plugin defaults to a 30-minute wait window; bump to 60. | |
| CENTRAL_WAIT_MAX_TIME: '3600' | |
| run: | | |
| # localCheckout=true makes release:perform clone from the local working | |
| # copy (which already contains the tag created by release:prepare moments | |
| # earlier) instead of doing a fresh https://github.com clone. That fresh | |
| # clone has no credentials available and fails non-interactively on a | |
| # runner. Skipping the remote re-clone is safe: release:prepare already | |
| # exited 0 after pushing the tag. | |
| ./mvnw release:perform -B \ | |
| -DreleaseVersion="${RELEASE_VERSION}" \ | |
| -Dgpg.passphrase="${MAVEN_GPG_PASSPHRASE}" \ | |
| -DlocalCheckout=true \ | |
| -Darguments="-DskipTests=true -Dskip.central.release=${SKIP_CENTRAL_RELEASE} -Dskip.camunda.release=${SKIP_CAMUNDA_RELEASE} -DwaitMaxTime=${CENTRAL_WAIT_MAX_TIME} -Dgpg.passphrase=${MAVEN_GPG_PASSPHRASE}" | |
| if [[ -d target/checkout ]]; then | |
| pushd target/checkout >/dev/null | |
| TAG_REVISION=$(git log -n 1 --pretty=format:'%h') | |
| echo "tagRevision=${TAG_REVISION}" >> "$GITHUB_OUTPUT" | |
| popd >/dev/null | |
| fi | |
| - name: Push release branch | |
| if: ${{ !inputs.dryRun }} | |
| env: | |
| RELEASE_BRANCH: ${{ steps.resolve.outputs.releaseBranch }} | |
| run: git push origin "${RELEASE_BRANCH}" | |
| - name: Create GitHub Release | |
| if: ${{ !inputs.dryRun }} | |
| env: | |
| RELEASE_BRANCH: ${{ steps.resolve.outputs.releaseBranch }} | |
| run: | | |
| gh release create "${RELEASE_TAG}" \ | |
| --repo "${{ github.repository }}" \ | |
| --target "${RELEASE_BRANCH}" \ | |
| --title "${RELEASE_VERSION}" \ | |
| --generate-notes | |
| - name: Generate AI release summary | |
| if: ${{ !inputs.dryRun }} | |
| # Uses GitHub Models API (https://docs.github.com/en/github-models) which | |
| # is accessible with GITHUB_TOKEN for repos that have GitHub Models enabled. | |
| # Falls back silently to the auto-generated notes if the API call fails | |
| # (rate limit, token permissions, network) so the release is never blocked. | |
| run: | | |
| python3 - <<'EOF' | |
| import json, os, subprocess, sys, tempfile, urllib.request, urllib.error | |
| import re | |
| release_tag = os.environ["RELEASE_TAG"] | |
| release_ver = os.environ["RELEASE_VERSION"] | |
| repo = os.environ["GITHUB_REPOSITORY"] | |
| # The workflow sets GH_TOKEN globally; fall back to GITHUB_TOKEN if present. | |
| token = os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN") | |
| if not token: | |
| print("Warning: no GitHub token found — skipping AI summary.") | |
| sys.exit(0) | |
| def extract_git_info(): | |
| """Extract information about changes to key documentation and API areas.""" | |
| info = {"adrs": [], "adopters_docs": [], "api_classes": []} | |
| try: | |
| # Get all commits since the previous tag | |
| # Try to find the previous tag before this release | |
| result = subprocess.run( | |
| ["git", "describe", "--tags", "--abbrev=0", release_tag + "^"], | |
| capture_output=True, text=True | |
| ) | |
| prev_tag = result.stdout.strip() if result.returncode == 0 else "HEAD~100" | |
| # Get added/modified/renamed files between previous tag and current release. | |
| # Using --diff-filter=AMR excludes deleted files so removed ADRs/docs/API classes | |
| # are not incorrectly reported as added/modified in structural_info. | |
| result = subprocess.run( | |
| ["git", "diff", "--name-only", "--diff-filter=AMR", f"{prev_tag}..{release_tag}"], | |
| capture_output=True, text=True, check=False | |
| ) | |
| if result.returncode == 0: | |
| changed_files = set(line.strip() for line in result.stdout.split("\n") if line.strip()) | |
| else: | |
| print( | |
| f"Warning: failed to diff {prev_tag}..{release_tag}; " | |
| f"falling back to files in {release_tag}. git diff stderr: {result.stderr.strip()}" | |
| ) | |
| fallback_result = subprocess.run( | |
| ["git", "diff-tree", "--no-commit-id", "--name-only", "-r", "--diff-filter=AMR", release_tag], | |
| capture_output=True, text=True, check=False | |
| ) | |
| if fallback_result.returncode == 0: | |
| changed_files = set( | |
| line.strip() for line in fallback_result.stdout.split("\n") if line.strip() | |
| ) | |
| else: | |
| print( | |
| f"Warning: fallback diff-tree for {release_tag} also failed; " | |
| f"continuing with no changed files. git diff-tree stderr: {fallback_result.stderr.strip()}" | |
| ) | |
| changed_files = set() | |
| def get_adr_title(path, slug): | |
| """Read ADR title from the tag itself to ensure accuracy.""" | |
| try: | |
| result = subprocess.run( | |
| ["git", "show", f"{release_tag}:{path}"], | |
| capture_output=True, text=True, check=False | |
| ) | |
| if result.returncode == 0: | |
| for raw_line in result.stdout.splitlines(): | |
| line = raw_line.strip() | |
| if line.startswith("# "): | |
| return line[2:].strip() | |
| else: | |
| print(f"Warning: failed to read ADR title from tag {release_tag}:{path}: {result.stderr.strip()}", file=sys.stderr) | |
| except Exception as e: | |
| print(f"Warning: failed to read ADR title from {path}: {e}", file=sys.stderr) | |
| return slug.replace("-", " ").title() | |
| # Filter for ADRs | |
| adr_files = [f for f in changed_files if f.startswith("docs/adr/") and f.endswith(".md")] | |
| for f in sorted(adr_files): | |
| match = re.search(r"(\d{4})-(.+)\.md", f) | |
| if match: | |
| num, adr_slug = match.groups() | |
| title = get_adr_title(f, adr_slug) | |
| info["adrs"].append(f"ADR-{num}: {title}") | |
| # Filter for adopters docs | |
| adopters_files = [f for f in changed_files if f.startswith("docs/adopters/") and f.endswith(".md")] | |
| for f in sorted(adopters_files): | |
| # Include full repo-relative path so the model and readers can locate the doc | |
| info["adopters_docs"].append(f) | |
| # Filter for API classes (Java files in api/src/main/java) | |
| api_files = [f for f in changed_files if f.startswith("api/src/main/java/") and f.endswith(".java")] | |
| for f in sorted(api_files): | |
| # Extract class name from file path | |
| parts = f.split("/") | |
| classname = parts[-1].replace(".java", "") | |
| package_parts = parts[4:-1] # Get package parts (skip api/src/main/java) | |
| if package_parts: | |
| package = ".".join(package_parts) | |
| info["api_classes"].append(f"{package}.{classname}") | |
| else: | |
| info["api_classes"].append(classname) | |
| except Exception as e: | |
| # Keep release non-blocking, but allow opt-in diagnostics when debugging. | |
| if os.environ.get("RELEASE_DEBUG_GIT_INFO", "").lower() in ("1", "true", "yes", "on"): | |
| print(f"Warning: failed to extract git info for release notes: {e}", file=sys.stderr) | |
| return info | |
| try: | |
| # Fetch the auto-generated notes that GitHub produced in the previous step. | |
| result = subprocess.run( | |
| ["gh", "release", "view", release_tag, "--repo", repo, | |
| "--json", "body", "--jq", ".body"], | |
| capture_output=True, text=True, check=True, | |
| ) | |
| notes = result.stdout.strip() | |
| if not notes: | |
| print("No release notes found — skipping AI summary.") | |
| sys.exit(0) | |
| # Extract additional information about ADRs, docs, and API changes | |
| git_info = extract_git_info() | |
| def format_structural_section(title, items, limit=20): | |
| if not items: | |
| return "" | |
| section = f"\n**{title}:**\n" | |
| for item in items[:limit]: | |
| section += f" - {item}\n" | |
| if len(items) > limit: | |
| section += f" - ... and {len(items) - limit} more\n" | |
| return section | |
| structural_info = "" | |
| structural_info += format_structural_section( | |
| "Architecture Decision Records (ADRs) added/modified", | |
| git_info["adrs"], | |
| ) | |
| structural_info += format_structural_section( | |
| "Adoption/Integration Documentation added/modified", | |
| git_info["adopters_docs"], | |
| ) | |
| structural_info += format_structural_section( | |
| "Public API classes added/modified", | |
| sorted(set(git_info["api_classes"])), | |
| ) | |
| # Budget total input size to avoid exceeding API limits | |
| MAX_TOTAL_INPUT = 4000 | |
| MAX_STRUCTURAL_INFO = 1500 | |
| MIN_MEANINGFUL_LENGTH = 100 # Minimum characters for meaningful content | |
| TRUNCATION_MARKER = "\n\n... (truncated due to size limits)\n" | |
| MIN_TRUNCATED_CONTENT_BUDGET = len(TRUNCATION_MARKER) + MIN_MEANINGFUL_LENGTH | |
| # Cap structural_info by characters, accounting for truncation marker | |
| if len(structural_info) > MAX_STRUCTURAL_INFO: | |
| # Guard against negative slice (though unlikely with current constants) | |
| truncate_at = max(0, MAX_STRUCTURAL_INFO - len(TRUNCATION_MARKER)) | |
| structural_info_content = structural_info[:truncate_at] + TRUNCATION_MARKER | |
| else: | |
| structural_info_content = structural_info | |
| # Adjust notes truncation to account for structural_info size and prompt prefix | |
| structural_suffix = f"\n\nKey structural changes in this release:\n{structural_info_content}" if structural_info_content else "" | |
| prompt_prefix = f"Generate release notes for version {release_ver}:\n\n" | |
| overhead = len(prompt_prefix) + len(structural_suffix) | |
| max_notes_len = MAX_TOTAL_INPUT - overhead | |
| # Handle notes truncation | |
| # Only enforce minimum length when truncating; short but complete notes are acceptable | |
| if len(notes) > max_notes_len: | |
| # Notes need truncation - ensure truncated result is meaningful | |
| if max_notes_len > MIN_TRUNCATED_CONTENT_BUDGET: | |
| notes_content = notes[:max_notes_len - len(TRUNCATION_MARKER)] + TRUNCATION_MARKER | |
| else: | |
| # Not enough space for meaningful truncated notes | |
| notes_content = "" | |
| else: | |
| # Notes fit within budget - include them regardless of length | |
| notes_content = notes | |
| payload = { | |
| "model": "gpt-4o-mini", | |
| "messages": [ | |
| { | |
| "role": "system", | |
| "content": ( | |
| "You are a technical writer creating release notes for the Camunda Security Library, " | |
| "a Java 21 / Spring Boot hexagonal security library. " | |
| "Given a raw list of commits or PR titles, produce concise, structured release notes with: " | |
| "a one-short-paragraph executive summary, then bullet-point sections titled " | |
| "Breaking Changes (omit entirely if none), New Features, Bug Fixes, and Improvements. " | |
| "Be precise and developer-friendly. Do not invent details not present in the input. " | |
| "\n\nWhen mentioned, specifically highlight:\n" | |
| "- New or modified Architecture Decision Records (ADRs) — reference them by their number/title\n" | |
| "- New or modified adoption/integration documentation\n" | |
| "- New or modified public API classes — these are important for library users\n" | |
| ), | |
| }, | |
| { | |
| "role": "user", | |
| "content": f"{prompt_prefix}{notes_content}{structural_suffix}", | |
| }, | |
| ], | |
| "max_tokens": 1000, | |
| } | |
| req = urllib.request.Request( | |
| "https://models.inference.ai.azure.com/chat/completions", | |
| data=json.dumps(payload).encode(), | |
| headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"}, | |
| ) | |
| with urllib.request.urlopen(req, timeout=30) as resp: | |
| data = json.loads(resp.read()) | |
| summary = data["choices"][0]["message"]["content"] | |
| new_body = ( | |
| f"## What's new in {release_ver}\n\n" | |
| f"{summary}\n\n" | |
| f"---\n\n" | |
| f"## Full Changelog\n\n" | |
| f"{notes}" | |
| ) | |
| # Write to a temp file to avoid command-line length limits. | |
| with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False) as tmp: | |
| tmp.write(new_body) | |
| tmp_path = tmp.name | |
| try: | |
| subprocess.run( | |
| ["gh", "release", "edit", release_tag, "--repo", repo, "--notes-file", tmp_path], | |
| check=True, | |
| ) | |
| finally: | |
| os.unlink(tmp_path) | |
| print("Release notes updated with AI summary.") | |
| except Exception as exc: | |
| print(f"Warning: AI summary skipped ({exc}). Keeping auto-generated notes.") | |
| sys.exit(0) | |
| EOF | |
| - name: Open mergeback PR | |
| if: ${{ steps.resolve.outputs.mode == 'create' && !inputs.dryRun }} | |
| env: | |
| BASE_BRANCH: ${{ steps.resolve.outputs.baseBranch }} | |
| RELEASE_BRANCH: ${{ steps.resolve.outputs.releaseBranch }} | |
| NEXT_DEV_VERSION: ${{ inputs.nextDevelopmentVersion }} | |
| run: | | |
| body=$(cat <<EOF | |
| Mergeback of \`${RELEASE_BRANCH}\` into \`${BASE_BRANCH}\` after the ${RELEASE_VERSION} release. | |
| Contains the version commits produced by \`maven-release-plugin\`: | |
| - \`${RELEASE_VERSION}\` release commit | |
| - \`${NEXT_DEV_VERSION}\` next development version commit | |
| Tag: [\`${RELEASE_TAG}\`](https://github.com/${{ github.repository }}/releases/tag/${RELEASE_TAG}) | |
| > **Note:** PRs opened via \`GITHUB_TOKEN\` do not trigger workflow runs. | |
| > To run CI on this PR, push an empty commit (\`git commit --allow-empty\`) or close-and-reopen the PR. | |
| EOF | |
| ) | |
| # Idempotency: GitHub keeps PRs indexed even after their head branch | |
| # is deleted and recreated, so a zombie PR from a previous run that | |
| # was manually cleaned up can re-attach to the new canary. Reusing | |
| # the existing PR avoids failing this step (and the whole workflow) | |
| # after the release/tag are already published. | |
| existing=$(gh pr list \ | |
| --repo "${{ github.repository }}" \ | |
| --head "${RELEASE_BRANCH}" \ | |
| --base "${BASE_BRANCH}" \ | |
| --state open \ | |
| --json number,url \ | |
| --jq '.[0].url // empty') | |
| if [[ -n "$existing" ]]; then | |
| echo "Mergeback PR already exists: ${existing} — skipping creation." | |
| exit 0 | |
| fi | |
| gh pr create \ | |
| --repo "${{ github.repository }}" \ | |
| --base "${BASE_BRANCH}" \ | |
| --head "${RELEASE_BRANCH}" \ | |
| --title "chore(release): merge back ${RELEASE_VERSION} into ${BASE_BRANCH}" \ | |
| --body "${body}" | |
| - name: Cleanup Maven Central GPG Key | |
| if: always() | |
| run: rm -rf "$HOME/.gnupg" |