From c7549f74b73effa2030db7b96c234fb360cb48ce Mon Sep 17 00:00:00 2001 From: David Budnick Date: Sun, 9 Aug 2026 13:57:27 -0500 Subject: [PATCH 1/3] ci(release): pin tags and fill changelog --- .github/workflows/release-plz.yml | 106 ++++++++- release-plz.toml | 25 +- scripts/release/.gitignore | 2 + scripts/release/augment_changelog.py | 271 ++++++++++++++++++++++ scripts/release/test_augment_changelog.py | 91 ++++++++ 5 files changed, 482 insertions(+), 13 deletions(-) create mode 100644 scripts/release/.gitignore create mode 100755 scripts/release/augment_changelog.py create mode 100755 scripts/release/test_augment_changelog.py diff --git a/.github/workflows/release-plz.yml b/.github/workflows/release-plz.yml index cd12b909..5aca77d5 100644 --- a/.github/workflows/release-plz.yml +++ b/.github/workflows/release-plz.yml @@ -58,6 +58,46 @@ jobs: env: GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} CARGO_REGISTRY_TOKEN: ${{ steps.app-token.outputs.cargo-registry-token }} + # release-plz only lists commits for packages it processes. App crates with + # release=false (gui/agent/agent-core) never contribute via changelog_include, + # so fold every release-worthy subject since the last v* tag into CHANGELOG.md. + - name: Augment release changelog with app-crate commits + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + set -euo pipefail + branch="$( + gh pr list \ + --repo "${{ github.repository }}" \ + --state open \ + --json headRefName \ + --jq '.[] | select(.headRefName | startswith("release-plz/")) | .headRefName' \ + | head -n1 + )" + if [[ -z "${branch}" ]]; then + echo "No open release-plz PR — nothing to augment." + exit 0 + fi + git config user.name "aprilnea[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git" + # The release-plz branch does not yet contain this script on first + # land; copy it from the master checkout before switching. + cp scripts/release/augment_changelog.py "${RUNNER_TEMP}/augment_changelog.py" + git fetch origin "${branch}" + git checkout --force "origin/${branch}" + # release-plz wrote CHANGELOG from package-scoped commits; re-scan the + # full history since the previous tag so gui/agent-only fixes appear. + python3 "${RUNNER_TEMP}/augment_changelog.py" \ + --changelog CHANGELOG.md \ + --repo-url "https://github.com/${{ github.repository }}" + if git diff --quiet -- CHANGELOG.md; then + echo "CHANGELOG already complete." + exit 0 + fi + git add CHANGELOG.md + git commit -m "chore(release): include all post-tag commits in changelog" + git push origin "HEAD:refs/heads/${branch}" # `release-plz/action` swallows a release-pr HTTP 422 as a warning and # reports no PR, which silently stalls releases (it looks identical to a # quiet week of commits). Fail loudly when release-plz opened/updated no @@ -131,11 +171,16 @@ jobs: } core.info(`No release PR and no release-worthy commits since ${lastTag || "repo start"}; nothing to release.`); - # On every push to master, publishes any crate whose manifest version is not yet - # on crates.io — i.e. a no-op until the release PR is merged, at which point it - # publishes the whole workspace and cuts one `v{version}` tag + GitHub Release. + # Publishes crates + cuts `v{version}` only from the release PR merge commit + # (`chore: release v*`). release_always=false in release-plz.toml is the primary + # gate; this job-level filter is defense in depth so a later feature push cannot + # tag HEAD after a failed-then-retried crates.io publish. On publish failure, + # re-run this workflow on the release commit SHA — never on a later master tip. release: name: release-plz release + if: >- + github.event_name == 'workflow_dispatch' || + startsWith(github.event.head_commit.message, 'chore: release') runs-on: ubuntu-latest permissions: contents: write @@ -145,25 +190,80 @@ jobs: with: fetch-depth: 0 persist-credentials: false + # Pin to the version-bump commit even if workflow_dispatch is fired from a + # later master tip (or a re-run that somehow resolves to the wrong SHA). + - name: Check out the version-bump commit + shell: bash + run: | + set -euo pipefail + version="$( + python3 - <<'PY' + import pathlib, re, sys + text = pathlib.Path("Cargo.toml").read_text() + m = re.search( + r'(?ms)^\[workspace\.package\].*?^version\s*=\s*"([^"]+)"', + text, + ) + if not m: + sys.exit("workspace.package version not found in Cargo.toml") + print(m.group(1)) + PY + )" + tag="v${version}" + if git rev-parse -q --verify "refs/tags/${tag}" >/dev/null; then + echo "Tag ${tag} already exists — nothing to release." + echo "skip=true" >> "$GITHUB_ENV" + exit 0 + fi + # Prefer the conventional release-PR squash subject; fall back to the + # first commit that set this workspace version in Cargo.toml. + bump_sha="$(git log -1 --format=%H --grep="^chore: release v${version}" || true)" + if [[ -z "${bump_sha}" ]]; then + bump_sha="$( + git log -G '^version = "' --format=%H -- Cargo.toml \ + | while read -r sha; do + if git show "${sha}:Cargo.toml" \ + | python3 -c "import pathlib,re,sys; t=sys.stdin.read(); m=re.search(r'(?ms)^\[workspace\.package\].*?^version\s*=\s*\"([^\"]+)\"', t); sys.exit(0 if m and m.group(1)==sys.argv[1] else 1)" \ + "${version}" + then + echo "${sha}" + break + fi + done + )" + fi + if [[ -z "${bump_sha}" ]]; then + echo "::error::Could not locate the version-bump commit for ${tag}" + exit 1 + fi + echo "Checking out version-bump commit ${bump_sha} for ${tag}" + git checkout --force "${bump_sha}" + echo "skip=false" >> "$GITHUB_ENV" + echo "release_sha=${bump_sha}" >> "$GITHUB_ENV" - uses: dtolnay/rust-toolchain@stable + if: env.skip != 'true' - uses: Swatinem/rust-cache@v2 + if: env.skip != 'true' with: prefix-key: v1-rust shared-key: linux-stable-debug # CI master is the canonical writer; release-plz only restores. save-if: false - name: Install Linux build deps + if: env.skip != 'true' run: | sudo apt-get update sudo apt-get install -y \ libudev-dev pkg-config gcc g++ clang libssl-dev libzstd-dev - name: Mint GitHub App token + if: env.skip != 'true' id: app-token uses: ./.github/actions/github-app-token-from-1password with: op-service-account-token: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} op-github-app-item: ${{ secrets.OP_GITHUB_APP_ITEM }} - name: Run release-plz (release) + if: env.skip != 'true' uses: release-plz/action@v0.5 with: command: release diff --git a/release-plz.toml b/release-plz.toml index 6605b7d6..f80f211d 100644 --- a/release-plz.toml +++ b/release-plz.toml @@ -18,6 +18,12 @@ # CHANGELOG.md, so release-plz aggregates all crates' sections into that one file # instead of scattering a CHANGELOG.md into each crate directory. (changelog_path # is per-package only — it can't be set in [workspace].) +# +# App crates (gui/agent/agent-core) stay release=false; release-plz cannot process +# them (git_only cargo-package fails on path/git deps), so changelog_include is a +# no-op for their commits. scripts/release/augment_changelog.py runs after +# release-pr and folds every release-worthy subject since the last v* tag into +# the root CHANGELOG so gui/agent-only fixes are not dropped. [workspace] # Open release PRs from a `release-plz/`-prefixed branch. @@ -29,6 +35,10 @@ semver_check = false # Per-crate tags/releases are off; the root crate owns the one workspace release. git_tag_enable = false git_release_enable = false +# Only publish/tag when the release PR merges (branch prefix above). A later +# master tip after a failed crates.io publish must not cut v{version} — re-run +# the failed release job on that same SHA once credentials are fixed. +release_always = false [[package]] name = "openlogi" @@ -78,26 +88,21 @@ name = "openlogi-hidpp" version_group = "openlogi" changelog_path = "CHANGELOG.md" -# Not publishable (git-only gpui deps); keep release-plz out of it entirely. -# Its version still follows the shared workspace version via inheritance. -# `publish = false` must mirror the crate's Cargo.toml: release-plz validates -# publish consistency across *all* workspace packages before honoring `release`. +# App crates: not crates.io packages (git gpui deps / login-item binary). They +# stay `release = false` so release-plz does not try to package them (git_only +# cargo-package fails on path/git deps). Their conventional commits still reach +# the root CHANGELOG via scripts/release/augment_changelog.py after release-pr. +# `publish = false` must mirror each crate's Cargo.toml. [[package]] name = "openlogi-gui" release = false publish = false -# Shared headless orchestration for the background agent. Not publishable -# (links git-only gpui-adjacent siblings); version follows the shared workspace -# version by inheritance. `publish = false` mirrors the crate's Cargo.toml — see -# the openlogi-gui note above. [[package]] name = "openlogi-agent-core" release = false publish = false -# The headless background agent binary — shipped as a login-item helper inside -# the .app, never published. publish=false mirrors its Cargo.toml. [[package]] name = "openlogi-agent" release = false diff --git a/scripts/release/.gitignore b/scripts/release/.gitignore new file mode 100644 index 00000000..7a60b85e --- /dev/null +++ b/scripts/release/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.pyc diff --git a/scripts/release/augment_changelog.py b/scripts/release/augment_changelog.py new file mode 100755 index 00000000..60c9d36e --- /dev/null +++ b/scripts/release/augment_changelog.py @@ -0,0 +1,271 @@ +#!/usr/bin/env python3 +"""Fold every release-worthy commit since the last tag into the root CHANGELOG. + +release-plz only attributes commits to packages it processes. App crates with +`release = false` (gui/agent/agent-core) never get a commit list, so +`changelog_include` cannot surface their work. This script re-reads +`git log` since the latest `v*` tag and merges any missing conventional +commits into the top version section of CHANGELOG.md. +""" + +from __future__ import annotations + +import argparse +import re +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path + +RELEASE_WORTHY = re.compile( + r"^(?Pfeat|fix|perf|security)(?P!)?" + r"(?:\((?P[^)]+)\))?!?:\s*(?P.+)$" +) +PR_IN_SUBJECT = re.compile(r"\(#(\d+)\)\s*$") +PR_IN_ENTRY = re.compile(r"\[#(\d+)\]") +VERSION_HEADER = re.compile(r"^## \[([^\]]+)\]") + +GROUP_FOR_TYPE = { + "feat": "Added", + "fix": "Fixed", + "perf": "Changed", + "security": "Security", +} + +DEFAULT_REPO = "https://github.com/AprilNEA/OpenLogi" + + +@dataclass(frozen=True) +class Commit: + type: str + scope: str | None + summary: str + pr: str | None + breaking: bool + + def entry_line(self, repo_url: str) -> str: + text = self.summary + if self.pr: + text = PR_IN_SUBJECT.sub("", text).rstrip() + scope = f"*({self.scope})* " if self.scope else "" + breaking = "[**breaking**] " if self.breaking else "" + line = f"- {scope}{breaking}{text}" + if self.pr: + line += f" ([#{self.pr}]({repo_url}/pull/{self.pr}))" + return line + + def fingerprint(self) -> str: + if self.pr: + return f"pr:{self.pr}" + return f"msg:{self.type}:{self.scope or ''}:{self.summary.strip().lower()}" + + +def parse_commit_subject(subject: str) -> Commit | None: + subject = subject.strip() + match = RELEASE_WORTHY.match(subject) + if not match: + return None + summary = match.group("summary").strip() + pr_match = PR_IN_SUBJECT.search(summary) + pr = pr_match.group(1) if pr_match else None + return Commit( + type=match.group("type"), + scope=match.group("scope"), + summary=summary, + pr=pr, + breaking=bool(match.group("breaking")) or "!" in subject.split(":", 1)[0], + ) + + +def git_output(*args: str, cwd: Path | None = None) -> str: + result = subprocess.run( + ["git", *args], + cwd=cwd, + check=True, + capture_output=True, + text=True, + ) + return result.stdout + + +def latest_version_tag(cwd: Path) -> str: + tags = [ + line.strip() + for line in git_output("tag", "--list", "v*", cwd=cwd).splitlines() + if re.fullmatch(r"v\d+\.\d+\.\d+", line.strip()) + ] + if not tags: + raise SystemExit("no vX.Y.Z tags found") + tags.sort(key=lambda t: [int(p) for p in t[1:].split(".")]) + return tags[-1] + + +def commits_since(tag: str, cwd: Path) -> list[Commit]: + raw = git_output("log", f"{tag}..HEAD", "--format=%s", cwd=cwd) + commits: list[Commit] = [] + seen: set[str] = set() + for line in raw.splitlines(): + commit = parse_commit_subject(line) + if commit is None: + continue + fp = commit.fingerprint() + if fp in seen: + continue + seen.add(fp) + commits.append(commit) + return commits + + +def existing_fingerprints(section: str) -> set[str]: + found: set[str] = set() + for line in section.splitlines(): + prs = PR_IN_ENTRY.findall(line) + if prs: + for pr in prs: + found.add(f"pr:{pr}") + continue + stripped = line.strip() + if stripped.startswith("- "): + found.add(f"msg:{stripped[2:].strip().lower()}") + return found + + +def split_top_version_section(changelog: str) -> tuple[str, str, str, str]: + """Return (prefix, version_header, section_body, suffix).""" + lines = changelog.splitlines(keepends=True) + header_idx = None + for i, line in enumerate(lines): + if VERSION_HEADER.match(line) and "Unreleased" not in line: + header_idx = i + break + if header_idx is None: + raise SystemExit("CHANGELOG.md has no version section to augment") + + end_idx = len(lines) + for j in range(header_idx + 1, len(lines)): + if VERSION_HEADER.match(lines[j]): + end_idx = j + break + + prefix = "".join(lines[:header_idx]) + version_header = lines[header_idx] + body = "".join(lines[header_idx + 1 : end_idx]) + suffix = "".join(lines[end_idx:]) + return prefix, version_header, body, suffix + + +def merge_commits_into_section(body: str, commits: list[Commit], repo_url: str) -> str: + present = existing_fingerprints(body) + missing = [c for c in commits if c.fingerprint() not in present] + if not missing: + return body + + groups: dict[str, list[str]] = {} + # Preserve existing groups and their lines. + current_group: str | None = None + residual: list[str] = [] + for line in body.splitlines(): + if line.startswith("### "): + current_group = line[4:].strip() + groups.setdefault(current_group, []) + continue + if current_group is None: + residual.append(line) + continue + if line.strip(): + groups[current_group].append(line) + + for commit in missing: + group = GROUP_FOR_TYPE.get(commit.type, "Other") + groups.setdefault(group, []).append(commit.entry_line(repo_url)) + + # Preferred group order matches Keep a Changelog. + order = ["Added", "Changed", "Deprecated", "Removed", "Fixed", "Security", "Other"] + ordered_names = [name for name in order if name in groups] + [ + name for name in groups if name not in order + ] + + parts: list[str] = [] + if residual and any(r.strip() for r in residual): + parts.extend(residual) + if parts and parts[-1] != "": + parts.append("") + + for name in ordered_names: + entries = groups[name] + if not entries: + continue + parts.append(f"### {name}") + parts.append("") + for entry in entries: + parts.append(entry if entry.endswith("\n") else entry) + parts.append("") + + text = "\n".join(parts) + if not text.endswith("\n"): + text += "\n" + # Keep a blank line after the version header when the section is non-empty. + if not text.startswith("\n"): + text = "\n" + text + return text + + +def augment_changelog(changelog: str, commits: list[Commit], repo_url: str) -> str: + prefix, version_header, body, suffix = split_top_version_section(changelog) + new_body = merge_commits_into_section(body, commits, repo_url) + return f"{prefix}{version_header}{new_body}{suffix}" + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--changelog", + type=Path, + default=Path("CHANGELOG.md"), + help="Path to CHANGELOG.md", + ) + parser.add_argument( + "--repo-url", + default=DEFAULT_REPO, + help="Repo URL used for PR links", + ) + parser.add_argument( + "--since-tag", + default=None, + help="Override the previous release tag (default: latest vX.Y.Z)", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Print the augmented changelog to stdout instead of writing", + ) + args = parser.parse_args(argv) + + cwd = Path.cwd() + tag = args.since_tag or latest_version_tag(cwd) + commits = commits_since(tag, cwd) + original = args.changelog.read_text() + updated = augment_changelog(original, commits, args.repo_url.rstrip("/")) + + if args.dry_run: + sys.stdout.write(updated) + return 0 + + if updated != original: + args.changelog.write_text(updated) + print( + f"augmented {args.changelog} with {len(commits)} release-worthy " + f"commit(s) since {tag}", + file=sys.stderr, + ) + else: + print( + f"no changelog changes needed ({len(commits)} release-worthy " + f"commit(s) since {tag})", + file=sys.stderr, + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/release/test_augment_changelog.py b/scripts/release/test_augment_changelog.py new file mode 100755 index 00000000..2f86f634 --- /dev/null +++ b/scripts/release/test_augment_changelog.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +"""Tests for scripts/release/augment_changelog.py (real shipped helpers).""" + +from __future__ import annotations + +import unittest +from pathlib import Path +import sys + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from augment_changelog import ( # noqa: E402 + augment_changelog, + parse_commit_subject, +) + + +SAMPLE = """# Changelog + +## [Unreleased] + +## [0.6.24](https://example/compare/v0.6.23...v0.6.24) - 2026-08-09 + +### Fixed + +- *(agent)* reuse inventory channels for input capture ([#522](https://github.com/AprilNEA/OpenLogi/pull/522)) + +## [0.6.23](https://example/compare/v0.6.22...v0.6.23) - 2026-08-02 + +### Fixed + +- *(hook)* grab only relative pointer devices ([#401](https://github.com/AprilNEA/OpenLogi/pull/401)) +""" + + +class AugmentChangelogTests(unittest.TestCase): + def test_parse_fix_with_scope_and_pr(self) -> None: + commit = parse_commit_subject( + "fix(i18n): complete Crowdin synchronization (#508)" + ) + assert commit is not None + self.assertEqual(commit.type, "fix") + self.assertEqual(commit.scope, "i18n") + self.assertEqual(commit.pr, "508") + + def test_parse_ignores_chore(self) -> None: + self.assertIsNone(parse_commit_subject("chore: release v0.6.24")) + + def test_merges_missing_gui_commit_into_top_section(self) -> None: + commits = [ + parse_commit_subject( + "fix(agent): reuse inventory channels for input capture (#522)" + ), + parse_commit_subject( + "fix(i18n): complete Crowdin synchronization (#508)" + ), + ] + assert all(c is not None for c in commits) + updated = augment_changelog( + SAMPLE, + [c for c in commits if c is not None], + "https://github.com/AprilNEA/OpenLogi", + ) + # Top section must list both PRs; older 0.6.23 body stays untouched. + top, _, older = updated.partition("## [0.6.23]") + self.assertIn("#508", top) + self.assertIn("#522", top) + self.assertIn("Crowdin synchronization", top) + self.assertIn("inventory channels", top) + self.assertNotIn("#508", older) + self.assertIn("#401", older) + + def test_idempotent_when_already_present(self) -> None: + commits = [ + parse_commit_subject( + "fix(agent): reuse inventory channels for input capture (#522)" + ), + ] + assert commits[0] is not None + once = augment_changelog( + SAMPLE, [commits[0]], "https://github.com/AprilNEA/OpenLogi" + ) + twice = augment_changelog( + once, [commits[0]], "https://github.com/AprilNEA/OpenLogi" + ) + self.assertEqual(once, twice) + self.assertEqual(once.count("#522"), 1) + + +if __name__ == "__main__": + unittest.main() From dba8c715eeb872268b9ebfe41988a787a62edd36 Mon Sep 17 00:00:00 2001 From: David Budnick Date: Sun, 9 Aug 2026 14:08:01 -0500 Subject: [PATCH 2/3] fix(release): make changelog augment idempotent --- scripts/release/augment_changelog.py | 102 +++++++++++++++++----- scripts/release/test_augment_changelog.py | 50 +++++++++++ 2 files changed, 130 insertions(+), 22 deletions(-) diff --git a/scripts/release/augment_changelog.py b/scripts/release/augment_changelog.py index 60c9d36e..7a45e3fd 100755 --- a/scripts/release/augment_changelog.py +++ b/scripts/release/augment_changelog.py @@ -23,6 +23,9 @@ ) PR_IN_SUBJECT = re.compile(r"\(#(\d+)\)\s*$") PR_IN_ENTRY = re.compile(r"\[#(\d+)\]") +PR_LINK_TAIL = re.compile(r"\s*\(\[#\d+\]\([^)]+\)\)\s*$") +SCOPE_PREFIX = re.compile(r"^\*\([^)]+\)\*\s*") +BREAKING_PREFIX = re.compile(r"^\[\*\*breaking\*\*\]\s*") VERSION_HEADER = re.compile(r"^## \[([^\]]+)\]") GROUP_FOR_TYPE = { @@ -35,6 +38,44 @@ DEFAULT_REPO = "https://github.com/AprilNEA/OpenLogi" +def _summary_text(summary: str, pr: str | None) -> str: + text = summary.strip() + if pr: + text = PR_IN_SUBJECT.sub("", text).rstrip() + return text + + +def _message_keys(scope: str | None, summary: str, breaking: bool = False) -> set[str]: + """Keys used to match a commit against an already-rendered changelog line.""" + text = summary.strip().lower() + keys = {f"msg:{text}"} + if scope: + keys.add(f"msg:*({scope.lower()})* {text}") + if breaking: + keys.add(f"msg:*({scope.lower()})* [**breaking**] {text}") + elif breaking: + keys.add(f"msg:[**breaking**] {text}") + return keys + + +def _keys_from_entry_line(line: str) -> set[str]: + stripped = line.strip() + if not stripped.startswith("- "): + return set() + body = stripped[2:].strip() + keys: set[str] = set() + for pr in PR_IN_ENTRY.findall(body): + keys.add(f"pr:{pr}") + # Drop the markdown PR link so bare summary matches commit subjects. + body = PR_LINK_TAIL.sub("", body).strip() + keys.add(f"msg:{body.lower()}") + bare = SCOPE_PREFIX.sub("", body) + bare = BREAKING_PREFIX.sub("", bare).strip() + if bare: + keys.add(f"msg:{bare.lower()}") + return keys + + @dataclass(frozen=True) class Commit: type: str @@ -44,9 +85,7 @@ class Commit: breaking: bool def entry_line(self, repo_url: str) -> str: - text = self.summary - if self.pr: - text = PR_IN_SUBJECT.sub("", text).rstrip() + text = _summary_text(self.summary, self.pr) scope = f"*({self.scope})* " if self.scope else "" breaking = "[**breaking**] " if self.breaking else "" line = f"- {scope}{breaking}{text}" @@ -54,11 +93,24 @@ def entry_line(self, repo_url: str) -> str: line += f" ([#{self.pr}]({repo_url}/pull/{self.pr}))" return line + def fingerprints(self) -> set[str]: + keys = _message_keys( + self.scope, + _summary_text(self.summary, self.pr), + breaking=self.breaking, + ) + if self.pr: + keys.add(f"pr:{self.pr}") + return keys + def fingerprint(self) -> str: + # Stable single key for de-duping within a commit list. if self.pr: return f"pr:{self.pr}" - return f"msg:{self.type}:{self.scope or ''}:{self.summary.strip().lower()}" - + text = _summary_text(self.summary, self.pr).strip().lower() + if self.scope: + return f"msg:*({self.scope.lower()})* {text}" + return f"msg:{text}" def parse_commit_subject(subject: str) -> Commit | None: subject = subject.strip() @@ -119,17 +171,28 @@ def commits_since(tag: str, cwd: Path) -> list[Commit]: def existing_fingerprints(section: str) -> set[str]: found: set[str] = set() for line in section.splitlines(): - prs = PR_IN_ENTRY.findall(line) - if prs: - for pr in prs: - found.add(f"pr:{pr}") - continue - stripped = line.strip() - if stripped.startswith("- "): - found.add(f"msg:{stripped[2:].strip().lower()}") + found |= _keys_from_entry_line(line) return found +def commit_is_present(commit: Commit, present: set[str]) -> bool: + return bool(commit.fingerprints() & present) + + +def ensure_section_spacing(body: str) -> str: + """Blank line after the version header and before the next version header.""" + if not body: + return "\n" + if not body.startswith("\n"): + body = "\n" + body + # Body must end with a blank line so suffix "## [older]" is separated. + if not body.endswith("\n"): + body += "\n" + if not body.endswith("\n\n"): + body += "\n" + return body + + def split_top_version_section(changelog: str) -> tuple[str, str, str, str]: """Return (prefix, version_header, section_body, suffix).""" lines = changelog.splitlines(keepends=True) @@ -156,9 +219,9 @@ def split_top_version_section(changelog: str) -> tuple[str, str, str, str]: def merge_commits_into_section(body: str, commits: list[Commit], repo_url: str) -> str: present = existing_fingerprints(body) - missing = [c for c in commits if c.fingerprint() not in present] + missing = [c for c in commits if not commit_is_present(c, present)] if not missing: - return body + return ensure_section_spacing(body) groups: dict[str, list[str]] = {} # Preserve existing groups and their lines. @@ -198,16 +261,11 @@ def merge_commits_into_section(body: str, commits: list[Commit], repo_url: str) parts.append(f"### {name}") parts.append("") for entry in entries: - parts.append(entry if entry.endswith("\n") else entry) + parts.append(entry.rstrip("\n")) parts.append("") text = "\n".join(parts) - if not text.endswith("\n"): - text += "\n" - # Keep a blank line after the version header when the section is non-empty. - if not text.startswith("\n"): - text = "\n" + text - return text + return ensure_section_spacing(text) def augment_changelog(changelog: str, commits: list[Commit], repo_url: str) -> str: diff --git a/scripts/release/test_augment_changelog.py b/scripts/release/test_augment_changelog.py index 2f86f634..e32fda15 100755 --- a/scripts/release/test_augment_changelog.py +++ b/scripts/release/test_augment_changelog.py @@ -86,6 +86,56 @@ def test_idempotent_when_already_present(self) -> None: self.assertEqual(once, twice) self.assertEqual(once.count("#522"), 1) + def test_idempotent_for_non_pr_rendered_entries(self) -> None: + """Rendered lines without PR links must not duplicate on re-run.""" + body = """# Changelog + +## [Unreleased] + +## [0.6.24](https://example/compare/v0.6.23...v0.6.24) - 2026-08-09 + +### Fixed + +- *(gui)* improve card contrast and depth + +## [0.6.23](https://example/compare/v0.6.22...v0.6.23) - 2026-08-02 + +### Fixed + +- *(hook)* grab only relative pointer devices +""" + commit = parse_commit_subject("fix(gui): improve card contrast and depth") + assert commit is not None + once = augment_changelog( + body, [commit], "https://github.com/AprilNEA/OpenLogi" + ) + twice = augment_changelog( + once, [commit], "https://github.com/AprilNEA/OpenLogi" + ) + self.assertEqual(once, twice) + self.assertEqual(once.count("improve card contrast and depth"), 1) + # Second call must not grow the Fixed section. + third = augment_changelog( + twice, [commit], "https://github.com/AprilNEA/OpenLogi" + ) + self.assertEqual(twice, third) + + def test_blank_line_before_next_version_header(self) -> None: + commits = [ + parse_commit_subject( + "fix(i18n): complete Crowdin synchronization (#508)" + ), + ] + assert commits[0] is not None + updated = augment_changelog( + SAMPLE, [commits[0]], "https://github.com/AprilNEA/OpenLogi" + ) + # The 0.6.24 section must end with a blank line before ## [0.6.23]. + self.assertRegex( + updated, + r"Crowdin synchronization \(\[#508\]\([^)]+\)\)\n\n## \[0\.6\.23\]", + ) + if __name__ == "__main__": unittest.main() From 6b6c77f42e86862c480132b7d203e878ff88a032 Mon Sep 17 00:00:00 2001 From: David Budnick Date: Sun, 9 Aug 2026 18:21:13 -0500 Subject: [PATCH 3/3] refactor(release): use git-cliff for whole-repo changelog --- .github/workflows/release-plz.yml | 32 ++- cliff.toml | 51 ++++ release-plz.toml | 30 +- scripts/release/.gitignore | 2 - scripts/release/augment_changelog.py | 329 ---------------------- scripts/release/test_augment_changelog.py | 141 ---------- scripts/release/write-changelog.sh | 57 ++++ 7 files changed, 133 insertions(+), 509 deletions(-) create mode 100644 cliff.toml delete mode 100644 scripts/release/.gitignore delete mode 100755 scripts/release/augment_changelog.py delete mode 100755 scripts/release/test_augment_changelog.py create mode 100755 scripts/release/write-changelog.sh diff --git a/.github/workflows/release-plz.yml b/.github/workflows/release-plz.yml index 5aca77d5..1e866ac1 100644 --- a/.github/workflows/release-plz.yml +++ b/.github/workflows/release-plz.yml @@ -58,10 +58,10 @@ jobs: env: GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} CARGO_REGISTRY_TOKEN: ${{ steps.app-token.outputs.cargo-registry-token }} - # release-plz only lists commits for packages it processes. App crates with - # release=false (gui/agent/agent-core) never contribute via changelog_include, - # so fold every release-worthy subject since the last v* tag into CHANGELOG.md. - - name: Augment release changelog with app-crate commits + # Whole-repo CHANGELOG via git-cliff (cliff.toml). release-plz is + # package-path-scoped and skips release=false app crates; git-cliff is the + # GoReleaser-style "every conventional commit since last tag" path. + - name: Write root changelog with git-cliff env: GH_TOKEN: ${{ steps.app-token.outputs.token }} run: | @@ -75,28 +75,30 @@ jobs: | head -n1 )" if [[ -z "${branch}" ]]; then - echo "No open release-plz PR — nothing to augment." + echo "No open release-plz PR — nothing to write." exit 0 fi git config user.name "aprilnea[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git" - # The release-plz branch does not yet contain this script on first - # land; copy it from the master checkout before switching. - cp scripts/release/augment_changelog.py "${RUNNER_TEMP}/augment_changelog.py" + # Script/config may not be on the release-plz branch yet — copy from master. + cp cliff.toml "${RUNNER_TEMP}/cliff.toml" + cp scripts/release/write-changelog.sh "${RUNNER_TEMP}/write-changelog.sh" + chmod +x "${RUNNER_TEMP}/write-changelog.sh" git fetch origin "${branch}" git checkout --force "origin/${branch}" - # release-plz wrote CHANGELOG from package-scoped commits; re-scan the - # full history since the previous tag so gui/agent-only fixes appear. - python3 "${RUNNER_TEMP}/augment_changelog.py" \ - --changelog CHANGELOG.md \ - --repo-url "https://github.com/${{ github.repository }}" + cp "${RUNNER_TEMP}/cliff.toml" cliff.toml + # git-cliff: same binary release-plz uses under the hood for formatting. + curl -sL "https://github.com/orhun/git-cliff/releases/download/v2.13.1/git-cliff-2.13.1-x86_64-unknown-linux-gnu.tar.gz" \ + | tar -xz -C "${RUNNER_TEMP}" + export PATH="${RUNNER_TEMP}/git-cliff-2.13.1:${PATH}" + "${RUNNER_TEMP}/write-changelog.sh" if git diff --quiet -- CHANGELOG.md; then echo "CHANGELOG already complete." exit 0 fi - git add CHANGELOG.md - git commit -m "chore(release): include all post-tag commits in changelog" + git add CHANGELOG.md cliff.toml 2>/dev/null || git add CHANGELOG.md + git commit -m "chore(release): write whole-repo changelog" git push origin "HEAD:refs/heads/${branch}" # `release-plz/action` swallows a release-pr HTTP 422 as a warning and # reports no PR, which silently stalls releases (it looks identical to a diff --git a/cliff.toml b/cliff.toml new file mode 100644 index 00000000..19f6601a --- /dev/null +++ b/cliff.toml @@ -0,0 +1,51 @@ +# Whole-repo changelog (git-cliff). release-plz only bumps versions; it is +# package-path-scoped and cannot see `release = false` app crates, so the root +# CHANGELOG is owned here — same model as GoReleaser's `changelog.use: git`. +# https://git-cliff.org/docs/configuration + +[changelog] +header = """# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +""" +# Keep-a-Changelog layout matching historical OpenLogi sections. +body = """ +## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }} + +{% for group, commits in commits | group_by(attribute="group") -%} +### {{ group | upper_first }} + +{% for commit in commits -%} +- {% if commit.scope %}*({{ commit.scope }})* {% endif %}{% if commit.breaking %}[**breaking**] {% endif %}{{ commit.message }} +{% endfor %} +{% endfor -%} +""" +trim = true + +[git] +conventional_commits = true +filter_unconventional = true +require_conventional = true +split_commits = false +commit_preprocessors = [ + { pattern = "\\(#([0-9]+)\\)", replace = "([#${1}](https://github.com/AprilNEA/OpenLogi/pull/${1}))" }, +] +commit_parsers = [ + { message = "^feat", group = "Added" }, + { message = "^fix", group = "Fixed" }, + { message = "^perf", group = "Changed" }, + { message = "^security", group = "Security" }, + { message = "^.*", skip = true }, +] +protect_breaking_commits = true +filter_commits = false +tag_pattern = "v[0-9].*" +sort_commits = "newest" +# No include_path / exclude_path — every conventional commit since the last tag +# counts, including gui/agent work that never touches a crates.io package. diff --git a/release-plz.toml b/release-plz.toml index f80f211d..ddb2a356 100644 --- a/release-plz.toml +++ b/release-plz.toml @@ -14,16 +14,11 @@ # could attach assets — "target_commitish cannot be changed when release is # immutable". Keep `git_release_enable = false` so release.yml owns the lifecycle.) # -# Single changelog: every crate points `changelog_path` at the repo-root -# CHANGELOG.md, so release-plz aggregates all crates' sections into that one file -# instead of scattering a CHANGELOG.md into each crate directory. (changelog_path -# is per-package only — it can't be set in [workspace].) -# -# App crates (gui/agent/agent-core) stay release=false; release-plz cannot process -# them (git_only cargo-package fails on path/git deps), so changelog_include is a -# no-op for their commits. scripts/release/augment_changelog.py runs after -# release-pr and folds every release-worthy subject since the last v* tag into -# the root CHANGELOG so gui/agent-only fixes are not dropped. +# Changelog: release-plz is package-path-scoped and skips `release = false` app +# crates (gui/agent), so it does NOT own CHANGELOG.md. Whole-repo notes are +# written by git-cliff via `cliff.toml` after each release-pr (same idea as +# GoReleaser's `changelog.use: git` — every conventional commit since the last +# tag, no per-crate path filter). [workspace] # Open release PRs from a `release-plz/`-prefixed branch. @@ -35,6 +30,8 @@ semver_check = false # Per-crate tags/releases are off; the root crate owns the one workspace release. git_tag_enable = false git_release_enable = false +# Version bumps only — root CHANGELOG.md is written by git-cliff (cliff.toml). +changelog_update = false # Only publish/tag when the release PR merges (branch prefix above). A later # master tip after a failed crates.io publish must not cut v{version} — re-run # the failed release job on that same SHA once credentials are fixed. @@ -43,7 +40,6 @@ release_always = false [[package]] name = "openlogi" version_group = "openlogi" -changelog_path = "CHANGELOG.md" git_tag_enable = true git_tag_name = "v{{ version }}" # GitHub Release is owned by release.yml (softprops), not release-plz — see header. @@ -52,46 +48,36 @@ git_release_enable = false [[package]] name = "openlogi-core" version_group = "openlogi" -changelog_path = "CHANGELOG.md" # OS input-event synthesis split out of openlogi-core (depends on it). Published # with the workspace under unified versioning. [[package]] name = "openlogi-inject" version_group = "openlogi" -changelog_path = "CHANGELOG.md" [[package]] name = "openlogi-hid" version_group = "openlogi" -changelog_path = "CHANGELOG.md" [[package]] name = "openlogi-assets" version_group = "openlogi" -changelog_path = "CHANGELOG.md" [[package]] name = "openlogi-cli" version_group = "openlogi" -changelog_path = "CHANGELOG.md" [[package]] name = "openlogi-hook" version_group = "openlogi" -changelog_path = "CHANGELOG.md" # Vendored fork of the `hidpp` crate (0BSD, from lus/logy). Published with the # workspace under unified versioning; upstream's 0.3.0 is provenance only. [[package]] name = "openlogi-hidpp" version_group = "openlogi" -changelog_path = "CHANGELOG.md" -# App crates: not crates.io packages (git gpui deps / login-item binary). They -# stay `release = false` so release-plz does not try to package them (git_only -# cargo-package fails on path/git deps). Their conventional commits still reach -# the root CHANGELOG via scripts/release/augment_changelog.py after release-pr. +# App crates: not crates.io packages (git gpui deps / login-item binary). # `publish = false` must mirror each crate's Cargo.toml. [[package]] name = "openlogi-gui" diff --git a/scripts/release/.gitignore b/scripts/release/.gitignore deleted file mode 100644 index 7a60b85e..00000000 --- a/scripts/release/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -__pycache__/ -*.pyc diff --git a/scripts/release/augment_changelog.py b/scripts/release/augment_changelog.py deleted file mode 100755 index 7a45e3fd..00000000 --- a/scripts/release/augment_changelog.py +++ /dev/null @@ -1,329 +0,0 @@ -#!/usr/bin/env python3 -"""Fold every release-worthy commit since the last tag into the root CHANGELOG. - -release-plz only attributes commits to packages it processes. App crates with -`release = false` (gui/agent/agent-core) never get a commit list, so -`changelog_include` cannot surface their work. This script re-reads -`git log` since the latest `v*` tag and merges any missing conventional -commits into the top version section of CHANGELOG.md. -""" - -from __future__ import annotations - -import argparse -import re -import subprocess -import sys -from dataclasses import dataclass -from pathlib import Path - -RELEASE_WORTHY = re.compile( - r"^(?Pfeat|fix|perf|security)(?P!)?" - r"(?:\((?P[^)]+)\))?!?:\s*(?P.+)$" -) -PR_IN_SUBJECT = re.compile(r"\(#(\d+)\)\s*$") -PR_IN_ENTRY = re.compile(r"\[#(\d+)\]") -PR_LINK_TAIL = re.compile(r"\s*\(\[#\d+\]\([^)]+\)\)\s*$") -SCOPE_PREFIX = re.compile(r"^\*\([^)]+\)\*\s*") -BREAKING_PREFIX = re.compile(r"^\[\*\*breaking\*\*\]\s*") -VERSION_HEADER = re.compile(r"^## \[([^\]]+)\]") - -GROUP_FOR_TYPE = { - "feat": "Added", - "fix": "Fixed", - "perf": "Changed", - "security": "Security", -} - -DEFAULT_REPO = "https://github.com/AprilNEA/OpenLogi" - - -def _summary_text(summary: str, pr: str | None) -> str: - text = summary.strip() - if pr: - text = PR_IN_SUBJECT.sub("", text).rstrip() - return text - - -def _message_keys(scope: str | None, summary: str, breaking: bool = False) -> set[str]: - """Keys used to match a commit against an already-rendered changelog line.""" - text = summary.strip().lower() - keys = {f"msg:{text}"} - if scope: - keys.add(f"msg:*({scope.lower()})* {text}") - if breaking: - keys.add(f"msg:*({scope.lower()})* [**breaking**] {text}") - elif breaking: - keys.add(f"msg:[**breaking**] {text}") - return keys - - -def _keys_from_entry_line(line: str) -> set[str]: - stripped = line.strip() - if not stripped.startswith("- "): - return set() - body = stripped[2:].strip() - keys: set[str] = set() - for pr in PR_IN_ENTRY.findall(body): - keys.add(f"pr:{pr}") - # Drop the markdown PR link so bare summary matches commit subjects. - body = PR_LINK_TAIL.sub("", body).strip() - keys.add(f"msg:{body.lower()}") - bare = SCOPE_PREFIX.sub("", body) - bare = BREAKING_PREFIX.sub("", bare).strip() - if bare: - keys.add(f"msg:{bare.lower()}") - return keys - - -@dataclass(frozen=True) -class Commit: - type: str - scope: str | None - summary: str - pr: str | None - breaking: bool - - def entry_line(self, repo_url: str) -> str: - text = _summary_text(self.summary, self.pr) - scope = f"*({self.scope})* " if self.scope else "" - breaking = "[**breaking**] " if self.breaking else "" - line = f"- {scope}{breaking}{text}" - if self.pr: - line += f" ([#{self.pr}]({repo_url}/pull/{self.pr}))" - return line - - def fingerprints(self) -> set[str]: - keys = _message_keys( - self.scope, - _summary_text(self.summary, self.pr), - breaking=self.breaking, - ) - if self.pr: - keys.add(f"pr:{self.pr}") - return keys - - def fingerprint(self) -> str: - # Stable single key for de-duping within a commit list. - if self.pr: - return f"pr:{self.pr}" - text = _summary_text(self.summary, self.pr).strip().lower() - if self.scope: - return f"msg:*({self.scope.lower()})* {text}" - return f"msg:{text}" - -def parse_commit_subject(subject: str) -> Commit | None: - subject = subject.strip() - match = RELEASE_WORTHY.match(subject) - if not match: - return None - summary = match.group("summary").strip() - pr_match = PR_IN_SUBJECT.search(summary) - pr = pr_match.group(1) if pr_match else None - return Commit( - type=match.group("type"), - scope=match.group("scope"), - summary=summary, - pr=pr, - breaking=bool(match.group("breaking")) or "!" in subject.split(":", 1)[0], - ) - - -def git_output(*args: str, cwd: Path | None = None) -> str: - result = subprocess.run( - ["git", *args], - cwd=cwd, - check=True, - capture_output=True, - text=True, - ) - return result.stdout - - -def latest_version_tag(cwd: Path) -> str: - tags = [ - line.strip() - for line in git_output("tag", "--list", "v*", cwd=cwd).splitlines() - if re.fullmatch(r"v\d+\.\d+\.\d+", line.strip()) - ] - if not tags: - raise SystemExit("no vX.Y.Z tags found") - tags.sort(key=lambda t: [int(p) for p in t[1:].split(".")]) - return tags[-1] - - -def commits_since(tag: str, cwd: Path) -> list[Commit]: - raw = git_output("log", f"{tag}..HEAD", "--format=%s", cwd=cwd) - commits: list[Commit] = [] - seen: set[str] = set() - for line in raw.splitlines(): - commit = parse_commit_subject(line) - if commit is None: - continue - fp = commit.fingerprint() - if fp in seen: - continue - seen.add(fp) - commits.append(commit) - return commits - - -def existing_fingerprints(section: str) -> set[str]: - found: set[str] = set() - for line in section.splitlines(): - found |= _keys_from_entry_line(line) - return found - - -def commit_is_present(commit: Commit, present: set[str]) -> bool: - return bool(commit.fingerprints() & present) - - -def ensure_section_spacing(body: str) -> str: - """Blank line after the version header and before the next version header.""" - if not body: - return "\n" - if not body.startswith("\n"): - body = "\n" + body - # Body must end with a blank line so suffix "## [older]" is separated. - if not body.endswith("\n"): - body += "\n" - if not body.endswith("\n\n"): - body += "\n" - return body - - -def split_top_version_section(changelog: str) -> tuple[str, str, str, str]: - """Return (prefix, version_header, section_body, suffix).""" - lines = changelog.splitlines(keepends=True) - header_idx = None - for i, line in enumerate(lines): - if VERSION_HEADER.match(line) and "Unreleased" not in line: - header_idx = i - break - if header_idx is None: - raise SystemExit("CHANGELOG.md has no version section to augment") - - end_idx = len(lines) - for j in range(header_idx + 1, len(lines)): - if VERSION_HEADER.match(lines[j]): - end_idx = j - break - - prefix = "".join(lines[:header_idx]) - version_header = lines[header_idx] - body = "".join(lines[header_idx + 1 : end_idx]) - suffix = "".join(lines[end_idx:]) - return prefix, version_header, body, suffix - - -def merge_commits_into_section(body: str, commits: list[Commit], repo_url: str) -> str: - present = existing_fingerprints(body) - missing = [c for c in commits if not commit_is_present(c, present)] - if not missing: - return ensure_section_spacing(body) - - groups: dict[str, list[str]] = {} - # Preserve existing groups and their lines. - current_group: str | None = None - residual: list[str] = [] - for line in body.splitlines(): - if line.startswith("### "): - current_group = line[4:].strip() - groups.setdefault(current_group, []) - continue - if current_group is None: - residual.append(line) - continue - if line.strip(): - groups[current_group].append(line) - - for commit in missing: - group = GROUP_FOR_TYPE.get(commit.type, "Other") - groups.setdefault(group, []).append(commit.entry_line(repo_url)) - - # Preferred group order matches Keep a Changelog. - order = ["Added", "Changed", "Deprecated", "Removed", "Fixed", "Security", "Other"] - ordered_names = [name for name in order if name in groups] + [ - name for name in groups if name not in order - ] - - parts: list[str] = [] - if residual and any(r.strip() for r in residual): - parts.extend(residual) - if parts and parts[-1] != "": - parts.append("") - - for name in ordered_names: - entries = groups[name] - if not entries: - continue - parts.append(f"### {name}") - parts.append("") - for entry in entries: - parts.append(entry.rstrip("\n")) - parts.append("") - - text = "\n".join(parts) - return ensure_section_spacing(text) - - -def augment_changelog(changelog: str, commits: list[Commit], repo_url: str) -> str: - prefix, version_header, body, suffix = split_top_version_section(changelog) - new_body = merge_commits_into_section(body, commits, repo_url) - return f"{prefix}{version_header}{new_body}{suffix}" - - -def main(argv: list[str] | None = None) -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument( - "--changelog", - type=Path, - default=Path("CHANGELOG.md"), - help="Path to CHANGELOG.md", - ) - parser.add_argument( - "--repo-url", - default=DEFAULT_REPO, - help="Repo URL used for PR links", - ) - parser.add_argument( - "--since-tag", - default=None, - help="Override the previous release tag (default: latest vX.Y.Z)", - ) - parser.add_argument( - "--dry-run", - action="store_true", - help="Print the augmented changelog to stdout instead of writing", - ) - args = parser.parse_args(argv) - - cwd = Path.cwd() - tag = args.since_tag or latest_version_tag(cwd) - commits = commits_since(tag, cwd) - original = args.changelog.read_text() - updated = augment_changelog(original, commits, args.repo_url.rstrip("/")) - - if args.dry_run: - sys.stdout.write(updated) - return 0 - - if updated != original: - args.changelog.write_text(updated) - print( - f"augmented {args.changelog} with {len(commits)} release-worthy " - f"commit(s) since {tag}", - file=sys.stderr, - ) - else: - print( - f"no changelog changes needed ({len(commits)} release-worthy " - f"commit(s) since {tag})", - file=sys.stderr, - ) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/release/test_augment_changelog.py b/scripts/release/test_augment_changelog.py deleted file mode 100755 index e32fda15..00000000 --- a/scripts/release/test_augment_changelog.py +++ /dev/null @@ -1,141 +0,0 @@ -#!/usr/bin/env python3 -"""Tests for scripts/release/augment_changelog.py (real shipped helpers).""" - -from __future__ import annotations - -import unittest -from pathlib import Path -import sys - -sys.path.insert(0, str(Path(__file__).resolve().parent)) - -from augment_changelog import ( # noqa: E402 - augment_changelog, - parse_commit_subject, -) - - -SAMPLE = """# Changelog - -## [Unreleased] - -## [0.6.24](https://example/compare/v0.6.23...v0.6.24) - 2026-08-09 - -### Fixed - -- *(agent)* reuse inventory channels for input capture ([#522](https://github.com/AprilNEA/OpenLogi/pull/522)) - -## [0.6.23](https://example/compare/v0.6.22...v0.6.23) - 2026-08-02 - -### Fixed - -- *(hook)* grab only relative pointer devices ([#401](https://github.com/AprilNEA/OpenLogi/pull/401)) -""" - - -class AugmentChangelogTests(unittest.TestCase): - def test_parse_fix_with_scope_and_pr(self) -> None: - commit = parse_commit_subject( - "fix(i18n): complete Crowdin synchronization (#508)" - ) - assert commit is not None - self.assertEqual(commit.type, "fix") - self.assertEqual(commit.scope, "i18n") - self.assertEqual(commit.pr, "508") - - def test_parse_ignores_chore(self) -> None: - self.assertIsNone(parse_commit_subject("chore: release v0.6.24")) - - def test_merges_missing_gui_commit_into_top_section(self) -> None: - commits = [ - parse_commit_subject( - "fix(agent): reuse inventory channels for input capture (#522)" - ), - parse_commit_subject( - "fix(i18n): complete Crowdin synchronization (#508)" - ), - ] - assert all(c is not None for c in commits) - updated = augment_changelog( - SAMPLE, - [c for c in commits if c is not None], - "https://github.com/AprilNEA/OpenLogi", - ) - # Top section must list both PRs; older 0.6.23 body stays untouched. - top, _, older = updated.partition("## [0.6.23]") - self.assertIn("#508", top) - self.assertIn("#522", top) - self.assertIn("Crowdin synchronization", top) - self.assertIn("inventory channels", top) - self.assertNotIn("#508", older) - self.assertIn("#401", older) - - def test_idempotent_when_already_present(self) -> None: - commits = [ - parse_commit_subject( - "fix(agent): reuse inventory channels for input capture (#522)" - ), - ] - assert commits[0] is not None - once = augment_changelog( - SAMPLE, [commits[0]], "https://github.com/AprilNEA/OpenLogi" - ) - twice = augment_changelog( - once, [commits[0]], "https://github.com/AprilNEA/OpenLogi" - ) - self.assertEqual(once, twice) - self.assertEqual(once.count("#522"), 1) - - def test_idempotent_for_non_pr_rendered_entries(self) -> None: - """Rendered lines without PR links must not duplicate on re-run.""" - body = """# Changelog - -## [Unreleased] - -## [0.6.24](https://example/compare/v0.6.23...v0.6.24) - 2026-08-09 - -### Fixed - -- *(gui)* improve card contrast and depth - -## [0.6.23](https://example/compare/v0.6.22...v0.6.23) - 2026-08-02 - -### Fixed - -- *(hook)* grab only relative pointer devices -""" - commit = parse_commit_subject("fix(gui): improve card contrast and depth") - assert commit is not None - once = augment_changelog( - body, [commit], "https://github.com/AprilNEA/OpenLogi" - ) - twice = augment_changelog( - once, [commit], "https://github.com/AprilNEA/OpenLogi" - ) - self.assertEqual(once, twice) - self.assertEqual(once.count("improve card contrast and depth"), 1) - # Second call must not grow the Fixed section. - third = augment_changelog( - twice, [commit], "https://github.com/AprilNEA/OpenLogi" - ) - self.assertEqual(twice, third) - - def test_blank_line_before_next_version_header(self) -> None: - commits = [ - parse_commit_subject( - "fix(i18n): complete Crowdin synchronization (#508)" - ), - ] - assert commits[0] is not None - updated = augment_changelog( - SAMPLE, [commits[0]], "https://github.com/AprilNEA/OpenLogi" - ) - # The 0.6.24 section must end with a blank line before ## [0.6.23]. - self.assertRegex( - updated, - r"Crowdin synchronization \(\[#508\]\([^)]+\)\)\n\n## \[0\.6\.23\]", - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/scripts/release/write-changelog.sh b/scripts/release/write-changelog.sh new file mode 100755 index 00000000..2c4df7fa --- /dev/null +++ b/scripts/release/write-changelog.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# Write the next workspace version section into CHANGELOG.md with git-cliff. +# Whole-repo conventional commits since the previous v* tag (cliff.toml). +set -euo pipefail + +root="$(git rev-parse --show-toplevel)" +cd "$root" + +version="$( + python3 - <<'PY' +import pathlib, re, sys +text = pathlib.Path("Cargo.toml").read_text() +m = re.search(r'(?ms)^\[workspace\.package\].*?^version\s*=\s*"([^"]+)"', text) +if not m: + sys.exit("workspace.package version not found in Cargo.toml") +print(m.group(1)) +PY +)" +tag="v${version}" + +last_tag="$( + git tag --list 'v*' \ + | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \ + | sort -V \ + | tail -n1 +)" +if [[ -z "${last_tag}" ]]; then + echo "error: no previous vX.Y.Z tag" >&2 + exit 1 +fi +if [[ "${last_tag}" == "${tag}" ]]; then + echo "error: workspace version ${version} is already tagged as ${tag}" >&2 + exit 1 +fi + +# Drop a stale section for this version (idempotent re-runs / release-pr updates). +if grep -qE "^## \[${version}\]" CHANGELOG.md; then + python3 - "${version}" <<'PY' +from pathlib import Path +import re +import sys + +version = sys.argv[1] +text = Path("CHANGELOG.md").read_text() +pattern = re.compile( + rf"(?ms)^## \[{re.escape(version)}\].*?(?=^## \[|\Z)" +) +Path("CHANGELOG.md").write_text(pattern.sub("", text, count=1)) +PY +fi + +git cliff "${last_tag}.." \ + --config cliff.toml \ + --tag "${tag}" \ + --prepend CHANGELOG.md + +echo "wrote ${tag} changelog from ${last_tag}..HEAD" >&2