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