Files
pulse/scripts/generate-release-notes.sh
T
2026-08-27 13:56:35 +01:00

324 lines
10 KiB
Bash
Executable File

#!/usr/bin/env bash
# Generate release notes with an agent that explores the actual repo history.
#
# Engine: headless Claude Code (`claude -p`, uses your Claude subscription —
# no API key), with Codex CLI (`codex exec`, OpenAI subscription) as fallback.
# Independent research, drafting, and review passes use read-only repository
# tools. The models decide what matters from the evidence; the shell only owns
# the comparison range and the public release-note shape.
#
# Usage: ./scripts/generate-release-notes.sh <version> [comparison-tag]
# ./scripts/generate-release-notes.sh --resolve-base <version>
#
# Contract: the release notes markdown is written to STDOUT (trigger-release.sh
# captures it); all progress/diagnostics go to STDERR. SAVE_TO_FILE=1 also
# writes release-notes-v<version>.md.
#
# Env overrides:
# RELEASE_NOTES_ENGINE=claude|codex force an engine (default: claude, codex fallback)
# RELEASE_NOTES_MODEL=<model> model for the claude engine (default: opus)
# RELEASE_NOTES_TRACE_DIR=<directory> retain each pass for inspection
set -euo pipefail
MODE=generate
if [ "${1:-}" = "--resolve-base" ]; then
MODE=resolve-base
shift
fi
VERSION=${1:-}
REQUESTED_COMPARISON_TAG=${2:-}
if [ -z "$VERSION" ]; then
echo "Usage: $0 <version> [comparison-tag]" >&2
echo " $0 --resolve-base <version>" >&2
echo "Example: $0 6.4.0-rc.6" >&2
exit 1
fi
cd "$(git rev-parse --show-toplevel)"
VERSION=${VERSION#v}
latest_stable_before() {
local target_tag="v$1"
local candidate
while IFS= read -r candidate; do
[ "$candidate" = "$target_tag" ] && continue
if [ "$(printf '%s\n%s\n' "$candidate" "$target_tag" | sort -V | head -n 1)" = "$candidate" ]; then
printf '%s\n' "$candidate"
fi
done < <(git tag --list 'v*' | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V)
}
resolve_comparison_tag() {
local version=$1
local base rc expected
if [[ "$version" =~ ^([0-9]+\.[0-9]+\.[0-9]+)-rc\.([0-9]+)$ ]]; then
base=${BASH_REMATCH[1]}
rc=${BASH_REMATCH[2]}
if (( rc > 1 )); then
expected="v${base}-rc.$((rc - 1))"
if ! git merge-base --is-ancestor "$expected" HEAD 2>/dev/null; then
echo "Expected immediately preceding RC tag '$expected' is not an ancestor of HEAD" >&2
return 1
fi
printf '%s\n' "$expected"
return
fi
elif [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Unsupported release version '$version'; expected X.Y.Z or X.Y.Z-rc.N" >&2
return 1
fi
latest_stable_before "${base:-$version}" | tail -n 1
}
EXPECTED_COMPARISON_TAG=$(resolve_comparison_tag "$VERSION")
if [ -z "$EXPECTED_COMPARISON_TAG" ]; then
echo "No valid comparison tag found for v${VERSION}" >&2
exit 1
fi
if [ -n "$REQUESTED_COMPARISON_TAG" ] && [ "$REQUESTED_COMPARISON_TAG" != "$EXPECTED_COMPARISON_TAG" ]; then
echo "Comparison tag '$REQUESTED_COMPARISON_TAG' violates the release-note range for v${VERSION}; expected '$EXPECTED_COMPARISON_TAG'" >&2
exit 1
fi
PREVIOUS_TAG=$EXPECTED_COMPARISON_TAG
if ! git rev-parse -q --verify "${PREVIOUS_TAG}^{commit}" >/dev/null; then
echo "Comparison tag '${PREVIOUS_TAG}' does not exist" >&2
exit 1
fi
if [ "$MODE" = "resolve-base" ]; then
printf '%s\n' "$PREVIOUS_TAG"
exit 0
fi
if [[ "$VERSION" == *-rc.* ]]; then
RELEASE_RANGE_GUIDANCE="This RC covers ${PREVIOUS_TAG}..HEAD."
else
RELEASE_RANGE_GUIDANCE="This stable release covers ${PREVIOUS_TAG}..HEAD. The same-version RC packets under docs/releases/ are available evidence."
fi
echo "Generating release notes for v${VERSION} (changes since ${PREVIOUS_TAG})..." >&2
read -r -d '' RESEARCH_PROMPT <<EOF || true
Create a comprehensive factual account of the user-observable differences in
Pulse between ${PREVIOUS_TAG} and the current HEAD.
Pulse is a self-hosted monitoring dashboard for Proxmox VE, PBS, Docker, and
Kubernetes, used mostly by homelab and small-ops users.
${RELEASE_RANGE_GUIDANCE}
Use the available read-only repository and GitHub tools as you judge useful.
Take the time needed to understand the range and final source state. Return the
account with supporting evidence in Markdown. It has no public length or item
limit.
EOF
read -r -d '' NOTE_FORMAT <<EOF || true
# Pulse v${VERSION} Release Notes
[One short paragraph explaining the customer outcome of this release.]
## What's improved
- **[Short outcome]** - [Where users notice it and why it matters.]
[Use a concise set of meaningful bullets. Each full bullet, including Markdown
links, must be 260 characters or fewer. Use no semicolon or em dash characters.]
## Before you upgrade
[Only information users must act on or understand before upgrading. Omit this
section when there is none.]
EOF
# Strip accidental markdown fences and anything before the release title.
clean_notes() {
sed -e 's/^```[a-z]*$//' -e 's/^```$//' | \
awk -v title="# Pulse v${VERSION} Release Notes" '$0 == title {found=1} found{print}'
}
# Both engines must run on the logged-in subscription (Claude Max / OpenAI
# plan), never on metered API keys. Stray env vars silently override
# subscription auth — scrub them before invoking either CLI.
scrub_env() {
env -u ANTHROPIC_API_KEY -u ANTHROPIC_AUTH_TOKEN -u ANTHROPIC_BASE_URL \
-u ANTHROPIC_PROFILE -u OPENAI_API_KEY "$@"
}
generate_with_claude() {
local prompt=$1
command -v claude >/dev/null || return 1
echo "Engine: claude (model: ${RELEASE_NOTES_MODEL:-opus})" >&2
scrub_env claude -p "$prompt" \
--model "${RELEASE_NOTES_MODEL:-opus}" \
--allowedTools \
"Bash(git log:*)" "Bash(git diff:*)" "Bash(git show:*)" \
"Bash(git describe:*)" "Bash(git tag:*)" "Bash(git rev-parse:*)" \
"Bash(git rev-list:*)" "Bash(git merge-base:*)" \
"Bash(git grep:*)" "Bash(git ls-tree:*)" "Bash(git blame:*)" \
"Bash(git status:*)" \
"Bash(gh issue view:*)" "Bash(gh pr view:*)" \
"Read" "Grep" "Glob" \
</dev/null
}
generate_with_codex() {
local prompt=$1
command -v codex >/dev/null || return 1
echo "Engine: codex" >&2
local out
out=$(mktemp)
# Session log goes to stderr; only the agent's final message is kept.
if ! scrub_env codex exec --sandbox read-only -o "$out" "$prompt" >&2 </dev/null; then
rm -f "$out"
return 1
fi
cat "$out"
rm -f "$out"
}
generate_notes() {
local prompt=$1
case "${RELEASE_NOTES_ENGINE:-claude}" in
codex)
generate_with_codex "$prompt" || {
echo "Codex generation failed" >&2
return 1
}
;;
*)
if ! generate_with_claude "$prompt"; then
echo "Claude generation failed, trying Codex fallback..." >&2
generate_with_codex "$prompt" || {
echo "Both engines failed (need 'claude' or 'codex' CLI, logged in)" >&2
return 1
}
fi
;;
esac
}
save_trace() {
local name=$1
local content=$2
[ -n "${RELEASE_NOTES_TRACE_DIR:-}" ] || return 0
mkdir -p "$RELEASE_NOTES_TRACE_DIR"
printf '%s\n' "$content" > "$RELEASE_NOTES_TRACE_DIR/$name"
}
echo "Researching the complete release range..." >&2
RESEARCH_BRIEF=$(generate_notes "$RESEARCH_PROMPT") || exit 1
if [ -z "$RESEARCH_BRIEF" ]; then
echo "Error: release research returned an empty brief" >&2
exit 1
fi
save_trace research.md "$RESEARCH_BRIEF"
read -r -d '' DRAFT_PROMPT <<EOF || true
Write the most useful customer release notes for Pulse v${VERSION}.
${RELEASE_RANGE_GUIDANCE}
Use your judgment to select and explain what matters most to users. The
read-only repository and GitHub tools are available as you judge useful.
Use exactly this public shape:
${NOTE_FORMAT}
Reply only with the release-notes Markdown beginning exactly
"# Pulse v${VERSION} Release Notes".
EOF
echo "Drafting an independent customer release story..." >&2
RELEASE_NOTES=$(generate_notes "$DRAFT_PROMPT") || exit 1
RELEASE_NOTES=$(printf '%s\n' "$RELEASE_NOTES" | clean_notes)
if [ -z "$RELEASE_NOTES" ]; then
echo "Error: release notes generation returned no canonical release title" >&2
exit 1
fi
save_trace independent-draft.md "$RELEASE_NOTES"
echo "Running an independent improvement pass..." >&2
read -r -d '' REVIEW_PROMPT <<EOF || true
Independently improve the Pulse v${VERSION} customer release notes below.
The comparison range is ${PREVIOUS_TAG}..HEAD. Use the available read-only
repository and GitHub tools as you judge useful, along with the internal brief.
Decide whether the draft selected, weighted, and explained the changes in the
way most useful to users, and make any changes you judge warranted.
Use this public shape:
${NOTE_FORMAT}
Reply only with the complete corrected Markdown beginning exactly
"# Pulse v${VERSION} Release Notes".
Factual account from a separate investigation:
${RESEARCH_BRIEF}
Candidate release notes:
${RELEASE_NOTES}
EOF
RELEASE_NOTES=$(generate_notes "$REVIEW_PROMPT") || exit 1
RELEASE_NOTES=$(printf '%s\n' "$RELEASE_NOTES" | clean_notes)
save_trace reviewed-notes.md "$RELEASE_NOTES"
validate_notes() {
printf '%s\n' "$1" | python3 scripts/release_control/render_release_body.py \
--version "$VERSION" \
--validate-notes-file /dev/stdin
}
if ! VALIDATION_ERROR=$(validate_notes "$RELEASE_NOTES" 2>&1); then
echo "Generated notes failed release-body validation; requesting one constrained revision..." >&2
echo "$VALIDATION_ERROR" >&2
read -r -d '' REPAIR_PROMPT <<EOF || true
Correct the Pulse v${VERSION} release notes below so they pass this exact
validation error while preserving their meaning:
${VALIDATION_ERROR}
Requirements:
- Output only Markdown beginning with # Pulse v${VERSION} Release Notes.
- Preserve the factual meaning selected by the independent review.
- Satisfy the reported format constraint.
Release notes to repair:
${RELEASE_NOTES}
EOF
RELEASE_NOTES=$(generate_notes "$REPAIR_PROMPT") || exit 1
RELEASE_NOTES=$(printf '%s\n' "$RELEASE_NOTES" | clean_notes)
if ! validate_notes "$RELEASE_NOTES"; then
echo "Error: revised release notes still fail release-body validation" >&2
exit 1
fi
fi
# Notes to stdout only — callers capture this.
printf '%s\n' "$RELEASE_NOTES"
if [ "${SAVE_TO_FILE:-}" = "1" ]; then
OUTPUT_FILE="release-notes-v${VERSION}.md"
printf '%s\n' "$RELEASE_NOTES" > "$OUTPUT_FILE"
echo "Saved to ${OUTPUT_FILE}" >&2
fi