name: Create GitHub Release on: push: tags: - "*.*.*" workflow_dispatch: inputs: tag: description: "Release tag to publish (e.g. 0.22.0)" required: true type: string dryRun: description: "Run in dry-run mode to preview release creation without publishing." required: false default: true type: boolean concurrency: group: github-release-${{ github.repository }} permissions: contents: write jobs: create_release: name: Create GitHub Release runs-on: ubuntu-latest env: GH_TA4J_REPO_TOKEN: ${{ secrets.GH_TA4J_REPO_TOKEN }} steps: - name: Determine checkout ref id: checkout_ref run: | INPUT_TAG="${{ github.event.inputs.tag }}" if [[ -n "$INPUT_TAG" ]]; then # For workflow_dispatch, construct tag ref from input if [[ "$INPUT_TAG" == refs/tags/* ]]; then INPUT_TAG="${INPUT_TAG#refs/tags/}" fi CHECKOUT_REF="refs/tags/${INPUT_TAG}" echo "tag=${INPUT_TAG}" >> $GITHUB_OUTPUT else # For tag push events, use the ref directly CHECKOUT_REF="${GITHUB_REF}" fi echo "ref=${CHECKOUT_REF}" >> $GITHUB_OUTPUT - name: Verify GH_TA4J_REPO_TOKEN present run: | if [[ -z "${GH_TA4J_REPO_TOKEN:-}" ]]; then echo "::error::GH_TA4J_REPO_TOKEN is required to create GitHub Releases." exit 1 fi - name: Checkout full history uses: actions/checkout@v6 with: fetch-depth: 0 ref: ${{ steps.checkout_ref.outputs.ref }} token: ${{ env.GH_TA4J_REPO_TOKEN }} - name: Checkout workflow support files uses: actions/checkout@v6 with: fetch-depth: 1 ref: ${{ github.sha }} path: workflow-support token: ${{ env.GH_TA4J_REPO_TOKEN }} - name: Normalize dry run input id: dry_run run: | if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then 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 else raw="" dry_run=false fi echo "audit:dry_run_input_raw='${raw}'" echo "audit:dry_run_normalized=$dry_run" echo "dryRun=$dry_run" >> $GITHUB_OUTPUT # ------------------------------------------------------------ # Determine Tag + Validate Release Notes File # ------------------------------------------------------------ - name: Determine Version id: version run: | echo "::group::Resolve GitHub Release tag and notes" INPUT_TAG="${{ steps.checkout_ref.outputs.tag }}" if [[ -n "$INPUT_TAG" ]]; then TAG="$INPUT_TAG" else if [[ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]]; then echo "Error: 'tag' input is required for workflow_dispatch runs." >&2 exit 1 fi TAG="${GITHUB_REF##*/}" fi if [[ ! "$TAG" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then echo "::error::GitHub Release tags must be bare SemVer major.minor.patch values. Got: $TAG" exit 1 fi echo "tag=$TAG" >> $GITHUB_OUTPUT NOTES_FILE="release/${TAG}.md" if [[ ! -f "$NOTES_FILE" ]]; then echo "Release notes file $NOTES_FILE not found" >&2 exit 1 fi echo "notes_file=$NOTES_FILE" >> $GITHUB_OUTPUT echo "::endgroup::" - name: Preflight release permissions (dry run) if: steps.dry_run.outputs.dryRun == 'true' id: token_preflight env: GH_TA4J_REPO_TOKEN: ${{ env.GH_TA4J_REPO_TOKEN }} run: | set -euo pipefail python - <<'PY' import json import os import sys import urllib.request 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) def viewer_permission(token: str) -> str: 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", }, ) with urllib.request.urlopen(req) as resp: data = json.load(resp) if "errors" in data: raise RuntimeError(data["errors"]) return data["data"]["repository"]["viewerPermission"] def has_write(permission: str) -> bool: return permission in ("WRITE", "MAINTAIN", "ADMIN") def check_token(token, label): if not token: print(f"{label}:missing") return None try: permission = viewer_permission(token) print(f"{label}:{permission}") return permission except Exception as exc: print(f"::warning::{label} permission check failed: {exc}") return None pat = os.environ.get("GH_TA4J_REPO_TOKEN", "") pat_perm = check_token(pat, "GH_TA4J_REPO_TOKEN") if not (pat_perm and has_write(pat_perm)): print("::error::GH_TA4J_REPO_TOKEN lacks write permission to create a release.") sys.exit(1) with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as fh: fh.write(f"repo_token_permission={pat_perm}\n") PY # ------------------------------------------------------------ # Prepare Release Notes Body # ------------------------------------------------------------ # ------------------------------------------------------------ # Build artifacts: core, examples, ALL modules # Produces: # - *.jar # - *-javadoc.jar # - *-sources.jar # ------------------------------------------------------------ - name: Set up JDK 25 uses: actions/setup-java@v5 with: distribution: temurin java-version: 25 cache: maven - name: Build all module artifacts shell: bash run: | set -euo pipefail echo "::group::Build GitHub Release artifacts" max_attempts=3 attempt=1 while true; do echo "audit:release_artifact_build attempt=${attempt}/${max_attempts}" if mvn -B -DskipTests -Dgpg.skip=true -Pproduction-release package 2>&1 | tee release-artifact-build.log; then break fi if ((attempt >= max_attempts)); then echo "::error::Release artifact build failed after ${max_attempts} attempts." exit 1 fi backoff_seconds=$((attempt * 15)) echo "::warning::Release artifact build failed on attempt ${attempt}. Retrying in ${backoff_seconds}s." sleep "${backoff_seconds}" attempt=$((attempt + 1)) done echo "::endgroup::" - name: Validate release artifacts id: artifacts run: | set -euo pipefail echo "::group::Validate exact GitHub Release artifact manifest" python3 workflow-support/scripts/release/release_helpers.py artifact-manifest \ --version "${{ steps.version.outputs.tag }}" \ --output artifact-manifest.txt \ --github-output "$GITHUB_OUTPUT" \ --strict echo "::endgroup::" - name: Dry-run summary if: steps.dry_run.outputs.dryRun == 'true' run: | echo "DRY RUN ENABLED: build completed; release publish skipped." echo "Tag: ${{ steps.version.outputs.tag }}" echo "Release notes: ${{ steps.version.outputs.notes_file }}" echo "GH_TA4J_REPO_TOKEN permission: ${{ steps.token_preflight.outputs.repo_token_permission }}" echo "Artifacts:" files="${{ steps.artifacts.outputs.files }}" if [[ -z "${files}" ]]; then echo " (none found)" else while IFS= read -r file; do echo " - ${file#./}" done <<< "${files}" fi # ------------------------------------------------------------ # Upload full artifact set to GitHub Release # ------------------------------------------------------------ - name: Create GitHub Release if: steps.dry_run.outputs.dryRun != 'true' uses: softprops/action-gh-release@v3 with: tag_name: ${{ steps.version.outputs.tag }} body_path: ${{ steps.version.outputs.notes_file }} files: ${{ steps.artifacts.outputs.files }} env: GITHUB_TOKEN: ${{ env.GH_TA4J_REPO_TOKEN }} - name: GitHub Release summary if: always() env: DRY_RUN: ${{ steps.dry_run.outputs.dryRun }} TAG: ${{ steps.version.outputs.tag }} NOTES_FILE: ${{ steps.version.outputs.notes_file }} run: | if [ "${DRY_RUN:-false}" = "true" ]; then mutation_plan="would create/update the GitHub Release and upload artifacts for ${TAG:-unknown}" rerun_guidance="rerun github-release.yml with dryRun=false for this tag, or let the tag push path run" else mutation_plan="completed GitHub Release publication" rerun_guidance="not needed for this non-dry-run GitHub Release run" fi cat > github-release-audit.json <> "$GITHUB_STEP_SUMMARY" - name: Upload GitHub Release audit artifacts if: always() uses: actions/upload-artifact@v7 with: name: github-release-audit-${{ github.run_id }} path: | github-release-audit.json artifact-manifest.txt release-artifact-build.log if-no-files-found: ignore retention-days: 14