mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
510 lines
17 KiB
Bash
Executable File
510 lines
17 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
|
|
# Generate release notes with an agent that explores the actual repo history.
|
|
#
|
|
# Engine: Codex CLI (`codex exec`, using the logged-in subscription).
|
|
# 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_REASONING_EFFORT=<level> Codex effort (default: medium)
|
|
# 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}'
|
|
}
|
|
|
|
# Run on the logged-in subscription, never on a metered API key. A stray
|
|
# environment variable can silently override subscription authentication.
|
|
scrub_env() {
|
|
env -u OPENAI_API_KEY "$@"
|
|
}
|
|
|
|
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 --ephemeral --sandbox read-only \
|
|
-c "model_reasoning_effort=\"${RELEASE_NOTES_REASONING_EFFORT:-medium}\"" \
|
|
-o "$out" "$prompt" >&2 </dev/null; then
|
|
rm -f "$out"
|
|
return 1
|
|
fi
|
|
cat "$out"
|
|
rm -f "$out"
|
|
}
|
|
|
|
generate_notes() {
|
|
local prompt=$1
|
|
generate_with_codex "$prompt" || {
|
|
echo "Codex generation failed (the Codex CLI must be installed and logged in)" >&2
|
|
return 1
|
|
}
|
|
}
|
|
|
|
generate_structured_notes() {
|
|
local prompt=$1
|
|
local schema=$2
|
|
command -v codex >/dev/null || return 1
|
|
local out schema_file
|
|
out=$(mktemp)
|
|
schema_file=$(mktemp)
|
|
printf '%s\n' "$schema" > "$schema_file"
|
|
echo "Engine: codex (structured output)" >&2
|
|
if ! scrub_env codex exec --ephemeral --sandbox read-only \
|
|
-c "model_reasoning_effort=\"${RELEASE_NOTES_REASONING_EFFORT:-medium}\"" \
|
|
--output-schema "$schema_file" -o "$out" "$prompt" >&2 </dev/null; then
|
|
rm -f "$out" "$schema_file"
|
|
echo "Codex structured generation failed" >&2
|
|
return 1
|
|
fi
|
|
cat "$out"
|
|
rm -f "$out" "$schema_file"
|
|
}
|
|
|
|
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 prior_research=${2:-}
|
|
local visual_research plan validation_error visual_schema
|
|
read -r -d '' VISUAL_RESEARCH_PROMPT <<EOF || true
|
|
Investigate the visible differences in Pulse between ${PREVIOUS_TAG} and the
|
|
current HEAD that could be communicated more clearly with screenshots than
|
|
with text alone.
|
|
|
|
Use the available read-only repository and GitHub tools as you judge useful.
|
|
Identify truthful candidate views in the previous and current source states,
|
|
including the route, visible state, and accessible UI labels needed to reach
|
|
and verify each view. You may conclude that no visual adds meaningful value.
|
|
Return a factual Markdown brief with no public length or item limit. Do not
|
|
return JSON yet.
|
|
|
|
Customer release notes:
|
|
|
|
${notes}
|
|
|
|
Prior factual release investigation, when available:
|
|
|
|
${prior_research}
|
|
EOF
|
|
|
|
echo "Researching visual release-note evidence..." >&2
|
|
visual_research=$(generate_notes "$VISUAL_RESEARCH_PROMPT") || return 1
|
|
if [ -z "$visual_research" ]; then
|
|
echo "Visual release-note research returned an empty brief" >&2
|
|
return 1
|
|
fi
|
|
save_trace visual-research.md "$visual_research"
|
|
visual_schema=$(python3 scripts/release_control/release_note_visuals.py schema)
|
|
|
|
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 factual visual investigation below. 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,
|
|
"decision": "Why the selected views improve the notes, or why no screenshot adds meaningful value",
|
|
"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. Locator names and values are literal accessible
|
|
text, not regular expressions. Use labels verified against the deterministic
|
|
generated demo data. The decision field records the model's evidence-based
|
|
reason for selecting these captures or selecting none. Use no semicolon or em
|
|
dash characters in public text.
|
|
|
|
Customer release notes:
|
|
|
|
${notes}
|
|
|
|
Factual visual investigation:
|
|
|
|
${visual_research}
|
|
EOF
|
|
|
|
echo "Selecting useful release-note visuals..." >&2
|
|
plan=$(generate_structured_notes "$VISUAL_PROMPT" "$visual_schema") || 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
|
|
Produce a fresh JSON visual plan that passes this exact validation error:
|
|
|
|
${validation_error}
|
|
|
|
Use the factual investigation below to reconstruct the decision. An invalid or
|
|
empty prior response is not evidence that screenshots add no value. Select zero
|
|
views only if that is your judgment from the evidence. Reply only with JSON in
|
|
the required schema.
|
|
|
|
Factual visual investigation:
|
|
|
|
${visual_research}
|
|
|
|
Customer release notes:
|
|
|
|
${notes}
|
|
|
|
Invalid prior response:
|
|
|
|
${plan}
|
|
EOF
|
|
plan=$(generate_structured_notes "$VISUAL_REPAIR_PROMPT" "$visual_schema") || 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"
|
|
|
|
echo "Auditing the customer story for material omissions..." >&2
|
|
read -r -d '' OMISSION_PROMPT <<EOF || true
|
|
Perform a final independent omission audit of 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 factual account.
|
|
Look for distinct user-observable changes that are absent or materially
|
|
underweighted. Decide whether any omission warrants changing the customer
|
|
story. Keep the notes concise by combining related work around customer
|
|
outcomes rather than listing implementation details.
|
|
|
|
Use this public shape:
|
|
|
|
${NOTE_FORMAT}
|
|
|
|
Reply only with the complete corrected Markdown beginning exactly
|
|
"# Pulse v${VERSION} Release Notes".
|
|
|
|
Factual account from the separate investigation:
|
|
|
|
${RESEARCH_BRIEF}
|
|
|
|
Candidate release notes:
|
|
|
|
${RELEASE_NOTES}
|
|
EOF
|
|
RELEASE_NOTES=$(generate_notes "$OMISSION_PROMPT") || exit 1
|
|
RELEASE_NOTES=$(printf '%s\n' "$RELEASE_NOTES" | clean_notes)
|
|
save_trace omission-audited-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" "$RESEARCH_BRIEF" > "$RELEASE_NOTE_VISUAL_PLAN_FILE"
|
|
echo "Visual plan saved to ${RELEASE_NOTE_VISUAL_PLAN_FILE}" >&2
|
|
fi
|