# 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 # 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 ..${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/block/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: only run if Release succeeded and tag is not 'stable' # 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/block/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@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 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@b7eabc95ff50cbeeedec83973935c8f306dfcd0b # v1.20.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/block/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"