#!/usr/bin/env bash set -euo pipefail # Simple stderr logger header() { echo "==> $*" >&2; } # Generate summarized release notes using Codex CLI based on PR dump. # Can also generate just the dump via --dump-only. usage() { cat <<'USAGE' Usage: scripts/release_gen.sh [--dump-only] [-q|--quiet] [owner/repo] [version] Examples: scripts/release_gen.sh openai/codex v0.23.0 v0.24.0 scripts/release_gen.sh v0.23.0 v0.24.0 # auto-detect repo from git remote scripts/release_gen.sh v0.23.0 v0.24.0 0.24.0 # auto-detect with explicit version scripts/release_gen.sh --dump-only v0.23.0 v0.24.0 # only generate releases/release_dump_.txt scripts/release_gen.sh -q v0.23.0 v0.24.0 # quiet Codex call with progress dots Notes: - Requires: gh and jq for dump generation; codex CLI for note generation. - If release_dump_.txt does not exist, it will be created automatically. - Then runs codex to generate .txt based on the dump (unless --dump-only). - If you omit tags, the script lists the last 20 releases for the repo. USAGE } # Parse flags (currently: --dump-only, --quiet) DUMP_ONLY=0 QUIET=0 ARGS=() for arg in "$@"; do case "$arg" in --dump-only) DUMP_ONLY=1 ;; -q|--quiet) QUIET=1 ;; -h|--help) usage exit 0 ;; *) ARGS+=("$arg") ;; esac done # Reset positional args safely under set -u, even if ARGS is empty if ((${#ARGS[@]})); then set -- "${ARGS[@]}" else set -- fi if [[ ${1:-} == "-h" || ${1:-} == "--help" ]]; then usage exit 1 fi # Resolve repo: allow optional first arg; otherwise detect from git remote detect_repo() { local remote remote=$(git remote get-url origin 2>/dev/null || git remote get-url upstream 2>/dev/null || true) if [[ -z "$remote" ]]; then echo ""; return 1 fi # Normalize and extract owner/repo from SSH or HTTPS/HTTP URL local path="$remote" # Strip protocols and user@ path="${path#git@}" path="${path#ssh://}" path="${path#https://}" path="${path#http://}" path="${path#*@}" # If contains github.com:, take after ':'; else after 'github.com/' if present if [[ "$path" == *":"* ]]; then path="${path#*:}" fi if [[ "$path" == *github.com/* ]]; then path="${path#*github.com/}" fi # Trim leading slashes path="${path#/}" # Drop trailing .git path="${path%.git}" # Ensure only owner/repo echo "$path" | awk -F/ '{print $1"/"$2}' } if [[ ${1:-} == */* ]]; then REPO="$1"; shift else REPO="$(detect_repo || true)" if [[ -z "$REPO" ]]; then echo "Error: failed to auto-detect repository from git remote. Provide [owner/repo] explicitly." >&2 exit 1 fi fi # Show a recent releases list if tags are missing show_recent_releases_and_exit() { local repo="$1" echo "" >&2 echo "Please pass a source/target release." >&2 echo "" >&2 echo "e.g.: ./scripts/release_gen.sh rust-v0.23.0 rust-v0.24.0" >&2 echo "" >&2 header "Recent releases for $repo:" echo "" >&2 local list list=$(gh release list --repo "$repo" --limit 20 2>/dev/null || true) if [[ -z "$list" ]]; then echo "Error: unable to fetch releases for $repo" >&2 exit 1 fi # Print only the tag (first column) as bullets to stderr printf '%s\n' "$list" | awk '{print "- " $1}' >&2 exit 1 } if [[ $# -lt 2 ]]; then show_recent_releases_and_exit "$REPO" fi FROM_TAG="$1" TO_TAG="$2" VER="${3:-$TO_TAG}" VER="${VER#v}" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" RELEASES_DIR="$SCRIPT_DIR/releases" DUMP_FILE="$RELEASES_DIR/release_dump_$VER.txt" GEN_FILE="$RELEASES_DIR/$VER.txt" # Ensure releases directory exists (under scripts/) mkdir -p "$RELEASES_DIR" abspath() { local p="$1" if command -v realpath >/dev/null 2>&1; then realpath "$p" else python3 -c 'import os,sys;print(os.path.abspath(sys.argv[1]))' "$p" 2>/dev/null || echo "$(pwd)/$p" fi } # ========== Dump generation logic (ported from release_dump_util.sh) ========== header() { echo "==> $*" >&2; } # Get an ISO 8601 datetime for a tag. Prefer release publish date; fallback to tag/commit date. get_tag_datetime_iso() { local repo="$1" tag="$2" # Try release by tag local ts ts=$(gh release view "$tag" --repo "$repo" --json publishedAt --jq '.publishedAt' 2>/dev/null || true) if [[ -n "$ts" && "$ts" != "null" ]]; then echo "$ts"; return 0 fi # Fallback: tag ref -> (annotated tag ->) commit -> date local ref obj_type obj_url commit_sha commit ref=$(gh api "repos/$repo/git/ref/tags/$tag") obj_type=$(jq -r '.object.type' <<<"$ref") obj_url=$(jq -r '.object.url' <<<"$ref") if [[ "$obj_type" == "tag" ]]; then local tag_obj tag_obj=$(gh api "$obj_url") commit_sha=$(jq -r '.object.sha' <<<"$tag_obj") else commit_sha=$(jq -r '.object.sha' <<<"$ref") fi commit=$(gh api "repos/$repo/commits/$commit_sha") jq -r '.commit.committer.date' <<<"$commit" } collect_prs_within_range() { local repo="$1" from_iso="$2" to_iso="$3" gh pr list --repo "$repo" --state merged --limit 1000 \ --json number,title,mergedAt,author,body | \ jq -c --arg from "$from_iso" --arg to "$to_iso" \ '[ .[] | select(.mergedAt != null and .mergedAt >= $from and .mergedAt <= $to) | { number: .number, title: .title, merged_at: .mergedAt, author: (.author.login // "-"), body: (.body // "") } ] | sort_by(.merged_at) | reverse | .[]' } format_related_issues() { # shellcheck disable=SC2016 sed 's/\r//g' | \ grep -Eio '(close|closed|closes|fix|fixed|fixes|resolve|resolved|resolves)[[:space:]:]+([[:alnum:]_.-]+\/[[:alnum:]_.-]+)?#[0-9]+' || true | \ grep -Eo '#[0-9]+' | tr -d '#' | sort -n -u | sed 's/^/#/' | paste -sd ', ' - } generate_dump() { local repo="$1" from_tag="$2" to_tag="$3" out_file="$4" command -v gh >/dev/null 2>&1 || { echo "Error: gh (GitHub CLI) is required" >&2; exit 1; } command -v jq >/dev/null 2>&1 || { echo "Error: jq is required" >&2; exit 1; } header "Resolving tag dates ($from_tag -> $to_tag)" local from_iso to_iso from_iso=$(get_tag_datetime_iso "$repo" "$from_tag") to_iso=$(get_tag_datetime_iso "$repo" "$to_tag") if [[ -z "$from_iso" || -z "$to_iso" ]]; then echo "Error: failed to resolve tag dates. from=$from_tag ($from_iso) to=$to_tag ($to_iso)" >&2 exit 1 fi header "Collecting merged PRs via gh pr list" local tmpdir sorted tmpdir=$(mktemp -d) sorted="$tmpdir/prs.sorted.ndjson" collect_prs_within_range "$repo" "$from_iso" "$to_iso" > "$sorted" local count count=$(wc -l < "$sorted" | tr -d ' ') header "Writing $out_file (Total PRs: $count)" { echo "Repository: $repo" echo "Range: $from_tag ($from_iso) -> $to_tag ($to_iso)" echo "Generated: $(date -u +%Y-%m-%dT%H:%M:%SZ)" echo "Total PRs: $count" echo "" } > "$out_file" if [[ "$count" -eq 0 ]]; then return 0 fi while IFS= read -r line; do local title number merged_at author body issues title=$(jq -r '.title' <<<"$line") number=$(jq -r '.number' <<<"$line") merged_at=$(jq -r '.merged_at' <<<"$line") author=$(jq -r '.author' <<<"$line") body=$(jq -r '.body' <<<"$line") issues=$(printf '%s' "$body" | format_related_issues || true) [[ -z "$issues" ]] && issues="-" { echo "PR #$number: $title" echo "Merged: $merged_at | Author: $author" echo "Related issues: $issues" echo "" # Skip verbose descriptions for Dependabot PRs if [[ "$author" != "app/dependabot" && "$author" != "dependabot[bot]" && ! "$author" =~ [Dd]ependabot ]]; then echo "Description:" # Limit descriptions to 2000 characters; add ellipses if truncated local max=2000 if (( ${#body} > max )); then printf '%s\n' "${body:0:max}..." else printf '%s\n' "$body" fi echo "" fi echo "-----" echo "" } >> "$out_file" done < "$sorted" header "Done -> $out_file" } # ========== Orchestrate dump + optional codex generation ========== # Create dump if missing if [[ ! -f "$DUMP_FILE" ]]; then header "Dump not found: $DUMP_FILE. Generating..." generate_dump "$REPO" "$FROM_TAG" "$TO_TAG" "$DUMP_FILE" else header "Using existing dump: $DUMP_FILE" fi if (( DUMP_ONLY )); then # Dump-only mode: no stdout output exit 0 fi # Now run codex to generate notes command -v codex >/dev/null 2>&1 || { echo "Error: codex CLI is required for generation. Use --dump-only to skip." >&2; exit 1; } DUMP_PATH="$(abspath "$DUMP_FILE")" PROMPT="`cat ${DUMP_PATH}`\n\n---\n\nPlease generate a summarized release note based on the list of PRs above. Then, write a file called $GEN_FILE with your suggested release notes. It should follow this structure (+ the style/tone/brevity in this example):\n\n\"- Highlights:\n - New commands and controls: support /mcp in TUI (#2430) and a slash command /approvals to control approvals (#2474).\n - Reasoning controls: change reasoning effort and model at runtime (#2435) /model; add “minimal” effort for GPT‑5 models (#2326).\n - Auth improvements: show login options when not signed in with ChatGPT (#2440) and auto‑refresh ChatGPT auth token (#2484).\n - UI/UX polish: Ghostty Ctrl‑b/Ctrl‑f fallback (#2427), Ctrl+H as backspace (#2412), cursor position tweak after tab completion (#2442), color/accessibility updates (#2401, #2421).\n - Distribution/infra: zip archived binaries added to releases (#2438) and DotSlash entry for Windows x86_64 (#2361); upgraded to Rust 1.89 (#2465, #2467).\n- Full list of merged PRs:\n\n - #2352 tui: skip identical consecutive entries in local composer history\n - #2355 fix: introduce codex-protocol crate\n...\"\n\nMake sure you limit the highlights to, at most, 5 bullet points." header "Calling codex to generate $GEN_FILE" if (( QUIET )); then # Quiet mode: run Codex silently and show progress dots ( set +x 2>/dev/null || true codex exec --sandbox workspace-write "$PROMPT" >/dev/null 2>&1 ) & CODEX_PID=$! while :; do kill -0 "$CODEX_PID" 2>/dev/null || break printf "." >&2 sleep 1 done wait "$CODEX_PID" || true CODEX_STATUS=$? echo "" >&2 else # Normal mode: stream Codex output to stderr as before codex exec --sandbox workspace-write "$PROMPT" 1>&2 fi if [[ -f "$GEN_FILE" ]]; then # On success, output only the generated release notes to stdout cat "$GEN_FILE" else echo "Warning: $GEN_FILE not created. Check codex output." >&2 exit 1 fi