#!/usr/bin/env bash # Generate release notes using LLM analysis of git commits # Usage: ./scripts/generate-release-notes.sh [previous-tag] set -euo pipefail VERSION=${1:-} PREVIOUS_TAG=${2:-} if [ -z "$VERSION" ]; then echo "Usage: $0 [previous-tag]" echo "Example: $0 4.29.0 v4.28.0" exit 1 fi # Find previous tag if not specified if [ -z "$PREVIOUS_TAG" ]; then PREVIOUS_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") if [ -z "$PREVIOUS_TAG" ]; then echo "No previous tag found, using all commits" PREVIOUS_TAG=$(git rev-list --max-parents=0 HEAD) fi fi echo "Generating release notes for v${VERSION}..." echo "Analyzing commits since ${PREVIOUS_TAG}..." # Get commit log COMMIT_LOG=$(git log ${PREVIOUS_TAG}..HEAD --pretty=format:"%h %s" --no-merges) if [ -z "$COMMIT_LOG" ]; then echo "No commits found since ${PREVIOUS_TAG}" exit 1 fi # Count commits COMMIT_COUNT=$(echo "$COMMIT_LOG" | wc -l) echo "Found ${COMMIT_COUNT} commits" # Generate release notes using LLM API # Supports both OpenAI and Anthropic Claude # Set either OPENAI_API_KEY or ANTHROPIC_API_KEY if [ -n "${ANTHROPIC_API_KEY:-}" ]; then LLM_PROVIDER="anthropic" elif [ -n "${OPENAI_API_KEY:-}" ]; then LLM_PROVIDER="openai" else echo "No LLM API keys detected – falling back to deterministic release notes." LLM_PROVIDER="fallback" fi echo "Using LLM provider: ${LLM_PROVIDER}" # Prepare prompt for LLM read -r -d '' PROMPT <&2 return 1 } response_type=$(echo "$response" | jq -r '.type // empty') if [ "$response_type" = "error" ]; then local message message=$(echo "$response" | jq -r '.error.message // "Unknown error"') echo "Anthropic API error: $message" >&2 return 1 fi content=$(echo "$response" | jq -r '.content[0].text // empty') if [ -z "$content" ] || [ "$content" = "null" ]; then echo "Anthropic API returned empty content: $response" >&2 return 1 fi printf '%s' "$content" } # Helper to call OpenAI (returns notes via stdout) generate_with_openai() { local response content error_msg response=$(curl -s https://api.openai.com/v1/chat/completions \ -H "Content-Type: application/json" \ -H "Authorization: Bearer ${OPENAI_API_KEY}" \ -d @- <&2 return 1 } error_msg=$(echo "$response" | jq -r '.error.message? // empty') if [ -n "$error_msg" ]; then echo "OpenAI API error: $error_msg" >&2 return 1 fi content=$(echo "$response" | jq -r '.choices[0].message.content // empty') if [ -z "$content" ] || [ "$content" = "null" ]; then echo "OpenAI API returned empty content: $response" >&2 return 1 fi printf '%s' "$content" } # Pretty-print a section of notes (expects array name) print_section() { local ref="$1" declare -n arr_ref="$ref" if [ ${#arr_ref[@]} -eq 0 ]; then echo "None" return fi local item for item in "${arr_ref[@]}"; do echo "- ${item}" done } # Deterministic fallback when no LLM providers are available generate_fallback_release_notes() { local raw_subjects=() mapfile -t raw_subjects < <(echo "$COMMIT_LOG" | sed 's/^[0-9a-f]\+ //') local subjects=() local subject lower for subject in "${raw_subjects[@]}"; do lower=$(echo "$subject" | tr '[:upper:]' '[:lower:]') if [[ "$lower" =~ (release[[:space:]-]?notes|release[[:space:]-]?workflow|workflow|github|ci|lint|docs|documentation|helm|auto-update|mock|dry[[:space:]-]?run|test|integration|build|validation|telemetry|release[[:space:]-]?assets|fallback|prepare) ]]; then continue fi subjects+=("$subject") done if [ ${#subjects[@]} -eq 0 ]; then subjects=("${raw_subjects[@]}") fi local features=() local bugs=() local improvements=() local breakings=() local subject lower for subject in "${subjects[@]}"; do [ -z "$subject" ] && continue lower=$(echo "$subject" | tr '[:upper:]' '[:lower:]') if [[ "$lower" =~ (feat|feature|add|introduc|support|new) ]]; then features+=("$subject") elif [[ "$lower" =~ (fix|bug|issue|patch|regress|correct) ]]; then bugs+=("$subject") elif [[ "$lower" =~ (breaking|deprecat|remove|drop|incompatib) ]]; then breakings+=("$subject") else improvements+=("$subject") fi done if [ ${#improvements[@]} -eq 0 ] && [ ${#features[@]} -eq 0 ] && [ ${#bugs[@]} -eq 0 ]; then improvements=("${subjects[@]}") fi local notes=() for subject in "${subjects[@]}"; do notes+=("$subject") [ ${#notes[@]} -ge 3 ] && break done if [ ${#notes[@]} -eq 0 ]; then notes+=("Routine maintenance and dependency updates.") fi { echo "## What's Changed" echo "" echo "### New Features" print_section features echo "" echo "### Bug Fixes" print_section bugs echo "" echo "### Improvements" print_section improvements echo "" echo "### Breaking Changes" print_section breakings echo "" echo "## Installation" echo "" echo "**Quick Install (systemd / LXC / Proxmox VE):**" cat <<'EOQI' ```bash curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/install.sh | bash ``` EOQI echo "" echo "**Docker:**" cat <<'EOQDock' ```bash docker pull rcourtman/pulse:v${VERSION} docker stop pulse && docker rm pulse docker run -d --name pulse \ --restart unless-stopped \ -p 7655:7655 -p 7656:7656 \ -v /opt/pulse/data:/data \ rcourtman/pulse:v${VERSION} ``` EOQDock echo "" echo "**Manual Binary (amd64 example):**" cat <<'EOQMan' ```bash curl -LO https://github.com/rcourtman/Pulse/releases/download/v${VERSION}/pulse-v${VERSION}-linux-amd64.tar.gz sudo systemctl stop pulse sudo tar -xzf pulse-v${VERSION}-linux-amd64.tar.gz -C /usr/local/bin pulse sudo systemctl start pulse ``` EOQMan echo "" echo "**Helm:**" cat <<'EOQHelm' ```bash helm upgrade --install pulse oci://ghcr.io/rcourtman/pulse-chart \ --version ${VERSION} \ --namespace pulse \ --create-namespace ``` EOQHelm echo "" echo "## Downloads" echo "- Universal tarball (auto-detects architecture): \`pulse-v${VERSION}.tar.gz\`" echo "- Architecture-specific: \`amd64\`, \`arm64\`, \`armv7\`, \`armv6\`, \`386\`" echo "- Host agent packages: macOS, Windows, Linux" echo "- Sensor proxy binaries: Linux (amd64/arm64/armv7/armv6/386)" echo "- Helm chart: \`pulse-${VERSION}.tgz\`" echo "- SHA256 checksums: \`checksums.txt\`" echo "" echo "## Notes" local item for item in "${notes[@]}"; do echo "- ${item}" done } } # Call LLM API based on provider with graceful fallback RELEASE_NOTES="" if [ "$LLM_PROVIDER" = "anthropic" ]; then if ! RELEASE_NOTES=$(generate_with_anthropic); then if [ -n "${OPENAI_API_KEY:-}" ]; then echo "Anthropic generation failed, falling back to OpenAI..." >&2 if ! RELEASE_NOTES=$(generate_with_openai); then echo "OpenAI fallback failed; generating heuristic release notes." >&2 RELEASE_NOTES=$(generate_fallback_release_notes) fi LLM_PROVIDER="openai" else echo "Anthropic generation failed and no OpenAI fallback is available; generating heuristic release notes." >&2 RELEASE_NOTES=$(generate_fallback_release_notes) fi fi else if ! RELEASE_NOTES=$(generate_with_openai); then echo "OpenAI generation failed; generating heuristic release notes." >&2 RELEASE_NOTES=$(generate_fallback_release_notes) fi fi if [ -z "$RELEASE_NOTES" ] || [ "$RELEASE_NOTES" = "null" ]; then echo "Error: Release notes generation returned empty content" >&2 exit 1 fi # Output release notes echo "" echo "Generated release notes:" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "$RELEASE_NOTES" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" # Optionally save to file if [ "${SAVE_TO_FILE:-}" = "1" ]; then OUTPUT_FILE="release-notes-v${VERSION}.md" echo "$RELEASE_NOTES" > "$OUTPUT_FILE" echo "" echo "Saved to: $OUTPUT_FILE" fi