From 86186a9afc9dcb8b8359d9807f1c84e37b2127da Mon Sep 17 00:00:00 2001 From: Rizel Scarlett Date: Wed, 25 Feb 2026 23:58:31 -0500 Subject: [PATCH] feat: add goose-powered release notes generator workflow (#7503) --- .github/workflows/goose-release-notes.yml | 350 ++++++++++++++++++++++ 1 file changed, 350 insertions(+) create mode 100644 .github/workflows/goose-release-notes.yml diff --git a/.github/workflows/goose-release-notes.yml b/.github/workflows/goose-release-notes.yml new file mode 100644 index 00000000..4efc16da --- /dev/null +++ b/.github/workflows/goose-release-notes.yml @@ -0,0 +1,350 @@ +# 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"