324 lines
12 KiB
YAML
324 lines
12 KiB
YAML
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 <<EOF
|
|
{
|
|
"dryRun": "${DRY_RUN:-}",
|
|
"tag": "${TAG:-}",
|
|
"notesFile": "${NOTES_FILE:-}",
|
|
"artifactManifest": "artifact-manifest.txt",
|
|
"mutationPlan": "${mutation_plan}",
|
|
"rerunGuidance": "${rerun_guidance}",
|
|
"runUrl": "https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
|
|
}
|
|
EOF
|
|
{
|
|
echo "## GitHub Release"
|
|
echo ""
|
|
echo "- dry run: ${DRY_RUN:-unknown}"
|
|
echo "- tag: ${TAG:-unknown}"
|
|
echo "- notes file: ${NOTES_FILE:-unknown}"
|
|
echo "- artifact manifest: artifact-manifest.txt"
|
|
echo "- mutation plan: ${mutation_plan}"
|
|
echo "- rerun guidance: ${rerun_guidance}"
|
|
} >> "$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
|