mirror of
https://github.com/openai/codex.git
synced 2026-09-13 11:47:17 +00:00
322 lines
10 KiB
Bash
Executable File
322 lines
10 KiB
Bash
Executable File
#!/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] <from_tag> <to_tag> [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_<ver>.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_<ver>.txt does not exist, it will be created automatically.
|
||
- Then runs codex to generate <ver>.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
|