Skip to content

Commit 47f09f0

Browse files
authored
Merge branch 'master' into feat/system-widgets-foundation
2 parents 9a78080 + 17fed92 commit 47f09f0

8 files changed

Lines changed: 347 additions & 28 deletions

File tree

.claude/commands/release.md

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -53,18 +53,9 @@ If "Previous tag": ask `"Which tag?"` with a text input (default: `v{oldVersionN
5353

5454
If "master" or if the release is minor/major: `{baseRef} = master`.
5555

56-
### 2c. Finalize Changelog
57-
58-
Read `CHANGELOG.md` and check whether `## [Unreleased]` has any entries beneath it.
59-
60-
**If entries exist:**
61-
1. Replace `## [Unreleased]` with `## [{newVersionName}] - {YYYY-MM-DD}` (today's date)
62-
2. Insert a fresh empty `## [Unreleased]` section above the new version heading
63-
3. Update the compare link references at the bottom of the file:
64-
- Change `[Unreleased]` link to compare from `v{newVersionName}...HEAD`
65-
- Add a new `[{newVersionName}]` link comparing `v{oldVersionName}...v{newVersionName}`
66-
67-
**If no entries:** Print `⚠ CHANGELOG.md has no unreleased entries — continuing without changelog update.` and proceed.
56+
Set `{changelogTarget}`:
57+
- If `{baseRef}` is `master`: `next`
58+
- Otherwise: `hotfix`
6859

6960
### 3. Create Release Branch & Bump Version
7061

@@ -86,18 +77,34 @@ Cherry-pick the commits you need onto this branch now, then continue.
8677
```
8778
Wait for the user to confirm they are done cherry-picking before proceeding.
8879

80+
Finalize changelog after the release branch contains all release commits:
81+
82+
```bash
83+
scripts/collect-changelog.sh --target {changelogTarget}
84+
```
85+
86+
Read `CHANGELOG.md` and check whether `## [Unreleased]` has any entries beneath it after collecting fragments.
87+
88+
**If entries exist:**
89+
1. Replace `## [Unreleased]` with `## [{newVersionName}] - {YYYY-MM-DD}` (today's date)
90+
2. Insert a fresh empty `## [Unreleased]` section above the new version heading
91+
3. Update the compare link references at the bottom of the file:
92+
- Change `[Unreleased]` link to compare from `v{newVersionName}...HEAD`
93+
- Add a new `[{newVersionName}]` link comparing `v{oldVersionName}...v{newVersionName}`
94+
95+
**If no entries:** Print `⚠ CHANGELOG.md has no unreleased entries — continuing without changelog update.` and proceed.
96+
8997
Edit `app/build.gradle.kts`:
9098
- Change `versionCode = {old}` to `versionCode = {newVersionCode}`
9199
- Change `versionName = "{old}"` to `versionName = "{newVersionName}"`
92100

93101
```bash
94102
git add app/build.gradle.kts
95-
# Only stage CHANGELOG.md if step 2c modified it (i.e. unreleased entries were found)
96103
git commit -m "chore: version {newVersionName}"
97104
git push -u origin release-{newVersionName}
98105
```
99106

100-
If step 2c updated `CHANGELOG.md`, also `git add CHANGELOG.md` before the commit.
107+
If changelog collection updated `CHANGELOG.md` or deleted consumed fragments, run `git add CHANGELOG.md changelog.d` before the commit.
101108

102109
### 4. Create Version Bump PR
103110

.cursor/rules/rules.main.mdc

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -63,12 +63,14 @@ alwaysApply: true
6363
---
6464

6565
## Changelog rules:
66-
- 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
67-
- use standard Keep a Changelog categories: `### Added`, `### Changed`, `### Deprecated`, `### Removed`, `### Fixed`, `### Security`
68-
- append `#PR_NUMBER` at the end of each changelog entry when the PR number is known
69-
- place new entries at the top of their category section (newest first)
70-
- never modify released version sections — only edit `## [Unreleased]`
71-
- create category headings on demand (don't add empty stubs)
66+
- never edit `CHANGELOG.md` in normal feature/fix PRs; release automation collects changelog fragments into it
67+
- 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
68+
- put normal release fragments in `changelog.d/next/` and hotfix fragments in `changelog.d/hotfix/`
69+
- name fragments `<issue-or-pr>.<category>.md`, where category is one of `added`, `changed`, `deprecated`, `removed`, `fixed`, or `security`
70+
- write the fragment as one polished user-facing sentence without a leading bullet and without a PR number
71+
- never add multiple changelog fragments for the same PR — summarize all changes in one concise fragment
72+
- release commits consume fragments with `scripts/collect-changelog.sh --target next|hotfix`, update `CHANGELOG.md`, and delete consumed fragment files
73+
- never modify released version sections manually
7274

7375
---
7476

.github/pull_request_template.md

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

55
### Description

AGENTS.md

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -240,13 +240,14 @@ suspend fun getData(): Result<Data> = withContext(Dispatchers.IO) {
240240

241241
### Changelog
242242

243-
- 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
244-
- NEVER add multiple changelog lines for the same PR — summarize all changes in a single concise entry
245-
- USE standard Keep a Changelog categories: `### Added`, `### Changed`, `### Deprecated`, `### Removed`, `### Fixed`, `### Security`
246-
- ALWAYS append `#PR_NUMBER` at the end of each changelog entry when the PR number is known
247-
- ALWAYS place new entries at the top of their category section (newest first)
248-
- NEVER modify released version sections — only edit `## [Unreleased]`
249-
- ALWAYS create category headings on demand (don't add empty stubs)
243+
- NEVER edit `CHANGELOG.md` in normal feature/fix PRs; release automation collects changelog fragments into it
244+
- 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
245+
- PUT normal release fragments in `changelog.d/next/` and hotfix fragments in `changelog.d/hotfix/`
246+
- NAME fragments `<issue-or-pr>.<category>.md`, where category is one of `added`, `changed`, `deprecated`, `removed`, `fixed`, or `security`
247+
- WRITE the fragment as one polished user-facing sentence without a leading bullet and without a PR number
248+
- NEVER add multiple changelog fragments for the same PR — summarize all changes in one concise fragment
249+
- Release commits consume fragments with `scripts/collect-changelog.sh --target next|hotfix`, update `CHANGELOG.md`, and delete consumed fragment files
250+
- NEVER modify released version sections manually
250251

251252
### Device Debugging (adb)
252253

changelog.d/hotfix/.gitkeep

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+

changelog.d/next/.gitkeep

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+

scripts/collect-changelog.sh

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
usage() {
5+
cat <<'EOF'
6+
Usage: scripts/collect-changelog.sh --target next|hotfix
7+
8+
Collect changelog fragments from changelog.d/<target>/ into CHANGELOG.md.
9+
EOF
10+
}
11+
12+
target=""
13+
14+
while [[ $# -gt 0 ]]; do
15+
case "$1" in
16+
--target)
17+
if [[ $# -lt 2 ]]; then
18+
echo "--target requires a value" >&2
19+
usage >&2
20+
exit 1
21+
fi
22+
target="$2"
23+
shift 2
24+
;;
25+
-h|--help)
26+
usage
27+
exit 0
28+
;;
29+
*)
30+
echo "Unknown argument: $1" >&2
31+
usage >&2
32+
exit 1
33+
;;
34+
esac
35+
done
36+
37+
if [[ "$target" != "next" && "$target" != "hotfix" ]]; then
38+
echo "--target must be either 'next' or 'hotfix'" >&2
39+
usage >&2
40+
exit 1
41+
fi
42+
43+
python3 - "$target" <<'PY'
44+
from __future__ import annotations
45+
46+
import re
47+
import sys
48+
from pathlib import Path
49+
50+
TARGET = sys.argv[1]
51+
ROOT = Path.cwd()
52+
CHANGELOG = ROOT / "CHANGELOG.md"
53+
FRAGMENT_DIR = ROOT / "changelog.d" / TARGET
54+
55+
CATEGORY_LABELS = {
56+
"added": "Added",
57+
"changed": "Changed",
58+
"deprecated": "Deprecated",
59+
"removed": "Removed",
60+
"fixed": "Fixed",
61+
"security": "Security",
62+
}
63+
CATEGORY_ORDER = list(CATEGORY_LABELS.values())
64+
FRAGMENT_PATTERN = re.compile(
65+
r"^(?P<ref>[A-Za-z0-9][A-Za-z0-9._-]*)\.(?P<category>added|changed|deprecated|removed|fixed|security)\.md$"
66+
)
67+
68+
69+
def fail(message: str) -> None:
70+
raise SystemExit(f"collect-changelog: {message}")
71+
72+
73+
def fragment_entry(path: Path) -> tuple[str, str]:
74+
match = FRAGMENT_PATTERN.match(path.name)
75+
if not match:
76+
fail(
77+
f"invalid fragment name '{path.relative_to(ROOT)}'; "
78+
"expected <issue-or-pr>.<category>.md"
79+
)
80+
81+
lines = [line.strip() for line in path.read_text().splitlines() if line.strip()]
82+
if not lines:
83+
fail(f"fragment '{path.relative_to(ROOT)}' is empty")
84+
85+
text = " ".join(lines)
86+
if text.startswith("-"):
87+
fail(f"fragment '{path.relative_to(ROOT)}' must not start with a bullet")
88+
89+
ref = match.group("ref")
90+
category = CATEGORY_LABELS[match.group("category")]
91+
suffix = f" #{ref}" if ref.isdigit() else ""
92+
return category, f"- {text}{suffix}"
93+
94+
95+
def unreleased_bounds(changelog: str) -> tuple[int, int]:
96+
header = re.search(r"^## \[Unreleased\]\n", changelog, flags=re.MULTILINE)
97+
if not header:
98+
fail("could not find '## [Unreleased]' in CHANGELOG.md")
99+
100+
next_release = re.search(r"^## \[[^\]]+\].*$", changelog[header.end() :], flags=re.MULTILINE)
101+
start = header.end()
102+
end = start + next_release.start() if next_release else len(changelog)
103+
return start, end
104+
105+
106+
def category_heading(category: str) -> re.Pattern[str]:
107+
return re.compile(rf"^### {re.escape(category)}\n", flags=re.MULTILINE)
108+
109+
110+
def insert_entries(body: str, category: str, entries: list[str]) -> str:
111+
heading = category_heading(category).search(body)
112+
entries_text = "\n".join(entries) + "\n"
113+
114+
if heading:
115+
insert_at = heading.end()
116+
return body[:insert_at] + entries_text + body[insert_at:]
117+
118+
block = f"### {category}\n{entries_text}\n"
119+
category_index = CATEGORY_ORDER.index(category)
120+
121+
for later_category in CATEGORY_ORDER[category_index + 1 :]:
122+
later_heading = category_heading(later_category).search(body)
123+
if later_heading:
124+
return body[: later_heading.start()] + block + body[later_heading.start() :]
125+
126+
stripped_body = body.rstrip()
127+
if not stripped_body:
128+
return f"\n{block}"
129+
130+
trailing = body[len(stripped_body) :]
131+
return f"{stripped_body}\n\n{block}{trailing}"
132+
133+
134+
if not CHANGELOG.exists():
135+
fail("CHANGELOG.md does not exist")
136+
137+
if not FRAGMENT_DIR.exists():
138+
fail(f"fragment directory '{FRAGMENT_DIR.relative_to(ROOT)}' does not exist")
139+
140+
fragments = sorted(
141+
path for path in FRAGMENT_DIR.glob("*.md") if path.is_file() and path.name != ".gitkeep"
142+
)
143+
144+
if not fragments:
145+
print(f"No changelog fragments found in {FRAGMENT_DIR.relative_to(ROOT)}.")
146+
raise SystemExit(0)
147+
148+
entries_by_category: dict[str, list[str]] = {category: [] for category in CATEGORY_ORDER}
149+
for fragment in fragments:
150+
category, entry = fragment_entry(fragment)
151+
entries_by_category[category].append(entry)
152+
153+
changelog = CHANGELOG.read_text()
154+
start, end = unreleased_bounds(changelog)
155+
body = changelog[start:end]
156+
existing_entries = set(body.splitlines())
157+
158+
inserted = 0
159+
for category in CATEGORY_ORDER:
160+
entries = [entry for entry in entries_by_category[category] if entry not in existing_entries]
161+
if not entries:
162+
continue
163+
164+
body = insert_entries(body, category, entries)
165+
existing_entries.update(entries)
166+
inserted += len(entries)
167+
168+
CHANGELOG.write_text(changelog[:start] + body + changelog[end:])
169+
170+
for fragment in fragments:
171+
fragment.unlink()
172+
173+
print(f"Collected {inserted} changelog entr{'y' if inserted == 1 else 'ies'} from changelog.d/{TARGET}.")
174+
PY

0 commit comments

Comments
 (0)