Files
goldenChart/ta4j-master/.github/workflows/prepare-release.yml
T
2026-05-23 15:11:48 +09:00

1023 lines
41 KiB
YAML

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`,
"",
"<!-- release-meta",
`releaseVersion: ${releaseVersion}`,
`nextVersion: ${nextVersion}`,
`releaseCommit: ${releaseCommit}`,
"-->"
].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 <<EOF
{
"dryRun": "${DRY_RUN:-}",
"directPush": "${DIRECT_PUSH:-}",
"release": "${RELEASE:-}",
"current": "${CURRENT:-}",
"next": "${NEXT:-}",
"releaseCommit": "${RELEASE_COMMIT:-}",
"mutationPlan": "${mutation_plan}",
"rerunGuidance": "${rerun_guidance}",
"removalIssuePlans": "${REMOVAL_PLAN_COUNT:-0}",
"removalIssuesCreated": "${REMOVAL_CREATED_COUNT:-0}",
"removalIssuesUpdated": "${REMOVAL_UPDATED_COUNT:-0}",
"removalIssuesReopened": "${REMOVAL_REOPENED_COUNT:-0}",
"removalIssuesClosedStale": "${REMOVAL_CLOSED_STALE_COUNT:-0}",
"runUrl": "https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
}
EOF
{
echo "## Prepare Release"
echo ""
echo "- dry run: ${DRY_RUN:-unknown}"
echo "- direct push: ${DIRECT_PUSH:-unknown}"
echo "- current: ${CURRENT:-unknown}"
echo "- release: ${RELEASE:-unknown}"
echo "- next snapshot: ${NEXT:-unknown}"
echo "- release commit: ${RELEASE_COMMIT:-none}"
echo "- removal-ready deprecation issue plans: ${REMOVAL_PLAN_COUNT:-0}"
echo "- removal-ready issues created: ${REMOVAL_CREATED_COUNT:-0}"
echo "- removal-ready issues updated: ${REMOVAL_UPDATED_COUNT:-0}"
echo "- removal-ready issues reopened: ${REMOVAL_REOPENED_COUNT:-0}"
echo "- removal-ready stale issues closed: ${REMOVAL_CLOSED_STALE_COUNT:-0}"
echo "- mutation plan: ${mutation_plan}"
echo "- rerun guidance: ${rerun_guidance}"
} >> "$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