Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,21 @@ jobs:
path: dist/
retention-days: 1

update-release-notes:
name: Update Release Notes
runs-on: ubuntu-latest
needs: release
if: needs.release.outputs.tag != ''
steps:
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
fetch-depth: 0
- name: Compile openapi-generation release notes
run: bash scripts/compile-release-notes.sh "${{ needs.release.outputs.tag }}"
env:
GH_TOKEN: ${{ secrets.BOT_REPO_TOKEN }}

docker:
name: Build Docker Images
runs-on:
Expand Down
229 changes: 229 additions & 0 deletions scripts/compile-release-notes.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
#!/usr/bin/env bash
set -euo pipefail

# compile-release-notes.sh
#
# Compiles release notes from openapi-generation releases that occurred between
# the previous and current CLI release, then appends them to the CLI's GitHub
# release notes.
#
# Usage: compile-release-notes.sh <current-tag> [previous-tag]
#
# Requires: gh (GitHub CLI), jq, git

SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)
REPO_DIR="${SCRIPT_DIR}/.."
OG_REPO="speakeasy-api/openapi-generation"
CLI_REPO="speakeasy-api/speakeasy"

CURRENT_TAG="${1:?Usage: compile-release-notes.sh <current-tag> [previous-tag]}"
PREV_TAG="${2:-}"

# Find the previous non-RC release tag if not provided
if [[ -z "$PREV_TAG" ]]; then
PREV_TAG=$(gh release list --repo "$CLI_REPO" --limit 50 --json tagName \
| jq -r '[.[] | select(.tagName | test("-rc") | not)] | .[1].tagName')
fi

if [[ -z "$PREV_TAG" ]]; then
echo "Could not determine previous release tag"
exit 0
fi

echo "CLI release range: $PREV_TAG -> $CURRENT_TAG"

# Extract the openapi-generation version from go.mod at a given git ref
get_og_version() {
local ref="$1"
git -C "$REPO_DIR" show "${ref}:go.mod" \
| grep 'github.com/speakeasy-api/openapi-generation/v2 ' \
| awk '{print $2}'
}

CURRENT_OG_VERSION=$(get_og_version "$CURRENT_TAG")
PREV_OG_VERSION=$(get_og_version "$PREV_TAG")

if [[ -z "$CURRENT_OG_VERSION" || -z "$PREV_OG_VERSION" ]]; then
echo "Could not extract openapi-generation versions from go.mod"
exit 0
fi

echo "openapi-generation range: $PREV_OG_VERSION -> $CURRENT_OG_VERSION"

if [[ "$CURRENT_OG_VERSION" == "$PREV_OG_VERSION" ]]; then
echo "No openapi-generation version change between releases"
exit 0
fi

# Check if the release has already been updated (idempotency)
# If the body already contains target/language headers, it's been compiled
CURRENT_BODY=$(gh release view "$CURRENT_TAG" --repo "$CLI_REPO" --json body -q '.body')
if echo "$CURRENT_BODY" | grep -q "^### All Targets\|^### TypeScript\|^### Python\|^### Go\|^### Terraform"; then
echo "Release notes already compiled, skipping"
exit 0
fi

