Skip to content
Merged
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
175 changes: 175 additions & 0 deletions .github/scripts/generate-notes.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
#!/usr/bin/env bash
# Generate structured release notes from conventional commits between two tags.
# Usage: generate-notes.sh <new-tag> [previous-tag]
# If previous-tag is omitted, uses the tag before new-tag.
#
# Release tags here point at a digest-pin commit that lives off main, so one
# release tag is never an ancestor of the next and `git describe` cannot find
# the previous one — release.yml always passes previous-tag explicitly. The
# two-dot range still works: it walks from the previous pin commit's merge base
# with the new tag, so exactly the commits merged between the two releases are
# listed. Pin and version-bump commits are filtered as release noise.
#
# Breaking changes are detected from the *merged PR* — a `breaking-change` label
# or a `BREAKING CHANGE:` footer in the PR body — as well as from the commit
# itself (a `!` type marker or a `BREAKING CHANGE:` footer). The PR is the
# reliable source: a squash commit's body is often dropped at merge time
# (e.g. the GitHub mobile app), but PR labels/body always survive. PR lookups
# need both the `gh` binary and GH_TOKEN; when either is missing (local runs
# without auth) the commit-only signals are used. A lookup that fails despite
# being enabled warns on stderr rather than silently dropping the signal.

set -euo pipefail

NEW_TAG="${1:?Usage: generate-notes.sh <new-tag> [previous-tag]}"
# --match restricts to semver tags — without it, git describe would pick up
# the floating major tag (v0) and truncate the range.
PREV_TAG="${2:-$(git describe --tags --abbrev=0 --match "v[0-9]*.[0-9]*.[0-9]*" "$NEW_TAG"^ 2>/dev/null || echo "")}"

if [ -n "$PREV_TAG" ]; then
RANGE="${PREV_TAG}..${NEW_TAG}"
else
RANGE="$NEW_TAG"
fi

# Write one record per non-merge commit as `<subject>\x1f<body>`, records
# NUL-separated, to a temp file. A file (not a pipe) is used because the Python
# program is fed to `python3 -` on stdin via the heredoc. Release commits are
# filtered out in Python.
NOTES_INPUT="$(mktemp)"
trap 'rm -f "$NOTES_INPUT"' EXIT
git log --no-merges --format='%s%x1f%b%x00' "$RANGE" > "$NOTES_INPUT"

python3 - "$NOTES_INPUT" << 'PYTHON'
import sys, re, os, json, shutil, subprocess

with open(sys.argv[1], "rb") as fh:
raw = fh.read().decode("utf-8", "replace")
records = [r for r in raw.split("\x00") if r.strip()]

# Category config: (prefix, heading)
categories = [
("feat", "Features"),
("fix", "Bug Fixes"),
("refactor", "Refactoring"),
("docs", "Documentation"),
("ci", "CI / Infrastructure"),
("chore", "Maintenance"),
]
cat_map = {c[0]: c[1] for c in categories}
cat_order = [c[0] for c in categories]

buckets = {c[0]: [] for c in categories}
other = []
breaking = []

# Pattern: type(scope)!: description — scope and the breaking `!` are optional.
pat = re.compile(r'^([a-z]+)(?:\(([^)]+)\))?(!)?:\s+(.+)$')
# Release noise: the version-bump commit ("release: vX.Y.Z") and the
# digest-pin commit ("chore(release): pin image to sha256:…").
release_pat = re.compile(r'^(release|chore\(release\)):')
# A BREAKING CHANGE footer — conventional commits allows a space or hyphen.
breaking_pat = re.compile(r'^BREAKING[ -]CHANGE:\s*(.+)$', re.MULTILINE)
# Squash subjects end with the PR number, e.g. "… (#142)".
pr_pat = re.compile(r'\(#(\d+)\)\s*$')

# PR lookups need both the `gh` binary and a token. CI passes GH_TOKEN; a local
# run without it skips lookups and falls back to commit-only signals (the
# documented offline behavior) instead of firing unauthenticated calls.
GH = shutil.which("gh") if os.environ.get("GH_TOKEN") else None
REPO = os.environ.get("GITHUB_REPOSITORY", "")
BREAKING_LABEL = "breaking-change"
_pr_cache = {}

def warn(message):
print(f"generate-notes: {message}", file=sys.stderr)

def pr_info(pr_number):
"""Return {labels, body} for a merged PR via `gh`, or None when lookups are
disabled (no `gh`/token) or the lookup fails. A failed lookup warns on
stderr — a silent None could drop a breaking change that lives only in the
PR body/label from the release notes."""
if not GH:
return None
if pr_number in _pr_cache:
return _pr_cache[pr_number]
info = None
try:
repo_args = ["-R", REPO] if REPO else []
result = subprocess.run(
[GH, "pr", "view", pr_number, *repo_args, "--json", "labels,body"],
capture_output=True, text=True, timeout=20,
)
if result.returncode == 0:
data = json.loads(result.stdout)
info = {
"labels": {label["name"] for label in data.get("labels", [])},
"body": data.get("body") or "",
}
else:
warn(f"gh pr view #{pr_number} failed (exit {result.returncode}): "
f"{result.stderr.strip()} — falling back to commit-only signals")
except Exception as error:
warn(f"gh pr view #{pr_number} errored: {error} — "
f"falling back to commit-only signals")
_pr_cache[pr_number] = info
return info

for rec in records:
subject, _, body = rec.partition("\x1f")
subject = subject.strip()
if not subject or release_pat.match(subject):
continue

match = pat.match(subject)
pr_num = pr_pat.search(subject)
pr = pr_info(pr_num.group(1)) if pr_num else None

