diff --git a/.github/prompts/release-announcement.prompt.yml b/.github/prompts/release-announcement.prompt.yml new file mode 100644 index 0000000000..f23e69bbe3 --- /dev/null +++ b/.github/prompts/release-announcement.prompt.yml @@ -0,0 +1,93 @@ +# AI prompt for maintaining ReleaseAnnouncement.md. +# Used by .github/workflows/update-release-announcement.yml via actions/ai-inference. +# Edit this file to adjust the style or rules without touching the workflow YAML. + +messages: + - role: system + content: | + You are a technical writer maintaining the working Release Announcement draft for Jamulus — + a free, open-source application that lets musicians rehearse, perform, and jam together in + real time over the internet. + + This is a RELEASE ANNOUNCEMENT for end users, NOT a technical ChangeLog. Write in a + friendly, editorial voice — as if sharing good news with the community. Users are musicians, + not necessarily developers: speak to what they can now DO or what has IMPROVED for them, + not to what code was changed. + + STYLE — NARRATIVE PROSE, NOT BULLET POINTS: + The announcement is organised into audience-grouped sections, each written as short, + friendly narrative paragraphs — the way a magazine might preview an upcoming release. + DO NOT use bullet-point lists. Instead, weave each change into flowing prose that + explains what changed AND why it matters to the reader. + + Each audience section starts with a level-2 heading (##) and is separated from the next + by "---". Use these standard section headings when applicable: + + ## For everyone + ## For Windows users + ## For macOS users + ## For mobile users (iOS & Android) + ## For server operators + ## Translations + + When a change is significant enough to deserve its own spotlight, give it a dedicated + level-2 heading (e.g. "## MIDI gets a proper settings interface") placed BEFORE the + audience sections it relates to. Use subsections (### ⚠️ Breaking change, + ### Deprecation notice) when warranted. + + Rules: + - Integrate each new change into the MOST APPROPRIATE audience section of the document + as narrative prose. Create a new audience section if one does not yet exist for the + relevant audience. Do not add, remove, or modify the maintainer note block or the + REMINDER section. + - When the document contains the HTML placeholder comment + "", + insert new content immediately ABOVE that comment and leave the comment in place. + - Write in plain, friendly language. Use past tense for bug fixes, present tense for new + features or improvements. Every paragraph must be complete, grammatically correct prose. + - Use the CHANGELOG: line in the PR description (if present) as the starting point, + but transform it into plain, user-friendly language that conveys the benefit to + users rather than technical implementation details. Strip the category prefix + (Client:, Server:, Build:, Tools:, etc.) unless keeping it adds helpful context for + a non-technical reader — e.g. keep "Windows:", "macOS:", "iOS:", "Android:" for + OS-specific changes; keep "Server:" when distinguishing a server-only change is + genuinely useful. + - Do NOT credit individual contributors inline. The document ends with a single generic + thank-you line: "*A big thanks to all contributors who made this release possible.*" + - Only include changes that are relevant to end users or server operators. + Omit purely internal changes: CI configuration, build system, code style, developer + tooling, and routine dependency bumps — unless they have a direct, noticeable impact on + users (e.g. a bundled library upgrade that fixes a crash or enables a new feature). + - Within each section, mention more impactful changes first. + - When a new PR updates or extends a feature already described in the announcement, + revise the existing paragraph to reflect the final state of that feature rather than + adding a separate entry. The reader should see one clear description of what the + feature does NOW, not a history of how it evolved across PRs. + - Do not remove paragraphs about unrelated features. Only rewrite prose that directly + overlaps with the new PR's changes. + - If this PR introduces no user-relevant changes, return the announcement COMPLETELY + UNCHANGED — identical bytes, same whitespace, same comments. + - Output the COMPLETE updated Markdown document and nothing else. Do not add any + explanation, preamble, commentary, or markdown code fences outside the document. + + - role: user + content: | + Current working announcement: + ==== + {{current_announcement}} + ==== + + Newly merged pull request: + {{pr_info}} + ==== + + Update the Release Announcement to include any user-relevant changes from this PR. + Return the complete updated Markdown document only. + +model: openai/gpt-4o +modelParameters: + # High token limit to ensure the full document is always returned without truncation. + # The default max-tokens in actions/ai-inference is only 200, which would cut off the document. + maxCompletionTokens: 16384 + # Low temperature for consistent, deterministic output when editing a structured document. + temperature: 0.2 diff --git a/.github/release-announcement-template.md b/.github/release-announcement-template.md new file mode 100644 index 0000000000..b14257c852 --- /dev/null +++ b/.github/release-announcement-template.md @@ -0,0 +1,41 @@ +# Jamulus Next Release — Working Announcement Draft + +> **Note for maintainers:** This is a working draft, automatically updated by GitHub Copilot +> as PRs are merged to `main`. Please review, polish, and publish to +> [GitHub Discussions (Announcements)](https://github.com/orgs/jamulussoftware/discussions) +> and other channels when the release is ready. +> +> Run [`tools/get_release_contributors.py`](tools/get_release_contributors.py) to compile +> the full contributor list before publishing. +> +> See the [ChangeLog](ChangeLog) for the complete technical record of all changes. + +Here's what's new in the next release of Jamulus: + + + +## For everyone + +## For Windows users + +## For macOS users + +## For mobile users (iOS & Android) + +## For server operators + +## Translations + +--- + +As always, all feedback on the new version is welcome. Please raise any problems in a new bug report or discussion topic. + +--- + +**REMINDER:** Those of you with virus checkers are likely to find the Windows installer incorrectly flagged as a virus. This is because the installer is open source and virus checkers cannot be bothered to check what it installs, so assume that it's going to be malign. If you download the installer *only from the official release*, you should be safe to ignore any warning. + +--- + +*A big thanks to all contributors who made this release possible.* + +*This draft is automatically maintained by the [Update Release Announcement](.github/workflows/update-release-announcement.yml) workflow.* diff --git a/.github/workflows/backfill-release-announcement.yml b/.github/workflows/backfill-release-announcement.yml new file mode 100644 index 0000000000..19e67c159c --- /dev/null +++ b/.github/workflows/backfill-release-announcement.yml @@ -0,0 +1,70 @@ +name: Backfill Release Announcement + +# This workflow runs tools/backfill-release-announcement.sh to populate +# ReleaseAnnouncement.md with every merged PR since a given release tag. +# +# Trigger it once after a release tag is cut (e.g. r3_11_0) and this workflow +# file is merged to main. It processes PRs in chronological order, calling +# the GitHub Models API (gpt-4o-mini) for each one so the announcement builds +# up exactly as it would have done if the per-PR workflow had been running all +# along. +# +# The script commits one separate commit per PR that produced a user-relevant +# change, then this workflow pushes them all to main in one go. Review the +# individual commits and amend or revert as needed before the release is +# published. + +on: + workflow_dispatch: + inputs: + since_tag: + description: >- + Git release tag to backfill from. + PRs merged *after* this tag will be processed. + (default: r3_11_0) + required: false + default: 'r3_11_0' + dry_run: + description: >- + Set to 'true' to print what would happen without making any changes. + required: false + default: 'false' + type: choice + options: + - 'false' + - 'true' + +permissions: {} + +jobs: + backfill: + name: Backfill from ${{ inputs.since_tag }} + # Only run in the main jamulussoftware repo to avoid accidentally pushing + # to a fork and consuming AI quota. + if: github.repository_owner == 'jamulussoftware' + runs-on: ubuntu-latest + permissions: + contents: write + models: read + + steps: + - uses: actions/checkout@v6 + with: + ref: main + + - name: Run backfill script + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SOURCE_REPO: jamulussoftware/jamulus + DRY_RUN: ${{ inputs.dry_run }} + run: | + bash tools/backfill-release-announcement.sh "${{ inputs.since_tag }}" + + - name: Push commits + if: inputs.dry_run != 'true' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # The backfill script commits one commit per PR that changed the + # announcement. All that is left to do here is push them. + git push diff --git a/.github/workflows/update-release-announcement.yml b/.github/workflows/update-release-announcement.yml new file mode 100644 index 0000000000..26e886160d --- /dev/null +++ b/.github/workflows/update-release-announcement.yml @@ -0,0 +1,337 @@ +name: Update Release Announcement + +# This workflow maintains ReleaseAnnouncement.md — a working draft of the release +# announcement for Client users and Server operators — separate from the technical ChangeLog. +# +# On every merged PR to main: GitHub Copilot updates the draft with any user-relevant +# changes from that PR, in the same conversational bullet-point style used in real +# Jamulus beta/release announcements on GitHub Discussions. +# +# On every push to an autobuild* branch: GitHub Copilot updates the draft on that branch +# with any user-relevant changes from the HEAD commit. This lets developers preview how +# their changes would appear in the announcement before the PR is merged. +# +# On every full release tag (r__, no suffix): the draft is reset to the pristine +# template, ready for the next development cycle. Pre-release tags (beta, rc) do NOT reset +# the draft, so it can continue to build up until the final release. +# +# Security note (pull_request_target): +# - The workflow file and the AI prompt always come from main, never from the PR branch. +# - PR content is written to a temp file via an env variable before being passed to the +# AI — it never touches a YAML value directly, preventing injection issues. +# - No code from the PR is ever checked out or executed. +# See: https://securitylab.github.com/research/github-actions-preventing-pwn-requests/ + +on: + pull_request_target: + types: + - closed + branches: + - main + push: + branches: + - "autobuild**" + tags: + - "r*" + +permissions: {} + +jobs: + update-announcement: + name: Update announcement for merged PR + # Only run on actual merges (not just closed PRs) in the main jamulussoftware repo. + if: >- + github.repository_owner == 'jamulussoftware' && + github.event.pull_request.merged == true + runs-on: ubuntu-latest + permissions: + contents: write + models: read + + steps: + - uses: actions/checkout@v6 + with: + # Always check out the base branch (main), never the PR branch. + ref: main + + - name: Check if announcement update should be skipped + id: check-skip + env: + PR_BODY: ${{ github.event.pull_request.body }} + run: | + # Skip when the PR author explicitly marked the change as not user-facing. + if printf '%s\n' "$PR_BODY" | grep -qE '^CHANGELOG:[[:space:]]*SKIP[[:space:]]*$'; then + echo "Skipping: PR is marked CHANGELOG: SKIP" + echo "skip=true" >> "$GITHUB_OUTPUT" + else + echo "skip=false" >> "$GITHUB_OUTPUT" + fi + + - name: Prepare PR metadata for AI prompt + id: prep-pr-info + if: steps.check-skip.outputs.skip == 'false' + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_TITLE: ${{ github.event.pull_request.title }} + PR_AUTHOR: ${{ github.event.pull_request.user.login }} + PR_BODY: ${{ github.event.pull_request.body }} + run: | + # Write all PR metadata to a temp file so it can be safely passed to the AI, + # avoiding any injection issues from PR body content. + printf 'PR #%s — %s\nby @%s\n\n%s\n' \ + "$PR_NUMBER" "$PR_TITLE" "$PR_AUTHOR" "$PR_BODY" \ + > "${RUNNER_TEMP}/pr_info.txt" + + - name: Build complete AI prompt + id: build-prompt + if: steps.check-skip.outputs.skip == 'false' + run: | + # Build a complete, self-contained YAML prompt file using Python so that the + # multi-line file content is properly indented and escaped. This avoids the + # issue where actions/ai-inference performs a raw {{variable}} text substitution + # before YAML parsing, which breaks the block-scalar structure when the + # substituted content contains lines starting at column 0. + python3 - "${RUNNER_TEMP}/prompt.yml" "${RUNNER_TEMP}/pr_info.txt" \ + ReleaseAnnouncement.md \ + '.github/prompts/release-announcement.prompt.yml' <<'PYEOF' + import sys, yaml + output_file, info_file, announcement_file, template_file = sys.argv[1:5] + with open(announcement_file, encoding='utf-8') as f: + announcement = f.read() + with open(info_file, encoding='utf-8') as f: + info = f.read() + with open(template_file, encoding='utf-8') as f: + template = yaml.safe_load(f) + system_prompt = next( + m['content'] for m in template['messages'] if m['role'] == 'system' + ) + user_content = ( + 'Current working announcement:\n====\n' + + announcement + + '\n====\n\nNewly merged pull request:\n' + + info + + '\n====\n\nUpdate the Release Announcement to include any user-relevant ' + + 'changes from this PR.\nReturn the complete updated Markdown document only.' + ) + complete = { + 'messages': [ + {'role': 'system', 'content': system_prompt}, + {'role': 'user', 'content': user_content}, + ], + 'model': template.get('model', 'openai/gpt-4o'), + 'modelParameters': template.get('modelParameters', {}), + } + with open(output_file, 'w', encoding='utf-8') as f: + yaml.dump(complete, f, allow_unicode=True, + default_flow_style=False, sort_keys=False) + PYEOF + + - name: Update Release Announcement with GitHub Copilot + id: update-announcement + if: steps.check-skip.outputs.skip == 'false' + uses: actions/ai-inference@v2 + with: + # Use the pre-built prompt that has all content already embedded and + # properly escaped — no file_input substitution needed. + prompt-file: '${{ runner.temp }}/prompt.yml' + + - name: Write updated Release Announcement + if: steps.check-skip.outputs.skip == 'false' + env: + RESPONSE_FILE: ${{ steps.update-announcement.outputs.response-file }} + run: | + # Guard against an empty or missing response, which would wipe the file. + if [ ! -s "$RESPONSE_FILE" ]; then + echo "Warning: AI returned an empty response — skipping update." + exit 0 + fi + cp "$RESPONSE_FILE" ReleaseAnnouncement.md + + - name: Commit and push updated announcement + if: steps.check-skip.outputs.skip == 'false' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + git config --global user.email "actions@github.com" + git config --global user.name "github-actions[bot]" + git add ReleaseAnnouncement.md + # Exit cleanly if the AI returned the document unchanged (nothing to commit). + git diff --staged --quiet && exit 0 + git commit -m "docs: update Release Announcement for PR #${{ github.event.pull_request.number }}" + git push + + update-announcement-on-autobuild: + name: Update announcement for autobuild push + # Run on autobuild branch pushes. + # Skip when the pusher is the bot itself to prevent an infinite commit loop: + # autobuild.yml already has ReleaseAnnouncement.md in paths-ignore, so the + # bot commit will not re-trigger the build, but it would re-trigger this workflow. + if: >- + startsWith(github.ref, 'refs/heads/autobuild') && + github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + permissions: + contents: write + models: read + + steps: + - uses: actions/checkout@v6 + with: + # Check out the autobuild branch being pushed, so the announcement is + # updated and committed directly on that branch. + ref: ${{ github.ref }} + + - name: Check if announcement update should be skipped + id: check-skip + env: + COMMIT_MESSAGE: ${{ github.event.head_commit.message }} + run: | + # Skip when the commit message explicitly marks the change as not user-facing. + if printf '%s\n' "$COMMIT_MESSAGE" | grep -qE '^CHANGELOG:[[:space:]]*SKIP[[:space:]]*$'; then + echo "Skipping: commit is marked CHANGELOG: SKIP" + echo "skip=true" >> "$GITHUB_OUTPUT" + else + echo "skip=false" >> "$GITHUB_OUTPUT" + fi + + - name: Prepare commit metadata for AI prompt + id: prep-commit-info + if: steps.check-skip.outputs.skip == 'false' + env: + COMMIT_SHA: ${{ github.event.head_commit.id }} + COMMIT_MESSAGE: ${{ github.event.head_commit.message }} + COMMIT_AUTHOR: ${{ github.event.head_commit.author.username }} + BRANCH_NAME: ${{ github.ref_name }} + run: | + # Write commit metadata to a temp file so it can be safely passed to the AI, + # avoiding any injection issues from the commit message content. + printf 'Commit %s on branch %s\nby @%s\n\n%s\n' \ + "$COMMIT_SHA" "$BRANCH_NAME" "$COMMIT_AUTHOR" "$COMMIT_MESSAGE" \ + > "${RUNNER_TEMP}/commit_info.txt" + + - name: Build complete AI prompt + id: build-prompt + if: steps.check-skip.outputs.skip == 'false' + run: | + # Build a complete, self-contained YAML prompt file using Python so that the + # multi-line file content is properly indented and escaped. This avoids the + # issue where actions/ai-inference performs a raw {{variable}} text substitution + # before YAML parsing, which breaks the block-scalar structure when the + # substituted content contains lines starting at column 0. + python3 - "${RUNNER_TEMP}/prompt.yml" "${RUNNER_TEMP}/commit_info.txt" \ + ReleaseAnnouncement.md \ + '.github/prompts/release-announcement.prompt.yml' <<'PYEOF' + import sys, yaml + output_file, info_file, announcement_file, template_file = sys.argv[1:5] + with open(announcement_file, encoding='utf-8') as f: + announcement = f.read() + with open(info_file, encoding='utf-8') as f: + info = f.read() + with open(template_file, encoding='utf-8') as f: + template = yaml.safe_load(f) + system_prompt = next( + m['content'] for m in template['messages'] if m['role'] == 'system' + ) + user_content = ( + 'Current working announcement:\n====\n' + + announcement + + '\n====\n\nRecently pushed commit:\n' + + info + + '\n====\n\nUpdate the Release Announcement to include any user-relevant ' + + 'changes from this commit.\nReturn the complete updated Markdown document only.' + ) + complete = { + 'messages': [ + {'role': 'system', 'content': system_prompt}, + {'role': 'user', 'content': user_content}, + ], + 'model': template.get('model', 'openai/gpt-4o'), + 'modelParameters': template.get('modelParameters', {}), + } + with open(output_file, 'w', encoding='utf-8') as f: + yaml.dump(complete, f, allow_unicode=True, + default_flow_style=False, sort_keys=False) + PYEOF + + - name: Update Release Announcement with GitHub Copilot + id: update-announcement + if: steps.check-skip.outputs.skip == 'false' + uses: actions/ai-inference@v2 + with: + # Use the pre-built prompt that has all content already embedded and + # properly escaped — no file_input substitution needed. + prompt-file: '${{ runner.temp }}/prompt.yml' + + - name: Write updated Release Announcement + if: steps.check-skip.outputs.skip == 'false' + env: + RESPONSE_FILE: ${{ steps.update-announcement.outputs.response-file }} + run: | + # Guard against an empty or missing response, which would wipe the file. + if [ ! -s "$RESPONSE_FILE" ]; then + echo "Warning: AI returned an empty response — skipping update." + exit 0 + fi + cp "$RESPONSE_FILE" ReleaseAnnouncement.md + + - name: Commit and push updated announcement + if: steps.check-skip.outputs.skip == 'false' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + git config --global user.email "actions@github.com" + git config --global user.name "github-actions[bot]" + git add ReleaseAnnouncement.md + # Exit cleanly if the AI returned the document unchanged (nothing to commit). + git diff --staged --quiet && exit 0 + git commit -m "docs: update Release Announcement for ${{ github.sha }}" + git push + + reset-after-release: + name: Reset announcement after full release + # Only run on tag pushes in the main jamulussoftware repo. + if: >- + github.repository_owner == 'jamulussoftware' && + github.event_name == 'push' + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - uses: actions/checkout@v6 + with: + ref: main + + - name: Check if this is a full (non-prerelease) release tag + id: check-tag + run: | + # Match only clean version tags like r3_12_0. + # Tags with any suffix (e.g. r3_12_0beta1, r3_12_0rc1) are pre-releases + # and intentionally do NOT reset the draft, so it keeps building up + # towards the final release announcement. + if [[ "${GITHUB_REF_NAME}" =~ ^r([0-9]+)_([0-9]+)_([0-9]+)$ ]]; then + major="${BASH_REMATCH[1]}" + minor="${BASH_REMATCH[2]}" + patch="${BASH_REMATCH[3]}" + echo "is_full_release=true" >> "$GITHUB_OUTPUT" + echo "version=${major}.${minor}.${patch}" >> "$GITHUB_OUTPUT" + else + echo "is_full_release=false" >> "$GITHUB_OUTPUT" + fi + + - name: Reset Release Announcement to template + if: steps.check-tag.outputs.is_full_release == 'true' + run: | + cp .github/release-announcement-template.md ReleaseAnnouncement.md + + - name: Commit and push reset announcement + if: steps.check-tag.outputs.is_full_release == 'true' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + git config --global user.email "actions@github.com" + git config --global user.name "github-actions[bot]" + git add ReleaseAnnouncement.md + git diff --staged --quiet && exit 0 + git commit -m "docs: reset Release Announcement after v${{ steps.check-tag.outputs.version }} release" + git push diff --git a/.gitignore b/.gitignore index 0b08c24ee7..09ecd7e52d 100644 --- a/.gitignore +++ b/.gitignore @@ -58,3 +58,4 @@ jamulus_plugin_import.cpp /debian/ .github/instructions +node_modules diff --git a/ReleaseAnnouncement.md b/ReleaseAnnouncement.md new file mode 100644 index 0000000000..98dbc12a7f --- /dev/null +++ b/ReleaseAnnouncement.md @@ -0,0 +1,20 @@ +```markdown +# Jamulus Next Release — Working Announcement Draft + +> **Note for maintainers:** This is a working draft, automatically updated by GitHub Copilot +> as PRs are merged to `main`. Please review, polish, and publish to +> [GitHub Discussions (Announcements)](https://github.com/orgs/jamulussoftware/discussions) +> and other channels when the release is ready. +> +> Run [`tools/get_release_contributors.py`](tools/get_release_contributors.py) to compile +> the full contributor list before publishing. +> +> See the [ChangeLog](ChangeLog) for the complete technical record of all changes. + +Here’s what’s new in the next release of Jamulus: + + + +## For everyone + +Jamulus continues to make your musical collaborations smoother and more enjoyable. This release introduces improved performance, bug fixes, and updates under the hood to ensure a more stable and \ No newline at end of file diff --git a/tools/backfill-release-announcement.sh b/tools/backfill-release-announcement.sh new file mode 100755 index 0000000000..b1f68e27fe --- /dev/null +++ b/tools/backfill-release-announcement.sh @@ -0,0 +1,551 @@ +#!/bin/bash +############################################################################## +# Copyright (c) 2024-2026 +# +# Author(s): +# The Jamulus Development Team +# +############################################################################## +# +# This program is free software; you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free Software +# Foundation; either version 2 of the License, or (at your option) any later +# version. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA +# +############################################################################## + +# Backfills ReleaseAnnouncement.md with every PR merged since a given release +# tag, processing them one by one so the announcement builds up naturally. +# +# Requirements: git, GitHub CLI (gh), jq, curl +# +# Usage: +# ./tools/backfill-release-announcement.sh [SINCE_TAG [UNTIL_TAG]] +# +# SINCE_TAG git tag of the release to start from (default: r3_11_0). +# PRs merged *after* this tag's date will be included. +# UNTIL_TAG git tag or release to stop at (optional). +# PRs merged *on or before* this tag's date will be included. +# Useful for processing in batches to avoid API rate limits. +# +# Environment variables: +# GITHUB_TOKEN GitHub token with 'models: read' scope (required for AI). +# SOURCE_REPO Repository to read PRs from (default: jamulussoftware/jamulus). +# ANNOUNCEMENT_FILE Path to the announcement file to update +# (default: ReleaseAnnouncement.md). +# DRY_RUN Set to 'true' to print what would be done without making +# any changes (useful for testing). +# UNTIL_DATE ISO 8601 date/timestamp upper bound for merged PRs. +# Alternative to UNTIL_TAG when you don't have a tag handy +# (e.g. UNTIL_DATE=2025-06-01T00:00:00Z). +# FROM_PR Only process PRs with number >= this value (inclusive). +# Useful for resuming a partial run or processing in batches +# by PR number (e.g. FROM_PR=3560). +# TO_PR Only process PRs with number <= this value (inclusive). +# Combine with FROM_PR to process a specific PR number range +# (e.g. TO_PR=3580). +# DELAY_SECS Seconds to sleep between AI API calls (default: 0). +# Set to 4 or 5 when running long backfills to stay within +# the GitHub Models API rate limit (≈15 requests/minute on +# the free tier). Example: DELAY_SECS=5 +# +# The script calls the GitHub Models API (openai/gpt-4o) to produce a +# user-friendly summary for each PR and adds it to the announcement document. +# For each PR, it fetches the full discussion thread (body, comments, reviews) +# and feeds it directly to the AI along with the current announcement. +# Changes that are not user-facing (CI, build tooling, code style, routine +# dependency bumps) are omitted by the AI automatically. +# +# PRs whose body contains a line matching "CHANGELOG: SKIP" are skipped +# unconditionally, consistent with the per-PR workflow behaviour. +# +# Rate limiting: the GitHub Models API free tier allows ~15 requests/minute +# and ~150 requests/day. When backfilling many PRs in one run the script will +# hit the per-minute limit. Remedies: +# 1. Set DELAY_SECS=5 to pace calls (processes ~12 PRs/minute). +# 2. Use UNTIL_TAG / FROM_PR + TO_PR to split the work into smaller batches +# and run each batch separately. + +set -eu -o pipefail + +SINCE_TAG="${1:-r3_11_0}" +UNTIL_TAG="${2:-}" +SOURCE_REPO="${SOURCE_REPO:-jamulussoftware/jamulus}" +ANNOUNCEMENT_FILE="${ANNOUNCEMENT_FILE:-ReleaseAnnouncement.md}" +DRY_RUN="${DRY_RUN:-false}" +MODELS_ENDPOINT="${MODELS_ENDPOINT:-https://models.github.ai/inference/chat/completions}" +MODEL="${MODEL:-openai/gpt-4o}" +UNTIL_DATE="${UNTIL_DATE:-}" +FROM_PR="${FROM_PR:-}" +TO_PR="${TO_PR:-}" +DELAY_SECS="${DELAY_SECS:-0}" +PR_LIST_LIMIT=500 +MAX_PR_NUMBER=2147483647 # upper bound when TO_PR is not specified + +# Colours for interactive output (suppressed when not on a terminal) +if [[ -t 1 ]]; then + C_BOLD='\033[1m' + C_GREEN='\033[0;32m' + C_YELLOW='\033[0;33m' + C_CYAN='\033[0;36m' + C_RESET='\033[0m' +else + C_BOLD='' C_GREEN='' C_YELLOW='' C_CYAN='' C_RESET='' +fi + +# ── helpers ──────────────────────────────────────────────────────────────── + +info() { printf "${C_CYAN}→${C_RESET} %s\n" "$*"; } +success() { printf "${C_GREEN}✓${C_RESET} %s\n" "$*"; } +warn() { printf "${C_YELLOW}⚠${C_RESET} %s\n" "$*" >&2; } +heading() { printf "\n${C_BOLD}%s${C_RESET}\n" "$*"; } + +require_cmd() { + local cmd=$1 + if ! command -v "$cmd" &> /dev/null; then + echo "ERROR: '$cmd' is required but was not found in PATH." >&2 + exit 1 + fi +} + +# ── pre-flight checks ────────────────────────────────────────────────────── + +require_cmd git +require_cmd gh +require_cmd jq +require_cmd curl + +if [[ -z "${GITHUB_TOKEN:-}" ]]; then + # Try to pick up the token from the gh CLI credential store + GITHUB_TOKEN=$(gh auth token 2>/dev/null || true) + if [[ -z "$GITHUB_TOKEN" ]]; then + echo "ERROR: GITHUB_TOKEN is not set and 'gh auth token' returned nothing." >&2 + echo " Run 'gh auth login' or export GITHUB_TOKEN before running this script." >&2 + exit 1 + fi +fi +export GITHUB_TOKEN + +if [[ ! -f "$ANNOUNCEMENT_FILE" ]]; then + echo "ERROR: Announcement file not found: $ANNOUNCEMENT_FILE" >&2 + echo " Run this script from the repository root." >&2 + exit 1 +fi + +# ── look up the tag date ─────────────────────────────────────────────────── + +heading "Resolving release tag ${SINCE_TAG} in ${SOURCE_REPO} …" + +# Try the Releases API first (gives the published date), then fall back to the +# git tag object (annotated tags), then to the tagged commit (lightweight tags). +TAG_DATE=$( + GH_REPO="$SOURCE_REPO" gh release view "$SINCE_TAG" \ + --json publishedAt --jq '.publishedAt' 2>/dev/null \ + || GH_REPO="$SOURCE_REPO" gh api \ + "/repos/${SOURCE_REPO}/git/refs/tags/${SINCE_TAG}" \ + --jq '.object.sha' 2>/dev/null \ + | xargs -I{} GH_REPO="$SOURCE_REPO" gh api \ + "/repos/${SOURCE_REPO}/git/tags/{}" \ + --jq '.tagger.date' 2>/dev/null \ + || true +) + +if [[ -z "$TAG_DATE" ]]; then + # Last resort: look up the commit that the tag points to and use its date + TAG_DATE=$( + GH_REPO="$SOURCE_REPO" gh api \ + "/repos/${SOURCE_REPO}/commits/tags/${SINCE_TAG}" \ + --jq '.commit.committer.date' 2>/dev/null || true + ) +fi + +if [[ -z "$TAG_DATE" ]]; then + echo "ERROR: Could not resolve date for tag '${SINCE_TAG}' in ${SOURCE_REPO}." >&2 + echo " Check that the tag exists and that your token has read access." >&2 + exit 1 +fi + +info "Tag ${SINCE_TAG} resolves to date: ${TAG_DATE}" + +# ── look up the until-tag date (if specified) ────────────────────────────── + +if [[ -n "$UNTIL_TAG" && -z "$UNTIL_DATE" ]]; then + heading "Resolving upper-bound tag ${UNTIL_TAG} in ${SOURCE_REPO} …" + + UNTIL_DATE=$( + GH_REPO="$SOURCE_REPO" gh release view "$UNTIL_TAG" \ + --json publishedAt --jq '.publishedAt' 2>/dev/null \ + || GH_REPO="$SOURCE_REPO" gh api \ + "/repos/${SOURCE_REPO}/git/refs/tags/${UNTIL_TAG}" \ + --jq '.object.sha' 2>/dev/null \ + | xargs -I{} GH_REPO="$SOURCE_REPO" gh api \ + "/repos/${SOURCE_REPO}/git/tags/{}" \ + --jq '.tagger.date' 2>/dev/null \ + || true + ) + + if [[ -z "$UNTIL_DATE" ]]; then + UNTIL_DATE=$( + GH_REPO="$SOURCE_REPO" gh api \ + "/repos/${SOURCE_REPO}/commits/tags/${UNTIL_TAG}" \ + --jq '.commit.committer.date' 2>/dev/null || true + ) + fi + + if [[ -z "$UNTIL_DATE" ]]; then + echo "ERROR: Could not resolve date for tag '${UNTIL_TAG}' in ${SOURCE_REPO}." >&2 + echo " Check that the tag exists and that your token has read access." >&2 + exit 1 + fi + + info "Tag ${UNTIL_TAG} resolves to date: ${UNTIL_DATE}" +fi + +# ── fetch the PR list ────────────────────────────────────────────────────── + +if [[ -n "$UNTIL_DATE" ]]; then + heading "Fetching merged PRs in ${SOURCE_REPO} after ${TAG_DATE} up to ${UNTIL_DATE} …" +else + heading "Fetching merged PRs in ${SOURCE_REPO} after ${TAG_DATE} …" +fi + +# gh pr list returns JSON; we sort by mergedAt ascending so the announcement +# builds up in the same order that changes actually landed. +PR_SEARCH="merged:>${TAG_DATE} base:main" +if [[ -n "$UNTIL_DATE" ]]; then + PR_SEARCH="${PR_SEARCH} merged:<=${UNTIL_DATE}" +fi +PR_JSON=$( + GH_REPO="$SOURCE_REPO" gh pr list \ + --state merged \ + --base main \ + --search "$PR_SEARCH" \ + --limit "$PR_LIST_LIMIT" \ + --json number,title,author,body,mergedAt,labels \ + --jq 'sort_by(.mergedAt)' +) + +# Apply optional PR-number range filter (FROM_PR / TO_PR) +if [[ -n "$FROM_PR" || -n "$TO_PR" ]]; then + _from="${FROM_PR:-0}" + _to="${TO_PR:-$MAX_PR_NUMBER}" + PR_JSON=$(jq --argjson from "$_from" --argjson to "$_to" \ + '[.[] | select(.number >= $from and .number <= $to)]' <<< "$PR_JSON") +fi + +PR_COUNT=$(jq 'length' <<< "$PR_JSON") +info "Found ${PR_COUNT} merged PRs to process." + +if [[ "$PR_COUNT" -eq 0 ]]; then + warn "No PRs found — nothing to do." + exit 0 +fi + +# ── read the AI system prompt ────────────────────────────────────────────── + +PROMPT_FILE="${PROMPT_FILE:-.github/prompts/release-announcement.prompt.yml}" + +if [[ ! -f "$PROMPT_FILE" ]]; then + echo "ERROR: Prompt file not found: $PROMPT_FILE" >&2 + exit 1 +fi + +# Extract the system message content from the YAML prompt file. +# The file uses the actions/ai-inference format; the system content is the +# first 'content' block under 'messages'. +SYSTEM_PROMPT=$( + python3 - "$PROMPT_FILE" <<'PYEOF' +import sys, yaml +with open(sys.argv[1]) as f: + data = yaml.safe_load(f) +for msg in data.get("messages", []): + if msg.get("role") == "system": + print(msg["content"], end="") + break +PYEOF +) + +if [[ -z "$SYSTEM_PROMPT" ]]; then + echo "ERROR: Could not extract system prompt from ${PROMPT_FILE}." >&2 + exit 1 +fi + +# ── configure git identity (required for commits) ───────────────────────── +# Use existing config when available; fall back to neutral defaults so the +# script works in CI environments that have no pre-configured identity. + +if [[ "$DRY_RUN" != "true" ]]; then + if ! git config user.email > /dev/null 2>&1; then + git config user.email "actions@github.com" + fi + if ! git config user.name > /dev/null 2>&1; then + git config user.name "github-actions[bot]" + fi +fi + +# ── process each PR ──────────────────────────────────────────────────────── + +# strip_code_fences TEXT +# If TEXT is wrapped in a markdown code fence (```lang...```) strip the +# opening and closing fence lines and any leading/trailing blank lines. +strip_code_fences() { + local text="$1" + # Remove the opening fence line (``` or ```markdown, etc.) + text=$(printf '%s' "$text" | sed '1s/^```[a-zA-Z]*$//') + # Remove the closing fence line (exactly ```) + text=$(printf '%s' "$text" | sed '$s/^```$//') + # Drop leading blank lines + text=$(printf '%s' "$text" | sed '/./,$!d') + # Drop trailing blank lines + text=$(printf '%s' "$text" | sed -e :a -e '/^\n*$/{$d;N;ba}') + printf '%s' "$text" +} + +# fetch_pr_thread PR_NUMBER +# Fetches the full PR discussion thread (body, comments, reviews) and +# prints a formatted text block to stdout. Returns 1 on failure. +fetch_pr_thread() { + local pr_number="$1" + + local pr_data + pr_data=$(GH_REPO="$SOURCE_REPO" gh pr view "$pr_number" \ + --json body,comments,reviews 2>/dev/null) || return 1 + + if [[ -z "$pr_data" ]]; then + return 1 + fi + + printf '%s' "$pr_data" | python3 -c ' +import json, sys + +try: + data = json.load(sys.stdin) +except (json.JSONDecodeError, ValueError): + sys.exit(1) + +body = data.get("body", "") or "" +comments = data.get("comments", []) or [] +reviews = data.get("reviews", []) or [] + +parts = [] +if body.strip(): + parts.append("## PR Description\n" + body.strip()) + +if comments: + comment_parts = [] + for c in comments: + author = c.get("author", {}).get("login", "unknown") + created = (c.get("createdAt", "") or "")[:10] + cbody = (c.get("body", "") or "").strip() + if cbody: + comment_parts.append(f"@{author} ({created}):\n{cbody}") + if comment_parts: + parts.append("## Discussion\n" + "\n\n".join(comment_parts)) + +if reviews: + review_parts = [] + for r in reviews: + author = r.get("author", {}).get("login", "unknown") + state = r.get("state", "") + created = (r.get("submittedAt", r.get("createdAt", "")) or "")[:10] + rbody = (r.get("body", "") or "").strip() + if rbody: + review_parts.append(f"@{author} ({state}, {created}):\n{rbody}") + if review_parts: + parts.append("## Reviews\n" + "\n\n".join(review_parts)) + +if not parts: + sys.exit(1) + +print("\n\n".join(parts), end="") +' +} + +call_model_api() { + # Arguments: + # $1 current announcement text + # $2 PR info text + # Prints the updated announcement text to stdout. + local current_announcement="$1" + local pr_info="$2" + + local user_content + user_content=$(printf \ + 'Current working announcement:\n====\n%s\n====\n\nNewly merged pull request:\n%s\n====\n\nUpdate the Release Announcement to include any user-relevant changes from this PR.\nReturn the complete updated Markdown document only.' \ + "$current_announcement" "$pr_info") + + # Build the request payload using jq so that strings are properly escaped. + local payload + payload=$(jq -n \ + --arg model "$MODEL" \ + --arg system "$SYSTEM_PROMPT" \ + --arg user "$user_content" \ + '{ + model: $model, + messages: [ + { role: "system", content: $system }, + { role: "user", content: $user } + ], + max_completion_tokens: 16384, + temperature: 0.1 + }') + + # Use a temp file so we can capture both the body and the HTTP status code. + # curl -s -f would hide the status; -w '%{http_code}' lets us detect 429. + local tmpfile + tmpfile=$(mktemp) + local http_code + http_code=$(curl -s \ + -o "$tmpfile" \ + -w '%{http_code}' \ + -X POST "$MODELS_ENDPOINT" \ + -H "Authorization: Bearer ${GITHUB_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "$payload") 2>/dev/null || true + local response + response=$(cat "$tmpfile" 2>/dev/null || true) + rm -f "$tmpfile" + + if [[ "$http_code" != "200" ]]; then + if [[ "$http_code" == "429" ]]; then + warn "Models API rate limit hit (HTTP 429) — keeping current announcement." + warn " Tip: set DELAY_SECS=5 to pace calls, or use UNTIL_TAG/FROM_PR/TO_PR" + warn " to split the backfill into smaller batches." + else + warn "HTTP ${http_code} from Models API — keeping current announcement." + fi + printf '%s' "$current_announcement" + return + fi + + local updated + updated=$(jq -r '.choices[0].message.content // empty' <<< "$response") + + if [[ -z "$updated" ]]; then + warn "Model returned an empty response — keeping current announcement." + printf '%s' "$current_announcement" + return + fi + + # Strip markdown code fences if the model wrapped the output in them + # (some models output ```markdown ... ``` or ``` ... ```). + if [[ "$updated" =~ ^'```' ]]; then + updated=$(strip_code_fences "$updated") + fi + + # Guard: if the response doesn't start with a Markdown heading it is not a + # valid document — keep the current announcement to avoid corrupting the file. + if [[ ! "$updated" =~ ^'#' ]]; then + warn "AI response does not look like a Markdown document — keeping current announcement." + printf '%s' "$current_announcement" + return + fi + + printf '%s' "$updated" +} + +PROCESSED=0 +SKIPPED=0 +UNCHANGED=0 + +for row in $(jq -r '.[] | @base64' <<< "$PR_JSON"); do + # Decode this PR's JSON object + pr=$(echo "$row" | base64 --decode) + pr_number=$(jq -r '.number' <<< "$pr") + pr_title=$(jq -r '.title' <<< "$pr") + pr_author=$(jq -r '.author.login' <<< "$pr") + pr_body=$(jq -r '.body // ""' <<< "$pr") + merged_at=$(jq -r '.mergedAt' <<< "$pr") + + heading "PR #${pr_number}: ${pr_title} (@${pr_author}, ${merged_at})" + + # ── skip check ──────────────────────────────────────────────────────── + if printf '%s\n' "$pr_body" | grep -qE '^CHANGELOG:[[:space:]]*SKIP[[:space:]]*$'; then + info "Skipping (CHANGELOG: SKIP)" + SKIPPED=$((SKIPPED + 1)) + continue + fi + + if [[ "$DRY_RUN" == "true" ]]; then + info "[DRY RUN] Would call AI for PR #${pr_number}" + PROCESSED=$((PROCESSED + 1)) + continue + fi + + # ── fetch the full PR thread ─────────────────────────────────────────── + info "Fetching full PR thread for #${pr_number}…" + thread_text=$(fetch_pr_thread "$pr_number") || true + if [[ -n "$thread_text" ]]; then + info "Fetched PR thread ($(printf '%s' "$thread_text" | wc -c | tr -d ' ') bytes)." + else + info "Could not fetch full PR thread — using PR body only." + thread_text="$pr_body" + fi + + # ── build the PR info block ──────────────────────────────────────────── + pr_info=$(printf 'PR #%s — %s\nby @%s\n\n%s\n' \ + "$pr_number" "$pr_title" "$pr_author" "$thread_text") + + # ── call the AI ──────────────────────────────────────────────────────── + current_announcement=$(cat "$ANNOUNCEMENT_FILE") + updated_announcement=$(call_model_api "$current_announcement" "$pr_info") + + # ── detect whether the AI made any changes ───────────────────────────── + if [[ "$updated_announcement" == "$current_announcement" ]]; then + info "No user-relevant changes — announcement unchanged." + UNCHANGED=$((UNCHANGED + 1)) + continue + fi + + # ── write the updated file ───────────────────────────────────────────── + printf '%s\n' "$updated_announcement" > "$ANNOUNCEMENT_FILE" + success "Updated announcement with changes from PR #${pr_number}." + + # ── commit this PR's change as its own commit ────────────────────────── + git add "$ANNOUNCEMENT_FILE" + # Guard: the content comparison above caught most unchanged cases, but + # whitespace normalisation (e.g. trailing newlines) can produce byte-identical + # files that still compare as different strings. Check git's view before + # committing so we don't fail under set -eu with "nothing to commit". + if git diff --staged --quiet; then + info "No git diff after write — announcement effectively unchanged." + UNCHANGED=$((UNCHANGED + 1)) + git restore --staged "$ANNOUNCEMENT_FILE" 2>/dev/null \ + || git reset HEAD "$ANNOUNCEMENT_FILE" 2>/dev/null \ + || true + continue + fi + git commit -m "docs: Release Announcement for PR #${pr_number} — ${pr_title}" + PROCESSED=$((PROCESSED + 1)) + + # ── optional delay between API calls ────────────────────────────────── + if [[ "${DELAY_SECS:-0}" -gt 0 ]]; then + info "Sleeping ${DELAY_SECS}s before next API call (DELAY_SECS)…" + sleep "$DELAY_SECS" + fi +done + +# ── summary ──────────────────────────────────────────────────────────────── + +heading "Done." +info "PRs processed (with AI): ${PROCESSED}" +info "PRs skipped (CHANGELOG: SKIP): ${SKIPPED}" +info "PRs with no user-relevant changes: ${UNCHANGED}" + +if [[ "$DRY_RUN" == "true" ]]; then + warn "DRY_RUN=true — no files were modified." +else + success "ReleaseAnnouncement.md has been updated and each change committed." + echo + echo "Push all commits with:" + echo " git push" +fi diff --git a/tools/release-announcement.py b/tools/release-announcement.py new file mode 100644 index 0000000000..35e8d527fb --- /dev/null +++ b/tools/release-announcement.py @@ -0,0 +1,225 @@ +#!/usr/bin/env python3 +############################################################################## +# Copyright (c) 2026 +# +# Author(s): +# The Jamulus Development Team +# +############################################################################## +# +# This program is free software; you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free Software +# Foundation; either version 2 of the License, or (at your option) any later +# version. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA +# +############################################################################## + +import subprocess +import json +import argparse +import sys +import os +import ollama +import re + +def get_universal_timestamp(identifier): + """ + Resolves an identifier to an ISO8601 timestamp. + Supports: pr123, Tags, SHAs, and Branches. + """ + target = identifier + + # 1. Resolve PR to its merge commit SHA + if identifier.lower().startswith("pr"): + pr_id = identifier[2:] + try: + print(f" > Resolving PR #{pr_id} to its merge commit...") + cmd = f"gh pr view {pr_id} --json mergeCommit -q .mergeCommit.oid" + target = subprocess.check_output(cmd, shell=True, text=True).strip() + + if not target or target == "null": + print(f"Error: PR #{pr_id} has no merge commit (is it merged?)") + sys.exit(1) + except subprocess.CalledProcessError: + print(f"Error: Failed to fetch PR #{pr_id} from GitHub.") + sys.exit(1) + + # 2. Get authoritative Git timestamp for the target + try: + return subprocess.check_output( + ["git", "show", "-s", "--format=%cI", target], + text=True + ).strip() + except subprocess.CalledProcessError: + print(f"Error: Could not resolve '{target}' as a Git object.") + sys.exit(1) + +def get_ordered_pr_list(start_iso, end_iso): + """Fetches PRs merged in range, ordered oldest to newest.""" + cmd = f'gh pr list --state merged --search "merged:{start_iso}..{end_iso}" --json number,title,mergedAt' + prs = json.loads(subprocess.check_output(cmd, shell=True, text=True)) + return sorted(prs, key=lambda x: x['mergedAt']) + +def sanitize_pr_data(raw_json): + """Strips metadata and GitHub noise to save tokens.""" + data = json.loads(raw_json) + # Remove "(Fixes #123)" and "(Closes #123)" + clean_body = re.sub(r'\(?(Fixes|Closes) #\d+\)?', '', data.get("body", ""), flags=re.IGNORECASE) + + return { + "number": data.get("number"), + "title": data.get("title"), + "body": clean_body, + "comments": [c.get("body") for c in data.get("comments", []) if c.get("body")], + "reviews": [r.get("body") for r in data.get("reviews", []) if r.get("body")] + } + +def run_ollama_logic(new_pr_data, old_summary, model): + # Determine if this looks like a minor fix + is_fix = any(word in new_pr_data['title'].lower() for word in ['fix', 'correct', 'typo']) + + """Enforces narrative style using Few-Shot examples.""" + style_guide = """ + STYLE GUIDELINES: + - NO BULLET POINTS. Use friendly, editorial narrative prose. + - GROUP BY AUDIENCE: ## For everyone, ## For Windows users, ## For macOS users, ## For mobile users (iOS & Android), ## For server operators, ## Translations. + - FOCUS ON BENEFITS: Describe what users can DO now, not code changes. + - DEDUPLICATE: If a feature is already mentioned, update the existing paragraph. + - CLARITY: If you are unsure of the user benefit, do not write a whole new heading. + Add a single, simple sentence to the most relevant 'For users' section. + Example: "The iOS 'About' dialog now correctly displays the operating system version." + - NEVER MAKE CHANGES TO THE TEMPLATE TEXT. Only add new sections if the PR introduces a significant new feature or improvement that is not already covered. + SPECIAL INSTRUCTION FOR THIS PR: + {'This is a minor fix. DO NOT create a new level-2 (##) heading. Only add a single sentence to an existing section.' if is_fix else 'Integrate this normally.'} + """ + high_bar_rule = """ + - THE HIGH BAR RULE: Only mention changes that significantly improve the + musician's experience. If a PR is purely "housekeeping" (like copyright + updates, minor internal refactoring, or CI fixes), return the document + UNCHANGED. + - ALWAYS MENTION: New features, new languages (specify the language), extending existing features to new platforms, and significant bug fixes that affect user experience. Highlight deprecated features and platform compatibility changes. + - CRITICAL RULES FOR TRUTH: + 1. NO GUESSING: Do not invent benefits. Only describe benefits explicitly mentioned + in the PR Body or Developer Comments. + 2. THE "ABOUT BOX" RULE: If a fix only affects internal metadata or the 'About' + dialog, describe it simply as "UI polish" or "Information accuracy." + 3. CHECK THE CODE: If the diff shows changes to `src/util.h` or version strings, + it usually means the 'About' box or internal identification is being corrected. + - AGGREGATE SMALL FIXES: Do not give a dedicated paragraph to every bug fix. + For example, small iOS fixes should be woven into one "Mobile Improvements" paragraph. + - DELETE THE FILLER: If the existing text contains fluff (e.g., "demonstrates + our ongoing commitment"), remove it. Keep the announcement punchy. + - CHANGELOG SKIP: Unless counter-indicated in the discussion, such PRs should be skipped. + """ + + prompt = f""" + You are a technical writer for Jamulus. You must follow the following rules and guidance. + {high_bar_rule} + {style_guide} + + TASK: + Integrate this Pull Request into the EXISTING Release Announcement. + Apply the "High Bar Rule" to determine if this PR should be mentioned at all. + If the PR is not significant enough to mention, return the existing announcement UNCHANGED. + If the change is significant, give it a dedicated level-2 heading before the audience sections. + RETURN THE COMPLETE UPDATED MARKDOWN DOCUMENT ONLY WITHOUT ANY PREFIX OR SUFFIX TEXT OR MARKERS. + + EXISTING ANNOUNCEMENT: + {old_summary if old_summary else "# Jamulus Release Announcement"} + + NEW PR TO INTEGRATE: + {json.dumps(new_pr_data, indent=2)} + + RETURN THE COMPLETE UPDATED MARKDOWN DOCUMENT ONLY WITHOUT ANY PREFIX OR SUFFIX TEXT OR MARKERS. Do not explain your reasoning. Do not include any notes or comments. Only return the markdown content. + """ + + response = ollama.chat(model=model, messages=[{'role': 'user', 'content': prompt}]) + return response['message']['content'].strip() + +def strip_markdown_fences(text): + """ + Removes ```markdown ... ``` or ``` ... ``` wrappers + that LLMs often add to their responses. + """ + # Remove the opening fence (with or without 'markdown' tag) + text = re.sub(r'^```(markdown)?\n', '', text, flags=re.IGNORECASE) + # Remove the closing fence + text = re.sub(r'\n```$', '', text) + return text.strip() + +def main(): + parser = argparse.ArgumentParser(description="Progressive Release Announcement Generator") + parser.add_argument("start", help="Starting boundary (e.g. pr3409 or v3.11.0)") + parser.add_argument("end", help="Ending boundary (e.g. pr3500 or HEAD)") + parser.add_argument("--file", required=True, help="Markdown file to update") + parser.add_argument("--model", default="mistral-large-3:675b-cloud") + args = parser.parse_args() + + # 1. Resolve Timeframes + print(f"--- Resolving boundaries: {args.start} -> {args.end} ---") + start_ts = get_universal_timestamp(args.start) + end_ts = get_universal_timestamp(args.end) + + # 2. Fetch and Filter + all_prs = get_ordered_pr_list(start_ts, end_ts) + start_pr_num = args.start[2:] if args.start.lower().startswith("pr") else None + todo_prs = [p for p in all_prs if str(p['number']) != start_pr_num] + + if not todo_prs: + print("No new PRs found to process.") + return + + print(f"--- Found {len(todo_prs)} PRs to merge oldest-to-newest ---") + + # 3. Progressive Merge Loop + for pr in todo_prs: + pr_num = pr['number'] + pr_title = pr['title'] + + with open(args.file, 'r') as f: + current_content = f.read() + + print(f"--- Processing PR #{pr_num}: {pr_title} ---") + + raw_details = subprocess.check_output(f'gh pr view {pr_num} --json number,title,body,comments,reviews', shell=True, text=True) + sanitized_pr = sanitize_pr_data(raw_details) + + # 4. AI processing + updated_ra = run_ollama_logic(sanitized_pr, current_content, args.model) + + # ... clean the output + updated_ra = strip_markdown_fences(updated_ra) + + with open(args.file, 'w') as f: + f.write(updated_ra) + + # ... Verification Check: Did it actually change? + # -w ignores whitespace changes. --exit-code returns 0 if NO changes. + diff_check = subprocess.run( + ["git", "diff", "-w", "--exit-code", args.file], + capture_output=True + ) + + if diff_check.returncode == 0: + print(f" > Result: No user-facing changes for #{pr_num}. Skipping commit.") + continue + + # 5. Git Commit + commit_msg = f"[bot] RA: Merge #{pr_num}: {pr_title}" + subprocess.run(["git", "add", args.file], check=True) + subprocess.run(["git", "commit", "-m", commit_msg], check=True) + print(f" Successfully committed.") + + print(f"\n--- Done! Processed {len(todo_prs)} PRs. ---") + +if __name__ == "__main__": + main() diff --git a/tools/update-release-announcement.sh b/tools/update-release-announcement.sh new file mode 100755 index 0000000000..b0feaab46d --- /dev/null +++ b/tools/update-release-announcement.sh @@ -0,0 +1,31 @@ +#!/bin/bash +############################################################################## +# Copyright (c) 2026 +# +# Author(s): +# The Jamulus Development Team +# +############################################################################## +# +# This program is free software; you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free Software +# Foundation; either version 2 of the License, or (at your option) any later +# version. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA +# +############################################################################## + +set -eu -o pipefail + +python3 -m venv /tmp/release-annoucement.venv +source /tmp/release-annoucement.venv/bin/activate +pip install ollama +python tools/release-announcement.py "$@"