diff --git a/.claude/commands/release.md b/.claude/commands/release.md index 6cd2cdf637..7e56d29f8d 100644 --- a/.claude/commands/release.md +++ b/.claude/commands/release.md @@ -53,18 +53,9 @@ If "Previous tag": ask `"Which tag?"` with a text input (default: `v{oldVersionN If "master" or if the release is minor/major: `{baseRef} = master`. -### 2c. Finalize Changelog - -Read `CHANGELOG.md` and check whether `## [Unreleased]` has any entries beneath it. - -**If entries exist:** -1. Replace `## [Unreleased]` with `## [{newVersionName}] - {YYYY-MM-DD}` (today's date) -2. Insert a fresh empty `## [Unreleased]` section above the new version heading -3. Update the compare link references at the bottom of the file: - - Change `[Unreleased]` link to compare from `v{newVersionName}...HEAD` - - Add a new `[{newVersionName}]` link comparing `v{oldVersionName}...v{newVersionName}` - -**If no entries:** Print `⚠ CHANGELOG.md has no unreleased entries — continuing without changelog update.` and proceed. +Set `{changelogTarget}`: +- If `{baseRef}` is `master`: `next` +- Otherwise: `hotfix` ### 3. Create Release Branch & Bump Version @@ -86,18 +77,34 @@ Cherry-pick the commits you need onto this branch now, then continue. ``` Wait for the user to confirm they are done cherry-picking before proceeding. +Finalize changelog after the release branch contains all release commits: + +```bash +scripts/collect-changelog.sh --target {changelogTarget} +``` + +Read `CHANGELOG.md` and check whether `## [Unreleased]` has any entries beneath it after collecting fragments. + +**If entries exist:** +1. Replace `## [Unreleased]` with `## [{newVersionName}] - {YYYY-MM-DD}` (today's date) +2. Insert a fresh empty `## [Unreleased]` section above the new version heading +3. Update the compare link references at the bottom of the file: + - Change `[Unreleased]` link to compare from `v{newVersionName}...HEAD` + - Add a new `[{newVersionName}]` link comparing `v{oldVersionName}...v{newVersionName}` + +**If no entries:** Print `⚠ CHANGELOG.md has no unreleased entries — continuing without changelog update.` and proceed. + Edit `app/build.gradle.kts`: - Change `versionCode = {old}` to `versionCode = {newVersionCode}` - Change `versionName = "{old}"` to `versionName = "{newVersionName}"` ```bash git add app/build.gradle.kts -# Only stage CHANGELOG.md if step 2c modified it (i.e. unreleased entries were found) git commit -m "chore: version {newVersionName}" git push -u origin release-{newVersionName} ``` -If step 2c updated `CHANGELOG.md`, also `git add CHANGELOG.md` before the commit. +If changelog collection updated `CHANGELOG.md` or deleted consumed fragments, run `git add CHANGELOG.md changelog.d` before the commit. ### 4. Create Version Bump PR diff --git a/.cursor/rules/rules.main.mdc b/.cursor/rules/rules.main.mdc index b09f345ff5..ff0409ac55 100644 --- a/.cursor/rules/rules.main.mdc +++ b/.cursor/rules/rules.main.mdc @@ -63,12 +63,14 @@ alwaysApply: true --- ## Changelog rules: -- add an entry under `## [Unreleased]` in `CHANGELOG.md` for `feat:` and `fix:` PRs; skip for `chore:`, `ci:`, `refactor:`, `test:`, `docs:` unless the change is user-facing -- use standard Keep a Changelog categories: `### Added`, `### Changed`, `### Deprecated`, `### Removed`, `### Fixed`, `### Security` -- append `#PR_NUMBER` at the end of each changelog entry when the PR number is known -- place new entries at the top of their category section (newest first) -- never modify released version sections — only edit `## [Unreleased]` -- create category headings on demand (don't add empty stubs) +- never edit `CHANGELOG.md` in normal feature/fix PRs; release automation collects changelog fragments into it +- add exactly one changelog fragment for user-facing `feat:` and `fix:` PRs; skip for `chore:`, `ci:`, `refactor:`, `test:`, `docs:` unless the change is user-facing +- put normal release fragments in `changelog.d/next/` and hotfix fragments in `changelog.d/hotfix/` +- name fragments `..md`, where category is one of `added`, `changed`, `deprecated`, `removed`, `fixed`, or `security` +- write the fragment as one polished user-facing sentence without a leading bullet and without a PR number +- never add multiple changelog fragments for the same PR — summarize all changes in one concise fragment +- release commits consume fragments with `scripts/collect-changelog.sh --target next|hotfix`, update `CHANGELOG.md`, and delete consumed fragment files +- never modify released version sections manually --- diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index cfd4a327f4..46b428a228 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,5 +1,5 @@ - + ### Description diff --git a/AGENTS.md b/AGENTS.md index b7fef0e829..585ea46e8d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -240,13 +240,14 @@ suspend fun getData(): Result = withContext(Dispatchers.IO) { ### Changelog -- ALWAYS add exactly ONE entry per PR under `## [Unreleased]` in `CHANGELOG.md` for `feat:` and `fix:` PRs; skip for `chore:`, `ci:`, `refactor:`, `test:`, `docs:` unless the change is user-facing -- NEVER add multiple changelog lines for the same PR — summarize all changes in a single concise entry -- USE standard Keep a Changelog categories: `### Added`, `### Changed`, `### Deprecated`, `### Removed`, `### Fixed`, `### Security` -- ALWAYS append `#PR_NUMBER` at the end of each changelog entry when the PR number is known -- ALWAYS place new entries at the top of their category section (newest first) -- NEVER modify released version sections — only edit `## [Unreleased]` -- ALWAYS create category headings on demand (don't add empty stubs) +- NEVER edit `CHANGELOG.md` in normal feature/fix PRs; release automation collects changelog fragments into it +- ALWAYS add exactly ONE changelog fragment for user-facing `feat:` and `fix:` PRs; skip for `chore:`, `ci:`, `refactor:`, `test:`, `docs:` unless the change is user-facing +- PUT normal release fragments in `changelog.d/next/` and hotfix fragments in `changelog.d/hotfix/` +- NAME fragments `..md`, where category is one of `added`, `changed`, `deprecated`, `removed`, `fixed`, or `security` +- WRITE the fragment as one polished user-facing sentence without a leading bullet and without a PR number +- NEVER add multiple changelog fragments for the same PR — summarize all changes in one concise fragment +- Release commits consume fragments with `scripts/collect-changelog.sh --target next|hotfix`, update `CHANGELOG.md`, and delete consumed fragment files +- NEVER modify released version sections manually ### Device Debugging (adb) diff --git a/changelog.d/hotfix/.gitkeep b/changelog.d/hotfix/.gitkeep new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/changelog.d/hotfix/.gitkeep @@ -0,0 +1 @@ + diff --git a/changelog.d/next/.gitkeep b/changelog.d/next/.gitkeep new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/changelog.d/next/.gitkeep @@ -0,0 +1 @@ + diff --git a/scripts/collect-changelog.sh b/scripts/collect-changelog.sh new file mode 100755 index 0000000000..2f8772668e --- /dev/null +++ b/scripts/collect-changelog.sh @@ -0,0 +1,174 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: scripts/collect-changelog.sh --target next|hotfix + +Collect changelog fragments from changelog.d// into CHANGELOG.md. +EOF +} + +target="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --target) + if [[ $# -lt 2 ]]; then + echo "--target requires a value" >&2 + usage >&2 + exit 1 + fi + target="$2" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +if [[ "$target" != "next" && "$target" != "hotfix" ]]; then + echo "--target must be either 'next' or 'hotfix'" >&2 + usage >&2 + exit 1 +fi + +python3 - "$target" <<'PY' +from __future__ import annotations + +import re +import sys +from pathlib import Path + +TARGET = sys.argv[1] +ROOT = Path.cwd() +CHANGELOG = ROOT / "CHANGELOG.md" +FRAGMENT_DIR = ROOT / "changelog.d" / TARGET + +CATEGORY_LABELS = { + "added": "Added", + "changed": "Changed", + "deprecated": "Deprecated", + "removed": "Removed", + "fixed": "Fixed", + "security": "Security", +} +CATEGORY_ORDER = list(CATEGORY_LABELS.values()) +FRAGMENT_PATTERN = re.compile( + r"^(?P[A-Za-z0-9][A-Za-z0-9._-]*)\.(?Padded|changed|deprecated|removed|fixed|security)\.md$" +) + + +def fail(message: str) -> None: + raise SystemExit(f"collect-changelog: {message}") + + +def fragment_entry(path: Path) -> tuple[str, str]: + match = FRAGMENT_PATTERN.match(path.name) + if not match: + fail( + f"invalid fragment name '{path.relative_to(ROOT)}'; " + "expected ..md" + ) + + lines = [line.strip() for line in path.read_text().splitlines() if line.strip()] + if not lines: + fail(f"fragment '{path.relative_to(ROOT)}' is empty") + + text = " ".join(lines) + if text.startswith("-"): + fail(f"fragment '{path.relative_to(ROOT)}' must not start with a bullet") + + ref = match.group("ref") + category = CATEGORY_LABELS[match.group("category")] + suffix = f" #{ref}" if ref.isdigit() else "" + return category, f"- {text}{suffix}" + + +def unreleased_bounds(changelog: str) -> tuple[int, int]: + header = re.search(r"^## \[Unreleased\]\n", changelog, flags=re.MULTILINE) + if not header: + fail("could not find '## [Unreleased]' in CHANGELOG.md") + + next_release = re.search(r"^## \[[^\]]+\].*$", changelog[header.end() :], flags=re.MULTILINE) + start = header.end() + end = start + next_release.start() if next_release else len(changelog) + return start, end + + +def category_heading(category: str) -> re.Pattern[str]: + return re.compile(rf"^### {re.escape(category)}\n", flags=re.MULTILINE) + + +def insert_entries(body: str, category: str, entries: list[str]) -> str: + heading = category_heading(category).search(body) + entries_text = "\n".join(entries) + "\n" + + if heading: + insert_at = heading.end() + return body[:insert_at] + entries_text + body[insert_at:] + + block = f"### {category}\n{entries_text}\n" + category_index = CATEGORY_ORDER.index(category) + + for later_category in CATEGORY_ORDER[category_index + 1 :]: + later_heading = category_heading(later_category).search(body) + if later_heading: + return body[: later_heading.start()] + block + body[later_heading.start() :] + + stripped_body = body.rstrip() + if not stripped_body: + return f"\n{block}" + + trailing = body[len(stripped_body) :] + return f"{stripped_body}\n\n{block}{trailing}" + + +if not CHANGELOG.exists(): + fail("CHANGELOG.md does not exist") + +if not FRAGMENT_DIR.exists(): + fail(f"fragment directory '{FRAGMENT_DIR.relative_to(ROOT)}' does not exist") + +fragments = sorted( + path for path in FRAGMENT_DIR.glob("*.md") if path.is_file() and path.name != ".gitkeep" +) + +if not fragments: + print(f"No changelog fragments found in {FRAGMENT_DIR.relative_to(ROOT)}.") + raise SystemExit(0) + +entries_by_category: dict[str, list[str]] = {category: [] for category in CATEGORY_ORDER} +for fragment in fragments: + category, entry = fragment_entry(fragment) + entries_by_category[category].append(entry) + +changelog = CHANGELOG.read_text() +start, end = unreleased_bounds(changelog) +body = changelog[start:end] +existing_entries = set(body.splitlines()) + +inserted = 0 +for category in CATEGORY_ORDER: + entries = [entry for entry in entries_by_category[category] if entry not in existing_entries] + if not entries: + continue + + body = insert_entries(body, category, entries) + existing_entries.update(entries) + inserted += len(entries) + +CHANGELOG.write_text(changelog[:start] + body + changelog[end:]) + +for fragment in fragments: + fragment.unlink() + +print(f"Collected {inserted} changelog entr{'y' if inserted == 1 else 'ies'} from changelog.d/{TARGET}.") +PY diff --git a/scripts/preview-changelog.sh b/scripts/preview-changelog.sh new file mode 100755 index 0000000000..0e47ca2f9f --- /dev/null +++ b/scripts/preview-changelog.sh @@ -0,0 +1,133 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: scripts/preview-changelog.sh [--target next|hotfix|all] + +Preview pending changelog fragments without modifying files. +EOF +} + +target="all" + +while [[ $# -gt 0 ]]; do + case "$1" in + --target) + if [[ $# -lt 2 ]]; then + echo "--target requires a value" >&2 + usage >&2 + exit 1 + fi + target="$2" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +if [[ "$target" != "next" && "$target" != "hotfix" && "$target" != "all" ]]; then + echo "--target must be 'next', 'hotfix', or 'all'" >&2 + usage >&2 + exit 1 +fi + +python3 - "$target" <<'PY' +from __future__ import annotations + +import re +import sys +from pathlib import Path + +TARGET = sys.argv[1] +ROOT = Path.cwd() +CHANGELOG_DIR = ROOT / "changelog.d" + +CATEGORY_LABELS = { + "added": "Added", + "changed": "Changed", + "deprecated": "Deprecated", + "removed": "Removed", + "fixed": "Fixed", + "security": "Security", +} +CATEGORY_ORDER = list(CATEGORY_LABELS.values()) +FRAGMENT_PATTERN = re.compile( + r"^(?P[A-Za-z0-9][A-Za-z0-9._-]*)\.(?Padded|changed|deprecated|removed|fixed|security)\.md$" +) + + +def fail(message: str) -> None: + raise SystemExit(f"preview-changelog: {message}") + + +def fragment_entry(path: Path) -> tuple[str, str]: + match = FRAGMENT_PATTERN.match(path.name) + if not match: + fail( + f"invalid fragment name '{path.relative_to(ROOT)}'; " + "expected ..md" + ) + + lines = [line.strip() for line in path.read_text().splitlines() if line.strip()] + if not lines: + fail(f"fragment '{path.relative_to(ROOT)}' is empty") + + text = " ".join(lines) + if text.startswith("-"): + fail(f"fragment '{path.relative_to(ROOT)}' must not start with a bullet") + + ref = match.group("ref") + category = CATEGORY_LABELS[match.group("category")] + suffix = f" #{ref}" if ref.isdigit() else "" + return category, f"- {text}{suffix} ({path.relative_to(ROOT)})" + + +def target_dirs() -> list[tuple[str, Path]]: + targets = ["next", "hotfix"] if TARGET == "all" else [TARGET] + return [(target, CHANGELOG_DIR / target) for target in targets] + + +found_any = False + +for target, directory in target_dirs(): + if not directory.exists(): + fail(f"fragment directory '{directory.relative_to(ROOT)}' does not exist") + + fragments = sorted( + path for path in directory.glob("*.md") if path.is_file() and path.name != ".gitkeep" + ) + + print(f"## {target}") + if not fragments: + print("No pending changelog fragments.\n") + continue + + found_any = True + entries_by_category: dict[str, list[str]] = {category: [] for category in CATEGORY_ORDER} + for fragment in fragments: + category, entry = fragment_entry(fragment) + entries_by_category[category].append(entry) + + for category in CATEGORY_ORDER: + entries = entries_by_category[category] + if not entries: + continue + + print(f"\n### {category}") + for entry in entries: + print(entry) + + print() + +if not found_any: + raise SystemExit(0) +PY