Skip to content

release

release #69

Workflow file for this run

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"