Files
pulse/scripts/generate-release-notes.sh
T
2026-08-28 11:29:44 +01:00

441 lines
14 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>
# ./scripts/generate-release-notes.sh --visual-plan <version> <notes-file>
#
# 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
# RELEASE_NOTE_VISUAL_PLAN_FILE=<path> write a validated optional visual plan
set -euo pipefail
MODE=generate
if [ "${1:-}" = "--resolve-base" ]; then
MODE=resolve-base
shift
elif [ "${1:-}" = "--visual-plan" ]; then
MODE=visual-plan
shift
fi
VERSION=${1:-}
VISUAL_NOTES_FILE=""
if [ "$MODE" = "visual-plan" ]; then
VISUAL_NOTES_FILE=${2:-}
REQUESTED_COMPARISON_TAG=${3:-}
else
REQUESTED_COMPARISON_TAG=${2:-}
fi
if [ -z "$VERSION" ]; then
echo "Usage: $0 <version> [comparison-tag]" >&2
echo " $0 --resolve-base <version>" >&2
echo " $0 --visual-plan <version> <notes-file> [comparison-tag]" >&2
echo "Example: $0 6.4.0-rc.6" >&2
exit 1
fi
if [ "$MODE" = "visual-plan" ] && [ ! -s "$VISUAL_NOTES_FILE" ]; then
echo "Visual planning requires a non-empty release-notes file" >&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
if [ "$MODE" = "generate" ]; then
echo "Generating release notes for v${VERSION} (changes since ${PREVIOUS_TAG})..." >&2
fi
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"
}
clean_visual_plan() {
sed -e 's/^```json$//' -e 's/^```$//'
}
generate_visual_plan() {
local notes=$1
local plan validation_error
read -r -d '' VISUAL_PROMPT <<EOF || true
Decide whether screenshots would materially improve the customer release notes
for Pulse v${VERSION}. The comparison range is ${PREVIOUS_TAG}..HEAD.
Use the available read-only repository and GitHub tools as you judge useful to
understand both source states and identify views that communicate the release
better than text alone. Select no views when screenshots would add little. The
capture system can open same-origin routes and use accessible click or wait
steps. It will render the exact comparison tag and current HEAD with identical
generated demo data.
Return only JSON in this shape, with at most three captures:
{
"schema_version": 1,
"captures": [
{
"id": "lower-case-hyphenated-id",
"title": "Short customer-facing title",
"description": "Why this visible difference matters",
"viewport": {"width": 1440, "height": 900},
"before": {
"route": "/same-origin-path",
"steps": [
{
"action": "click",
"locator": {
"kind": "role",
"role": "button",
"name": "Accessible name",
"exact": true,
"nth": 0
}
}
],
"ready": {"kind": "text", "value": "Visible text", "exact": true, "nth": 0}
},
"after": {
"route": "/same-origin-path",
"steps": [],
"ready": {"kind": "text", "value": "Visible text", "exact": true, "nth": 0}
}
}
]
}
The before state may be null when a truthful comparison is unavailable and a
current-state image is still useful. Locator kind may be role, text, label, or
testid. Actions may be click or wait. Role locators use role and name. Other
locators use value. Every state needs a ready locator for content that must be
visible in the finished image. Use no semicolon or em dash characters in public
text.
Customer release notes:
${notes}
EOF
echo "Selecting useful release-note visuals..." >&2
plan=$(generate_notes "$VISUAL_PROMPT") || return 1
plan=$(printf '%s\n' "$plan" | clean_visual_plan)
if ! validation_error=$(printf '%s\n' "$plan" | \
python3 scripts/release_control/release_note_visuals.py validate --plan - 2>&1); then
echo "Visual plan failed validation; requesting one constrained revision..." >&2
echo "$validation_error" >&2
read -r -d '' VISUAL_REPAIR_PROMPT <<EOF || true
Correct the JSON visual plan below so it passes this exact validation error:
${validation_error}
Preserve the selected views and their meaning. Reply only with corrected JSON.
${plan}
EOF
plan=$(generate_notes "$VISUAL_REPAIR_PROMPT") || return 1
plan=$(printf '%s\n' "$plan" | clean_visual_plan)
fi
printf '%s\n' "$plan" | \
python3 scripts/release_control/release_note_visuals.py validate --plan -
}
if [ "$MODE" = "visual-plan" ]; then
VISUAL_NOTES=$(cat "$VISUAL_NOTES_FILE")
generate_visual_plan "$VISUAL_NOTES"
exit 0
fi
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
if [ -n "${RELEASE_NOTE_VISUAL_PLAN_FILE:-}" ]; then
generate_visual_plan "$RELEASE_NOTES" > "$RELEASE_NOTE_VISUAL_PLAN_FILE"
echo "Visual plan saved to ${RELEASE_NOTE_VISUAL_PLAN_FILE}" >&2
fi