# 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 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 <> "$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 < @${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):
Show paths ${binary_changes}
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 });