# Categorize from the commit subject.
if match:
typ, scope, _bang, desc = match.group(1, 2, 3, 4)
desc = desc[0].upper() + desc[1:] # capitalize
entry = f"- **{scope}:** {desc}" if scope else f"- {desc}"
(buckets[typ] if typ in buckets else other).append(entry)
else:
desc = subject[0].upper() + subject[1:] if subject else subject
other.append(f"- {desc}")

# Breaking signals, in preference order for the descriptive line:
# PR body footer > commit body footer > subject (when only `!`/label flags).
commit_footer = breaking_pat.search(body or "")
pr_footer = breaking_pat.search(pr["body"]) if pr else None
has_label = bool(pr and BREAKING_LABEL in pr["labels"])
if (match and match.group(3)) or commit_footer or pr_footer or has_label:
if pr_footer:
note = pr_footer.group(1).strip()
elif commit_footer:
note = commit_footer.group(1).strip()
else:
note = desc
breaking.append(f"- {note}")

# Collapse identical breaking lines. A follow-up PR (a docs PR documenting the
# change, or a revert) can carry the same BREAKING CHANGE footer that another PR
# in the range already contributed; without this, the same line is listed twice.
# dict.fromkeys preserves first-seen order.
breaking = list(dict.fromkeys(breaking))

# Output — BREAKING CHANGES lead so they're impossible to miss.
out = []
if breaking:
out += ["### ⚠ BREAKING CHANGES", ""] + breaking

for typ in cat_order:
if buckets[typ]:
if out:
out.append("")
out += [f"### {cat_map[typ]}", ""] + buckets[typ]

if other:
if out:
out.append("")
out += ["### Other Changes", ""] + other

print("\n".join(out) if out else "No notable changes.")
PYTHON
45 changes: 45 additions & 0 deletions .github/scripts/update-changelog.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
# Update CHANGELOG.md with release notes for a given version.
# Usage: update-changelog.sh <version> <notes-file>
# Replaces [Unreleased] section if present, otherwise inserts after header.

set -euo pipefail

VERSION="${1:?Usage: update-changelog.sh <version> <notes-file>}"
NOTES_FILE="${2:?Usage: update-changelog.sh <version> <notes-file>}"

NOTES=$(cat "$NOTES_FILE")
DATE=$(date +%Y-%m-%d)

python3 - "$VERSION" "$DATE" "$NOTES" << 'PYTHON'
import sys, re, os

version, date, notes = sys.argv[1], sys.argv[2], sys.argv[3]
entry = f"## [{version}] — {date}\n\n{notes}"

changelog_path = "CHANGELOG.md"
if os.path.exists(changelog_path):
with open(changelog_path) as f:
text = f.read()
# Replace [Unreleased] section if present
if re.search(r'^## \[Unreleased\]', text, re.MULTILINE):
text = re.sub(
r'## \[Unreleased\].*?(?=\n## \[|$)',
entry,
text, count=1, flags=re.DOTALL
)
else:
# Insert before the first version entry (## [x.y.z])
lines = text.split('\n')
insert_at = next(
(i for i in range(len(lines)) if lines[i].startswith('## [')),
len(lines)
)
lines.insert(insert_at, f"\n{entry}\n")
text = '\n'.join(lines)
else:
text = f"# Changelog\n\n{entry}\n"

with open(changelog_path, 'w') as f:
f.write(text)
PYTHON
37 changes: 29 additions & 8 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,11 @@ jobs:
release:
needs: build-and-push
runs-on: ubuntu-latest
# generate-notes.sh reads merged PRs (labels/body) for breaking-change
# detection with GITHUB_TOKEN, so the job needs pull-requests: read.
permissions:
contents: write
pull-requests: read
steps:
- uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
id: app-token
Expand Down Expand Up @@ -124,16 +127,34 @@ jobs:
git tag -f "$MAJOR"
git push origin "$MAJOR" --force

# The pin commit lives off main, so the previous release tag is never an
# ancestor of this one and git cannot discover it — resolve it from the
# release list instead. A first-ever release yields an empty list (exit 0)
# and the script notes the whole history; an API failure exits non-zero
# and fails the step rather than silently producing wrong notes.
- name: Generate release notes
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ github.ref_name }}
run: |
PREVIOUS_TAG="$(gh release list --exclude-drafts --limit 1 --json tagName --jq '.[0].tagName // empty')"
bash .github/scripts/generate-notes.sh "$TAG" ${PREVIOUS_TAG:+"$PREVIOUS_TAG"} > "$RUNNER_TEMP/release-notes.md"
cat "$RUNNER_TEMP/release-notes.md"

- name: Create GitHub Release
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
TAG: ${{ github.ref_name }}
# The pin commit lives off main, so the previous release tag is never an
# ancestor of this one and GitHub's auto-detected start tag falls back to
# the repo's first commit. Name the previous release explicitly. A
# first-ever release yields an empty list (exit 0) and skips the flag;
# an API failure exits non-zero and fails the step rather than
# silently regenerating full-history notes.
run: gh release create "$TAG" --title "$TAG" --notes-file "$RUNNER_TEMP/release-notes.md"

# The checkout is detached at the pin commit; CHANGELOG.md lives on main.
- name: Update CHANGELOG.md on main
env:
TAG: ${{ github.ref_name }}
run: |
PREVIOUS_TAG="$(gh release list --exclude-drafts --limit 1 --json tagName --jq '.[0].tagName // empty')"
gh release create "$TAG" --generate-notes ${PREVIOUS_TAG:+--notes-start-tag "$PREVIOUS_TAG"}
git fetch origin main
git checkout main
bash .github/scripts/update-changelog.sh "${TAG#v}" "$RUNNER_TEMP/release-notes.md"
git add CHANGELOG.md
git commit -m "docs: update CHANGELOG.md for $TAG"
git push origin main
Loading
Loading