999 lines
41 KiB
YAML
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 });
|