name: Prepare Release concurrency: group: prepare-release-${{ github.repository }} on: workflow_dispatch: inputs: releaseVersion: description: "OPTIONAL: Release version (e.g. 0.20.0). Leave blank to auto-detect." required: false nextVersion: description: "OPTIONAL: Next snapshot version (e.g. 0.20.1-SNAPSHOT). Leave blank to auto-generate." required: false dryRun: description: "Run prepare workflow in dry-run mode (no issue sync, push, or PR creation)." required: false default: true type: boolean permissions: actions: write contents: write issues: write pull-requests: write jobs: prepare: name: Prepare Release runs-on: ubuntu-latest steps: # ----------------------- # Setup and Validation # ----------------------- - name: Normalize dry run input id: dry_run run: | echo "::group::Normalize prepare-release dry-run input" 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 echo "audit:dry_run_input_raw='${raw}'" echo "audit:dry_run_normalized=$dry_run" echo "dryRun=$dry_run" >> $GITHUB_OUTPUT echo "::endgroup::" - name: Normalize direct push flag id: direct_push env: DIRECT_PUSH: ${{ vars.RELEASE_DIRECT_PUSH }} run: | echo "::group::Normalize RELEASE_DIRECT_PUSH" raw="${DIRECT_PUSH:-}" normalized=$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]') if [ "$normalized" = "true" ] || [ "$normalized" = "1" ] || [ "$normalized" = "yes" ]; then enabled=true else enabled=false fi echo "audit:release_direct_push=$enabled raw='${raw}'" echo "enabled=$enabled" >> $GITHUB_OUTPUT 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 }} DIRECT_PUSH: ${{ steps.direct_push.outputs.enabled }} run: | set -euo pipefail python3 - <<'PY' import json import os import socket import sys import urllib.error import urllib.request token = os.environ.get("GH_TA4J_REPO_TOKEN", "") dry_run = os.environ.get("DRY_RUN", "false").lower() == "true" direct_push = os.environ.get("DIRECT_PUSH", "false").lower() == "true" output_path = os.environ.get("GITHUB_OUTPUT") def write_output(line: str) -> None: if output_path: with open(output_path, "a", encoding="utf-8") as fh: fh.write(line + "\n") def warn_or_error(message: str) -> bool: if dry_run: print(f"::warning::{message}") return False if direct_push: print(f"::error::{message}") return True print(f"::warning::{message}") return False if not token: print("audit:repo_token_present=false") write_output("repo_token_present=false") write_output("use_repo_token=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) request_timeout_seconds = 30 def request_json(url: str, payload=None): headers = { "Authorization": f"Bearer {token}", "User-Agent": "ta4j-release-preflight", "Accept": "application/vnd.github+json", } data = None if payload is not None: headers["Content-Type"] = "application/json" data = json.dumps(payload).encode("utf-8") req = urllib.request.Request(url, data=data, headers=headers) try: with urllib.request.urlopen(req, timeout=request_timeout_seconds) as resp: body = json.load(resp) response_headers = {k.lower(): v for k, v in resp.headers.items()} except TimeoutError as exc: raise TimeoutError( f"Request to {url} timed out after {request_timeout_seconds}s" ) from exc except urllib.error.URLError as exc: if isinstance(exc.reason, socket.timeout): raise TimeoutError( f"Request to {url} timed out after {request_timeout_seconds}s" ) from exc raise return body, response_headers query = { "query": "query($owner:String!,$repo:String!){repository(owner:$owner,name:$repo){viewerPermission}}", "variables": {"owner": owner, "repo": repo}, } try: graphql_data, _ = request_json("https://api.github.com/graphql", query) repo_data, repo_headers = request_json(f"https://api.github.com/repos/{owner}/{repo}") except Exception as exc: msg = f"GH_TA4J_REPO_TOKEN permission check failed: {exc}" should_fail = warn_or_error(msg) write_output("repo_token_present=true") write_output("use_repo_token=false") if should_fail: sys.exit(1) sys.exit(0) if "errors" in graphql_data: msg = f"GH_TA4J_REPO_TOKEN permission check failed: {graphql_data['errors']}" should_fail = warn_or_error(msg) write_output("repo_token_present=true") write_output("use_repo_token=false") if should_fail: sys.exit(1) sys.exit(0) permission = graphql_data.get("data", {}).get("repository", {}).get("viewerPermission") push_permission = bool(repo_data.get("permissions", {}).get("push")) scopes_header = repo_headers.get("x-oauth-scopes", "") scopes = [scope.strip() for scope in scopes_header.split(",") if scope.strip()] has_repo_scope = "repo" in scopes has_public_repo_scope = "public_repo" in scopes scope_check = "unknown" scope_allows_push = True if scopes: scope_allows_push = has_repo_scope or has_public_repo_scope scope_check = "pass" if scope_allows_push else "fail" permission_allows_push = permission in ("WRITE", "MAINTAIN", "ADMIN") or push_permission allowed = permission_allows_push and scope_allows_push write_output("repo_token_present=true") write_output(f"repo_token_permission={permission}") write_output(f"repo_token_push_permission={'true' if push_permission else 'false'}") write_output(f"repo_token_scope_check={scope_check}") write_output(f"use_repo_token={'true' if allowed else 'false'}") if not allowed: reasons = [] if not permission_allows_push: reasons.append( f"viewerPermission={permission}, permissions.push={'true' if push_permission else 'false'}" ) if not scope_allows_push: reasons.append(f"oauth_scopes='{scopes_header}'") reason_text = "; ".join(reasons) if reasons else "unknown policy mismatch" msg = f"GH_TA4J_REPO_TOKEN lacks push capability ({reason_text})." should_fail = warn_or_error(msg) if should_fail: sys.exit(1) sys.exit(0) print(f"audit:repo_token_permission={permission}") print(f"audit:repo_token_push_permission={'true' if push_permission else 'false'}") print(f"audit:repo_token_scope_check={scope_check}") print("audit:repo_token_usable=true") PY - name: Checkout repository uses: actions/checkout@v6 with: fetch-depth: 0 persist-credentials: true token: ${{ steps.token_preflight.outputs.use_repo_token == 'true' && secrets.GH_TA4J_REPO_TOKEN || github.token }} - name: Validate documentation integrity run: | echo "::group::Validate documentation links and example references" chmod +x scripts/docs-integrity-check.sh scripts/docs-integrity-check.sh echo "::endgroup::" - name: Set up JDK 25 uses: actions/setup-java@v5 with: distribution: temurin java-version: 25 cache: maven # ----------------------- # Version Detection # ----------------------- - name: Read current version from POM id: read_current shell: bash run: | CURRENT=$(mvn -q -DforceStdout help:evaluate -Dexpression=project.version) echo "Detected project.version: $CURRENT" if [[ -z "${CURRENT}" ]]; then echo "Error: Failed to read project.version from pom.xml" exit 1 fi echo "current=$CURRENT" >> $GITHUB_OUTPUT - name: Capture user input versions id: user_input env: INPUT_RELEASE: ${{ github.event.inputs.releaseVersion }} INPUT_NEXT: ${{ github.event.inputs.nextVersion }} run: | echo "Inputs: release='${INPUT_RELEASE}', next='${INPUT_NEXT}'" echo "release_input=${INPUT_RELEASE:-}" >> $GITHUB_OUTPUT echo "next_input=${INPUT_NEXT:-}" >> $GITHUB_OUTPUT - name: Determine release version id: determine_release shell: bash env: CURRENT: ${{ steps.read_current.outputs.current }} INPUT_RELEASE: ${{ steps.user_input.outputs.release_input }} run: | set -euo pipefail if [[ -z "${INPUT_RELEASE}" ]]; then if [[ "${CURRENT}" != *-SNAPSHOT ]]; then echo "Error: Current version '${CURRENT}' is not a SNAPSHOT version." echo "Cannot auto-detect release version from a non-SNAPSHOT version." echo "Please provide an explicit releaseVersion input or ensure the POM contains a SNAPSHOT version." exit 1 fi RELEASE="${CURRENT%-SNAPSHOT}" echo "Auto-detected release version: ${RELEASE} (from ${CURRENT})" else RELEASE="${INPUT_RELEASE}" fi if [[ "${RELEASE}" == *-SNAPSHOT* ]]; then echo "Error: releaseVersion must be a stable version (no -SNAPSHOT suffix). Got: ${RELEASE}" exit 1 fi if [[ ! "${RELEASE}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then echo "Error: releaseVersion must be major.minor.patch (e.g. 0.20.0). Got: ${RELEASE}" exit 1 fi if [[ "${CURRENT}" == *-SNAPSHOT ]]; then CURRENT_BASE="${CURRENT%-SNAPSHOT}" else CURRENT_BASE="${CURRENT}" fi if [[ -z "${CURRENT_BASE}" ]]; then echo "Error: unable to determine current base version from pom.xml" exit 1 fi if [[ "$(printf '%s\n' "${CURRENT_BASE}" "${RELEASE}" | sort -V | head -n1)" != "${CURRENT_BASE}" ]]; then echo "Error: releaseVersion (${RELEASE}) is lower than the current pom.xml version (${CURRENT_BASE})." echo "Refusing to release a version lower than the POM." exit 1 fi echo "release=$RELEASE" >> $GITHUB_OUTPUT - name: Determine next snapshot version id: determine_next shell: bash env: RELEASE: ${{ steps.determine_release.outputs.release }} INPUT_NEXT: ${{ steps.user_input.outputs.next_input }} run: | set -euo pipefail if [[ -z "${INPUT_NEXT}" ]]; then BASE="${RELEASE}" if [[ "${BASE}" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then MAJOR="${BASH_REMATCH[1]}" MINOR="${BASH_REMATCH[2]}" PATCH="${BASH_REMATCH[3]}" NEXT="${MAJOR}.${MINOR}.$((PATCH+1))-SNAPSHOT" else echo "Error: Unsupported release version format '${BASE}'. Expected major.minor.patch" exit 1 fi else NEXT="${INPUT_NEXT}" fi if [[ "${NEXT}" != *-SNAPSHOT ]]; then echo "Error: nextVersion must end with -SNAPSHOT. Got: ${NEXT}" exit 1 fi if [[ ! "${NEXT}" =~ ^[0-9]+\.[0-9]+\.[0-9]+-SNAPSHOT$ ]]; then echo "Error: nextVersion must be major.minor.patch-SNAPSHOT (e.g. 0.20.1-SNAPSHOT). Got: ${NEXT}" exit 1 fi NEXT_BASE="${NEXT%-SNAPSHOT}" # Verify that nextVersion is actually greater than releaseVersion highest=$(printf '%s\n' "${RELEASE}" "${NEXT_BASE}" | sort -V | tail -n1) if [[ "${highest}" != "${NEXT_BASE}" ]] || [[ "${NEXT_BASE}" == "${RELEASE}" ]]; then echo "Error: nextVersion (${NEXT_BASE}) must be greater than releaseVersion (${RELEASE})" exit 1 fi echo "next=$NEXT" >> $GITHUB_OUTPUT - name: Export version outputs id: versions shell: bash env: RELEASE: ${{ steps.determine_release.outputs.release }} NEXT: ${{ steps.determine_next.outputs.next }} CURRENT: ${{ steps.read_current.outputs.current }} run: | echo "Using release: $RELEASE" echo "Using next snapshot: $NEXT" echo "release=$RELEASE" >> $GITHUB_OUTPUT echo "next=$NEXT" >> $GITHUB_OUTPUT echo "current=$CURRENT" >> $GITHUB_OUTPUT - name: Preflight git push capability env: DIRECT_PUSH: ${{ steps.direct_push.outputs.enabled }} TARGET_BRANCH: ${{ github.event.repository.default_branch }} RELEASE_BRANCH: release/${{ steps.versions.outputs.release }} USE_REPO_TOKEN: ${{ steps.token_preflight.outputs.use_repo_token }} DRY_RUN: ${{ steps.dry_run.outputs.dryRun }} run: | set -euo pipefail echo "::group::Git push dry-run capability probe" if [ "${DIRECT_PUSH}" = "true" ]; then if [ -z "${TARGET_BRANCH:-}" ]; then echo "Default branch was not set; refusing to probe push capability." exit 1 fi refspec="HEAD:refs/heads/${TARGET_BRANCH}" mode="default-branch" else refspec="HEAD:refs/heads/${RELEASE_BRANCH}" mode="release-branch" fi echo "audit:push_probe_mode=${mode} refspec=${refspec} use_repo_token=${USE_REPO_TOKEN:-false} dryRun=${DRY_RUN:-false}" set +e if [ "${DIRECT_PUSH}" = "true" ]; then output=$(git push --dry-run origin "${refspec}" 2>&1) else output=$(git push --dry-run --force-with-lease origin "${refspec}" 2>&1) fi status=$? set -e printf '%s\n' "$output" if [ "$status" -ne 0 ]; then echo "::error::Push capability probe failed for ${mode}. Check token/repository permissions." exit "$status" fi echo "::endgroup::" # ----------------------- # Release Preparation # ----------------------- - name: Dry-run mode notice if: steps.dry_run.outputs.dryRun == 'true' run: | echo "DRY RUN ENABLED: skipping commits, push, and PR creation." - name: Prepare Release Notes run: | echo "::group::Prepare release notes and version references" chmod +x scripts/prepare-release.sh scripts/prepare-release.sh "${{ steps.versions.outputs.release }}" echo "::endgroup::" - name: Configure git user if: steps.dry_run.outputs.dryRun != 'true' run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" # ----------------------- # Apply Release Version # ----------------------- - name: Set Maven version to release if: steps.dry_run.outputs.dryRun != 'true' env: RELEASE_VERSION: ${{ steps.versions.outputs.release }} run: | mvn -B versions:set -DnewVersion=$RELEASE_VERSION - name: Commit Maven version change if: steps.dry_run.outputs.dryRun != 'true' run: | mvn -B versions:commit - name: Validate Maven Central metadata run: | echo "::group::Validate Maven Central metadata" chmod +x scripts/validate-central-metadata.sh scripts/validate-central-metadata.sh echo "::endgroup::" - name: Commit release version and notes if: steps.dry_run.outputs.dryRun != 'true' run: | git add release/${{ steps.versions.outputs.release }}.md git add -u git commit -am "Release ${{ steps.versions.outputs.release }}" - name: Capture release commit id: release_commit if: steps.dry_run.outputs.dryRun != 'true' run: | sha=$(git rev-parse HEAD) echo "release_commit=$sha" >> $GITHUB_OUTPUT - name: Gate release-ready deprecations id: deprecation_release_gate env: DRY_RUN: ${{ steps.dry_run.outputs.dryRun }} RELEASE_VERSION: ${{ steps.versions.outputs.release }} REPORT_JSON: ${{ runner.temp }}/release-ready-deprecations.json REPORT_MD: ${{ runner.temp }}/release-ready-deprecations.md run: | echo "::group::Gate release-ready deprecations" mvn -B -q -pl ta4j-examples -am -DskipTests install set +e mvn -B -q -pl ta4j-examples exec:java \ -Dexec.mainClass=ta4jexamples.doc.RemovalReadyDeprecationScanner \ "-Dexec.args=--repo-root ${GITHUB_WORKSPACE} --pom-file pom.xml --target-removal-version ${RELEASE_VERSION} --include-overdue --fail-on-due --output-json ${REPORT_JSON} --output-md ${REPORT_MD}" scan_status=$? set -e echo "::endgroup::" echo "scan_status=$scan_status" >> $GITHUB_OUTPUT echo "report_json=$REPORT_JSON" >> $GITHUB_OUTPUT echo "report_md=$REPORT_MD" >> $GITHUB_OUTPUT { echo "" cat "$REPORT_MD" } >> "$GITHUB_STEP_SUMMARY" if [[ "$DRY_RUN" == "true" ]]; then exit 0 fi exit "$scan_status" - name: Upload release-ready deprecation gate report if: always() && steps.deprecation_release_gate.outputs.report_json != '' uses: actions/upload-artifact@v7 with: name: release-ready-deprecations-${{ steps.versions.outputs.release }} path: | ${{ steps.deprecation_release_gate.outputs.report_json }} ${{ steps.deprecation_release_gate.outputs.report_md }} # ----------------------- # Apply Next Snapshot Version # ----------------------- - name: Set Maven version to next snapshot if: steps.dry_run.outputs.dryRun != 'true' env: NEXT_VERSION: ${{ steps.versions.outputs.next }} run: | mvn -B versions:set -DnewVersion=$NEXT_VERSION - name: Commit Maven version change for next if: steps.dry_run.outputs.dryRun != 'true' run: | mvn -B versions:commit - name: Commit next snapshot version if: steps.dry_run.outputs.dryRun != 'true' run: | git commit -am "Start ${{ steps.versions.outputs.next }}" - name: Scan removal-ready deprecations id: deprecation_scan env: NEXT_VERSION: ${{ steps.versions.outputs.next }} REPORT_JSON: ${{ runner.temp }}/removal-ready-deprecations.json REPORT_MD: ${{ runner.temp }}/removal-ready-deprecations.md run: | echo "::group::Scan removal-ready deprecations" NEXT_REMOVAL_VERSION="${NEXT_VERSION%-SNAPSHOT}" mvn -B -q -pl ta4j-examples -am -DskipTests install mvn -B -q -pl ta4j-examples exec:java \ -Dexec.mainClass=ta4jexamples.doc.RemovalReadyDeprecationScanner \ "-Dexec.args=--repo-root ${GITHUB_WORKSPACE} --pom-file pom.xml --target-removal-version ${NEXT_REMOVAL_VERSION} --include-overdue --output-json ${REPORT_JSON} --output-md ${REPORT_MD}" echo "::endgroup::" echo "report_json=$REPORT_JSON" >> $GITHUB_OUTPUT echo "report_md=$REPORT_MD" >> $GITHUB_OUTPUT { echo "" cat "$REPORT_MD" } >> "$GITHUB_STEP_SUMMARY" - name: Create or update removal-ready deprecation issues id: deprecation_issues if: steps.dry_run.outputs.dryRun != 'true' uses: actions/github-script@v9 env: REPORT_JSON: ${{ steps.deprecation_scan.outputs.report_json }} REPORT_WITH_ISSUES_JSON: ${{ runner.temp }}/removal-ready-deprecations-with-issues.json REPORT_WITH_ISSUES_MD: ${{ runner.temp }}/removal-ready-deprecations-with-issues.md with: github-token: ${{ steps.token_preflight.outputs.use_repo_token == 'true' && secrets.GH_TA4J_REPO_TOKEN || github.token }} script: | const fs = require("fs"); const reportPath = process.env.REPORT_JSON; const outputJsonPath = process.env.REPORT_WITH_ISSUES_JSON; const outputMarkdownPath = process.env.REPORT_WITH_ISSUES_MD; const owner = context.repo.owner; const repo = context.repo.repo; const syncedIssuesByMarker = new Map(); if (!reportPath) { throw new Error("Missing REPORT_JSON for deprecation issue sync."); } const report = JSON.parse(fs.readFileSync(reportPath, "utf8")); const issuePlans = Array.isArray(report.issuePlans) ? report.issuePlans : []; const removalVersion = report.removalVersion; const activeMarkers = new Set(issuePlans.map((plan) => plan.issueMarker).filter(Boolean)); function escapeSearchPhrase(value) { return `"${value.replace(/["\\\\]/g, "\\\\$&")}"`; } async function findExistingIssue(marker) { if (syncedIssuesByMarker.has(marker)) { return syncedIssuesByMarker.get(marker); } const issues = await github.paginate(github.rest.search.issuesAndPullRequests, { q: `repo:${owner}/${repo} is:issue in:body ${escapeSearchPhrase(marker)}`, per_page: 10 }); const existingIssue = issues.find((issue) => !issue.pull_request && (issue.body || "").includes(marker)) || null; syncedIssuesByMarker.set(marker, existingIssue); return existingIssue; } async function findManagedIssuesForVersion() { if (!removalVersion) { return []; } const markerSearch = `ta4j:deprecation-removal version=${removalVersion}`; const issues = await github.paginate(github.rest.search.issuesAndPullRequests, { q: `repo:${owner}/${repo} is:issue in:body ${escapeSearchPhrase(markerSearch)}`, per_page: 100 }); return issues.filter((issue) => !issue.pull_request && (issue.body || "").includes(markerSearch)); } let createdCount = 0; let updatedCount = 0; let reopenedCount = 0; let closedStaleCount = 0; for (const plan of issuePlans) { const marker = plan.issueMarker; if (!marker) { throw new Error(`Missing issue marker for dedupe key ${plan.dedupeKey}`); } let existingIssue = await findExistingIssue(marker); if (existingIssue) { if (existingIssue.title !== plan.issueTitle || (existingIssue.body || "") !== plan.issueBody) { existingIssue = (await github.rest.issues.update({ owner, repo, issue_number: existingIssue.number, title: plan.issueTitle, body: plan.issueBody })).data; updatedCount += 1; } if (existingIssue.state !== "open") { existingIssue = (await github.rest.issues.update({ owner, repo, issue_number: existingIssue.number, state: "open" })).data; reopenedCount += 1; } } else { existingIssue = (await github.rest.issues.create({ owner, repo, title: plan.issueTitle, body: plan.issueBody })).data; createdCount += 1; } syncedIssuesByMarker.set(marker, existingIssue); plan.githubIssue = { number: existingIssue.number, state: existingIssue.state, url: existingIssue.html_url }; } for (const issue of await findManagedIssuesForVersion()) { const body = issue.body || ""; const stillPlanned = [...activeMarkers].some((marker) => body.includes(marker)); if (!stillPlanned && issue.state === "open") { await github.rest.issues.update({ owner, repo, issue_number: issue.number, state: "closed", state_reason: "completed" }); closedStaleCount += 1; } } report.githubIssueSummary = { createdCount, updatedCount, reopenedCount, closedStaleCount, planCount: issuePlans.length }; const markdownLines = [ `# Removal-ready deprecation issue sync for ${report.snapshotVersion}`, "", `- Planned issues: ${issuePlans.length}`, `- Created: ${createdCount}`, `- Updated: ${updatedCount}`, `- Reopened: ${reopenedCount}`, `- Closed stale: ${closedStaleCount}`, "" ]; if (issuePlans.length === 0) { markdownLines.push("No removal-ready deprecations matched the current snapshot version."); } else { for (const plan of issuePlans) { const issueUrl = plan.githubIssue?.url || "(missing issue url)"; const issueNumber = plan.githubIssue?.number || "n/a"; markdownLines.push(`## ${plan.issueTitle}`); markdownLines.push(""); markdownLines.push(`- Module: \`${plan.module}\``); markdownLines.push(`- File: \`${plan.filePath}\``); markdownLines.push(`- GitHub issue: #${issueNumber} (${issueUrl})`); markdownLines.push("- Symbols:"); for (const symbol of plan.symbols || []) { markdownLines.push(` - \`${symbol.name}\` (${symbol.kind}, line ${symbol.line})`); } markdownLines.push(""); } } fs.writeFileSync(outputJsonPath, JSON.stringify(report, null, 2)); fs.writeFileSync(outputMarkdownPath, markdownLines.join("\n")); core.setOutput("report_json", outputJsonPath); core.setOutput("report_md", outputMarkdownPath); core.setOutput("plan_count", String(issuePlans.length)); core.setOutput("created_count", String(createdCount)); core.setOutput("updated_count", String(updatedCount)); core.setOutput("reopened_count", String(reopenedCount)); core.setOutput("closed_stale_count", String(closedStaleCount)); - name: Upload removal-ready deprecation report if: always() && steps.deprecation_scan.outputs.report_json != '' uses: actions/upload-artifact@v7 with: name: removal-ready-deprecations-${{ steps.versions.outputs.next }} path: | ${{ steps.deprecation_scan.outputs.report_json }} ${{ steps.deprecation_scan.outputs.report_md }} ${{ steps.deprecation_issues.outputs.report_json }} ${{ steps.deprecation_issues.outputs.report_md }} - name: Fail dry-run on release-ready deprecations if: steps.dry_run.outputs.dryRun == 'true' && steps.deprecation_release_gate.outputs.scan_status != '0' env: SCAN_STATUS: ${{ steps.deprecation_release_gate.outputs.scan_status }} run: | echo "Dry-run deprecation gate found release-blocking findings. See the release-ready deprecation report artifact." exit "$SCAN_STATUS" # ----------------------- # Push or PR # ----------------------- - name: Push release commits directly to default branch if: steps.dry_run.outputs.dryRun != 'true' && steps.direct_push.outputs.enabled == 'true' env: TARGET_BRANCH: ${{ github.event.repository.default_branch }} run: | set -euo pipefail branch="${TARGET_BRANCH:-}" if [ -z "$branch" ]; then echo "Default branch was not set; refusing to push." exit 1 fi git fetch origin "${branch}" if ! git merge-base --is-ancestor "origin/${branch}" HEAD; then echo "Default branch '${branch}' advanced after preparation; refusing to push." exit 1 fi echo "Pushing release commits directly to ${branch}." git push origin "HEAD:${branch}" - name: Dispatch publish-release workflow (direct push) if: steps.dry_run.outputs.dryRun != 'true' && steps.direct_push.outputs.enabled == 'true' uses: actions/github-script@v9 env: RELEASE_VERSION: ${{ steps.versions.outputs.release }} RELEASE_COMMIT: ${{ steps.release_commit.outputs.release_commit }} DRY_RUN: ${{ steps.dry_run.outputs.dryRun }} with: github-token: ${{ steps.token_preflight.outputs.use_repo_token == 'true' && secrets.GH_TA4J_REPO_TOKEN || github.token }} script: | const releaseVersion = process.env.RELEASE_VERSION; const releaseCommit = process.env.RELEASE_COMMIT; const dryRun = process.env.DRY_RUN || "false"; if (!releaseVersion || !releaseCommit) { throw new Error("Missing release version or release commit for publish dispatch."); } const ref = "${{ github.event.repository.default_branch }}"; await github.rest.actions.createWorkflowDispatch({ owner: context.repo.owner, repo: context.repo.repo, workflow_id: "publish-release.yml", ref, inputs: { releaseVersion, releaseCommit, dryRun } }); - name: Create release branch if: steps.dry_run.outputs.dryRun != 'true' && steps.direct_push.outputs.enabled != 'true' env: RELEASE_BRANCH: release/${{ steps.versions.outputs.release }} run: | git checkout -b "${RELEASE_BRANCH}" - name: Push release branch if: steps.dry_run.outputs.dryRun != 'true' && steps.direct_push.outputs.enabled != 'true' env: RELEASE_BRANCH: release/${{ steps.versions.outputs.release }} run: | git push --force-with-lease origin "HEAD:refs/heads/${RELEASE_BRANCH}" - name: Create or update release PR if: steps.dry_run.outputs.dryRun != 'true' && steps.direct_push.outputs.enabled != 'true' uses: actions/github-script@v9 env: RELEASE_VERSION: ${{ steps.versions.outputs.release }} NEXT_VERSION: ${{ steps.versions.outputs.next }} RELEASE_COMMIT: ${{ steps.release_commit.outputs.release_commit }} RELEASE_BRANCH: release/${{ steps.versions.outputs.release }} TARGET_BRANCH: ${{ github.event.repository.default_branch }} RELEASE_OWNER: ${{ vars.RELEASE_OWNER || 'TheCookieLab' }} REMOVAL_PLAN_COUNT: ${{ steps.deprecation_issues.outputs.plan_count }} REMOVAL_CREATED_COUNT: ${{ steps.deprecation_issues.outputs.created_count }} REMOVAL_UPDATED_COUNT: ${{ steps.deprecation_issues.outputs.updated_count }} REMOVAL_REOPENED_COUNT: ${{ steps.deprecation_issues.outputs.reopened_count }} REMOVAL_CLOSED_STALE_COUNT: ${{ steps.deprecation_issues.outputs.closed_stale_count }} with: github-token: ${{ steps.token_preflight.outputs.use_repo_token == 'true' && secrets.GH_TA4J_REPO_TOKEN || github.token }} script: | const releaseVersion = process.env.RELEASE_VERSION; const nextVersion = process.env.NEXT_VERSION; const releaseCommit = process.env.RELEASE_COMMIT; const releaseBranch = process.env.RELEASE_BRANCH; const baseBranch = process.env.TARGET_BRANCH; const removalPlanCount = process.env.REMOVAL_PLAN_COUNT || "0"; const removalCreatedCount = process.env.REMOVAL_CREATED_COUNT || "0"; const removalUpdatedCount = process.env.REMOVAL_UPDATED_COUNT || "0"; const removalReopenedCount = process.env.REMOVAL_REOPENED_COUNT || "0"; const removalClosedStaleCount = process.env.REMOVAL_CLOSED_STALE_COUNT || "0"; if (!releaseVersion || !nextVersion || !releaseCommit || !releaseBranch || !baseBranch) { throw new Error("Missing required release metadata for PR creation."); } const owner = context.repo.owner; const repo = context.repo.repo; const head = `${owner}:${releaseBranch}`; const body = [ `Automated release PR for ${releaseVersion}.`, "", "Contains:", `- Set version to ${releaseVersion}`, `- Generated release notes: release/${releaseVersion}.md`, `- Bump to next snapshot: ${nextVersion}`, `- Synced removal-ready deprecation issues: ${removalPlanCount} plan(s), ${removalCreatedCount} created, ${removalUpdatedCount} updated, ${removalReopenedCount} reopened, ${removalClosedStaleCount} stale closed`, "", "" ].join("\n"); const existing = await github.rest.pulls.list({ owner, repo, state: "open", head }); let prNumber; if (existing.data.length > 0) { const pr = existing.data[0]; prNumber = pr.number; await github.rest.pulls.update({ owner, repo, pull_number: pr.number, title: `Release ${releaseVersion}`, base: baseBranch, body }); } else { const created = await github.rest.pulls.create({ owner, repo, title: `Release ${releaseVersion}`, head: releaseBranch, base: baseBranch, body }); prNumber = created.data.number; } try { await github.rest.issues.addLabels({ owner, repo, issue_number: prNumber, labels: ["release"] }); } catch (error) { console.warn(`Failed to add release label: ${error.message}`); } const releaseOwner = process.env.RELEASE_OWNER?.trim() || "TheCookieLab"; try { await github.rest.issues.addAssignees({ owner, repo, issue_number: prNumber, assignees: [releaseOwner] }); } catch (error) { throw new Error(`Failed to assign release owner ${releaseOwner}: ${error.message}`); } try { await github.rest.pulls.requestReviewers({ owner, repo, pull_number: prNumber, reviewers: [releaseOwner] }); } catch (error) { throw new Error(`Failed to request review from ${releaseOwner}: ${error.message}`); } # ----------------------- # Summary # ----------------------- - name: Prepare release summary if: always() env: DRY_RUN: ${{ steps.dry_run.outputs.dryRun }} DIRECT_PUSH: ${{ steps.direct_push.outputs.enabled }} RELEASE: ${{ steps.versions.outputs.release }} NEXT: ${{ steps.versions.outputs.next }} CURRENT: ${{ steps.versions.outputs.current }} RELEASE_COMMIT: ${{ steps.release_commit.outputs.release_commit }} REMOVAL_PLAN_COUNT: ${{ steps.deprecation_issues.outputs.plan_count }} REMOVAL_CREATED_COUNT: ${{ steps.deprecation_issues.outputs.created_count }} REMOVAL_UPDATED_COUNT: ${{ steps.deprecation_issues.outputs.updated_count }} REMOVAL_REOPENED_COUNT: ${{ steps.deprecation_issues.outputs.reopened_count }} REMOVAL_CLOSED_STALE_COUNT: ${{ steps.deprecation_issues.outputs.closed_stale_count }} run: | echo "summary: dryRun=${DRY_RUN:-}" echo "summary: directPush=${DIRECT_PUSH:-}" echo "summary: release=${RELEASE:-}" echo "summary: current=${CURRENT:-}" echo "summary: next=${NEXT:-}" echo "summary: releaseCommit=${RELEASE_COMMIT:-}" echo "summary: removalIssuePlans=${REMOVAL_PLAN_COUNT:-0}" echo "summary: removalIssuesCreated=${REMOVAL_CREATED_COUNT:-0}" echo "summary: removalIssuesUpdated=${REMOVAL_UPDATED_COUNT:-0}" echo "summary: removalIssuesReopened=${REMOVAL_REOPENED_COUNT:-0}" echo "summary: removalIssuesClosedStale=${REMOVAL_CLOSED_STALE_COUNT:-0}" if [ "${DRY_RUN:-false}" = "true" ]; then mutation_plan="would create/update release artifacts, sync deprecation issues, push the release branch, and create/update the release PR" rerun_guidance="rerun prepare-release.yml with dryRun=false after inspecting these computed values and artifacts" else mutation_plan="completed non-dry-run prepare-release mutations for the selected mode" rerun_guidance="not needed for this non-dry-run prepare run" fi cat > release-audit.json <> "$GITHUB_STEP_SUMMARY" - name: Upload prepare-release audit artifacts if: always() uses: actions/upload-artifact@v7 with: name: prepare-release-audit-${{ github.run_id }} path: | release-audit.json release/${{ steps.versions.outputs.release }}.md if-no-files-found: ignore retention-days: 14