From c90ca8fa248f4b34023277da674fbbe46f6d147c Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Thu, 27 Aug 2026 18:01:42 -0400 Subject: [PATCH] ci(release): adopt CHANGELOG.md pattern with generated release notes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add generate-notes.sh (conventional-commit grouping, breaking-change detection from PR labels/body) and update-changelog.sh, shared with the other repos. release.yml now generates the notes from the previous release, publishes them as the GitHub Release body, and commits the CHANGELOG.md entry to main. Backfill CHANGELOG.md for v0.2.0–v0.3.13. Co-Authored-By: Claude Fable 5 --- .github/scripts/generate-notes.sh | 175 ++++++++++++++++++++++++++++ .github/scripts/update-changelog.sh | 45 +++++++ .github/workflows/release.yml | 37 ++++-- CHANGELOG.md | 170 ++++++++++++++++++++++++++- 4 files changed, 413 insertions(+), 14 deletions(-) create mode 100755 .github/scripts/generate-notes.sh create mode 100755 .github/scripts/update-changelog.sh diff --git a/.github/scripts/generate-notes.sh b/.github/scripts/generate-notes.sh new file mode 100755 index 0000000..1d71396 --- /dev/null +++ b/.github/scripts/generate-notes.sh @@ -0,0 +1,175 @@ +#!/usr/bin/env bash +# Generate structured release notes from conventional commits between two tags. +# Usage: generate-notes.sh [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 [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 `\x1f`, 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 diff --git a/.github/scripts/update-changelog.sh b/.github/scripts/update-changelog.sh new file mode 100755 index 0000000..6fe24f8 --- /dev/null +++ b/.github/scripts/update-changelog.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Update CHANGELOG.md with release notes for a given version. +# Usage: update-changelog.sh +# Replaces [Unreleased] section if present, otherwise inserts after header. + +set -euo pipefail + +VERSION="${1:?Usage: update-changelog.sh }" +NOTES_FILE="${2:?Usage: update-changelog.sh }" + +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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 42b3052..1be8d38 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 23067d9..200e332 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,168 @@ # Changelog -All notable changes to this project are documented in this file. It will be -updated by the release workflow (not yet built) from -[Conventional Commit](https://www.conventionalcommits.org) messages, and each -version will correspond to a GitHub Release. +## [0.3.13] — 2026-08-27 -## [Unreleased] +### Features -Initial development — no releases yet. +- Surface priority-doc skips and token budget in job summary (#70) + +## [0.3.12] — 2026-08-27 + +### Features + +- Add priority doc visibility note and budget floor (#67) + +### Maintenance + +- Add CLAUDE.md as symlink to AGENTS.md (#65) +- Sync code standards from vault (#64) + +## [0.3.11] — 2026-08-20 + +### Features + +- Append model attribution to every posted finding comment (#63) + +### Maintenance + +- **deps:** Bump @openrouter/sdk in the production group (#61) +- **deps:** Bump docker/setup-buildx-action from 4.2.0 to 4.3.0 (#60) +- **deps-dev:** Bump typescript-eslint in the development group (#62) + +## [0.3.10] — 2026-08-19 + +### Features + +- Add `exclude_paths` input to prune folders from workspace scan (#59) + +## [0.3.9] — 2026-08-18 + +### Bug Fixes + +- Replace SDK AbortSignal.timeout with application-level AbortController (#58) +- Drop false maxScanBytes justification in priorityDocsInContext comment (#57) + +## [0.3.8] — 2026-08-18 + +### Features + +- Refresh review instructions from upstream skill updates (#55) + +## [0.3.7] — 2026-08-18 + +### Bug Fixes + +- Disable SDK-internal retry so request_timeout_seconds bounds the attempt (#54) +- Never render a file's full text twice in the review prompt (#53) + +## [0.3.6] — 2026-08-15 + +### Bug Fixes + +- Add per-attempt request timeout so a hung provider call fails into the retry ladder (#52) + +## [0.3.5] — 2026-08-13 + +### Bug Fixes + +- Filter prior-finding resolution confirmations from review output (#51) + +### Maintenance + +- **deps:** Bump node from `6f7b03f` to `3638d9a` (#48) +- **deps:** Bump @openrouter/sdk in the production group (#49) +- **deps-dev:** Bump the development group with 4 updates (#50) + +## [0.3.4] — 2026-08-08 + +### Maintenance + +- **deps:** Bump undici from 6.27.0 to 6.28.0 (#47) +- **deps:** Bump @openrouter/sdk in the production group (#45) +- **deps-dev:** Bump the development group with 2 updates (#46) + +## [0.3.3] — 2026-08-04 + +### Bug Fixes + +- Set maxCompletionTokens to prevent truncated structured output (#44) + +## [0.3.2] — 2026-08-03 + +### Bug Fixes + +- Log oversized files skipped during workspace scan (#43) + +## [0.3.1] — 2026-08-03 + +### Features + +- **review:** Self-describing finding headers (#42) + +## [0.3.0] — 2026-07-30 + +### Features + +- Branded check run via Checks API (#41) + +### Maintenance + +- **deps:** Bump @openrouter/sdk in the production group (#38) +- **deps-dev:** Bump the development group with 3 updates (#39) +- **deps:** Bump actions/checkout from 7.0.0 to 7.0.1 (#37) +- **deps:** Bump docker/login-action from 4.4.0 to 4.6.0 (#36) + +## [0.2.0] — 2026-07-27 + +### Features + +- Manual release workflow — one-click version bump and tag (#32) +- Expand review dimension instructions with systematic checks and reporting rules (#31) +- Feed prior bot comments to model for conceptual dedup (#27) +- Extend non-finding filter to title + suggestion fields (#24) +- Doc-staleness context — mention-based reverse-reference scan (#10) +- Quiet re-runs — cross-run dedup, one status comment, no per-run review blocks (#12) +- Request bot as PR reviewer via GraphQL botIds (#11) +- Cross-run finding dedup + updatable summary on re-run (#8) +- Filter non-findings and constrain comment verbosity (#9) +- V1 orchestration pipeline, workflows, and README (#7) +- V1 I/O clients — github, openrouter, workspace context (#3) +- Scaffold V1 — configs, action metadata, pure review core (#1) + +### Bug Fixes + +- **release:** Correct private-key secret name — RELEASE_APP_PRIVATE_KEY (#35) +- Include changed lockfiles as diff-only — lockfiles starve the context budget (#33) +- Migrate app-id to client-id + add pipeline observability logs (#26) + +### Refactoring + +- Remove requestBotReview — GitHub silently drops bot reviewer requests (#23) + +### Documentation + +- **AGENTS:** Distill cross-project code standards from vault (#25) + +### CI / Infrastructure + +- Skip self-review on dependabot PRs (#21) + +### Maintenance + +- **deps-dev:** Bump postcss from 8.5.16 to 8.5.23 (#34) +- **deps:** Bump @openrouter/sdk in the production group (#29) +- **deps:** Bump actions/checkout from 7.0.0 to 7.0.1 (#28) +- **deps-dev:** Bump the development group with 3 updates (#30) +- **deps:** Bump docker/setup-buildx-action from 3.12.0 to 4.2.0 (#17) +- **deps:** Bump docker/login-action from 3.7.0 to 4.4.0 (#16) +- **deps:** Bump docker/metadata-action from 5.10.0 to 6.2.0 (#14) +- **deps:** Bump docker/build-push-action from 6.19.2 to 7.3.0 (#13) +- **deps:** Bump actions/setup-node from 6.4.0 to 7.0.0 (#18) +- **deps:** Bump node from `cb4e8f7` to `6f7b03f` (#15) +- **deps-dev:** Bump typescript-eslint in the development group (#20) +- **deps:** Bump @openrouter/sdk in the production group (#19) +- **deps-dev:** Bump the development group with 2 updates (#6) +- **deps:** Bump @openrouter/sdk in the production group (#5) +- Expand dependabot config — npm, github-actions, docker major ignore (#4) +- Remove misleading parseJsonOrNull comment +- Initial commit — license, readme, gitignore