# Step 1: Get recent release tags from openapi-generation (fast, no body)
# 200 is more than enough to cover the gap between two CLI releases
TAGS_IN_RANGE=$(gh release list -R "$OG_REPO" --limit 200 --json tagName \
| jq -r --arg prev "$PREV_OG_VERSION" --arg curr "$CURRENT_OG_VERSION" '
def parse_ver: ltrimstr("v") | split(".") | map(tonumber);
def ver_cmp(a; b):
if (a | length) == 0 and (b | length) == 0 then 0
elif (a | length) == 0 then -1
elif (b | length) == 0 then 1
elif a[0] < b[0] then -1
elif a[0] > b[0] then 1
else ver_cmp(a[1:]; b[1:])
end;
($prev | parse_ver) as $pv |
($curr | parse_ver) as $cv |
[ .[]
| select(.tagName | test("^v[0-9]+\\.[0-9]+\\.[0-9]+$"))
| (.tagName | parse_ver) as $tv
| select(ver_cmp($tv; $pv) > 0 and ver_cmp($tv; $cv) <= 0)
]
| sort_by(.tagName | parse_ver)
| reverse
| .[].tagName
')

if [[ -z "$TAGS_IN_RANGE" ]]; then
echo "No openapi-generation releases found in range ($PREV_OG_VERSION, $CURRENT_OG_VERSION]"
exit 0
fi

echo "Found releases in range:"
echo "$TAGS_IN_RANGE"

# Step 2: Fetch all release bodies and concatenate
ALL_BODIES=""
while IFS= read -r tag; do
echo " Fetching notes for $tag..."
BODY=$(gh release view "$tag" -R "$OG_REPO" --json body -q '.body')
if [[ -n "$BODY" ]]; then
ALL_BODIES+="${BODY}"$'\n'
fi
done <<< "$TAGS_IN_RANGE"

if [[ -z "$ALL_BODIES" ]]; then
echo "All releases in range had empty bodies"
exit 0
fi

# Step 3: Group items by target/language, then by change type within each
COMPILED_NOTES=$(echo "$ALL_BODIES" | awk '
BEGIN {
# Display names for targets
dn["all"] = "All Targets"
dn["csharp"] = "C#"
dn["go"] = "Go"
dn["java"] = "Java"
dn["php"] = "PHP"
dn["python"] = "Python"
dn["ruby"] = "Ruby"
dn["swift"] = "Swift"
dn["terraform"] = "Terraform"
dn["typescript"] = "TypeScript"
dn["unity"] = "Unity"

cats[1] = "New Features"
cats[2] = "Bug Fixes"
cats[3] = "Chores"
nCats = 3
}

/^### :/ {
c = $0
if (c ~ /New Features/) cat = "New Features"
else if (c ~ /Bug Fixes/) cat = "Bug Fixes"
else if (c ~ /Chores/) cat = "Chores"
else { sub(/^### :[^:]+: /, "", c); cat = c }
next
}

/^- / {
if (cat == "") next
line = $0
scope = ""

# Extract **scope**: if present
if (match(line, /\*\*[^*]+\*\*/)) {
scope = substr(line, RSTART + 2, RLENGTH - 4)
# Take first part of comma-separated scopes
if (index(scope, ",") > 0) {
scope = substr(scope, 1, index(scope, ",") - 1)
}
# Remove **scope**: from the line (redundant once grouped)
sub(/\*\*[^*]+\*\*: /, "", line)
}

if (scope == "") {
unscoped = unscoped line "\n"
Comment on lines +161 to +162

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

馃煛 Unscoped items are always labeled as "Chores" regardless of actual category

Items without a **scope**: prefix (e.g., - [\abc123`](url) - some new feature) are all grouped under ### Chores`, even if they were parsed under a "New Features" or "Bug Fixes" heading.

Root Cause

At scripts/compile-release-notes.sh:160-162, when scope is empty, the item is appended to the unscoped variable without preserving its cat (category). Then at lines 218-219, all unscoped items are unconditionally printed under ### Chores:

if (unscoped != "") {
    print "### Chores"
    printf "%s", unscoped
    print ""
}

This means a new feature or bug fix without a language/target scope will be mislabeled as a chore in the compiled release notes.

Impact: Release notes may misclassify unscoped new features and bug fixes as chores, giving users an inaccurate picture of what changed.

Prompt for agents
In scripts/compile-release-notes.sh, the awk script at lines 160-162 puts all unscoped items into a single 'unscoped' variable, then at lines 218-221 prints them all under '### Chores'. Instead, unscoped items should be tracked per category. Replace the single 'unscoped' variable with a per-category approach, e.g. 'unscoped_items[cat] = unscoped_items[cat] line "\n"' at line 162, and then in the END block (lines 218-222) iterate over the categories and print each non-empty unscoped category with its proper heading (New Features, Bug Fixes, Chores) under a general '### Other' or similar section header.
Open in Devin Review

Was this helpful? React with 馃憤 or 馃憥 to provide feedback.

} else {
key = scope "|" cat
items[key] = items[key] line "\n"
if (!(scope in scopeSeen)) {
scopeSeen[scope] = 1
scopeList[++nScopes] = scope
}
}
next
}

END {
# Fixed target order
nOrder = split("all,typescript,python,go,terraform,java,ruby,csharp,php", order, ",")

# Print targets in the defined order
for (o = 1; o <= nOrder; o++) {
sc = order[o]
name = (sc in dn) ? dn[sc] : sc
header_printed = 0
for (c = 1; c <= nCats; c++) {
key = sc "|" cats[c]
if (key in items) {
if (!header_printed) {
print "### " name
header_printed = 1
}
print "**" cats[c] "**"
printf "%s", items[key]
}
}
if (header_printed) print ""
delete scopeSeen[sc]
}

# Print any remaining targets not in the predefined order
for (s = 1; s <= nScopes; s++) {
sc = scopeList[s]
if (!(sc in scopeSeen)) continue
name = (sc in dn) ? dn[sc] : sc
header_printed = 0
for (c = 1; c <= nCats; c++) {
key = sc "|" cats[c]
if (key in items) {
if (!header_printed) {
print "### " name
header_printed = 1
}
print "**" cats[c] "**"
printf "%s", items[key]
}
}
if (header_printed) print ""
}

if (unscoped != "") {
print "### Chores"
printf "%s", unscoped
print ""
}
}
')

# Update the GitHub release, replacing the body entirely
echo "$COMPILED_NOTES" | gh release edit "$CURRENT_TAG" --repo "$CLI_REPO" --notes-file -

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

馃敶 Release body is replaced instead of appended, discarding original CLI changelog

The script overwrites the entire GitHub release body with only the compiled openapi-generation notes, discarding the original CLI changelog generated by goreleaser/conventional-changelog.

Root Cause

At scripts/compile-release-notes.sh:60, the original release body is fetched into CURRENT_BODY, but it is only used for the idempotency check on line 61. On line 227, the release is updated with only COMPILED_NOTES:

echo "$COMPILED_NOTES" | gh release edit "$CURRENT_TAG" --repo "$CLI_REPO" --notes-file -

This replaces the entire release body. The original CLI release notes (commit changelog from goreleaser) are permanently lost. The PR description says the script "appends them to the CLI's GitHub release body", but the actual behavior is a full replacement.

Impact: Every CLI release will lose its original changelog (listing CLI-specific commits, breaking changes, etc.) and only show the openapi-generation dependency changes.

Suggested change
echo "$COMPILED_NOTES" | gh release edit "$CURRENT_TAG" --repo "$CLI_REPO" --notes-file -
UPDATED_BODY="${CURRENT_BODY}
---
${COMPILED_NOTES}"
echo "$UPDATED_BODY" | gh release edit "$CURRENT_TAG" --repo "$CLI_REPO" --notes-file -
Open in Devin Review

Was this helpful? React with 馃憤 or 馃憥 to provide feedback.


echo "Successfully updated release notes for $CURRENT_TAG with openapi-generation changes"
Loading