Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 21 additions & 14 deletions .claude/commands/release.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down
14 changes: 8 additions & 6 deletions .cursor/rules/rules.main.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<issue-or-pr>.<category>.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

---

Expand Down
2 changes: 1 addition & 1 deletion .github/pull_request_template.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<!-- Closes | Fixes | Resolves #ISSUE_ID -->
<!-- Changelog: Add an entry under ## [Unreleased] in CHANGELOG.md for user-facing changes (skip for chores/CI/refactors). -->
<!-- Changelog: For user-facing changes, add one fragment in changelog.d/next/ or changelog.d/hotfix/. Do not edit CHANGELOG.md in normal PRs. -->
<!-- Brief summary of the PR changes, linking to the related resources (issue/design/bug/etc) if applicable. -->

### Description
Expand Down
15 changes: 8 additions & 7 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -240,13 +240,14 @@ suspend fun getData(): Result<Data> = 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 `<issue-or-pr>.<category>.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)

Expand Down
1 change: 1 addition & 0 deletions changelog.d/hotfix/.gitkeep
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

1 change: 1 addition & 0 deletions changelog.d/next/.gitkeep
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

174 changes: 174 additions & 0 deletions scripts/collect-changelog.sh
Original file line number Diff line number Diff line change
@@ -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/<target>/ 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<ref>[A-Za-z0-9][A-Za-z0-9._-]*)\.(?P<category>added|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 <issue-or-pr>.<category>.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
Loading
Loading