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

999 lines
41 KiB
YAML

# Flow:
# - analyze (checkout, diff, changelog, AI decision, compute version) -> job outputs
# - approval (environment-protected) -> reserved for future major-release re-enable
# - publish (patch/minor) -> depends on analyze
# - publish_major -> reserved for future major-release re-enable
#
#
# NOTE: major bumps are currently disabled and downgraded to minor.
# The major-release approval path is kept for future re-enable.
name: AI Semantic Release Scheduler
concurrency:
group: semantic-release-ai-${{ github.repository }}
on:
schedule:
- cron: "0 9 */14 * *" # every 14 days
workflow_dispatch:
inputs:
dryRun:
description: "Simulate only; trigger prepare-release.yml with dryRun=true"
required: false
default: true
type: boolean
permissions:
contents: write
actions: write
discussions: write
jobs:
analyze:
name: Analyze changes & ask AI
runs-on: ubuntu-latest
if: ${{ github.event_name != 'schedule' || vars.RELEASE_SCHEDULER_ENABLED == 'true' }}
outputs:
should_release: ${{ steps.parsed.outputs.should_release }}
bump: ${{ steps.parsed.outputs.bump }}
version: ${{ steps.version.outputs.version }}
reason: ${{ steps.parsed.outputs.reason }}
warning: ${{ steps.parsed.outputs.warning }}
dryRun: ${{ steps.run_meta.outputs.dryRun }}
binary_lines: ${{ steps.diff.outputs.lines }}
binary_changes: ${{ steps.diff.outputs.binary_changes }}
changelog_len: ${{ steps.changelog.outputs.length }}
gate_proceed: ${{ steps.gate.outputs.proceed }}
token_present: ${{ steps.model_token.outputs.present }}
token_gate: ${{ steps.token_gate.outputs.proceed }}
model: ${{ steps.model_catalog.outputs.model_id }}
model_max_input_tokens: ${{ steps.model_catalog.outputs.model_max_input_tokens }}
model_max_output_tokens: ${{ steps.model_catalog.outputs.model_max_output_tokens }}
dossier_chars: ${{ steps.build_request.outputs.dossier_chars }}
last_tag: ${{ steps.lasttag.outputs.last_reachable_tag }}
last_first_parent_tag: ${{ steps.lasttag.outputs.last_first_parent_tag }}
pom_version: ${{ steps.pom_version.outputs.version }}
pom_base: ${{ steps.pom_version.outputs.base }}
steps:
# -----------------------
# Setup and Discovery
# -----------------------
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Fetch tags
run: git fetch --tags
- name: Resolve release tag baselines
id: lasttag
run: |
default_branch="${{ github.event.repository.default_branch }}"
target_ref="origin/${default_branch}"
git fetch origin "$default_branch" --tags
tag_state_file=$(mktemp)
scripts/resolve-release-tags.sh "$target_ref" > "$tag_state_file"
latest_tag="none"
last_reachable_tag="none"
last_first_parent_tag="none"
latest_tag_reachable="n/a"
while IFS='=' read -r key value; do
case "$key" in
latest_tag) latest_tag="$value" ;;
last_reachable_tag) last_reachable_tag="$value" ;;
last_first_parent_tag) last_first_parent_tag="$value" ;;
latest_tag_reachable) latest_tag_reachable="$value" ;;
esac
done < "$tag_state_file"
cat "$tag_state_file" >> "$GITHUB_OUTPUT"
rm -f "$tag_state_file"
echo "audit:last_tag=$last_reachable_tag default_branch=$default_branch first_parent_tag=$last_first_parent_tag prefer_no_v_tags=true"
echo "audit:latest_tag=$latest_tag latest_tag_reachable=$latest_tag_reachable"
- name: Capture event and normalize dry run flag
id: run_meta
env:
EVENT_NAME: ${{ github.event_name }}
DRY_RUN_INPUT: ${{ github.event.inputs.dryRun }}
run: |
event="${EVENT_NAME:-}"
if [ "$event" = "workflow_dispatch" ]; then
raw="${DRY_RUN_INPUT:-true}"
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_INPUT:-}"
dry_run=false
fi
echo "audit:event_name=$event"
echo "audit:dry_run_input_raw='${raw}'"
echo "audit:dry_run_normalized=$dry_run"
echo "dryRun=$dry_run" >> $GITHUB_OUTPUT
- name: Read version from pom.xml
id: pom_version
run: |
set -euo pipefail
version=$(python3 -c $'import xml.etree.ElementTree as ET\nimport sys\n\ndef find_version(path):\n tree = ET.parse(path)\n root = tree.getroot()\n def find_first(elem):\n if elem is None:\n return None\n for child in elem:\n if child.tag.endswith(\"version\"):\n text = (child.text or \"\").strip()\n if text:\n return text\n return None\n return find_first(root) or find_first(root.find(\"./{*}parent\"))\n\nver = find_version(\"pom.xml\")\nif not ver:\n sys.exit(1)\nprint(ver)\n') || { echo \"Could not read <version> from pom.xml\" >&2; exit 1; }
base="$version"
is_snapshot=false
if [[ "$version" == *-SNAPSHOT ]]; then
base="${version%-SNAPSHOT}"
is_snapshot=true
fi
echo "audit:pom_version=$version base=$base snapshot=$is_snapshot"
echo "version=$version" >> $GITHUB_OUTPUT
echo "base=$base" >> $GITHUB_OUTPUT
echo "snapshot=$is_snapshot" >> $GITHUB_OUTPUT
# -----------------------
# Change Detection
# -----------------------
- name: Collect binary-impacting changes since last release
id: diff
env:
LAST_TAG: ${{ steps.lasttag.outputs.last_reachable_tag }}
run: |
if [ "$LAST_TAG" = "none" ]; then
changed_files=$(git ls-tree -r --name-only HEAD)
else
changed_files=$(git diff --name-only "$LAST_TAG"..HEAD)
fi
binary_files=$(printf '%s\n' "$changed_files" | grep -E '(^|/)pom\.xml$|(^|/)src/main/' || true)
binary_files=$(printf '%s\n' "$binary_files" | sort -u)
# sanitize: mask URLs, common GH tokens, and long alnum tokens (avoid redacting long Java identifiers)
sanitized=$(printf '%s\n' "$binary_files" | perl -pe 's#https?://\S+#[REDACTED_URL]#g; s/(gh[oprsu]_[A-Za-z0-9]{20,})/[REDACTED_TOKEN]/g; s/((?=[A-Za-z0-9]{32,})(?=.*[0-9])[A-Za-z0-9]+)/[REDACTED_SECRET]/g')
if [ -z "$sanitized" ]; then
count=0
else
count=$(printf '%s\n' "$sanitized" | wc -l | tr -d ' ')
fi
summary="(none)"
if [ "$count" -gt 0 ]; then
summary=$(printf '%s\n' "$sanitized" | awk -F/ '
NF>=3 && $2 == "src" && $3 == "main" {key=$1"/"$2"/"$3; counts[key]++; next}
NF>=2 {key=$1"/"$2; counts[key]++; next}
{counts[$1]++}
END {for (k in counts) printf "%s: %d files\n", k, counts[k]}
' | sort)
fi
sample=""
if [ "$count" -gt 0 ] && [ "$count" -le 40 ]; then
sample="$sanitized"
fi
binary_prompt="$summary"
if [ -n "$sample" ]; then
binary_prompt="${binary_prompt}\nSample paths:\n${sample}"
fi
binary_prompt=$(printf '%s' "$binary_prompt" | cut -c1-1200)
binary_prompt_b64=$(printf '%s' "$binary_prompt" | base64 -w0)
binary_prompt_len=$(printf '%s' "$binary_prompt" | wc -c | tr -d ' ')
# truncate to 2000 chars for discussion output
sanitized_truncated=$(printf '%s' "$sanitized" | cut -c1-2000)
echo "audit:binary_change_lines=$count sanitized=true truncated=2000 summary_prompt_chars=$binary_prompt_len"
diff_b64=$(printf '%s' "$sanitized_truncated" | base64 -w0)
echo "binary_prompt_b64=$binary_prompt_b64" >> $GITHUB_OUTPUT
echo "binary_prompt_len=$binary_prompt_len" >> $GITHUB_OUTPUT
echo "diff_b64=$diff_b64" >> $GITHUB_OUTPUT
echo "lines=$count" >> $GITHUB_OUTPUT
delim="BIN_CHANGES_$(date +%s%N)"
{
echo "binary_changes<<$delim"
printf '%s\n' "$sanitized_truncated"
echo "$delim"
} >> "$GITHUB_OUTPUT"
- name: Check if CHANGELOG exists
id: changelog_exists
run: |
if [ ! -f CHANGELOG.md ]; then
echo "present=false" >> $GITHUB_OUTPUT
echo "audit:changelog_present=false"
else
echo "present=true" >> $GITHUB_OUTPUT
echo "audit:changelog_present=true"
fi
- name: Extract Unreleased section from CHANGELOG
id: changelog
run: |
if [ ! -f CHANGELOG.md ]; then
echo "audit:changelog_present=false"
echo "changelog_b64=" >> $GITHUB_OUTPUT
echo "length=0" >> $GITHUB_OUTPUT
exit 0
fi
unreleased=$(awk '
BEGIN{inSection=0}
/^## *\[?Unreleased\]?/ {inSection=1; next}
/^## / && inSection {exit}
inSection {print}
' CHANGELOG.md)
filtered=$(printf '%s\n' "$unreleased" | grep -E '^[[:space:]]*(#{2,4}|[-*])' || true)
if [ -n "$filtered" ]; then
unreleased="$filtered"
fi
# truncate to keep payload bounded and token friendly
unreleased=$(printf '%s' "$unreleased" | cut -c1-2000)
changelog_b64=$(printf '%s' "$unreleased" | base64 -w0)
changelog_len=$(printf '%s' "$unreleased" | wc -c | tr -d ' ')
echo "audit:changelog_present=true"
echo "changelog_b64=$changelog_b64" >> $GITHUB_OUTPUT
echo "length=$changelog_len" >> $GITHUB_OUTPUT
# -----------------------
# Gate Checks
# -----------------------
- name: Check if changes detected
id: gate
run: |
binary_lines=${{ steps.diff.outputs.lines }}
changelog_len=${{ steps.changelog.outputs.length }}
binary_lines=${binary_lines:-0}
changelog_len=${changelog_len:-0}
if [ "$binary_lines" -eq 0 ]; then
echo "proceed=false" >> $GITHUB_OUTPUT
echo "audit:short_circuit=true reason=\"no binary-impacting changes\""
else
echo "proceed=true" >> $GITHUB_OUTPUT
echo "audit:short_circuit=false"
fi
- name: No release - no binary-impacting changes
if: steps.gate.outputs.proceed != 'true'
run: echo "No release recommended (no binary-impacting changes)."
- name: Check for GH_MODELS_TOKEN
id: model_token
if: steps.gate.outputs.proceed == 'true'
env:
GH_MODELS_TOKEN: ${{ secrets.GH_MODELS_TOKEN }}
run: |
if [ -z "${GH_MODELS_TOKEN:-}" ]; then
echo "present=false" >> $GITHUB_OUTPUT
echo "audit:models_token_present=false"
exit 0
fi
echo "::add-mask::$GH_MODELS_TOKEN"
echo "GH_MODELS_TOKEN=$GH_MODELS_TOKEN" >> $GITHUB_ENV
echo "present=true" >> $GITHUB_OUTPUT
echo "audit:models_token_present=true"
- name: Check token gate
id: token_gate
if: steps.gate.outputs.proceed == 'true'
run: |
present="${{ steps.model_token.outputs.present }}"
present=${present:-false}
if [ "$present" = "true" ]; then
echo "proceed=true" >> $GITHUB_OUTPUT
echo "audit:short_circuit_token=false"
else
echo "proceed=false" >> $GITHUB_OUTPUT
echo "audit:short_circuit_token=true reason=\"missing GH_MODELS_TOKEN\""
fi
- name: No release - missing GH_MODELS_TOKEN
if: steps.gate.outputs.proceed == 'true' && steps.token_gate.outputs.proceed != 'true'
run: echo "GH_MODELS_TOKEN not provided; skipping release."
# -----------------------
# AI Analysis
# -----------------------
- name: Preflight GitHub Models catalog
id: model_catalog
if: steps.gate.outputs.proceed == 'true' && steps.token_gate.outputs.proceed == 'true'
env:
RELEASE_AI_MODEL: ${{ vars.RELEASE_AI_MODEL }}
run: |
set -euo pipefail
model="${RELEASE_AI_MODEL:-openai/gpt-4.1}"
echo "::group::GitHub Models catalog preflight"
python3 scripts/release/release_helpers.py catalog-preflight \
--model "$model" \
--output release-ai-model.json
echo "::endgroup::"
- name: Build release dossier
id: dossier
if: steps.gate.outputs.proceed == 'true' && steps.token_gate.outputs.proceed == 'true'
run: |
set -euo pipefail
echo "::group::Build release dossier"
python3 scripts/release/release_helpers.py build-dossier \
--last-tag "${{ steps.lasttag.outputs.last_reachable_tag }}" \
--current-version "${{ steps.pom_version.outputs.version }}" \
--pom-base "${{ steps.pom_version.outputs.base }}" \
--max-diff-chars 600000 \
--output release-dossier.md \
--audit-output release-audit.json
echo "::endgroup::"
- name: Build and validate AI request JSON
id: build_request
if: steps.gate.outputs.proceed == 'true' && steps.token_gate.outputs.proceed == 'true'
run: |
set -euo pipefail
echo "::group::Build AI request"
python3 scripts/release/release_helpers.py build-ai-request \
--model "${{ steps.model_catalog.outputs.model_id }}" \
--dossier release-dossier.md \
--semver-rules .github/workflows/semver-rules-override.txt \
--output request.json
if ! jq -e '.model and .messages and (.messages | length > 0)' request.json > /dev/null; then
echo "audit:request_json_valid=false" >&2
exit 1
fi
echo "audit:request_json_valid=true"
echo "::endgroup::"
- name: Call AI API with retry
id: ai_call
if: steps.gate.outputs.proceed == 'true' && steps.token_gate.outputs.proceed == 'true'
env:
GH_MODELS_TOKEN: ${{ secrets.GH_MODELS_TOKEN }}
run: |
set -uo pipefail
echo "::group::Call GitHub Models inference"
echo "audit:request_json_size_bytes=$(wc -c < request.json | tr -d ' ')"
echo "audit:dossier_chars=${{ steps.build_request.outputs.dossier_chars }}"
echo "audit:model=${{ steps.model_catalog.outputs.model_id }}"
echo "audit:model_max_input_tokens=${{ steps.model_catalog.outputs.model_max_input_tokens }}"
echo "audit:model_max_output_tokens=${{ steps.model_catalog.outputs.model_max_output_tokens }}"
echo "audit:external_call target=models.github.ai payload=request.json sanitized=true"
attempts=3
backoff=2
response_status=0
: > response.json
for attempt in $(seq 1 "$attempts"); do
if response_status=$(curl -s -o response.json -w '%{http_code}' \
-X POST --max-time 30 \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $GH_MODELS_TOKEN" \
https://models.github.ai/inference/chat/completions \
--data-binary @request.json); then
:
else
response_status="000"
fi
if [ "$response_status" -eq 200 ]; then
break
fi
echo "audit:ai_request_retry attempt=$attempt status=$response_status backoff=${backoff}s" >&2
if [ "$attempt" -lt "$attempts" ]; then
sleep "$backoff"
backoff=$((backoff * 2))
fi
done
echo "response_status=$response_status" >> $GITHUB_OUTPUT
echo "::endgroup::"
- name: Handle AI API failure
id: ai_failure
if: steps.gate.outputs.proceed == 'true' && steps.token_gate.outputs.proceed == 'true' && steps.ai_call.outputs.response_status != '200'
env:
RESPONSE_STATUS: ${{ steps.ai_call.outputs.response_status }}
ATTEMPTS: 3
run: |
err_msg=$(jq -r '.error.message // empty' response.json 2>/dev/null || true)
err_msg=${err_msg:-"AI call failed (http ${RESPONSE_STATUS})"}
echo "audit:ai_request_failed attempts=$ATTEMPTS status=$RESPONSE_STATUS" >&2
echo "response (truncated to 500 chars):"
head -c 500 response.json || true
fallback=$(jq -n --arg msg "$err_msg" --arg status "$RESPONSE_STATUS" \
'{should_release:false,bump:"patch",warning:$msg,reason:$msg}')
printf '%s\n' "$fallback" > ai-content.txt
delim="AI_RESP_$(date +%s%N)"
{
echo "content<<$delim"
printf '%s\n' "$fallback"
echo "$delim"
} >> $GITHUB_OUTPUT
- name: Extract AI response content
id: ai
if: steps.gate.outputs.proceed == 'true' && steps.token_gate.outputs.proceed == 'true' && steps.ai_call.outputs.response_status == '200'
run: |
resp_size=$(wc -c < response.json | tr -d ' ')
echo "audit:ai_response_bytes=$resp_size saved_to=response.json"
if ! jq -e '.choices and .choices[0].message and (.choices[0].message.content != null)' response.json > /dev/null; then
echo "audit:ai_response_missing_content=true" >&2
delim="AI_RESP_$(date +%s%N)"
{
echo "content<<$delim"
echo ""
echo "$delim"
} >> $GITHUB_OUTPUT
exit 0
fi
content=$(jq -r '.choices[0].message.content // ""' response.json)
printf '%s\n' "$content" > ai-content.txt
delim="AI_RESP_$(date +%s%N)"
{
echo "content<<$delim"
printf '%s\n' "$content"
echo "$delim"
} >> $GITHUB_OUTPUT
- name: Parse AI JSON
id: parsed
if: always()
env:
GATE_PROCEED: ${{ steps.gate.outputs.proceed }}
MODEL_TOKEN_PRESENT: ${{ steps.model_token.outputs.present }}
run: |
gate_proceed="${GATE_PROCEED:-false}"
if [ "$gate_proceed" != "true" ]; then
echo '{"should_release":false,"bump":"patch","confidence":1.0,"warning":"No binary changes","reason":"No binary-impacting changes detected"}' > ai-content.txt
else
model_token_present="${MODEL_TOKEN_PRESENT:-false}"
if [ "$model_token_present" != "true" ]; then
echo "AI call skipped because GH_MODELS_TOKEN is missing." >&2
echo '{"should_release":false,"bump":"patch","confidence":1.0,"warning":"GH_MODELS_TOKEN missing","reason":"GH_MODELS_TOKEN secret missing; cannot call AI"}' > ai-content.txt
elif [ ! -s ai-content.txt ]; then
echo '{"should_release":false,"bump":"patch","confidence":0.0,"warning":"Missing AI response","reason":"AI response content missing"}' > ai-content.txt
fi
fi
echo "::group::Parse AI release decision"
python3 scripts/release/release_helpers.py parse-decision \
--raw-file ai-content.txt \
--output release-decision.json \
--github-output "$GITHUB_OUTPUT"
echo "::endgroup::"
- name: Stop if AI says no release
if: steps.parsed.outputs.should_release == 'false'
env:
REASON: ${{ steps.parsed.outputs.reason }}
run: |
echo "No release recommended."
if [ -n "${REASON:-}" ]; then
echo "Reason: ${REASON}"
fi
# -----------------------
# Version Computation
# -----------------------
- name: Compute new version
id: version
if: steps.parsed.outputs.should_release == 'true'
run: |
set -euo pipefail
last="${{ steps.lasttag.outputs.last_reachable_tag }}"
current="${{ steps.pom_version.outputs.version }}"
base="${{ steps.pom_version.outputs.base }}"
bump="${{ steps.parsed.outputs.bump }}"
if [ -z "$current" ] || [ -z "$base" ]; then
echo "Failed to read version from pom.xml" >&2
exit 1
fi
# Normalize base version (allow major.minor or major.minor.patch)
base_norm="$base"
if [[ "$base" =~ ^([0-9]+)\.([0-9]+)$ ]]; then
base_norm="${base}.0"
elif [[ "$base" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
: # already normalized
else
echo "POM version '$current' (base '$base') is not in a supported SemVer format (major.minor or major.minor.patch)." >&2
exit 1
fi
bump_base="$base"
# Prevent regressions relative to the last reachable default-branch release tag
if [ "$last" != "none" ] && echo "$last" | grep -Eq '^v?[0-9]+\.[0-9]+(\.[0-9]+)?([-+].*)?$'; then
last_norm=$(printf '%s\n' "$last" | sed -E 's/^v?([0-9]+)\.([0-9]+)(\.[0-9]+)?([-+].*)?$/\1.\2\3/')
last_norm=${last_norm:-}
last_norm=${last_norm%.}
if echo "$last_norm" | grep -Eq '^[0-9]+\.[0-9]+$'; then
last_norm="${last_norm}.0"
fi
if [ -n "$last_norm" ]; then
highest=$(printf '%s\n' "$last_norm" "$base_norm" | sort -V | tail -n1)
if [ "$highest" != "$base_norm" ]; then
echo "POM base version '$base' is behind the last reachable default-branch tag '$last_norm'; refusing to compute a lower release." >&2
exit 1
fi
bump_base="$last_norm"
fi
fi
# Use the selected bump base (prefer the last reachable tag when present) for incrementing
if [[ "$bump_base" =~ ^([0-9]+)\.([0-9]+)$ ]]; then
major="${BASH_REMATCH[1]}"
minor="${BASH_REMATCH[2]}"
patch=0
elif [[ "$bump_base" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
major="${BASH_REMATCH[1]}"
minor="${BASH_REMATCH[2]}"
patch="${BASH_REMATCH[3]}"
else
echo "Bump base '$bump_base' is not in a supported SemVer format (major.minor or major.minor.patch)." >&2
exit 1
fi
case "$bump" in
major) major=$((major+1)); minor=0; patch=0 ;;
minor) minor=$((minor+1)); patch=0 ;;
patch|"") patch=$((patch+1)) ;;
*) echo "Invalid bump value: $bump" >&2; exit 1 ;;
esac
new="${major}.${minor}.${patch}"
new_norm="$new"
if echo "$new_norm" | grep -Eq '^[0-9]+\.[0-9]+$'; then
new_norm="${new_norm}.0"
fi
# Ensure we never release a version lower than the POM base
if [ "$(printf '%s\n' "$new_norm" "$base_norm" | sort -V | tail -n1)" != "$new_norm" ]; then
echo "Computed version '$new' would be lower than pom.xml base '$base'; raising to pom base." >&2
new="$base"
new_norm="$base_norm"
fi
if git rev-parse "refs/tags/$new" >/dev/null 2>&1 || git rev-parse "refs/tags/v$new" >/dev/null 2>&1 || { [ "$new" != "$new_norm" ] && (git rev-parse "refs/tags/$new_norm" >/dev/null 2>&1 || git rev-parse "refs/tags/v$new_norm" >/dev/null 2>&1); }; then
echo "Computed tag $new (or v$new) already exists. Aborting." >&2
exit 1
fi
echo "version=$new" >> $GITHUB_OUTPUT
# -----------------------
# Summary
# -----------------------
- name: Debug decision summary
if: always()
env:
EVENT_NAME: ${{ github.event_name }}
DRY_RUN_INPUT: ${{ github.event.inputs.dryRun }}
DRY_RUN: ${{ steps.run_meta.outputs.dryRun }}
PROCEED: ${{ steps.gate.outputs.proceed }}
TOKEN_PRESENT: ${{ steps.model_token.outputs.present }}
TOKEN_GATE: ${{ steps.token_gate.outputs.proceed }}
MODEL: ${{ steps.model_catalog.outputs.model_id }}
MODEL_MAX_INPUT_TOKENS: ${{ steps.model_catalog.outputs.model_max_input_tokens }}
MODEL_MAX_OUTPUT_TOKENS: ${{ steps.model_catalog.outputs.model_max_output_tokens }}
DOSSIER_CHARS: ${{ steps.build_request.outputs.dossier_chars }}
REQUEST_JSON_SIZE_BYTES: ${{ steps.build_request.outputs.request_json_size_bytes }}
SHOULD_RELEASE: ${{ steps.parsed.outputs.should_release }}
BUMP: ${{ steps.parsed.outputs.bump }}
CONFIDENCE: ${{ steps.parsed.outputs.confidence }}
REASON: ${{ steps.parsed.outputs.reason }}
VERSION: ${{ steps.version.outputs.version }}
POM_VERSION: ${{ steps.pom_version.outputs.version }}
POM_BASE: ${{ steps.pom_version.outputs.base }}
LAST_TAG: ${{ steps.lasttag.outputs.last_reachable_tag }}
run: |
if [ "${DRY_RUN:-false}" = "true" ]; then
mutation_plan="would dispatch prepare-release.yml with dryRun=true; no discussion post is created"
rerun_guidance="rerun release-scheduler.yml with dryRun=false, or wait for the scheduled release path"
else
mutation_plan="dispatches prepare-release.yml with dryRun=false when the scheduler decides to release"
rerun_guidance="not needed for scheduled production runs"
fi
cat > release-scheduler-mutation-plan.txt <<EOF
dryRun=${DRY_RUN:-}
computedVersion=${VERSION:-}
bump=${BUMP:-}
mutationPlan=${mutation_plan}
rerunGuidance=${rerun_guidance}
EOF
echo "decision:event_name=${EVENT_NAME:-}"
echo "decision:dry_run_input='${DRY_RUN_INPUT:-}' normalized=${DRY_RUN:-}"
echo "decision:gate_proceed=${PROCEED:-}"
echo "decision:token_present=${TOKEN_PRESENT:-}"
echo "decision:token_gate_proceed=${TOKEN_GATE:-}"
echo "decision:model=${MODEL:-}"
echo "decision:model_max_input_tokens=${MODEL_MAX_INPUT_TOKENS:-}"
echo "decision:model_max_output_tokens=${MODEL_MAX_OUTPUT_TOKENS:-}"
echo "decision:dossier_chars=${DOSSIER_CHARS:-}"
echo "decision:request_json_size_bytes=${REQUEST_JSON_SIZE_BYTES:-}"
echo "decision:should_release=${SHOULD_RELEASE:-}"
echo "decision:bump=${BUMP:-}"
echo "decision:confidence=${CONFIDENCE:-}"
echo "decision:reason=${REASON:-}"
echo "decision:computed_version=${VERSION:-}"
echo "decision:pom_version=${POM_VERSION:-} pom_base=${POM_BASE:-}"
echo "decision:last_reachable_default_branch_tag=${LAST_TAG:-}"
echo "decision:last_first_parent_tag=${{ steps.lasttag.outputs.last_first_parent_tag }}"
{
echo "## Release Scheduler Decision"
echo ""
echo "- model: ${MODEL:-unknown}"
echo "- model max input tokens: ${MODEL_MAX_INPUT_TOKENS:-unknown}"
echo "- model max output tokens: ${MODEL_MAX_OUTPUT_TOKENS:-unknown}"
echo "- dossier chars: ${DOSSIER_CHARS:-0}"
echo "- request bytes: ${REQUEST_JSON_SIZE_BYTES:-0}"
echo "- should release: ${SHOULD_RELEASE:-unknown}"
echo "- bump: ${BUMP:-unknown}"
echo "- confidence: ${CONFIDENCE:-unknown}"
echo "- computed version: ${VERSION:-none}"
echo "- last reachable tag: ${LAST_TAG:-unknown}"
echo "- reason: ${REASON:-none}"
echo "- mutation plan: ${mutation_plan}"
echo "- rerun guidance: ${rerun_guidance}"
} >> "$GITHUB_STEP_SUMMARY"
- name: Upload release scheduler audit artifacts
if: always()
uses: actions/upload-artifact@v7
with:
name: release-scheduler-audit-${{ github.run_id }}
path: |
release-ai-model.json
release-dossier.md
release-audit.json
request.json
response.json
ai-content.txt
release-decision.json
release-scheduler-mutation-plan.txt
if-no-files-found: ignore
retention-days: 14
# Reserved major-release approval path.
# Major bumps are currently downgraded to minor in analyze/parsing and this job should not run.
approval:
name: Major release approval (human)
needs: analyze
runs-on: ubuntu-latest
if: ${{ needs.analyze.outputs.should_release == 'true' && needs.analyze.outputs.bump == 'major' }}
environment:
name: major-release
steps:
- name: Wait for approval (environment protects this job)
run: |
echo "This job is blocked until an authorized reviewer approves or rejects in the GitHub UI."
echo "Computed version to approve: ${{ needs.analyze.outputs.version }}"
echo "Approvers must use the Environments UI or the Actions run page to approve."
# Publish job: runs automatically for non-major bumps, and after approval for major bumps.
publish:
name: Trigger publish workflow (patch/minor)
needs: analyze
runs-on: ubuntu-latest
if: needs.analyze.outputs.should_release == 'true' && needs.analyze.outputs.bump != 'major'
steps:
- name: Trigger publish workflow
uses: actions/github-script@v9
with:
github-token: ${{ github.token }}
script: |
const shouldRelease = "${{ needs.analyze.outputs.should_release }}";
if (shouldRelease !== "true") {
console.log("audit:skip_dispatch reason=should_release_false");
return;
}
const version = "${{ needs.analyze.outputs.version }}";
const dryRun = "${{ needs.analyze.outputs.dryRun }}";
const ref = "${{ github.event.repository.default_branch }}";
console.log(`audit:dispatch_workflow workflow=prepare-release.yml ref=${ref} version=${version} dryRun=${dryRun}`);
await github.rest.actions.createWorkflowDispatch({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: "prepare-release.yml",
ref,
inputs: {
releaseVersion: version,
dryRun
}
})
publish_major:
name: Trigger publish workflow (major after approval)
needs: [analyze, approval]
runs-on: ubuntu-latest
if: needs.analyze.outputs.should_release == 'true' && needs.analyze.outputs.bump == 'major' && needs.approval.result == 'success'
steps:
- name: Trigger publish workflow
uses: actions/github-script@v9
with:
github-token: ${{ github.token }}
script: |
const shouldRelease = "${{ needs.analyze.outputs.should_release }}";
if (shouldRelease !== "true") {
console.log("audit:skip_dispatch reason=should_release_false");
return;
}
const version = "${{ needs.analyze.outputs.version }}";
const dryRun = "${{ needs.analyze.outputs.dryRun }}";
const ref = "${{ github.event.repository.default_branch }}";
console.log(`audit:dispatch_workflow workflow=prepare-release.yml ref=${ref} version=${version} dryRun=${dryRun}`);
await github.rest.actions.createWorkflowDispatch({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: "prepare-release.yml",
ref,
inputs: {
releaseVersion: version,
dryRun
}
})
# Dry-run convenience job to show the recommendation alongside the dry-run prepare dispatch.
dry_run_output:
name: Dry-run output
runs-on: ubuntu-latest
needs: analyze
if: ${{ needs.analyze.outputs.dryRun == 'true' && needs.analyze.outputs.should_release == 'true' }}
steps:
- name: Show dry-run results
run: |
echo "======================="
echo " DRY RUN MODE "
echo "======================="
echo "AI recommends release:"
echo " bump: ${{ needs.analyze.outputs.bump }}"
echo " version: ${{ needs.analyze.outputs.version }}"
echo ""
echo "(Dry run enabled - propagating dryRun=true to prepare-release.yml)"
{
echo "## Release Scheduler Dry Run"
echo ""
echo "- recommended bump: ${{ needs.analyze.outputs.bump }}"
echo "- computed version: ${{ needs.analyze.outputs.version }}"
echo "- mutation plan: would dispatch prepare-release.yml with dryRun=true"
echo "- rerun to mutate: rerun release-scheduler.yml with dryRun=false, or wait for the scheduled release path"
} >> "$GITHUB_STEP_SUMMARY"
notify:
name: Notify release-scheduler outcome
runs-on: ubuntu-latest
needs: [analyze, approval, publish, publish_major, dry_run_output]
if: always()
steps:
- name: Build discussion comment
id: notify_summary
if: always()
env:
EVENT_NAME: ${{ github.event_name }}
ANALYZE_RESULT: ${{ needs.analyze.result }}
APPROVAL_RESULT: ${{ needs.approval.result }}
PUBLISH_RESULT: ${{ needs.publish.result }}
PUBLISH_MAJOR_RESULT: ${{ needs.publish_major.result }}
DRY_RUN_RESULT: ${{ needs.dry_run_output.result }}
SHOULD_RELEASE: ${{ needs.analyze.outputs.should_release }}
BUMP: ${{ needs.analyze.outputs.bump }}
VERSION: ${{ needs.analyze.outputs.version }}
REASON: ${{ needs.analyze.outputs.reason }}
WARNING: ${{ needs.analyze.outputs.warning }}
DRY_RUN: ${{ needs.analyze.outputs.dryRun }}
BINARY_LINES: ${{ needs.analyze.outputs.binary_lines }}
BINARY_CHANGES: ${{ needs.analyze.outputs.binary_changes }}
CHANGELOG_LEN: ${{ needs.analyze.outputs.changelog_len }}
GATE_PROCEED: ${{ needs.analyze.outputs.gate_proceed }}
TOKEN_PRESENT: ${{ needs.analyze.outputs.token_present }}
TOKEN_GATE: ${{ needs.analyze.outputs.token_gate }}
MODEL: ${{ needs.analyze.outputs.model }}
MODEL_MAX_INPUT_TOKENS: ${{ needs.analyze.outputs.model_max_input_tokens }}
MODEL_MAX_OUTPUT_TOKENS: ${{ needs.analyze.outputs.model_max_output_tokens }}
DOSSIER_CHARS: ${{ needs.analyze.outputs.dossier_chars }}
LAST_TAG: ${{ needs.analyze.outputs.last_tag }}
LAST_FIRST_PARENT_TAG: ${{ needs.analyze.outputs.last_first_parent_tag }}
POM_VERSION: ${{ needs.analyze.outputs.pom_version }}
POM_BASE: ${{ needs.analyze.outputs.pom_base }}
RELEASE_NOTIFY_USER: ${{ vars.RELEASE_NOTIFY_USER }}
run: |
status="success"
for result in "$ANALYZE_RESULT" "$APPROVAL_RESULT" "$PUBLISH_RESULT" "$PUBLISH_MAJOR_RESULT" "$DRY_RUN_RESULT"; do
if [ "$result" = "failure" ] || [ "$result" = "cancelled" ]; then
status="failure"
break
fi
done
status_upper=$(printf '%s' "$status" | tr '[:lower:]' '[:upper:]')
run_url="https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
gate_proceed="${GATE_PROCEED:-unknown}"
binary_lines="${BINARY_LINES:-0}"
changelog_len="${CHANGELOG_LEN:-0}"
token_present="${TOKEN_PRESENT:-unknown}"
token_gate="${TOKEN_GATE:-unknown}"
model="${MODEL:-}"
model_max_input_tokens="${MODEL_MAX_INPUT_TOKENS:-}"
model_max_output_tokens="${MODEL_MAX_OUTPUT_TOKENS:-}"
dossier_chars="${DOSSIER_CHARS:-}"
should_release="${SHOULD_RELEASE:-unknown}"
bump="${BUMP:-unknown}"
version="${VERSION:-}"
reason="${REASON:-}"
warning="${WARNING:-}"
dry_run="${DRY_RUN:-unknown}"
last_tag="${LAST_TAG:-}"
last_first_parent_tag="${LAST_FIRST_PARENT_TAG:-}"
pom_version="${POM_VERSION:-}"
pom_base="${POM_BASE:-}"
binary_changes="${BINARY_CHANGES:-}"
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')
if [ -z "$binary_changes" ]; then
binary_changes="(none)"
fi
if [ -z "$version" ]; then
version="(none)"
fi
if [ -z "$reason" ]; then
reason="(none)"
fi
if [ -z "$warning" ]; then
warning="(none)"
fi
if [ -z "$last_tag" ]; then
last_tag="(none)"
fi
if [ -z "$last_first_parent_tag" ]; then
last_first_parent_tag="(none)"
fi
if [ -z "$pom_version" ]; then
pom_version="(unknown)"
fi
if [ -z "$pom_base" ]; then
pom_base="(unknown)"
fi
if [ -z "$model" ]; then
model="(none)"
fi
if [ -z "$model_max_input_tokens" ]; then
model_max_input_tokens="(unknown)"
fi
if [ -z "$model_max_output_tokens" ]; then
model_max_output_tokens="(unknown)"
fi
if [ -z "$dossier_chars" ]; then
dossier_chars="0"
fi
notify_user="${RELEASE_NOTIFY_USER:-TheCookieLab}"
cat > notification-body.txt <<EOF
<!-- ta4j:post-type=release-scheduler;run=${marker_run};lastTag=${last_tag};firstParentTag=${last_first_parent_tag};pomVersion=${pom_version};pomBase=${pom_base} -->
@${notify_user}
**Release Scheduler ${mode_label} ${completion_text} at ${timestamp}**
- Repository: ${GITHUB_REPOSITORY}
- Run: ${run_url}
- Event: ${EVENT_NAME}
- Status: ${status_upper}
Decision Summary:
- gate proceed: ${gate_proceed}
- binary change count: ${binary_lines}
- changelog length: ${changelog_len}
- models token present: ${token_present}
- token gate proceed: ${token_gate}
- model: ${model}
- model max input tokens: ${model_max_input_tokens}
- model max output tokens: ${model_max_output_tokens}
- dossier chars: ${dossier_chars}
- should release: ${should_release}
- bump: ${bump}
- computed version: ${version}
- reason: ${reason}
- warning: ${warning}
- dryRun: ${dry_run}
- last reachable tag: ${last_tag}
- last first-parent tag: ${last_first_parent_tag}
- pom version: ${pom_version}
- pom base: ${pom_base}
Binary-impacting changes (paths):
<details>
<summary>Show paths</summary>
${binary_changes}
</details>
Job Results:
- analyze: ${ANALYZE_RESULT}
- approval: ${APPROVAL_RESULT}
- publish: ${PUBLISH_RESULT}
- publish_major: ${PUBLISH_MAJOR_RESULT}
- dry_run_output: ${DRY_RUN_RESULT}
EOF
- name: Post to Release Scheduler discussion
if: always() && needs.analyze.result != 'skipped' && needs.analyze.outputs.dryRun != 'true'
uses: actions/github-script@v9
env:
RELEASE_SCHEDULER_DISCUSSION_NUMBER: ${{ vars.RELEASE_SCHEDULER_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_SCHEDULER_DISCUSSION_NUMBER;
const number = rawNumber ? Number(rawNumber) : 1414;
if (!Number.isFinite(number)) {
throw new Error(`Invalid Release Scheduler 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 discussion = queryResult?.repository?.discussion;
const discussionId = 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 });