fix(release): keep changelog synthesis model-led

This commit is contained in:
rcourtman 2026-08-27 13:37:45 +01:00
parent 9622f89fe6
commit 9e17122713
6 changed files with 212 additions and 83 deletions

View file

@ -4,8 +4,9 @@
#
# Engine: headless Claude Code (`claude -p`, uses your Claude subscription —
# no API key), with Codex CLI (`codex exec`, OpenAI subscription) as fallback.
# The agent runs read-only git/gh commands itself instead of being fed
# pre-chewed diff fragments, so nothing user-visible is missed by grep luck.
# 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>
@ -16,7 +17,8 @@
#
# Env overrides:
# RELEASE_NOTES_ENGINE=claude|codex force an engine (default: claude, codex fallback)
# RELEASE_NOTES_MODEL=<model> model for the claude engine (default: sonnet)
# RELEASE_NOTES_MODEL=<model> model for the claude engine (default: opus)
# RELEASE_NOTES_TRACE_DIR=<directory> retain each pass for inspection
set -euo pipefail
@ -100,77 +102,44 @@ if [ "$MODE" = "resolve-base" ]; then
fi
if [[ "$VERSION" == *-rc.* ]]; then
RELEASE_RANGE_GUIDANCE="This is an RC release. Cover only the user-visible changes since the immediately preceding RC, ${PREVIOUS_TAG}. Do not repeat changes already announced in an earlier RC."
RELEASE_RANGE_GUIDANCE="This RC covers ${PREVIOUS_TAG}..HEAD."
else
RELEASE_RANGE_GUIDANCE="This is a stable GA release. Cover the complete release train since the previous stable release, ${PREVIOUS_TAG}. Synthesize that potentially large commit range into a few user-relevant themes; do not concatenate RC notes or enumerate every commit."
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 '' PROMPT <<EOF || true
You are generating the release notes for Pulse v${VERSION}.
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.
The repo is checked out at the commit that will become v${VERSION}. The
previous release tag is ${PREVIOUS_TAG}. Investigate the changes yourself —
start with \`git log ${PREVIOUS_TAG}..HEAD --oneline\` and
\`git diff ${PREVIOUS_TAG}..HEAD --stat\`, then use targeted \`git diff\` /
\`git show\` and read source files where the diff alone is unclear. For
commits that reference GitHub issues (#1234), you may use \`gh issue view\` to
understand the user-facing symptom. Only describe changes that exist in the
final code state; verify anything you are unsure about before writing it.
${RELEASE_RANGE_GUIDANCE}
Focus on USER-VISIBLE changes only: features, fixes, and behavior users will
notice. Ignore internal refactors, test changes, CI/tooling, and docs. Group
related commits into the outcome a user would recognize, such as a rewritten
alert system, rather than listing the implementation steps separately.
Write the release notes in exactly this format:
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. Lead
with what feels better or works now, not how it was implemented.]
[One short paragraph explaining the customer outcome of this release.]
## What's improved
- **[Short outcome]** — [Where users notice it and why it matters.]
[Use 4-6 meaningful bullets for a normal RC or minor release. A narrow patch
may use fewer. Keep every bullet concrete and independently useful.]
[Use a concise set of meaningful bullets. Each full bullet, including Markdown
links, must be 260 characters or fewer.]
## Before you upgrade
[Only user-relevant compatibility, migration, signing, companion-app, or known
risk information. Omit this section when there is nothing users need to do or
understand before upgrading.]
Guidelines:
- Plain, factual, understated. No marketing language, no emojis.
- Omit any section that has no items.
- Every bullet must stand on its own. A reader should understand where they
would notice the change and what is different without knowing Pulse's
implementation.
- Put features and fixes together under What's improved. Describe each
user-visible outcome exactly once; do not repeat it under another heading or
split one outcome into separate feature and fix bullets.
- Avoid internal release and architecture vocabulary such as canonical,
governed, schema, provider transport, preflight, convergence, or runtime
boundary unless that exact term is visible to the user in the product.
- Do not use vague entries such as "improved agent handling" or "various UI
fixes". Split unrelated changes and name the behavior that changed.
- Do NOT write Install, Roll back, Promotion Metadata, Release Qualification,
validation, gate, workflow, or governance sections. The release pipeline
appends concise install and rollback instructions, while machine promotion
records stay outside the customer changelog.
Your reply must be ONLY the release-notes markdown, starting with
"# Pulse v${VERSION} Release Notes" — no preamble, no code fences, no
commentary.
[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.
@ -188,25 +157,30 @@ scrub_env() {
}
generate_with_claude() {
local prompt=$1
command -v claude >/dev/null || return 1
echo "Engine: claude (model: ${RELEASE_NOTES_MODEL:-sonnet})" >&2
scrub_env claude -p "$PROMPT" \
--model "${RELEASE_NOTES_MODEL:-sonnet}" \
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
if ! scrub_env codex exec --sandbox read-only -o "$out" "$prompt" >&2 </dev/null; then
rm -f "$out"
return 1
fi
@ -214,24 +188,62 @@ generate_with_codex() {
rm -f "$out"
}
RELEASE_NOTES=""
case "${RELEASE_NOTES_ENGINE:-claude}" in
codex)
RELEASE_NOTES=$(generate_with_codex) || {
echo "Codex generation failed" >&2
exit 1
}
;;
*)
if ! RELEASE_NOTES=$(generate_with_claude); then
echo "Claude generation failed, trying Codex fallback..." >&2
RELEASE_NOTES=$(generate_with_codex) || {
echo "Both engines failed (need 'claude' or 'codex' CLI, logged in)" >&2
exit 1
generate_notes() {
local prompt=$1
case "${RELEASE_NOTES_ENGINE:-claude}" in
codex)
generate_with_codex "$prompt" || {
echo "Codex generation failed" >&2
return 1
}
fi
;;
esac
;;
*)
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)
@ -239,6 +251,67 @@ 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"