#!/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 [comparison-tag] # ./scripts/generate-release-notes.sh --resolve-base # ./scripts/generate-release-notes.sh --visual-plan # # 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.md. # # Env overrides: # RELEASE_NOTES_REASONING_EFFORT= Codex effort (default: medium) # RELEASE_NOTES_TRACE_DIR= retain each pass for inspection # RELEASE_NOTE_VISUAL_PLAN_FILE= 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 [comparison-tag]" >&2 echo " $0 --resolve-base " >&2 echo " $0 --visual-plan [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 </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 &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 &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 <&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 <&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 <&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 <&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 <&2 read -r -d '' OMISSION_PROMPT <&1); then echo "Generated notes failed release-body validation; requesting one constrained revision..." >&2 echo "$VALIDATION_ERROR" >&2 read -r -d '' REPAIR_PROMPT <&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