name: Publish Release to Maven Central concurrency: group: publish-release-${{ github.repository }} on: pull_request: types: [closed] workflow_dispatch: inputs: releaseVersion: description: "Release version to publish (e.g. 0.22.2)." required: true type: string releaseCommit: description: "Optional: Release commit SHA on the default branch (auto-detects from release/.md if blank)." required: false type: string dryRun: description: "Run publish workflow in dry-run mode (no tag/push/deploy)." required: false default: true type: boolean recoveryMode: description: "Require release-recovery environment approval before allowing an explicit releaseCommit that is not reachable from the default branch." required: false default: false type: boolean permissions: actions: write contents: write discussions: write jobs: recovery_approval: name: Release recovery approval runs-on: ubuntu-latest if: ${{ github.event_name == 'workflow_dispatch' && inputs.recoveryMode == true }} environment: name: release-recovery steps: - name: Approve release recovery mode run: | echo "Recovery mode was explicitly requested for publish-release.yml." echo "Environment approval gates manual publishing of a release commit that may not be reachable from the default branch." publish: name: Publish Release to Maven Central runs-on: ubuntu-latest needs: recovery_approval if: | always() && ( github.event_name == 'workflow_dispatch' || (github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release')) ) && (needs.recovery_approval.result == 'success' || needs.recovery_approval.result == 'skipped') steps: # ----------------------- # Setup and Metadata # ----------------------- - name: Checkout repository uses: actions/checkout@v6 with: fetch-depth: 0 persist-credentials: true token: ${{ secrets.GH_TA4J_REPO_TOKEN || github.token }} - name: Normalize dry run input id: dry_run run: | echo "::group::Normalize publish-release inputs" if [ "${{ github.event_name }}" = "pull_request" ]; then dry_run=false else raw="${{ github.event.inputs.dryRun }}" if [ -z "$raw" ]; then raw=true fi normalized=$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]') if [ "$normalized" = "false" ] || [ "$normalized" = "0" ] || [ "$normalized" = "no" ]; then dry_run=false else dry_run=true fi fi echo "audit:dry_run_normalized=$dry_run" echo "dryRun=$dry_run" >> $GITHUB_OUTPUT echo "audit:recovery_mode=${{ github.event.inputs.recoveryMode }}" echo "::endgroup::" - name: Preflight GH_TA4J_REPO_TOKEN permissions id: token_preflight env: GH_TA4J_REPO_TOKEN: ${{ secrets.GH_TA4J_REPO_TOKEN }} DRY_RUN: ${{ steps.dry_run.outputs.dryRun }} run: | set -euo pipefail python3 - <<'PY' import json import os import sys import urllib.request token = os.environ.get("GH_TA4J_REPO_TOKEN", "") dry_run = os.environ.get("DRY_RUN", "false").lower() == "true" if not token: print("audit:repo_token_present=false") sys.exit(0) owner = os.environ.get("GITHUB_REPOSITORY_OWNER") repo = os.environ.get("GITHUB_REPOSITORY", "").split("/", 1)[-1] if not owner or not repo: print("::error::Unable to resolve repository context for permission preflight.") sys.exit(1) query = { "query": "query($owner:String!,$repo:String!){repository(owner:$owner,name:$repo){viewerPermission}}", "variables": {"owner": owner, "repo": repo}, } req = urllib.request.Request( "https://api.github.com/graphql", data=json.dumps(query).encode("utf-8"), headers={ "Authorization": f"Bearer {token}", "Content-Type": "application/json", "User-Agent": "ta4j-release-preflight", }, ) try: with urllib.request.urlopen(req) as resp: data = json.load(resp) except Exception as exc: msg = f"GH_TA4J_REPO_TOKEN permission check failed: {exc}" if dry_run: print(f"::warning::{msg}") sys.exit(0) print(f"::error::{msg}") sys.exit(1) if "errors" in data: msg = f"GH_TA4J_REPO_TOKEN permission check failed: {data['errors']}" if dry_run: print(f"::warning::{msg}") sys.exit(0) print(f"::error::{msg}") sys.exit(1) permission = data.get("data", {}).get("repository", {}).get("viewerPermission") allowed = permission in ("WRITE", "MAINTAIN", "ADMIN") output_path = os.environ.get("GITHUB_OUTPUT") if output_path: with open(output_path, "a", encoding="utf-8") as fh: fh.write("repo_token_present=true\n") fh.write(f"repo_token_permission={permission}\n") if not allowed: msg = f"GH_TA4J_REPO_TOKEN permission '{permission}' lacks write access." if dry_run: print(f"::warning::{msg}") sys.exit(0) print(f"::error::{msg}") sys.exit(1) print(f"audit:repo_token_permission={permission}") PY - name: Extract release metadata id: meta uses: actions/github-script@v9 with: github-token: ${{ github.token }} script: | const { execSync } = require("child_process"); const eventName = context.eventName; const defaultBranch = context.payload.repository?.default_branch || ""; const owner = context.repo.owner; const repo = context.repo.repo; let releaseVersion = ""; let releaseCommit = ""; let nextVersion = ""; let mergeCommit = ""; let prNumber = ""; let releaseCommitInPr = "false"; const normalizeSha = (candidate) => { const value = typeof candidate === "string" ? candidate.trim() : ""; return /^[0-9a-f]{7,40}$/i.test(value) ? value : ""; }; const fetchDefaultBranch = () => { if (!defaultBranch) { throw new Error("Default branch not available; cannot resolve release metadata."); } execSync(`git fetch origin ${defaultBranch} --tags`, { stdio: "inherit" }); }; const fetchPullRequestHead = (number) => { execSync( `git fetch origin pull/${number}/head:refs/remotes/origin/pull/${number}/head`, { stdio: "inherit" } ); }; const fetchCommitBySha = (sha) => { try { execSync(`git fetch origin ${sha}`, { stdio: "ignore" }); return true; } catch (error) { return false; } }; const detectReleaseCommitFromDefaultBranch = (version) => { const releaseNotesPath = `release/${version}.md`; let output = ""; try { output = execSync( `git log --diff-filter=A --format=%H origin/${defaultBranch} -- ${releaseNotesPath}`, { encoding: "utf8" } ).trim(); } catch (error) { throw new Error(`Failed to search for release commit on origin/${defaultBranch}: ${error.message}`); } if (!output) { throw new Error( `Unable to auto-detect release commit. '${releaseNotesPath}' not found on origin/${defaultBranch}.` ); } return output.split(/\r?\n/)[0].trim(); }; const commitExists = (sha) => { try { execSync(`git cat-file -e ${sha}^{commit}`, { stdio: "ignore" }); return true; } catch (error) { return false; } }; if (eventName === "workflow_dispatch") { const dispatchInputs = context.payload.inputs || {}; const readDispatchInput = (key) => { const candidateKeys = [key, key.toLowerCase()]; for (const candidate of candidateKeys) { const value = dispatchInputs[candidate]; if (typeof value === "string" && value.trim()) { return value.trim(); } } const fromCore = core.getInput(key, { required: false }); return typeof fromCore === "string" ? fromCore.trim() : ""; }; releaseVersion = readDispatchInput("releaseVersion"); releaseCommit = normalizeSha(readDispatchInput("releaseCommit")); if (!releaseVersion) { const providedKeys = Object.keys(dispatchInputs); throw new Error( `releaseVersion is required for workflow_dispatch. Provided inputs: ${ providedKeys.length > 0 ? providedKeys.join(", ") : "(none)" }` ); } } else { const pr = context.payload.pull_request; prNumber = pr?.number ? String(pr.number) : ""; mergeCommit = pr?.merge_commit_sha || ""; const body = pr?.body || ""; const match = body.match(//i); if (!match) { throw new Error("Release metadata block not found in PR body."); } const metaText = match[1]; const valueFor = (key) => { const re = new RegExp(`^\\s*${key}\\s*:\\s*(.+)\\s*$`, "mi"); const found = metaText.match(re); return found ? found[1].trim() : ""; }; releaseVersion = valueFor("releaseVersion"); nextVersion = valueFor("nextVersion"); releaseCommit = normalizeSha(valueFor("releaseCommit")); if (!prNumber) { throw new Error("Release PR number missing from event payload."); } const prCommitList = await github.paginate(github.rest.pulls.listCommits, { owner, repo, pull_number: Number(prNumber), per_page: 100 }); if (!prCommitList.length) { throw new Error(`Release PR #${prNumber} has no commits.`); } const releaseHeadline = `Release ${releaseVersion}`; const releaseCommitByHeadline = prCommitList.find((entry) => { const headline = String(entry?.commit?.message || "").split(/\r?\n/, 1)[0].trim(); return headline === releaseHeadline; }); const prCommitShas = new Set(prCommitList.map((entry) => entry.sha)); if (releaseCommit) { if (!prCommitShas.has(releaseCommit)) { throw new Error( `releaseCommit ${releaseCommit} from PR metadata is not part of PR #${prNumber}.` ); } } else if (releaseCommitByHeadline?.sha) { releaseCommit = releaseCommitByHeadline.sha; core.info( `Inferred release commit ${releaseCommit} from PR commit headline '${releaseHeadline}'.` ); } else { throw new Error( `Missing releaseCommit metadata and no '${releaseHeadline}' commit found in PR #${prNumber}.` ); } releaseCommitInPr = "true"; } if (!releaseVersion) { throw new Error("Missing releaseVersion in metadata."); } fetchDefaultBranch(); if (eventName === "pull_request") { fetchPullRequestHead(prNumber); } if (!releaseCommit) { releaseCommit = detectReleaseCommitFromDefaultBranch(releaseVersion); core.info( `Using auto-detected release commit ${releaseCommit} from origin/${defaultBranch}.` ); } else if (!commitExists(releaseCommit)) { const fetched = fetchCommitBySha(releaseCommit); if (!fetched || !commitExists(releaseCommit)) { throw new Error(`Release commit ${releaseCommit} could not be fetched.`); } core.info(`Fetched release commit ${releaseCommit} by SHA.`); } core.setOutput("release_version", releaseVersion); core.setOutput("release_commit", releaseCommit); core.setOutput("next_version", nextVersion); core.setOutput("merge_commit", mergeCommit); core.setOutput("pr_number", prNumber); core.setOutput("release_commit_in_pr", releaseCommitInPr); - name: Verify release commit ancestry env: RELEASE_VERSION: ${{ steps.meta.outputs.release_version }} RELEASE_COMMIT: ${{ steps.meta.outputs.release_commit }} RELEASE_COMMIT_IN_PR: ${{ steps.meta.outputs.release_commit_in_pr }} MERGE_COMMIT: ${{ steps.meta.outputs.merge_commit }} DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} EVENT_NAME: ${{ github.event_name }} RECOVERY_MODE: ${{ github.event.inputs.recoveryMode }} run: | set -euo pipefail echo "::group::Verify release commit ancestry" if [ -z "${RELEASE_VERSION:-}" ] || [ -z "${RELEASE_COMMIT:-}" ]; then echo "Missing release metadata; cannot proceed." >&2 exit 1 fi if [[ "${RELEASE_VERSION}" == *-SNAPSHOT* ]]; then echo "Release version must not include -SNAPSHOT: ${RELEASE_VERSION}" >&2 exit 1 fi if ! git cat-file -e "${RELEASE_COMMIT}^{commit}" 2>/dev/null; then echo "Release commit ${RELEASE_COMMIT} not found." >&2 exit 1 fi if [ -z "${DEFAULT_BRANCH:-}" ]; then echo "Default branch not provided by event." >&2 exit 1 fi git fetch origin "${DEFAULT_BRANCH}" --tags if [ "${EVENT_NAME}" = "pull_request" ]; then if [ "${RELEASE_COMMIT_IN_PR:-false}" != "true" ]; then echo "Release commit ${RELEASE_COMMIT} was not validated as part of the release PR." >&2 exit 1 fi if [ -z "${MERGE_COMMIT:-}" ]; then echo "Merge commit SHA missing for release PR." >&2 exit 1 fi if ! git cat-file -e "${MERGE_COMMIT}^{commit}" 2>/dev/null; then echo "Merge commit ${MERGE_COMMIT} not found." >&2 exit 1 fi if ! git merge-base --is-ancestor "${MERGE_COMMIT}" "origin/${DEFAULT_BRANCH}"; then echo "Merge commit ${MERGE_COMMIT} is not on origin/${DEFAULT_BRANCH}." >&2 exit 1 fi parents=$(git cat-file -p "${MERGE_COMMIT}" | awk '/^parent / {count++} END {print count+0}') if [ "${parents:-0}" -ge 2 ]; then if ! git merge-base --is-ancestor "${RELEASE_COMMIT}" "${MERGE_COMMIT}"; then echo "Release commit ${RELEASE_COMMIT} is not an ancestor of merge commit ${MERGE_COMMIT}." >&2 exit 1 fi else echo "Merge commit ${MERGE_COMMIT} has ${parents:-0} parent(s); accepting release commit from PR metadata." fi elif ! git merge-base --is-ancestor "${RELEASE_COMMIT}" "origin/${DEFAULT_BRANCH}"; then recovery_mode=$(printf '%s' "${RECOVERY_MODE:-false}" | tr '[:upper:]' '[:lower:]') if [ "$recovery_mode" != "true" ]; then echo "::error::Release commit ${RELEASE_COMMIT} is not on origin/${DEFAULT_BRANCH}." echo "::error::Re-run workflow_dispatch with recoveryMode=true to require release-recovery environment approval." exit 1 fi echo "::warning::Release commit ${RELEASE_COMMIT} is not on origin/${DEFAULT_BRANCH}; continuing only because workflow_dispatch recoveryMode=true passed environment approval." else echo "Release commit ${RELEASE_COMMIT} is on origin/${DEFAULT_BRANCH}." fi echo "::endgroup::" - name: Check if tag already exists id: check_tag env: RELEASE_VERSION: ${{ steps.meta.outputs.release_version }} run: | exists=false found="" for candidate in "$RELEASE_VERSION" "v$RELEASE_VERSION"; do if git rev-parse "refs/tags/$candidate" >/dev/null 2>&1; then exists=true found="$candidate" break fi done echo "exists=$exists" >> $GITHUB_OUTPUT echo "existing_tag=$found" >> $GITHUB_OUTPUT - name: Fail if tag exists if: steps.check_tag.outputs.exists == 'true' && steps.dry_run.outputs.dryRun != 'true' env: RELEASE_VERSION: ${{ steps.meta.outputs.release_version }} EXISTING_TAG: ${{ steps.check_tag.outputs.existing_tag }} run: | tag="${EXISTING_TAG:-$RELEASE_VERSION}" echo "Error: tag '$tag' already exists (checked '$RELEASE_VERSION' and 'v$RELEASE_VERSION'). Aborting." exit 1 - name: Report existing tag in dry-run mode if: steps.check_tag.outputs.exists == 'true' && steps.dry_run.outputs.dryRun == 'true' env: RELEASE_VERSION: ${{ steps.meta.outputs.release_version }} EXISTING_TAG: ${{ steps.check_tag.outputs.existing_tag }} run: | tag="${EXISTING_TAG:-$RELEASE_VERSION}" echo "::warning::Tag '$tag' already exists (checked '$RELEASE_VERSION' and 'v$RELEASE_VERSION'); dry-run validation will continue without tag mutation." echo "audit:existing_tag_dry_run=${tag}" # ----------------------- # Secrets and Environment # ----------------------- - name: Check for MAVEN_CENTRAL_TOKEN_USER id: check_maven_user env: MAVEN_CENTRAL_TOKEN_USER: ${{ secrets.MAVEN_CENTRAL_TOKEN_USER }} run: | if [ -z "${MAVEN_CENTRAL_TOKEN_USER:-}" ]; then echo "present=false" >> $GITHUB_OUTPUT else echo "present=true" >> $GITHUB_OUTPUT fi - name: Check for MAVEN_CENTRAL_TOKEN_PASS id: check_maven_pass env: MAVEN_CENTRAL_TOKEN_PASS: ${{ secrets.MAVEN_CENTRAL_TOKEN_PASS }} run: | if [ -z "${MAVEN_CENTRAL_TOKEN_PASS:-}" ]; then echo "present=false" >> $GITHUB_OUTPUT else echo "present=true" >> $GITHUB_OUTPUT fi - name: Check for GPG_PRIVATE_KEY id: check_gpg_key env: GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }} run: | if [ -z "${GPG_PRIVATE_KEY:-}" ]; then echo "present=false" >> $GITHUB_OUTPUT else echo "present=true" >> $GITHUB_OUTPUT fi - name: Check for GPG_PASSPHRASE id: check_gpg_passphrase env: GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} run: | if [ -z "${GPG_PASSPHRASE:-}" ]; then echo "present=false" >> $GITHUB_OUTPUT else echo "present=true" >> $GITHUB_OUTPUT fi - name: Collect missing secrets id: collect_missing env: DRY_RUN: ${{ steps.dry_run.outputs.dryRun }} MAVEN_USER: ${{ steps.check_maven_user.outputs.present }} MAVEN_PASS: ${{ steps.check_maven_pass.outputs.present }} GPG_KEY: ${{ steps.check_gpg_key.outputs.present }} GPG_PASS: ${{ steps.check_gpg_passphrase.outputs.present }} run: | missing=() if [ "${MAVEN_USER:-false}" != "true" ]; then missing+=("MAVEN_CENTRAL_TOKEN_USER") fi if [ "${MAVEN_PASS:-false}" != "true" ]; then missing+=("MAVEN_CENTRAL_TOKEN_PASS") fi if [ "${GPG_KEY:-false}" != "true" ]; then missing+=("GPG_PRIVATE_KEY") fi if [ "${GPG_PASS:-false}" != "true" ]; then missing+=("GPG_PASSPHRASE") fi if [ "${#missing[@]}" -gt 0 ]; then echo "missing=${missing[*]}" >> $GITHUB_OUTPUT echo "has_missing=true" >> $GITHUB_OUTPUT else echo "missing=" >> $GITHUB_OUTPUT echo "has_missing=false" >> $GITHUB_OUTPUT fi - name: Handle missing secrets in dry-run mode if: steps.collect_missing.outputs.has_missing == 'true' && steps.dry_run.outputs.dryRun == 'true' env: MISSING: ${{ steps.collect_missing.outputs.missing }} run: | echo "::warning::DRY RUN MODE: Missing required secrets/resources detected" echo "::warning::The following required resources are not configured: ${MISSING}" echo "::warning::A real (non-dry-run) execution would fail without these resources." echo "::warning::Please configure these secrets in the repository settings before running a real release." echo "audit:missing_secrets_in_dry_run=${MISSING}" - name: Fail if secrets missing in non-dry-run mode if: steps.collect_missing.outputs.has_missing == 'true' && steps.dry_run.outputs.dryRun != 'true' env: MISSING: ${{ steps.collect_missing.outputs.missing }} run: | echo "::error::RELEASE FAILED: Missing required secrets/resources" echo "::error::The following required resources are not configured: ${MISSING}" echo "" echo "::error::REQUIRED SECRETS:" echo "::error:: 1. MAVEN_CENTRAL_TOKEN_USER - Maven Central username/token" echo "::error:: 2. MAVEN_CENTRAL_TOKEN_PASS - Maven Central password/token" echo "::error:: 3. GPG_PRIVATE_KEY - GPG private key for artifact signing" echo "::error:: 4. GPG_PASSPHRASE - Passphrase for GPG private key" echo "" echo "::error::Please configure these secrets in:" echo "::error:: Repository Settings -> Secrets and variables -> Actions -> New repository secret" echo "" exit 1 - name: All secrets verified if: steps.collect_missing.outputs.has_missing != 'true' run: | echo "OK: All required secrets and resources are present" echo "audit:all_required_secrets_present=true" - name: Cache Maven packages uses: actions/cache@v5 with: path: ~/.m2 key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} restore-keys: | ${{ runner.os }}-maven- - name: Set up Java (dry-run without publishing secrets) if: steps.dry_run.outputs.dryRun == 'true' && steps.collect_missing.outputs.has_missing == 'true' uses: actions/setup-java@v5 with: distribution: temurin java-version: 25 cache: maven - name: Set up Java with publishing secrets if: steps.dry_run.outputs.dryRun != 'true' || steps.collect_missing.outputs.has_missing != 'true' uses: actions/setup-java@v5 with: distribution: temurin java-version: 25 cache: maven server-id: central server-username: ${{ secrets.MAVEN_CENTRAL_TOKEN_USER }} server-password: ${{ secrets.MAVEN_CENTRAL_TOKEN_PASS }} gpg-private-key: ${{ secrets.GPG_PRIVATE_KEY }} gpg-passphrase: ${{ secrets.GPG_PASSPHRASE }} # ----------------------- # Deployment # ----------------------- - name: Dry-run mode notice if: steps.dry_run.outputs.dryRun == 'true' run: | echo "DRY RUN ENABLED: skipping tag/push/deploy; running validation only." - name: Create Maven settings directory if: steps.dry_run.outputs.dryRun != 'true' run: | mkdir -p ~/.m2 - name: Write Maven settings.xml if: steps.dry_run.outputs.dryRun != 'true' run: | printf '%s\n' \ '' \ ' ' \ ' ' \ ' central' \ " ${{ secrets.MAVEN_CENTRAL_TOKEN_USER }}" \ " ${{ secrets.MAVEN_CENTRAL_TOKEN_PASS }}" \ ' ' \ ' ' \ '' > ~/.m2/settings.xml echo "settings.xml created" - name: Check for MAVEN_MASTER_PASSPHRASE id: check_maven_master if: steps.dry_run.outputs.dryRun != 'true' env: MAVEN_SECURITY_MASTER: ${{ secrets.MAVEN_MASTER_PASSPHRASE }} run: | if [ -z "${MAVEN_SECURITY_MASTER:-}" ]; then echo "present=false" >> $GITHUB_OUTPUT else echo "present=true" >> $GITHUB_OUTPUT fi - name: Write Maven settings-security.xml if: steps.dry_run.outputs.dryRun != 'true' && steps.check_maven_master.outputs.present == 'true' shell: bash env: MAVEN_SECURITY_MASTER: ${{ secrets.MAVEN_MASTER_PASSPHRASE }} run: | set -euo pipefail printf '%s\n' \ '' \ '' \ " ${MAVEN_SECURITY_MASTER}" \ '' > ~/.m2/settings-security.xml chmod 600 ~/.m2/settings-security.xml echo "settings-security.xml created" - name: Skip Maven security settings (not configured) if: steps.dry_run.outputs.dryRun != 'true' && steps.check_maven_master.outputs.present != 'true' run: | echo "MAVEN_MASTER_PASSPHRASE not set; skipping settings-security.xml generation" - name: Configure GPG for signing if: steps.dry_run.outputs.dryRun != 'true' shell: bash run: | set -euo pipefail mkdir -p ~/.gnupg chmod 700 ~/.gnupg echo "pinentry-mode loopback" >> ~/.gnupg/gpg.conf chmod 600 ~/.gnupg/gpg.conf touch ~/.gnupg/gpg-agent.conf echo "allow-loopback-pinentry" >> ~/.gnupg/gpg-agent.conf chmod 600 ~/.gnupg/gpg-agent.conf gpgconf --kill gpg-agent 2>/dev/null || true gpgconf --launch gpg-agent 2>/dev/null || true echo "gpg.conf and gpg-agent.conf created" - name: Check out release commit env: RELEASE_COMMIT: ${{ steps.meta.outputs.release_commit }} run: | echo "::group::Check out release commit" git checkout --detach "${RELEASE_COMMIT}" echo "::endgroup::" - name: Validate release commit version env: RELEASE_VERSION: ${{ steps.meta.outputs.release_version }} run: | set -euo pipefail echo "::group::Validate release commit version" current_version="$(mvn -q -DforceStdout help:evaluate -Dexpression=project.version | tr -d '\r')" if [ "${current_version}" != "${RELEASE_VERSION}" ]; then echo "Release commit version mismatch: expected ${RELEASE_VERSION}, got ${current_version}." >&2 exit 1 fi echo "audit:release_commit_version=${current_version}" echo "::endgroup::" - name: Release candidate full verification env: QUIET_BUILD_TIMEOUT_SECONDS: 1800 RELEASE_TEST_TAG_EXCLUSIONS: integration,slow run: | set -euo pipefail echo "::group::Release candidate full build gate" echo "audit:release_test_tag_exclusions=${RELEASE_TEST_TAG_EXCLUSIONS}" scripts/run-full-build-quiet.sh echo "::endgroup::" - name: Verify release candidate tree is clean run: | set -euo pipefail echo "::group::Verify release candidate worktree cleanliness" if ! git diff --exit-code -- . ':!target' >/tmp/release-candidate.diff; then echo "::error::Release candidate verification changed tracked files. Fix formatter/license drift before publishing." cat /tmp/release-candidate.diff exit 1 fi echo "audit:release_candidate_tree_clean=true" echo "::endgroup::" - name: Build and validate release artifact manifest id: artifacts env: RELEASE_VERSION: ${{ steps.meta.outputs.release_version }} run: | set -euo pipefail echo "::group::Build release artifacts for manifest validation" mvn -B -DskipTests -Dgpg.skip=true -Pproduction-release package 2>&1 | tee release-artifact-build.log python3 scripts/release/release_helpers.py artifact-manifest \ --version "${RELEASE_VERSION}" \ --output artifact-manifest.txt \ --github-output "$GITHUB_OUTPUT" \ --strict echo "::endgroup::" - name: Create release tag if: steps.dry_run.outputs.dryRun != 'true' env: RELEASE_VERSION: ${{ steps.meta.outputs.release_version }} RELEASE_COMMIT: ${{ steps.meta.outputs.release_commit }} run: | git config user.name "${{ github.actor }}" git config user.email "${{ github.actor }}@users.noreply.github.com" git tag -a "${RELEASE_VERSION}" -m "Release ${RELEASE_VERSION}" "${RELEASE_COMMIT}" - name: Deploy Release to Maven Central if: steps.dry_run.outputs.dryRun != 'true' env: MAVEN_GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} run: | set -euo pipefail echo "::group::Deploy release to Maven Central" mvn -B -Pproduction-release deploy 2>&1 | tee release-deploy.log echo "::endgroup::" - name: Validate Javadoc warning baseline id: javadoc_warnings if: always() run: | set -euo pipefail echo "::group::Validate Javadoc warnings" set +e python3 scripts/release/release_helpers.py javadoc-warnings \ --baseline scripts/release/javadoc-warning-baseline.txt \ --output javadoc-warnings.txt \ --github-output "$GITHUB_OUTPUT" \ --fail-on-new \ release-artifact-build.log release-deploy.log status=$? set -e echo "::endgroup::" exit "$status" - name: Push release tag if: steps.dry_run.outputs.dryRun != 'true' env: RELEASE_VERSION: ${{ steps.meta.outputs.release_version }} run: | # Push tag only after deploy succeeds to avoid publishing a tag that doesn't match deployed artifacts. git push origin "refs/tags/${RELEASE_VERSION}" - name: Verify pushed tag reachability if: steps.dry_run.outputs.dryRun != 'true' env: RELEASE_VERSION: ${{ steps.meta.outputs.release_version }} RELEASE_COMMIT: ${{ steps.meta.outputs.release_commit }} DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} run: | set -euo pipefail if [ -z "${DEFAULT_BRANCH:-}" ]; then echo "Default branch not provided by event." >&2 exit 1 fi git fetch origin "${DEFAULT_BRANCH}" --tags --force tag_commit=$(git rev-list -n 1 "refs/tags/${RELEASE_VERSION}" 2>/dev/null || true) if [ -z "${tag_commit}" ]; then echo "Tag ${RELEASE_VERSION} is not resolvable after push." >&2 exit 1 fi if [ "${tag_commit}" != "${RELEASE_COMMIT}" ]; then echo "Tag ${RELEASE_VERSION} points to ${tag_commit}, expected ${RELEASE_COMMIT}." >&2 exit 1 fi if ! git merge-base --is-ancestor "${tag_commit}" "origin/${DEFAULT_BRANCH}"; then echo "Tag ${RELEASE_VERSION} commit ${tag_commit} is not reachable from origin/${DEFAULT_BRANCH}." >&2 exit 1 fi - name: Dispatch snapshot publication if: steps.dry_run.outputs.dryRun != 'true' uses: actions/github-script@v9 env: RELEASE_VERSION: ${{ steps.meta.outputs.release_version }} RELEASE_COMMIT: ${{ steps.meta.outputs.release_commit }} DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} with: github-token: ${{ secrets.GH_TA4J_REPO_TOKEN || github.token }} script: | const releaseVersion = process.env.RELEASE_VERSION; const releaseCommit = process.env.RELEASE_COMMIT; const ref = process.env.DEFAULT_BRANCH; if (!releaseVersion || !releaseCommit || !ref) { throw new Error("Missing release metadata for snapshot dispatch."); } core.startGroup("Dispatch snapshot workflow"); core.info(`Dispatching snapshot.yml on ${ref} after release ${releaseVersion} (${releaseCommit}).`); await github.rest.actions.createWorkflowDispatch({ owner: context.repo.owner, repo: context.repo.repo, workflow_id: "snapshot.yml", ref, inputs: { sourceReleaseVersion: releaseVersion, sourceReleaseCommit: releaseCommit, dryRun: "false" } }); core.endGroup(); # ----------------------- # Summary # ----------------------- - name: Release decision summary if: always() env: DRY_RUN: ${{ steps.dry_run.outputs.dryRun }} RELEASE: ${{ steps.meta.outputs.release_version }} NEXT: ${{ steps.meta.outputs.next_version }} RELEASE_COMMIT: ${{ steps.meta.outputs.release_commit }} MERGE_COMMIT: ${{ steps.meta.outputs.merge_commit }} PR_NUMBER: ${{ steps.meta.outputs.pr_number }} RECOVERY_MODE: ${{ github.event.inputs.recoveryMode }} JAVADOC_WARNING_COUNT: ${{ steps.javadoc_warnings.outputs.javadoc_warning_count }} JAVADOC_WARNING_BASELINE_COUNT: ${{ steps.javadoc_warnings.outputs.javadoc_warning_baseline_count }} JAVADOC_WARNING_NEW_COUNT: ${{ steps.javadoc_warnings.outputs.javadoc_warning_new_count }} run: | echo "summary: dryRun=${DRY_RUN:-}" echo "summary: release=${RELEASE:-}" echo "summary: next=${NEXT:-}" echo "summary: releaseCommit=${RELEASE_COMMIT:-}" echo "summary: mergeCommit=${MERGE_COMMIT:-}" echo "summary: releasePR=${PR_NUMBER:-}" echo "summary: recoveryMode=${RECOVERY_MODE:-false}" echo "summary: javadocWarnings=${JAVADOC_WARNING_COUNT:-unknown}" echo "summary: javadocWarningBaseline=${JAVADOC_WARNING_BASELINE_COUNT:-unknown}" echo "summary: newJavadocWarnings=${JAVADOC_WARNING_NEW_COUNT:-unknown}" if [ "${DRY_RUN:-false}" = "true" ]; then mutation_plan="would create the release tag, deploy to Maven Central, push the tag, dispatch snapshot.yml, and post the release discussion summary" rerun_guidance="rerun publish-release.yml with dryRun=false after inspecting these computed values and artifacts" else mutation_plan="completed non-dry-run publish-release mutations" rerun_guidance="not needed for this non-dry-run publish run" fi cat > release-audit.json <> "$GITHUB_STEP_SUMMARY" - name: Upload publish-release audit artifacts if: always() uses: actions/upload-artifact@v7 with: name: publish-release-audit-${{ github.run_id }} path: | release-audit.json artifact-manifest.txt release-artifact-build.log release-deploy.log javadoc-warnings.txt .agents/logs/full-build-*.log if-no-files-found: ignore retention-days: 14 - name: Build discussion comment id: notify_summary if: always() env: JOB_STATUS: ${{ job.status }} EVENT_NAME: ${{ github.event_name }} DRY_RUN: ${{ steps.dry_run.outputs.dryRun }} RELEASE: ${{ steps.meta.outputs.release_version }} NEXT: ${{ steps.meta.outputs.next_version }} RELEASE_COMMIT: ${{ steps.meta.outputs.release_commit }} MERGE_COMMIT: ${{ steps.meta.outputs.merge_commit }} PR_NUMBER: ${{ steps.meta.outputs.pr_number }} TAG_EXISTS: ${{ steps.check_tag.outputs.exists }} EXISTING_TAG: ${{ steps.check_tag.outputs.existing_tag }} MISSING_SECRETS: ${{ steps.collect_missing.outputs.missing }} HAS_MISSING: ${{ steps.collect_missing.outputs.has_missing }} RELEASE_NOTIFY_USER: ${{ vars.RELEASE_NOTIFY_USER }} run: | status="${JOB_STATUS:-unknown}" status_upper=$(printf '%s' "$status" | tr '[:lower:]' '[:upper:]') run_url="https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" dry_run="${DRY_RUN:-unknown}" release="${RELEASE:-unknown}" next="${NEXT:-unknown}" release_commit="${RELEASE_COMMIT:-unknown}" merge_commit="${MERGE_COMMIT:-}" pr_number="${PR_NUMBER:-}" tag_exists="${TAG_EXISTS:-unknown}" existing_tag="${EXISTING_TAG:-}" missing_secrets="${MISSING_SECRETS:-}" has_missing="${HAS_MISSING:-unknown}" if [ -z "$merge_commit" ]; then merge_commit="(none)" fi if [ -z "$pr_number" ]; then pr_number="(none)" fi if [ -z "$existing_tag" ]; then existing_tag="(none)" fi if [ -z "$missing_secrets" ]; then missing_secrets="(none)" fi notify_user="${RELEASE_NOTIFY_USER:-TheCookieLab}" if [ "$dry_run" = "true" ]; then mode_label="dry-run" marker_run="dry-run" else mode_label="production" marker_run="real" fi if [ "$status" = "success" ]; then completion_text="completed successfully" else completion_text="completed with failures" fi timestamp=$(date '+%-I:%M:%S %p on %-m.%-d.%Y') cat > notification-body.txt < @${notify_user} **Maven Central Release ${mode_label} ${completion_text} at ${timestamp}** - Repository: ${GITHUB_REPOSITORY} - Run: ${run_url} - Event: ${EVENT_NAME} - Status: ${status_upper} Release Summary:
Show details - dryRun: ${dry_run} - release version: ${release} - next snapshot: ${next} - release commit: ${release_commit} - merge commit: ${merge_commit} - release PR: ${pr_number} - tag exists: ${tag_exists} - existing tag: ${existing_tag} - missing secrets: ${missing_secrets} - has missing secrets: ${has_missing}
EOF - name: Post to Maven Central Releases discussion if: always() && steps.dry_run.outputs.dryRun != 'true' uses: actions/github-script@v9 env: RELEASE_DISCUSSION_NUMBER: ${{ vars.RELEASE_DISCUSSION_NUMBER }} with: github-token: ${{ github.token }} script: | const fs = require("fs"); const body = fs.readFileSync("notification-body.txt", "utf8"); const owner = context.repo.owner; const repo = context.repo.repo; const rawNumber = process.env.RELEASE_DISCUSSION_NUMBER; const number = rawNumber ? Number(rawNumber) : 1415; if (!Number.isFinite(number)) { throw new Error(`Invalid Release discussion number: ${rawNumber}`); } const query = ` query($owner: String!, $repo: String!, $number: Int!) { repository(owner: $owner, name: $repo) { discussion(number: $number) { id } } } `; const queryResult = await github.graphql(query, { owner, repo, number }); const discussionId = queryResult?.repository?.discussion?.id; if (!discussionId) { throw new Error(`Discussion ${number} not found in ${owner}/${repo}`); } const mutation = ` mutation($discussionId: ID!, $body: String!) { addDiscussionComment(input: { discussionId: $discussionId, body: $body }) { comment { url } } } `; await github.graphql(mutation, { discussionId, body });