goose/.github/workflows/goose-release-notes.yml

352 lines
12 KiB
YAML

# goose Release Notes Generator
#
# Automatically generates release notes using goose AI agent when a new release is published.
# Updates the GitHub release with AI-generated categorized notes and posts to Discord.
#
# Uses workflow_run instead of release:published because GitHub doesn't trigger
# workflows for events created by GITHUB_TOKEN (to prevent infinite loops).
name: goose Release Notes Generator
on:
# Trigger when Release workflow completes (works around GITHUB_TOKEN limitation)
workflow_run:
workflows:
- Release
types:
- completed
branches-ignore:
- "release/**"
# Allow manual trigger for testing
workflow_dispatch:
inputs:
tag:
description: 'Release tag to generate notes for (e.g., v1.25.0)'
required: true
type: string
env:
GOOSE_RECIPE: |
version: "1.0.0"
title: "Release Notes Generator"
description: "Generate release notes for ${RELEASE_TAG}"
extensions:
- type: builtin
name: developer
instructions: |
Generate release notes for the goose release.
## Process
1. You are already in the goose repository. Do NOT clone or checkout anything.
2. Get the previous release tag by running: git describe --tags --abbrev=0 ${RELEASE_TAG}^
3. Get commits between tags: git log <previous_tag>..${RELEASE_TAG} --oneline --no-merges
4. Analyze the commits and categorize changes
## Output Format
Categorize changes into these sections (skip empty sections):
- ✨ **Features** - New functionality
- 🐛 **Bug Fixes** - Bug fixes
- 🔧 **Improvements** - Enhancements to existing features
- 📚 **Documentation** - Documentation updates
Format each item as:
- Concise description [#XXXX](https://github.com/aaif-goose/goose/pull/XXXX)
Rules:
- Extract PR numbers from commit messages (look for (#XXXX) pattern)
- Remove redundant words like "Added", "Fixed", "Documented" - the category headers make these clear
- Keep descriptions user-friendly and concise
- Order: Features → Bug Fixes → Improvements → Documentation
## Final Step
Write ONLY the release notes content to /tmp/release_notes.md (no extra commentary)
prompt: |
Generate release notes for ${RELEASE_TAG} in the goose repository.
permissions:
contents: write
concurrency:
group: release-notes-${{ github.event.workflow_run.head_branch || inputs.tag }}
cancel-in-progress: true
jobs:
generate-release-notes:
name: Generate Release Notes
runs-on: ubuntu-latest
# For workflow_run: release branch preflight builds are ignored by the trigger.
# For workflow_dispatch: only run if tag is not 'stable'
if: |
(github.event_name == 'workflow_dispatch' && inputs.tag != 'stable') ||
(github.event_name == 'workflow_run' &&
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.head_branch != 'stable')
container:
image: ghcr.io/aaif-goose/goose:latest
options: --user root
env:
GOOSE_PROVIDER: ${{ vars.GOOSE_PROVIDER || 'anthropic' }}
GOOSE_MODEL: ${{ vars.GOOSE_MODEL || 'claude-opus-4-5' }}
ANTHROPIC_API_KEY: ${{ secrets.RELEASE_BOT_ANTHROPIC_KEY }}
HOME: /tmp/goose-home
outputs:
release_notes: ${{ steps.read-notes.outputs.notes }}
notes_length: ${{ steps.read-notes.outputs.length }}
release_tag: ${{ steps.get-tag.outputs.tag }}
steps:
- name: Get release tag
id: get-tag
env:
EVENT_NAME: ${{ github.event_name }}
INPUT_TAG: ${{ inputs.tag }}
WORKFLOW_RUN_BRANCH: ${{ github.event.workflow_run.head_branch }}
run: |
if [ "$EVENT_NAME" = "workflow_dispatch" ]; then
echo "tag=$INPUT_TAG" >> $GITHUB_OUTPUT
else
echo "tag=$WORKFLOW_RUN_BRANCH" >> $GITHUB_OUTPUT
fi
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
fetch-tags: true
- name: Install tools
run: |
apt-get update
apt-get install -y gettext curl ripgrep git jq
- name: Run goose to generate release notes
env:
RELEASE_TAG: ${{ steps.get-tag.outputs.tag }}
run: |
mkdir -p $HOME/.local/share/goose/sessions
mkdir -p $HOME/.config/goose
git config --global --add safe.directory "$GITHUB_WORKSPACE"
# Checkout the release tag
git checkout "${RELEASE_TAG}"
# Create recipe from env var with variable substitution
echo "$GOOSE_RECIPE" | envsubst '$RELEASE_TAG' > /tmp/recipe.yaml
goose run --recipe /tmp/recipe.yaml
- name: Read release notes
id: read-notes
run: |
if [ -f /tmp/release_notes.md ]; then
# Use random delimiter to prevent injection
DELIMITER="EOF_$(openssl rand -hex 8)"
echo "notes<<$DELIMITER" >> $GITHUB_OUTPUT
cat /tmp/release_notes.md >> $GITHUB_OUTPUT
echo "" >> $GITHUB_OUTPUT
echo "$DELIMITER" >> $GITHUB_OUTPUT
LENGTH=$(wc -c < /tmp/release_notes.md)
echo "length=$LENGTH" >> $GITHUB_OUTPUT
echo "::notice::Release notes generated successfully (${LENGTH} chars)"
else
echo "::error::Release notes file not found at /tmp/release_notes.md"
echo "notes=Release notes generation failed." >> $GITHUB_OUTPUT
echo "length=0" >> $GITHUB_OUTPUT
exit 1
fi
update-github-release:
name: Update GitHub Release
runs-on: ubuntu-latest
needs: generate-release-notes
permissions:
contents: write
steps:
- name: Update release body
uses: ncipollo/release-action@339a81892b84b4eeb0f6e744e4574d79d0d9b8dd # v1.21.0
with:
tag: ${{ needs.generate-release-notes.outputs.release_tag }}
token: ${{ secrets.GITHUB_TOKEN }}
body: ${{ needs.generate-release-notes.outputs.release_notes }}
allowUpdates: true
omitNameDuringUpdate: true
omitPrereleaseDuringUpdate: true
post-to-discord:
name: Post to Discord
runs-on: ubuntu-latest
needs: generate-release-notes
steps:
- name: Post release announcement
env:
DISCORD_BOT_TOKEN: ${{ secrets.DISCORD_RELEASE_BOT_TOKEN }}
DISCORD_CHANNEL_ID: ${{ secrets.DISCORD_RELEASE_BOT_CHANNEL_ID }}
RELEASE_TAG: ${{ needs.generate-release-notes.outputs.release_tag }}
RELEASE_URL: https://github.com/aaif-goose/goose/releases/tag/${{ needs.generate-release-notes.outputs.release_tag }}
RELEASE_NOTES: ${{ needs.generate-release-notes.outputs.release_notes }}
NOTES_LENGTH: ${{ needs.generate-release-notes.outputs.notes_length }}
shell: bash
run: |
# Skip if Discord is not configured
if [ -z "$DISCORD_CHANNEL_ID" ] || [ -z "$DISCORD_BOT_TOKEN" ]; then
echo "::notice::Discord not configured, skipping"
exit 0
fi
# Discord message character limit is ~2000 for regular messages
SAFE_LIMIT=1800
# Function to send Discord message via bot API, returns message ID
send_discord() {
local content="$1"
local channel="$2"
content=$(echo "$content" | jq -Rs .)
response=$(curl -s -X POST "https://discord.com/api/v10/channels/${channel}/messages" \
-H "Authorization: Bot ${DISCORD_BOT_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"content\": $content, \"flags\": 4}")
# Debug: show response if there's an error
if echo "$response" | jq -e '.code' > /dev/null 2>&1; then
echo "::warning::Discord API error: $(echo "$response" | jq -c '.')" >&2
fi
echo "$response" | jq -r '.id // empty'
}
# Function to create a thread from a message
create_thread() {
local channel="$1"
local message_id="$2"
local thread_name="$3"
response=$(curl -s -X POST "https://discord.com/api/v10/channels/${channel}/messages/${message_id}/threads" \
-H "Authorization: Bot ${DISCORD_BOT_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"name\": \"${thread_name}\"}")
echo "$response" | jq -r '.id // empty'
}
# Build the announcement header
HEADER="## 🎉 goose ${RELEASE_TAG} is here!
📦 **Release:** ${RELEASE_URL}"
FOOTER="
@everyone
📝 *More in thread...*"
HEADER_LEN=${#HEADER}
FOOTER_LEN=${#FOOTER}
AVAILABLE=$((SAFE_LIMIT - HEADER_LEN - FOOTER_LEN - 10))
# Check if everything fits in one message (no thread needed)
FULL_MSG="${HEADER}
${RELEASE_NOTES}"
if [ ${#FULL_MSG} -le "$SAFE_LIMIT" ]; then
send_discord "$FULL_MSG" "$DISCORD_CHANNEL_ID"
echo "::notice::Discord notification sent successfully"
exit 0
fi
# Need to split - use awk to fit complete lines in main vs thread
echo "$RELEASE_NOTES" > /tmp/release_notes_full.txt
awk -v avail="$AVAILABLE" '
BEGIN { main_len = 0; in_thread = 0 }
{
line_len = length($0) + 1 # +1 for newline
if (!in_thread && (main_len + line_len) <= avail) {
print > "/tmp/main_content.txt"
main_len += line_len
} else {
in_thread = 1
print > "/tmp/thread_content.txt"
}
}
' /tmp/release_notes_full.txt
# Read the split content
MAIN_CONTENT=""
[ -f /tmp/main_content.txt ] && MAIN_CONTENT=$(cat /tmp/main_content.txt)
THREAD_CONTENT=""
[ -f /tmp/thread_content.txt ] && THREAD_CONTENT=$(cat /tmp/thread_content.txt)
# Build and send main message
MAIN_MESSAGE="${HEADER}
${MAIN_CONTENT}
${FOOTER}"
MESSAGE_ID=$(send_discord "$MAIN_MESSAGE" "$DISCORD_CHANNEL_ID")
if [ -z "$MESSAGE_ID" ]; then
echo "::error::Failed to send Discord message"
exit 1
fi
echo "Created message $MESSAGE_ID, creating thread..."
sleep 1
# Create thread
THREAD_ID=$(create_thread "$DISCORD_CHANNEL_ID" "$MESSAGE_ID" "Release Notes ${RELEASE_TAG}")
if [ -z "$THREAD_ID" ]; then
echo "::warning::Failed to create thread"
THREAD_ID="$DISCORD_CHANNEL_ID"
fi
sleep 1
# Post thread content in chunks (by complete lines)
if [ -n "$THREAD_CONTENT" ]; then
# Split thread content into chunks using awk
awk -v limit="$SAFE_LIMIT" '
BEGIN { chunk = ""; chunk_len = 0; chunk_num = 0 }
{
line_len = length($0) + 1
if (chunk_len + line_len > limit && chunk_len > 0) {
# Save current chunk and start new one
print chunk > "/tmp/chunk_" chunk_num ".txt"
chunk_num++
chunk = $0 "\n"
chunk_len = line_len
} else {
chunk = chunk $0 "\n"
chunk_len += line_len
}
}
END {
if (chunk_len > 0) {
print chunk > "/tmp/chunk_" chunk_num ".txt"
}
}
' /tmp/thread_content.txt
# Send each chunk
for chunk_file in /tmp/chunk_*.txt; do
[ -f "$chunk_file" ] || continue
CHUNK=$(cat "$chunk_file")
[ -n "$CHUNK" ] && send_discord "$CHUNK" "$THREAD_ID"
sleep 1
rm -f "$chunk_file"
done
fi
# Cleanup
rm -f /tmp/release_notes_full.txt /tmp/main_content.txt /tmp/thread_content.txt
echo "::notice::Discord notification sent successfully"