anthodev/lapin-futepublic Fork
⑂ 7aaa70b
Commits
⬇ Clone ▾
git clone https://git.rickub.com/anthodev/lapin-fute.git
git clone ssh://git@rickub.com/anthodev/lapin-fute.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

chore(release): prepare 1.1.0 with changelog-based release notes

Cedric ANTHONY committed 2026-09-19T22:05:56+02:00 Browse files
7aaa70b parent: 6ae56e9
modified .github/workflows/deploy-bunny.yml +9 -1
@@ -48,6 +48,14 @@ jobs:
4848 preview='^pre-v(0|[1-9][0-9]*)-(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$'
4949 [[ "$RELEASE_TAG" =~ $production || "$RELEASE_TAG" =~ $preview ]]
5050
51+ # Every mode that publishes release notes (a pushed v* tag, or a manual
52+ # dispatch on an existing v* tag) runs the publish job, so a missing,
53+ # empty, or duplicated changelog section must fail before any package or
54+ # deployment runs.
55+ - name: Validate release changelog section
56+ if: ${{ startsWith(github.ref_name, 'v') }}
57+ run: node scripts/generate-release-notes.mjs
58+
5159 - run: npm test
5260
5361 package:
@@ -380,7 +388,7 @@ jobs:
380388 - name: Generate release notes
381389 run: node scripts/generate-release-notes.mjs
382390
383- - name: Create release with categorized notes
391+ - name: Create release with changelog notes
384392 env:
385393 GH_TOKEN: ${{ github.token }}
386394 shell: bash
@@ -48,6 +48,14 @@ jobs:
48 preview='^pre-v(0|[1-9][0-9]*)-(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$'48 preview='^pre-v(0|[1-9][0-9]*)-(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$'
49 [[ "$RELEASE_TAG" =~ $production || "$RELEASE_TAG" =~ $preview ]]49 [[ "$RELEASE_TAG" =~ $production || "$RELEASE_TAG" =~ $preview ]]
50 50
51+ # Every mode that publishes release notes (a pushed v* tag, or a manual
52+ # dispatch on an existing v* tag) runs the publish job, so a missing,
53+ # empty, or duplicated changelog section must fail before any package or
54+ # deployment runs.
55+ - name: Validate release changelog section
56+ if: ${{ startsWith(github.ref_name, 'v') }}
57+ run: node scripts/generate-release-notes.mjs
58+
51 - run: npm test59 - run: npm test
52 60
53 package:61 package:
@@ -380,7 +388,7 @@ jobs:
380 - name: Generate release notes388 - name: Generate release notes
381 run: node scripts/generate-release-notes.mjs389 run: node scripts/generate-release-notes.mjs
382 390
383- - name: Create release with categorized notes391+ - name: Create release with changelog notes
384 env:392 env:
385 GH_TOKEN: ${{ github.token }}393 GH_TOKEN: ${{ github.token }}
386 shell: bash394 shell: bash
added CHANGELOG.md +139 -0
new file mode 100644
@@ -0,0 +1,139 @@
1+# Changelog
2+
3+## [1.1.0] - 2026-09-19
4+
5+Favorites now pair a departure stop and line with a reachable arrival stop, including intermediate stops. Available across all five transport modes, journeys stay on one line without connections. The configuration page lets you select the arrival stop. The watch combines full-line and short-turn departures chronologically, excludes services known not to reach your arrival stop, and marks uncertain journeys with `?` without changing their departure times.
6+
7+Traffic information selects the most recently updated active incident rather than the most severe one. Works, future incidents, and expired incidents are excluded. If the selected English message lacks a usable title or body, the complete French message for the same incident is used instead.
8+
9+Departure-to-arrival favorites require rebuilding and publishing the catalog with journey patterns.
10+
11+### Features
12+
13+- feat(arrivals): support departure-to-arrival favorites (#28) (#29) (`102d719`)
14+
15+### Fixes
16+
17+- fix(traffic): show the latest active incident and exclude works (`6ae56e9`)
18+
19+### Changes
20+
21+- chore(release): prepare 1.1.0 with changelog-based release notes
22+
23+## [1.0.4] - 2026-09-14
24+
25+This release fixes partial-service arrival pooling in the companion and relocates store assets. The documentation also clarifies stop and arrival terminology.
26+
27+### Changes
28+
29+- chore(release): bump version to 1.0.4 (`bd3b82f`)
30+- docs: clarify stop and arrival terminology (`59caea0`)
31+
32+### Fixes
33+
34+- fix(companion): pool partial-service arrivals and move store assets (`2635f28`)
35+
36+**Full changelog**: https://github.com/Anthodev/lapin-fute/compare/v1.0.3...v1.0.4
37+
38+[Release v1.0.4](https://github.com/Anthodev/lapin-fute/releases/tag/v1.0.4)
39+
40+## [1.0.3] - 2026-09-14
41+
42+This release clarifies stop and arrival terminology in the interface (#27).
43+
44+### Fixes
45+
46+- fix(ui): clarify stop and arrival terminology (#27) (`8835364`)
47+
48+**Full changelog**: https://github.com/Anthodev/lapin-fute/compare/v1.0.2...v1.0.3
49+
50+[Release v1.0.3](https://github.com/Anthodev/lapin-fute/releases/tag/v1.0.3)
51+
52+## [1.0.2] - 2026-09-13
53+
54+Line badges on the watch now carry a metro or tram prefix, so metro and tram lines are distinguishable at a glance (#24, #25). Release notes generation handles squash merges correctly, and the README is refreshed.
55+
56+### Features
57+
58+- feat(display): prefix metro and tram line labels on watch badges (#24) (#25) (`0cc3c0b`)
59+
60+### Changes
61+
62+- chore(docs): updated README.md (`1889044`)
63+
64+### Fixes
65+
66+- fix(release): handle squash merges when generating release notes (`9e36c16`)
67+
68+**Full changelog**: https://github.com/Anthodev/lapin-fute/compare/v1.0.1...v1.0.2
69+
70+[Release v1.0.2](https://github.com/Anthodev/lapin-fute/releases/tag/v1.0.2)
71+
72+## [1.0.1] - 2026-09-13
73+
74+Store readiness and polish rather than features. Departure detail spacing is balanced on both Emery and Gabbro, including the header, countdown, and separator framing. Publishing to RePebble now runs as a separate job after the GitHub release, with renewable Firebase authentication and artifact, ownership, and version checks, and release titles use version tags. Screenshots are refreshed, the MIT license lands in the repository and package metadata, and the configuration page About text and RePebble badge are updated to 1.0.1.
75+
76+### Fixes
77+
78+- Balance departure detail screen spacing on Emery and Gabbro, including the header, countdown, and Emery separator framing.
79+
80+### Release tooling
81+
82+- Publish the validated PBW to RePebble in a separate job after the GitHub release, using renewable Firebase authentication and artifact, ownership, and version checks.
83+- Use version tags as GitHub release titles.
84+
85+### Documentation and metadata
86+
87+- Refresh departure screenshots for Emery and Gabbro and simplify README badges.
88+- Add the MIT license and package license metadata.
89+- Update package versions, configuration-page About text, and the RePebble badge to 1.0.1.
90+- Document Store publishing setup and remove completed release planning documents.
91+
92+**Full changes between release snapshots**: https://github.com/Anthodev/lapin-fute/compare/v1.0.0..v1.0.1
93+
94+[Release v1.0.1](https://github.com/Anthodev/lapin-fute/releases/tag/v1.0.1)
95+
96+## [1.0.0] - 2026-09-13
97+
98+First public release. Lapin Futé puts Île-de-France arrivals on a Pebble watch: the companion fetches live PRIM departures and normalizes them phone-side (#12), a bounded importer resolves the IDFM service catalog (#11), and the watch renders only what the phone prepares (#17). Favorites persist and synchronize (#14), a configuration page runs on the phone (#13), settings are modernized with app branding (#19), and stale and failure handling is hardened (#20). The CI pipeline packages the PBW, verifies deploys with rollback rehearsal, and skips unchanged catalog uploads. Store icons, screenshots, and banner ship with the release.
99+
100+### Features
101+
102+- feat: verify published configuration site (`9dc8049`)
103+- feat: harden stale and failure handling (#20) (`835b18b`)
104+- feat(companion): modernize settings and introduce app branding (#19) (`2799b0f`)
105+- feat: migrate to phone-only data and phone-prepared watch displays (#17) (`a46bf24`)
106+- feat: deliver Pebble watch consultation (#15) (`aabca5b`)
107+- feat: persist and synchronize favorites (#14) (`5ff79d1`)
108+- feat: build companion configuration page (#13) (`f7d01bd`)
109+- feat: normalize live PRIM departures (#12) (`5a0b437`)
110+- feat: establish Alloy app foundation (#10) (`ffd859a`)
111+
112+### Changes
113+
114+- docs: link published RePebble release (`de5dd7f`)
115+- chore: add RePebble store screenshots and banner (`e008516`)
116+- chore: add RePebble store icons (`646389f`)
117+- docs: streamline project overview and controls (`3e430d6`)
118+- docs: add v1 release checklist (`d6184a2`)
119+- docs: research official v1 release requirements (`30a8f4c`)
120+- chore: ignore Pebble build lock (`6b61693`)
121+- chore: set release version to 1.0.0 (`e1d539d`)
122+- Resolve IDFM service catalog with bounded importer (#11) (`acf5bba`)
123+- chore: initialize develop branch (`4c1dead`)
124+
125+### Fixes
126+
127+- fix: normalize Emery store screenshot brightness (`befc545`)
128+- fix: close release verification gaps (`457bcb9`)
129+
130+### CI
131+
132+- ci: skip unchanged catalog uploads (`91a4982`)
133+- ci: retain and rehearse deployment rollback (`5f1ff8c`)
134+- ci: package PBW release assets (`30f04fc`)
135+- ci: verify deploys and restore failures (`f224ada`)
136+
137+**Full changelog**: https://github.com/Anthodev/lapin-fute/commits/v1.0.0
138+
139+[Release v1.0.0](https://github.com/Anthodev/lapin-fute/releases/tag/v1.0.0)
new file mode 100644
@@ -0,0 +1,139 @@
1+# Changelog
2+
3+## [1.1.0] - 2026-09-19
4+
5+Favorites now pair a departure stop and line with a reachable arrival stop, including intermediate stops. Available across all five transport modes, journeys stay on one line without connections. The configuration page lets you select the arrival stop. The watch combines full-line and short-turn departures chronologically, excludes services known not to reach your arrival stop, and marks uncertain journeys with `?` without changing their departure times.
6+
7+Traffic information selects the most recently updated active incident rather than the most severe one. Works, future incidents, and expired incidents are excluded. If the selected English message lacks a usable title or body, the complete French message for the same incident is used instead.
8+
9+Departure-to-arrival favorites require rebuilding and publishing the catalog with journey patterns.
10+
11+### Features
12+
13+- feat(arrivals): support departure-to-arrival favorites (#28) (#29) (`102d719`)
14+
15+### Fixes
16+
17+- fix(traffic): show the latest active incident and exclude works (`6ae56e9`)
18+
19+### Changes
20+
21+- chore(release): prepare 1.1.0 with changelog-based release notes
22+
23+## [1.0.4] - 2026-09-14
24+
25+This release fixes partial-service arrival pooling in the companion and relocates store assets. The documentation also clarifies stop and arrival terminology.
26+
27+### Changes
28+
29+- chore(release): bump version to 1.0.4 (`bd3b82f`)
30+- docs: clarify stop and arrival terminology (`59caea0`)
31+
32+### Fixes
33+
34+- fix(companion): pool partial-service arrivals and move store assets (`2635f28`)
35+
36+**Full changelog**: https://github.com/Anthodev/lapin-fute/compare/v1.0.3...v1.0.4
37+
38+[Release v1.0.4](https://github.com/Anthodev/lapin-fute/releases/tag/v1.0.4)
39+
40+## [1.0.3] - 2026-09-14
41+
42+This release clarifies stop and arrival terminology in the interface (#27).
43+
44+### Fixes
45+
46+- fix(ui): clarify stop and arrival terminology (#27) (`8835364`)
47+
48+**Full changelog**: https://github.com/Anthodev/lapin-fute/compare/v1.0.2...v1.0.3
49+
50+[Release v1.0.3](https://github.com/Anthodev/lapin-fute/releases/tag/v1.0.3)
51+
52+## [1.0.2] - 2026-09-13
53+
54+Line badges on the watch now carry a metro or tram prefix, so metro and tram lines are distinguishable at a glance (#24, #25). Release notes generation handles squash merges correctly, and the README is refreshed.
55+
56+### Features
57+
58+- feat(display): prefix metro and tram line labels on watch badges (#24) (#25) (`0cc3c0b`)
59+
60+### Changes
61+
62+- chore(docs): updated README.md (`1889044`)
63+
64+### Fixes
65+
66+- fix(release): handle squash merges when generating release notes (`9e36c16`)
67+
68+**Full changelog**: https://github.com/Anthodev/lapin-fute/compare/v1.0.1...v1.0.2
69+
70+[Release v1.0.2](https://github.com/Anthodev/lapin-fute/releases/tag/v1.0.2)
71+
72+## [1.0.1] - 2026-09-13
73+
74+Store readiness and polish rather than features. Departure detail spacing is balanced on both Emery and Gabbro, including the header, countdown, and separator framing. Publishing to RePebble now runs as a separate job after the GitHub release, with renewable Firebase authentication and artifact, ownership, and version checks, and release titles use version tags. Screenshots are refreshed, the MIT license lands in the repository and package metadata, and the configuration page About text and RePebble badge are updated to 1.0.1.
75+
76+### Fixes
77+
78+- Balance departure detail screen spacing on Emery and Gabbro, including the header, countdown, and Emery separator framing.
79+
80+### Release tooling
81+
82+- Publish the validated PBW to RePebble in a separate job after the GitHub release, using renewable Firebase authentication and artifact, ownership, and version checks.
83+- Use version tags as GitHub release titles.
84+
85+### Documentation and metadata
86+
87+- Refresh departure screenshots for Emery and Gabbro and simplify README badges.
88+- Add the MIT license and package license metadata.
89+- Update package versions, configuration-page About text, and the RePebble badge to 1.0.1.
90+- Document Store publishing setup and remove completed release planning documents.
91+
92+**Full changes between release snapshots**: https://github.com/Anthodev/lapin-fute/compare/v1.0.0..v1.0.1
93+
94+[Release v1.0.1](https://github.com/Anthodev/lapin-fute/releases/tag/v1.0.1)
95+
96+## [1.0.0] - 2026-09-13
97+
98+First public release. Lapin Futé puts Île-de-France arrivals on a Pebble watch: the companion fetches live PRIM departures and normalizes them phone-side (#12), a bounded importer resolves the IDFM service catalog (#11), and the watch renders only what the phone prepares (#17). Favorites persist and synchronize (#14), a configuration page runs on the phone (#13), settings are modernized with app branding (#19), and stale and failure handling is hardened (#20). The CI pipeline packages the PBW, verifies deploys with rollback rehearsal, and skips unchanged catalog uploads. Store icons, screenshots, and banner ship with the release.
99+
100+### Features
101+
102+- feat: verify published configuration site (`9dc8049`)
103+- feat: harden stale and failure handling (#20) (`835b18b`)
104+- feat(companion): modernize settings and introduce app branding (#19) (`2799b0f`)
105+- feat: migrate to phone-only data and phone-prepared watch displays (#17) (`a46bf24`)
106+- feat: deliver Pebble watch consultation (#15) (`aabca5b`)
107+- feat: persist and synchronize favorites (#14) (`5ff79d1`)
108+- feat: build companion configuration page (#13) (`f7d01bd`)
109+- feat: normalize live PRIM departures (#12) (`5a0b437`)
110+- feat: establish Alloy app foundation (#10) (`ffd859a`)
111+
112+### Changes
113+
114+- docs: link published RePebble release (`de5dd7f`)
115+- chore: add RePebble store screenshots and banner (`e008516`)
116+- chore: add RePebble store icons (`646389f`)
117+- docs: streamline project overview and controls (`3e430d6`)
118+- docs: add v1 release checklist (`d6184a2`)
119+- docs: research official v1 release requirements (`30a8f4c`)
120+- chore: ignore Pebble build lock (`6b61693`)
121+- chore: set release version to 1.0.0 (`e1d539d`)
122+- Resolve IDFM service catalog with bounded importer (#11) (`acf5bba`)
123+- chore: initialize develop branch (`4c1dead`)
124+
125+### Fixes
126+
127+- fix: normalize Emery store screenshot brightness (`befc545`)
128+- fix: close release verification gaps (`457bcb9`)
129+
130+### CI
131+
132+- ci: skip unchanged catalog uploads (`91a4982`)
133+- ci: retain and rehearse deployment rollback (`5f1ff8c`)
134+- ci: package PBW release assets (`30f04fc`)
135+- ci: verify deploys and restore failures (`f224ada`)
136+
137+**Full changelog**: https://github.com/Anthodev/lapin-fute/commits/v1.0.0
138+
139+[Release v1.0.0](https://github.com/Anthodev/lapin-fute/releases/tag/v1.0.0)
modified package-lock.json +6 -6
@@ -1,12 +1,12 @@
11 {
22 "name": "lapin-fute",
3- "version": "1.0.4",
3+ "version": "1.1.0",
44 "lockfileVersion": 3,
55 "requires": true,
66 "packages": {
77 "": {
88 "name": "lapin-fute",
9- "version": "1.0.4",
9+ "version": "1.1.0",
1010 "license": "MIT",
1111 "workspaces": [
1212 "packages/*"
@@ -57,7 +57,7 @@
5757 },
5858 "packages/catalog": {
5959 "name": "@lapin-fute/catalog",
60- "version": "1.0.4",
60+ "version": "1.1.0",
6161 "license": "MIT",
6262 "dependencies": {
6363 "csv-parse": "7.0.2",
@@ -66,17 +66,17 @@
6666 },
6767 "packages/companion": {
6868 "name": "@lapin-fute/companion",
69- "version": "1.0.4",
69+ "version": "1.1.0",
7070 "license": "MIT"
7171 },
7272 "packages/config-page": {
7373 "name": "@lapin-fute/config-page",
74- "version": "1.0.4",
74+ "version": "1.1.0",
7575 "license": "MIT"
7676 },
7777 "packages/watch": {
7878 "name": "lapin-fute-watch",
79- "version": "1.0.4",
79+ "version": "1.1.0",
8080 "license": "MIT"
8181 }
8282 }
@@ -1,12 +1,12 @@
1 {1 {
2 "name": "lapin-fute",2 "name": "lapin-fute",
3- "version": "1.0.4",3+ "version": "1.1.0",
4 "lockfileVersion": 3,4 "lockfileVersion": 3,
5 "requires": true,5 "requires": true,
6 "packages": {6 "packages": {
7 "": {7 "": {
8 "name": "lapin-fute",8 "name": "lapin-fute",
9- "version": "1.0.4",9+ "version": "1.1.0",
10 "license": "MIT",10 "license": "MIT",
11 "workspaces": [11 "workspaces": [
12 "packages/*"12 "packages/*"
@@ -57,7 +57,7 @@
57 },57 },
58 "packages/catalog": {58 "packages/catalog": {
59 "name": "@lapin-fute/catalog",59 "name": "@lapin-fute/catalog",
60- "version": "1.0.4",60+ "version": "1.1.0",
61 "license": "MIT",61 "license": "MIT",
62 "dependencies": {62 "dependencies": {
63 "csv-parse": "7.0.2",63 "csv-parse": "7.0.2",
@@ -66,17 +66,17 @@
66 },66 },
67 "packages/companion": {67 "packages/companion": {
68 "name": "@lapin-fute/companion",68 "name": "@lapin-fute/companion",
69- "version": "1.0.4",69+ "version": "1.1.0",
70 "license": "MIT"70 "license": "MIT"
71 },71 },
72 "packages/config-page": {72 "packages/config-page": {
73 "name": "@lapin-fute/config-page",73 "name": "@lapin-fute/config-page",
74- "version": "1.0.4",74+ "version": "1.1.0",
75 "license": "MIT"75 "license": "MIT"
76 },76 },
77 "packages/watch": {77 "packages/watch": {
78 "name": "lapin-fute-watch",78 "name": "lapin-fute-watch",
79- "version": "1.0.4",79+ "version": "1.1.0",
80 "license": "MIT"80 "license": "MIT"
81 }81 }
82 }82 }
modified package.json +1 -1
@@ -1,6 +1,6 @@
11 {
22 "name": "lapin-fute",
3- "version": "1.0.4",
3+ "version": "1.1.0",
44 "license": "MIT",
55 "private": true,
66 "type": "module",
@@ -1,6 +1,6 @@
1 {1 {
2 "name": "lapin-fute",2 "name": "lapin-fute",
3- "version": "1.0.4",3+ "version": "1.1.0",
4 "license": "MIT",4 "license": "MIT",
5 "private": true,5 "private": true,
6 "type": "module",6 "type": "module",
modified packages/catalog/package.json +1 -1
@@ -1,6 +1,6 @@
11 {
22 "name": "@lapin-fute/catalog",
3- "version": "1.0.4",
3+ "version": "1.1.0",
44 "license": "MIT",
55 "private": true,
66 "type": "module",
@@ -1,6 +1,6 @@
1 {1 {
2 "name": "@lapin-fute/catalog",2 "name": "@lapin-fute/catalog",
3- "version": "1.0.4",3+ "version": "1.1.0",
4 "license": "MIT",4 "license": "MIT",
5 "private": true,5 "private": true,
6 "type": "module",6 "type": "module",
modified packages/companion/package.json +1 -1
@@ -1,6 +1,6 @@
11 {
22 "name": "@lapin-fute/companion",
3- "version": "1.0.4",
3+ "version": "1.1.0",
44 "license": "MIT",
55 "private": true,
66 "type": "commonjs",
@@ -1,6 +1,6 @@
1 {1 {
2 "name": "@lapin-fute/companion",2 "name": "@lapin-fute/companion",
3- "version": "1.0.4",3+ "version": "1.1.0",
4 "license": "MIT",4 "license": "MIT",
5 "private": true,5 "private": true,
6 "type": "commonjs",6 "type": "commonjs",
modified packages/config-page/index.html +2 -2
@@ -153,7 +153,7 @@
153153 </div>
154154
155155 <div id="about-en" lang="en">
156- <p class="about-version">Version 1.0.4</p>
156+ <p class="about-version">Version 1.1.0</p>
157157 <h3>Publisher and contact</h3>
158158 <p><strong>Author and publisher:</strong> Anthodev, a private individual acting on a non-professional basis.</p>
159159 <p><strong>Publication director:</strong> Anthodev.</p>
@@ -175,7 +175,7 @@
175175 </div>
176176
177177 <div id="about-fr" lang="fr" hidden>
178- <p class="about-version">Version 1.0.4</p>
178+ <p class="about-version">Version 1.1.0</p>
179179 <h3>Édition et contact</h3>
180180 <p><strong>Auteur et éditeur :</strong> Anthodev, personne physique agissant à titre privé et non professionnel.</p>
181181 <p><strong>Directeur de la publication :</strong> Anthodev.</p>
@@ -153,7 +153,7 @@
153 </div>153 </div>
154 154
155 <div id="about-en" lang="en">155 <div id="about-en" lang="en">
156- <p class="about-version">Version 1.0.4</p>156+ <p class="about-version">Version 1.1.0</p>
157 <h3>Publisher and contact</h3>157 <h3>Publisher and contact</h3>
158 <p><strong>Author and publisher:</strong> Anthodev, a private individual acting on a non-professional basis.</p>158 <p><strong>Author and publisher:</strong> Anthodev, a private individual acting on a non-professional basis.</p>
159 <p><strong>Publication director:</strong> Anthodev.</p>159 <p><strong>Publication director:</strong> Anthodev.</p>
@@ -175,7 +175,7 @@
175 </div>175 </div>
176 176
177 <div id="about-fr" lang="fr" hidden>177 <div id="about-fr" lang="fr" hidden>
178- <p class="about-version">Version 1.0.4</p>178+ <p class="about-version">Version 1.1.0</p>
179 <h3>Édition et contact</h3>179 <h3>Édition et contact</h3>
180 <p><strong>Auteur et éditeur :</strong> Anthodev, personne physique agissant à titre privé et non professionnel.</p>180 <p><strong>Auteur et éditeur :</strong> Anthodev, personne physique agissant à titre privé et non professionnel.</p>
181 <p><strong>Directeur de la publication :</strong> Anthodev.</p>181 <p><strong>Directeur de la publication :</strong> Anthodev.</p>
modified packages/config-page/package.json +1 -1
@@ -1,6 +1,6 @@
11 {
22 "name": "@lapin-fute/config-page",
3- "version": "1.0.4",
3+ "version": "1.1.0",
44 "license": "MIT",
55 "private": true,
66 "type": "module",
@@ -1,6 +1,6 @@
1 {1 {
2 "name": "@lapin-fute/config-page",2 "name": "@lapin-fute/config-page",
3- "version": "1.0.4",3+ "version": "1.1.0",
4 "license": "MIT",4 "license": "MIT",
5 "private": true,5 "private": true,
6 "type": "module",6 "type": "module",
modified packages/config-page/test/generate-release-notes.test.js +146 -74
@@ -4,7 +4,124 @@ import { execFileSync } from "node:child_process";
44 import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
55 import { tmpdir } from "node:os";
66 import { join } from "node:path";
7-import { generateReleaseNotes } from "../../../scripts/generate-release-notes.mjs";
7+import { extractChangelogSection, generateReleaseNotes } from "../../../scripts/generate-release-notes.mjs";
8+
9+const CHANGELOG = [
10+ "# Changelog",
11+ "",
12+ "## [1.1.0] - 2026-09-19",
13+ "",
14+ "Favorites now pair a departure stop with a reachable arrival stop.",
15+ "",
16+ "### Features",
17+ "",
18+ "- feat(arrivals): support departure-to-arrival favorites (#28) (`102d719`)",
19+ "",
20+ "**Full changelog**: https://github.com/example/app/compare/v1.0.4...v1.1.0",
21+ "",
22+ "[Release v1.1.0](https://github.com/example/app/releases/tag/v1.1.0)",
23+ "",
24+ "## [1.0.4] - 2026-09-14",
25+ "",
26+ "This release fixes partial-service arrival pooling.",
27+ "",
28+].join("\n");
29+
30+test("release body keeps the whole section and excludes adjacent releases and the heading", () => {
31+ const notes = extractChangelogSection(CHANGELOG, "1.1.0");
32+ assert.match(notes, /^Favorites now pair a departure stop/u);
33+ assert.match(notes, /### Features\n\n- feat\(arrivals\): support departure-to-arrival favorites \(#28\) \(`102d719`\)/u);
34+ assert.match(notes, /\*\*Full changelog\*\*: https:\/\/github\.com\/example\/app\/compare\/v1\.0\.4\.\.\.v1\.1\.0/u);
35+ assert.match(notes, /\[Release v1\.1\.0\]\(https:\/\/github\.com\/example\/app\/releases\/tag\/v1\.1\.0\)/u);
36+ assert.doesNotMatch(notes, /## \[1\.1\.0\]|partial-service|Changelog/u);
37+ assert.match(notes, /\n$/u);
38+});
39+
40+test("adjacent releases stay isolated regardless of heading position", () => {
41+ const notes = extractChangelogSection(CHANGELOG, "1.0.4");
42+ assert.match(notes, /^This release fixes partial-service arrival pooling\.$/mu);
43+ assert.doesNotMatch(notes, /departure stop|Features|Release v1\.1\.0/u);
44+});
45+
46+test("exact bracket match does not confuse similar versions", () => {
47+ const changelog = ["## [1.10.0] - 2026-09-19", "", "ten", "", "## [1.1.0] - 2026-09-19", "", "one", ""].join("\n");
48+ assert.equal(extractChangelogSection(changelog, "1.1.0"), "one\n");
49+ assert.equal(extractChangelogSection(changelog, "1.10.0"), "ten\n");
50+});
51+
52+test("fenced code examples can neither duplicate nor truncate a section", () => {
53+ const changelog = [
54+ "## [1.1.0] - 2026-09-19",
55+ "",
56+ "Intro before example.",
57+ "",
58+ "```md",
59+ "## [1.1.0] - fake",
60+ "## [9.9.9] - fake",
61+ "fake body",
62+ "```",
63+ "",
64+ "Outro after example.",
65+ "",
66+ "## [1.0.4] - 2026-09-14",
67+ "",
68+ "previous release",
69+ "",
70+ ].join("\n");
71+ const notes = extractChangelogSection(changelog, "1.1.0");
72+ assert.match(notes, /Intro before example\./u);
73+ assert.match(notes, /## \[1\.1\.0\] - fake\n## \[9\.9\.9\] - fake\nfake body/u);
74+ assert.match(notes, /Outro after example\./u);
75+ assert.doesNotMatch(notes, /previous release/u);
76+});
77+
78+test("a fenced heading before any real section is not a section", () => {
79+ const changelog = ["# Changelog", "", "```md", "## [1.1.0] - fake", "```", "", "## [1.1.0] - 2026-09-19", "", "real", ""].join("\n");
80+ assert.equal(extractChangelogSection(changelog, "1.1.0"), "real\n");
81+});
82+
83+test("tilde fences and CRLF line endings are handled like backtick fences", () => {
84+ const changelog = [
85+ "## [1.1.0] - 2026-09-19",
86+ "",
87+ "~~~",
88+ "## [1.0.4] - fake",
89+ "~~~",
90+ "",
91+ "kept",
92+ "",
93+ "## [1.0.4] - 2026-09-14",
94+ ].join("\r\n");
95+ const notes = extractChangelogSection(changelog, "1.1.0");
96+ assert.match(notes, /## \[1\.0\.4\] - fake/u);
97+ assert.match(notes, /kept/u);
98+});
99+
100+test("missing, empty, and duplicate sections are rejected", () => {
101+ assert.throws(() => extractChangelogSection(CHANGELOG, "1.0.5"), /no section for version 1\.0\.5/u);
102+ assert.throws(() => extractChangelogSection("## [1.1.0] - 2026-09-19\n\n## [1.0.4] - 2026-09-14\n", "1.1.0"), /is empty/u);
103+ const duplicated = ["## [1.1.0] - 2026-09-19", "", "first", "", "## [1.1.0] - 2026-09-19", "", "second", ""].join("\n");
104+ assert.throws(() => extractChangelogSection(duplicated, "1.1.0"), /2 sections for version 1\.1\.0/u);
105+});
106+
107+test("a duplicate section is rejected even when separated by another release", () => {
108+ const changelog = [
109+ "## [1.1.0] - 2026-09-19",
110+ "",
111+ "first",
112+ "",
113+ "## [1.0.4] - 2026-09-14",
114+ "",
115+ "older release",
116+ "",
117+ "## [1.1.0] - 2026-09-19",
118+ "",
119+ "second",
120+ "",
121+ ].join("\n");
122+ assert.throws(() => extractChangelogSection(changelog, "1.1.0"), /2 sections for version 1\.1\.0/u);
123+ assert.equal(extractChangelogSection(changelog, "1.0.4"), "older release\n");
124+});
8125
9126 function fixture(t) {
10127 const cwd = mkdtempSync(join(tmpdir(), "lapin-fute-release-notes-"));
@@ -14,91 +131,46 @@ function fixture(t) {
14131 env: { ...process.env, GIT_AUTHOR_NAME: "Fixture", GIT_AUTHOR_EMAIL: "fixture@example.invalid", GIT_COMMITTER_NAME: "Fixture", GIT_COMMITTER_EMAIL: "fixture@example.invalid" },
15132 }).trim();
16133 git("init", "--quiet");
17- function commit(subject, content, parents = [], filename = "app.txt") {
18- writeFileSync(join(cwd, filename), content);
19- git("add", "--", filename);
134+ function commit(subject, parents = [], files = {}) {
135+ for (const [name, content] of Object.entries(files)) writeFileSync(join(cwd, name), content);
136+ git("add", "--", ...Object.keys(files));
20137 const tree = git("write-tree");
21138 return git("commit-tree", tree, ...parents.flatMap((parent) => ["-p", parent]), "-m", subject);
22139 }
23- const root = commit("chore: initialize", "root");
24- return {
25- git, commit, root,
26- notes: (tag, revision = tag) => generateReleaseNotes({ cwd, tag, revision, repository: "example/app" }),
27- };
140+ const root = commit("chore: initialize", [], { "app.txt": "root" });
141+ return { cwd, git, commit, root, notes: (tag, revision = tag) => generateReleaseNotes({ cwd, tag, revision }) };
28142 }
29143
30-test("empty commit subjects do not consume the following categorized commit", (t) => {
31- const f = fixture(t);
32- f.git("tag", "v1.0.0", f.root);
33- const fix = f.commit("fix: preserve this change", "fixed", [f.root]);
34- f.git("update-ref", "HEAD", fix);
35- f.git("-c", "commit.gpgsign=false", "-c", "core.hooksPath=/dev/null",
36- "commit", "--quiet", "--allow-empty", "--allow-empty-message", "-m", "");
37- const empty = f.git("rev-parse", "HEAD");
38- f.git("tag", "v1.0.1", empty);
39- const notes = f.notes("v1.0.1");
40- assert.ok(notes.includes(`## Changes\n\n- (no commit subject) (\`${f.git("rev-parse", "--short", empty)}\`)`));
41- assert.ok(notes.includes(`## Fixes\n\n- fix: preserve this change (\`${f.git("rev-parse", "--short", fix)}\`)`));
42- assert.doesNotMatch(notes, /initialize/u);
43-});
44-
45-test("numeric stable predecessor excludes future, prerelease, build and malformed tags", (t) => {
46- const f = fixture(t);
47- const old = f.commit("feat: already shipped", "old", [f.root]);
48- for (const tag of ["v1.9.9", "v1.10.0"]) f.git("tag", tag, old);
49- const misleading = f.commit("fix: belongs in these notes", "intermediate", [old]);
50- for (const tag of ["v1.10.1-rc.1", "v1.10.1+build", "v01.10.1", "v1.10.1junk", "v1.11.0", "v2.0.0"]) f.git("tag", tag, misleading);
51- const current = f.commit("ci: release", "current", [misleading]);
52- f.git("tag", "v1.10.1", current);
53- const notes = f.notes("v1.10.1");
54- assert.match(notes, /compare\/v1\.10\.0\.\.\.v1\.10\.1/u);
55- assert.match(notes, /## Fixes\n\n- fix: belongs in these notes/u);
56- assert.match(notes, /## CI\n\n- ci: release/u);
57- assert.doesNotMatch(notes, /already shipped|initialize/u);
58-});
59-
60-test("stable version comparison retains precision beyond Number safe integers", (t) => {
61- const f = fixture(t);
62- f.git("tag", "v9007199254740992.0.0", f.root);
63- f.git("tag", "v9007199254740993.0.0", f.root);
64- const current = f.commit("fix: new", "new", [f.root]);
65- f.git("tag", "v9007199254740994.0.0", current);
66- assert.match(f.notes("v9007199254740994.0.0"), /compare\/v9007199254740993\.0\.0\.\.\.v9007199254740994\.0\.0/u);
67-});
68-
69-test("divergent release uses net trees without replaying historical or squash commits", (t) => {
144+test("release notes come from the tagged changelog, never the working copy", (t) => {
70145 const f = fixture(t);
71- const previous = f.commit("feat: huge old feature", "released", [f.root]);
72- f.git("tag", "v1.0.0", previous);
73- const squash = f.commit("feat: ship all old features", "released", [f.root]);
74- const current = f.commit("fix: recent change", "new", [squash]);
75- f.git("tag", "v1.0.1", current);
76- const notes = f.notes("v1.0.1");
77- assert.match(notes, /## Net file changes/u);
78- assert.match(notes, /0 files added, 1 modified, 0 deleted, 0 changed type/u);
79- assert.match(notes, /compare\/v1\.0\.0\.\.v1\.0\.1\n/u);
80- assert.doesNotMatch(notes, /huge old feature|ship all old features|recent change|## Features/u);
146+ const tagged = f.commit("chore: release", [f.root], {
147+ "CHANGELOG.md": ["## [1.1.0] - 2026-09-19", "", "- feat: tagged feature (`102d719`)", ""].join("\n"),
148+ });
149+ f.git("tag", "v1.1.0", tagged);
150+ writeFileSync(join(f.cwd, "CHANGELOG.md"), ["## [1.1.0] - 2026-09-19", "", "- feat: uncommitted change", ""].join("\n"));
151+ const notes = f.notes("v1.1.0", tagged);
152+ assert.match(notes, /- feat: tagged feature \(`102d719`\)/u);
153+ assert.doesNotMatch(notes, /uncommitted change/u);
81154 });
82155
83-test("net counts preserve unusual filenames without injecting Markdown", (t) => {
156+test("a changelog committed after the tag cannot leak into the tagged release", (t) => {
84157 const f = fixture(t);
85- const previous = f.commit("feat: old", "old", [f.root]);
86- f.git("tag", "v1.0.0", previous);
87- const current = f.commit("feat: squash", "new", [f.root], "odd\n`[filename].txt");
88- f.git("tag", "v1.0.1", current);
89- const notes = f.notes("v1.0.1");
90- assert.match(notes, /1 files added, 0 modified, 0 deleted, 0 changed type/u);
91- assert.doesNotMatch(notes, /filename/u);
158+ const tagged = f.commit("chore: release", [f.root], {
159+ "CHANGELOG.md": ["## [1.1.0] - 2026-09-19", "", "- feat: tagged feature", ""].join("\n"),
160+ });
161+ f.git("tag", "v1.1.0", tagged);
162+ const later = f.commit("docs: edit changelog", [tagged], {
163+ "CHANGELOG.md": ["## [1.1.0] - 2026-09-19", "", "- feat: edited after tagging", ""].join("\n"),
164+ });
165+ assert.doesNotMatch(f.notes("v1.1.0", tagged), /edited after tagging/u);
166+ assert.throws(() => f.notes("v1.1.0", later), /does not match its tag/u);
92167 });
93168
94-test("initial release alone includes full history and rejects invalid current releases", (t) => {
169+test("releases reject non-stable tags and repositories without a tagged changelog", (t) => {
95170 const f = fixture(t);
96- f.git("tag", "v1.0.0", f.root);
97- assert.match(f.notes("v1.0.0"), /chore: initialize/u);
98- assert.match(f.notes("v1.0.0"), /\/commits\/v1\.0\.0/u);
99- for (const tag of [undefined, "v1.0", "v1.0.0-rc.1", "v01.0.0"]) {
171+ for (const tag of [undefined, "v1.0", "v1.0.0-rc.1", "v01.0.0", "pre-v1-0.0"]) {
100172 assert.throws(() => f.notes(tag), /strict stable version/u);
101173 }
102- const other = f.commit("fix: untagged", "other", [f.root]);
103- assert.throws(() => f.notes("v1.0.0", other), /does not match its tag/u);
174+ f.git("tag", "v1.1.0", f.root);
175+ assert.throws(() => f.notes("v1.1.0", f.root), /CHANGELOG\.md is missing/u);
104176 });
@@ -4,7 +4,124 @@ import { execFileSync } from "node:child_process";
4 import { mkdtempSync, rmSync, writeFileSync } from "node:fs";4 import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
5 import { tmpdir } from "node:os";5 import { tmpdir } from "node:os";
6 import { join } from "node:path";6 import { join } from "node:path";
7-import { generateReleaseNotes } from "../../../scripts/generate-release-notes.mjs";7+import { extractChangelogSection, generateReleaseNotes } from "../../../scripts/generate-release-notes.mjs";
8+
9+const CHANGELOG = [
10+ "# Changelog",
11+ "",
12+ "## [1.1.0] - 2026-09-19",
13+ "",
14+ "Favorites now pair a departure stop with a reachable arrival stop.",
15+ "",
16+ "### Features",
17+ "",
18+ "- feat(arrivals): support departure-to-arrival favorites (#28) (`102d719`)",
19+ "",
20+ "**Full changelog**: https://github.com/example/app/compare/v1.0.4...v1.1.0",
21+ "",
22+ "[Release v1.1.0](https://github.com/example/app/releases/tag/v1.1.0)",
23+ "",
24+ "## [1.0.4] - 2026-09-14",
25+ "",
26+ "This release fixes partial-service arrival pooling.",
27+ "",
28+].join("\n");
29+
30+test("release body keeps the whole section and excludes adjacent releases and the heading", () => {
31+ const notes = extractChangelogSection(CHANGELOG, "1.1.0");
32+ assert.match(notes, /^Favorites now pair a departure stop/u);
33+ assert.match(notes, /### Features\n\n- feat\(arrivals\): support departure-to-arrival favorites \(#28\) \(`102d719`\)/u);
34+ assert.match(notes, /\*\*Full changelog\*\*: https:\/\/github\.com\/example\/app\/compare\/v1\.0\.4\.\.\.v1\.1\.0/u);
35+ assert.match(notes, /\[Release v1\.1\.0\]\(https:\/\/github\.com\/example\/app\/releases\/tag\/v1\.1\.0\)/u);
36+ assert.doesNotMatch(notes, /## \[1\.1\.0\]|partial-service|Changelog/u);
37+ assert.match(notes, /\n$/u);
38+});
39+
40+test("adjacent releases stay isolated regardless of heading position", () => {
41+ const notes = extractChangelogSection(CHANGELOG, "1.0.4");
42+ assert.match(notes, /^This release fixes partial-service arrival pooling\.$/mu);
43+ assert.doesNotMatch(notes, /departure stop|Features|Release v1\.1\.0/u);
44+});
45+
46+test("exact bracket match does not confuse similar versions", () => {
47+ const changelog = ["## [1.10.0] - 2026-09-19", "", "ten", "", "## [1.1.0] - 2026-09-19", "", "one", ""].join("\n");
48+ assert.equal(extractChangelogSection(changelog, "1.1.0"), "one\n");
49+ assert.equal(extractChangelogSection(changelog, "1.10.0"), "ten\n");
50+});
51+
52+test("fenced code examples can neither duplicate nor truncate a section", () => {
53+ const changelog = [
54+ "## [1.1.0] - 2026-09-19",
55+ "",
56+ "Intro before example.",
57+ "",
58+ "```md",
59+ "## [1.1.0] - fake",
60+ "## [9.9.9] - fake",
61+ "fake body",
62+ "```",
63+ "",
64+ "Outro after example.",
65+ "",
66+ "## [1.0.4] - 2026-09-14",
67+ "",
68+ "previous release",
69+ "",
70+ ].join("\n");
71+ const notes = extractChangelogSection(changelog, "1.1.0");
72+ assert.match(notes, /Intro before example\./u);
73+ assert.match(notes, /## \[1\.1\.0\] - fake\n## \[9\.9\.9\] - fake\nfake body/u);
74+ assert.match(notes, /Outro after example\./u);
75+ assert.doesNotMatch(notes, /previous release/u);
76+});
77+
78+test("a fenced heading before any real section is not a section", () => {
79+ const changelog = ["# Changelog", "", "```md", "## [1.1.0] - fake", "```", "", "## [1.1.0] - 2026-09-19", "", "real", ""].join("\n");
80+ assert.equal(extractChangelogSection(changelog, "1.1.0"), "real\n");
81+});
82+
83+test("tilde fences and CRLF line endings are handled like backtick fences", () => {
84+ const changelog = [
85+ "## [1.1.0] - 2026-09-19",
86+ "",
87+ "~~~",
88+ "## [1.0.4] - fake",
89+ "~~~",
90+ "",
91+ "kept",
92+ "",
93+ "## [1.0.4] - 2026-09-14",
94+ ].join("\r\n");
95+ const notes = extractChangelogSection(changelog, "1.1.0");
96+ assert.match(notes, /## \[1\.0\.4\] - fake/u);
97+ assert.match(notes, /kept/u);
98+});
99+
100+test("missing, empty, and duplicate sections are rejected", () => {
101+ assert.throws(() => extractChangelogSection(CHANGELOG, "1.0.5"), /no section for version 1\.0\.5/u);
102+ assert.throws(() => extractChangelogSection("## [1.1.0] - 2026-09-19\n\n## [1.0.4] - 2026-09-14\n", "1.1.0"), /is empty/u);
103+ const duplicated = ["## [1.1.0] - 2026-09-19", "", "first", "", "## [1.1.0] - 2026-09-19", "", "second", ""].join("\n");
104+ assert.throws(() => extractChangelogSection(duplicated, "1.1.0"), /2 sections for version 1\.1\.0/u);
105+});
106+
107+test("a duplicate section is rejected even when separated by another release", () => {
108+ const changelog = [
109+ "## [1.1.0] - 2026-09-19",
110+ "",
111+ "first",
112+ "",
113+ "## [1.0.4] - 2026-09-14",
114+ "",
115+ "older release",
116+ "",
117+ "## [1.1.0] - 2026-09-19",
118+ "",
119+ "second",
120+ "",
121+ ].join("\n");
122+ assert.throws(() => extractChangelogSection(changelog, "1.1.0"), /2 sections for version 1\.1\.0/u);
123+ assert.equal(extractChangelogSection(changelog, "1.0.4"), "older release\n");
124+});
8 125
9 function fixture(t) {126 function fixture(t) {
10 const cwd = mkdtempSync(join(tmpdir(), "lapin-fute-release-notes-"));127 const cwd = mkdtempSync(join(tmpdir(), "lapin-fute-release-notes-"));
@@ -14,91 +131,46 @@ function fixture(t) {
14 env: { ...process.env, GIT_AUTHOR_NAME: "Fixture", GIT_AUTHOR_EMAIL: "fixture@example.invalid", GIT_COMMITTER_NAME: "Fixture", GIT_COMMITTER_EMAIL: "fixture@example.invalid" },131 env: { ...process.env, GIT_AUTHOR_NAME: "Fixture", GIT_AUTHOR_EMAIL: "fixture@example.invalid", GIT_COMMITTER_NAME: "Fixture", GIT_COMMITTER_EMAIL: "fixture@example.invalid" },
15 }).trim();132 }).trim();
16 git("init", "--quiet");133 git("init", "--quiet");
17- function commit(subject, content, parents = [], filename = "app.txt") {134+ function commit(subject, parents = [], files = {}) {
18- writeFileSync(join(cwd, filename), content);135+ for (const [name, content] of Object.entries(files)) writeFileSync(join(cwd, name), content);
19- git("add", "--", filename);136+ git("add", "--", ...Object.keys(files));
20 const tree = git("write-tree");137 const tree = git("write-tree");
21 return git("commit-tree", tree, ...parents.flatMap((parent) => ["-p", parent]), "-m", subject);138 return git("commit-tree", tree, ...parents.flatMap((parent) => ["-p", parent]), "-m", subject);
22 }139 }
23- const root = commit("chore: initialize", "root");140+ const root = commit("chore: initialize", [], { "app.txt": "root" });
24- return {141+ return { cwd, git, commit, root, notes: (tag, revision = tag) => generateReleaseNotes({ cwd, tag, revision }) };
25- git, commit, root,
26- notes: (tag, revision = tag) => generateReleaseNotes({ cwd, tag, revision, repository: "example/app" }),
27- };
28 }142 }
29 143
30-test("empty commit subjects do not consume the following categorized commit", (t) => {144+test("release notes come from the tagged changelog, never the working copy", (t) => {
31- const f = fixture(t);
32- f.git("tag", "v1.0.0", f.root);
33- const fix = f.commit("fix: preserve this change", "fixed", [f.root]);
34- f.git("update-ref", "HEAD", fix);
35- f.git("-c", "commit.gpgsign=false", "-c", "core.hooksPath=/dev/null",
36- "commit", "--quiet", "--allow-empty", "--allow-empty-message", "-m", "");
37- const empty = f.git("rev-parse", "HEAD");
38- f.git("tag", "v1.0.1", empty);
39- const notes = f.notes("v1.0.1");
40- assert.ok(notes.includes(`## Changes\n\n- (no commit subject) (\`${f.git("rev-parse", "--short", empty)}\`)`));
41- assert.ok(notes.includes(`## Fixes\n\n- fix: preserve this change (\`${f.git("rev-parse", "--short", fix)}\`)`));
42- assert.doesNotMatch(notes, /initialize/u);
43-});
44-
45-test("numeric stable predecessor excludes future, prerelease, build and malformed tags", (t) => {
46- const f = fixture(t);
47- const old = f.commit("feat: already shipped", "old", [f.root]);
48- for (const tag of ["v1.9.9", "v1.10.0"]) f.git("tag", tag, old);
49- const misleading = f.commit("fix: belongs in these notes", "intermediate", [old]);
50- for (const tag of ["v1.10.1-rc.1", "v1.10.1+build", "v01.10.1", "v1.10.1junk", "v1.11.0", "v2.0.0"]) f.git("tag", tag, misleading);
51- const current = f.commit("ci: release", "current", [misleading]);
52- f.git("tag", "v1.10.1", current);
53- const notes = f.notes("v1.10.1");
54- assert.match(notes, /compare\/v1\.10\.0\.\.\.v1\.10\.1/u);
55- assert.match(notes, /## Fixes\n\n- fix: belongs in these notes/u);
56- assert.match(notes, /## CI\n\n- ci: release/u);
57- assert.doesNotMatch(notes, /already shipped|initialize/u);
58-});
59-
60-test("stable version comparison retains precision beyond Number safe integers", (t) => {
61- const f = fixture(t);
62- f.git("tag", "v9007199254740992.0.0", f.root);
63- f.git("tag", "v9007199254740993.0.0", f.root);
64- const current = f.commit("fix: new", "new", [f.root]);
65- f.git("tag", "v9007199254740994.0.0", current);
66- assert.match(f.notes("v9007199254740994.0.0"), /compare\/v9007199254740993\.0\.0\.\.\.v9007199254740994\.0\.0/u);
67-});
68-
69-test("divergent release uses net trees without replaying historical or squash commits", (t) => {
70 const f = fixture(t);145 const f = fixture(t);
71- const previous = f.commit("feat: huge old feature", "released", [f.root]);146+ const tagged = f.commit("chore: release", [f.root], {
72- f.git("tag", "v1.0.0", previous);147+ "CHANGELOG.md": ["## [1.1.0] - 2026-09-19", "", "- feat: tagged feature (`102d719`)", ""].join("\n"),
73- const squash = f.commit("feat: ship all old features", "released", [f.root]);148+ });
74- const current = f.commit("fix: recent change", "new", [squash]);149+ f.git("tag", "v1.1.0", tagged);
75- f.git("tag", "v1.0.1", current);150+ writeFileSync(join(f.cwd, "CHANGELOG.md"), ["## [1.1.0] - 2026-09-19", "", "- feat: uncommitted change", ""].join("\n"));
76- const notes = f.notes("v1.0.1");151+ const notes = f.notes("v1.1.0", tagged);
77- assert.match(notes, /## Net file changes/u);152+ assert.match(notes, /- feat: tagged feature \(`102d719`\)/u);
78- assert.match(notes, /0 files added, 1 modified, 0 deleted, 0 changed type/u);153+ assert.doesNotMatch(notes, /uncommitted change/u);
79- assert.match(notes, /compare\/v1\.0\.0\.\.v1\.0\.1\n/u);
80- assert.doesNotMatch(notes, /huge old feature|ship all old features|recent change|## Features/u);
81 });154 });
82 155
83-test("net counts preserve unusual filenames without injecting Markdown", (t) => {156+test("a changelog committed after the tag cannot leak into the tagged release", (t) => {
84 const f = fixture(t);157 const f = fixture(t);
85- const previous = f.commit("feat: old", "old", [f.root]);158+ const tagged = f.commit("chore: release", [f.root], {
86- f.git("tag", "v1.0.0", previous);159+ "CHANGELOG.md": ["## [1.1.0] - 2026-09-19", "", "- feat: tagged feature", ""].join("\n"),
87- const current = f.commit("feat: squash", "new", [f.root], "odd\n`[filename].txt");160+ });
88- f.git("tag", "v1.0.1", current);161+ f.git("tag", "v1.1.0", tagged);
89- const notes = f.notes("v1.0.1");162+ const later = f.commit("docs: edit changelog", [tagged], {
90- assert.match(notes, /1 files added, 0 modified, 0 deleted, 0 changed type/u);163+ "CHANGELOG.md": ["## [1.1.0] - 2026-09-19", "", "- feat: edited after tagging", ""].join("\n"),
91- assert.doesNotMatch(notes, /filename/u);164+ });
165+ assert.doesNotMatch(f.notes("v1.1.0", tagged), /edited after tagging/u);
166+ assert.throws(() => f.notes("v1.1.0", later), /does not match its tag/u);
92 });167 });
93 168
94-test("initial release alone includes full history and rejects invalid current releases", (t) => {169+test("releases reject non-stable tags and repositories without a tagged changelog", (t) => {
95 const f = fixture(t);170 const f = fixture(t);
96- f.git("tag", "v1.0.0", f.root);171+ for (const tag of [undefined, "v1.0", "v1.0.0-rc.1", "v01.0.0", "pre-v1-0.0"]) {
97- assert.match(f.notes("v1.0.0"), /chore: initialize/u);
98- assert.match(f.notes("v1.0.0"), /\/commits\/v1\.0\.0/u);
99- for (const tag of [undefined, "v1.0", "v1.0.0-rc.1", "v01.0.0"]) {
100 assert.throws(() => f.notes(tag), /strict stable version/u);172 assert.throws(() => f.notes(tag), /strict stable version/u);
101 }173 }
102- const other = f.commit("fix: untagged", "other", [f.root]);174+ f.git("tag", "v1.1.0", f.root);
103- assert.throws(() => f.notes("v1.0.0", other), /does not match its tag/u);175+ assert.throws(() => f.notes("v1.1.0", f.root), /CHANGELOG\.md is missing/u);
104 });176 });
modified packages/watch/package.json +1 -1
@@ -1,7 +1,7 @@
11 {
22 "name": "lapin-fute-watch",
33 "author": "Anthodev",
4- "version": "1.0.4",
4+ "version": "1.1.0",
55 "license": "MIT",
66 "keywords": [
77 "pebble-app"
@@ -1,7 +1,7 @@
1 {1 {
2 "name": "lapin-fute-watch",2 "name": "lapin-fute-watch",
3 "author": "Anthodev",3 "author": "Anthodev",
4- "version": "1.0.4",4+ "version": "1.1.0",
5 "license": "MIT",5 "license": "MIT",
6 "keywords": [6 "keywords": [
7 "pebble-app"7 "pebble-app"
modified scripts/generate-release-notes.mjs +58 -75
@@ -3,98 +3,81 @@ import { writeFileSync } from "node:fs";
33 import { fileURLToPath } from "node:url";
44
55 const STABLE_TAG = /^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/u;
6+const FENCED_LINE = /^ {0,3}(`{3,}|~{3,})(.*)$/u;
67
7-function compareVersions(left, right) {
8- for (let index = 0; index < 3; index += 1) {
9- if (left[index] < right[index]) return -1;
10- if (left[index] > right[index]) return 1;
8+// Advance the fenced-code state for one line. Returns null when no block is
9+// open, or { char, length } for the currently open fence marker.
10+function nextFence(line, open) {
11+ const match = line.match(FENCED_LINE);
12+ if (!match) return open;
13+ const [, marker, info] = match;
14+ if (open) {
15+ // A closing fence reuses the character, is at least as long, and has no info string.
16+ return marker[0] === open.char && marker.length >= open.length && info.trim() === "" ? null : open;
1117 }
12- return 0;
18+ // A backtick fence's info string cannot contain backticks.
19+ if (marker[0] === "`" && info.includes("`")) return null;
20+ return { char: marker[0], length: marker.length };
1321 }
1422
15-function previousStableTag(tags, currentTag) {
16- const current = currentTag?.match(STABLE_TAG)?.slice(1).map(BigInt);
17- if (!current) throw new Error("Release tag must be a strict stable version: vMAJOR.MINOR.PATCH");
18- let previous = null;
19- for (const tag of tags) {
20- const version = tag.match(STABLE_TAG)?.slice(1).map(BigInt);
21- if (!version || compareVersions(version, current) >= 0) continue;
22- if (!previous || compareVersions(version, previous.version) > 0) previous = { tag, version };
23+// Return the body of the `## [<version>] - date` section, without the heading
24+// itself. Headings and section boundaries inside fenced code blocks are
25+// ignored so code examples can neither duplicate nor truncate a section.
26+export function extractChangelogSection(changelog, version) {
27+ const escaped = version.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
28+ const heading = new RegExp(`^ {0,3}## \\[${escaped}\\](?:[ \\t].*)?$`, "u");
29+ const lines = changelog.replace(/\r\n?/gu, "\n").split("\n");
30+ let open = null;
31+ let sections = 0;
32+ let collected = null;
33+ let collecting = false;
34+ for (const line of lines) {
35+ if (FENCED_LINE.test(line)) open = nextFence(line, open);
36+ if (open) {
37+ // Fenced content is part of the section; only heading detection ignores it.
38+ if (collecting) collected.push(line);
39+ continue;
40+ }
41+ if (heading.test(line)) {
42+ sections += 1;
43+ collecting = sections === 1;
44+ if (collecting) collected = [];
45+ continue;
46+ }
47+ if (!collecting) continue;
48+ if (/^ {0,3}## /u.test(line)) {
49+ // Another section began; keep scanning so a duplicated version heading
50+ // cannot hide after it.
51+ collecting = false;
52+ continue;
53+ }
54+ collected.push(line);
2355 }
24- return previous?.tag ?? null;
56+ if (sections === 0) throw new Error(`CHANGELOG.md has no section for version ${version}; expected a "## [${version}] - date" heading`);
57+ if (sections > 1) throw new Error(`CHANGELOG.md has ${sections} sections for version ${version}; expected exactly one`);
58+ const content = collected.join("\n").replace(/^\n+/u, "").replace(/\s+$/u, "");
59+ if (!content) throw new Error(`CHANGELOG.md section for version ${version} is empty`);
60+ return `${content}\n`;
2561 }
2662
27-export function generateReleaseNotes({ tag, revision = tag, repository, cwd = process.cwd() }) {
63+export function generateReleaseNotes({ tag, revision = tag, cwd = process.cwd() }) {
64+ if (!STABLE_TAG.test(tag)) throw new Error("Release tag must be a strict stable version: vMAJOR.MINOR.PATCH");
2865 const git = (...args) => execFileSync("git", args, { cwd, encoding: "utf8" });
29- if (!repository || !/^[\w.-]+\/[\w.-]+$/u.test(repository)) throw new Error("A GitHub owner/repository is required");
30- const previous = previousStableTag(git("tag", "--list").trim().split("\n"), tag);
3166 const currentCommit = git("rev-parse", "--verify", "--end-of-options", `${revision}^{commit}`).trim();
3267 const taggedCommit = git("rev-parse", "--verify", `refs/tags/${tag}^{commit}`).trim();
3368 if (currentCommit !== taggedCommit) throw new Error("Release revision does not match its tag");
34- const baseUrl = `https://github.com/${repository}`;
35- const notes = [];
36- let baseline = null;
37- let divergent = false;
38- if (previous) {
39- baseline = git("rev-parse", "--verify", `refs/tags/${previous}^{commit}`).trim();
40- try {
41- git("merge-base", "--is-ancestor", baseline, currentCommit);
42- } catch (error) {
43- if (error.status !== 1) throw error;
44- divergent = true;
45- baseline = null;
46- }
47- }
48-
49- if (divergent) {
50- // Two endpoints, not a merge-base diff: old feature commits must not look new.
51- const statuses = git("diff", "--no-renames", "--name-status", "-z", `refs/tags/${previous}`, currentCommit, "--")
52- .split("\0");
53- const counts = { A: 0, M: 0, D: 0, T: 0 };
54- for (let index = 0; index < statuses.length - 1; index += 2) counts[statuses[index]] += 1;
55- notes.push(
56- "## Net file changes",
57- "",
58- `The history of ${previous} is not an ancestor of ${tag}. This summary compares the two release trees directly; historical commit titles are omitted to avoid presenting previously shipped work as new.`,
59- "",
60- `- ${counts.A} files added, ${counts.M} modified, ${counts.D} deleted, ${counts.T} changed type.`,
61- "",
62- );
63- } else {
64- const groups = [
65- { key: "feat", title: "Features", commits: [] },
66- { key: "change", title: "Changes", commits: [] },
67- { key: "fix", title: "Fixes", commits: [] },
68- { key: "ci", title: "CI", commits: [] },
69- ];
70- const byKey = new Map(groups.map((group) => [group.key, group]));
71- const fields = git("log", "--first-parent", "-z", "--format=%s%x00%h", baseline ? `${baseline}..${currentCommit}` : currentCommit, "--")
72- .split("\0");
73- fields.pop(); // Remove only the record terminator; empty subjects are valid fields.
74- for (let index = 0; index < fields.length; index += 2) {
75- const subject = fields[index].trim() || "(no commit subject)";
76- const hash = fields[index + 1].trim();
77- const type = subject.match(/^([a-z]+)(?:\([^)]*\))?!?:\s+/i)?.[1].toLowerCase();
78- const key = ["feat", "fix", "ci"].includes(type) ? type : "change";
79- byKey.get(key).commits.push(`- ${subject} (\`${hash}\`)`);
80- }
81- for (const group of groups) {
82- if (group.commits.length === 0) continue;
83- notes.push(`## ${group.title}`, "", ...group.commits, "");
84- }
69+ let changelog;
70+ try {
71+ changelog = git("show", `${currentCommit}:CHANGELOG.md`);
72+ } catch {
73+ throw new Error(`CHANGELOG.md is missing at the tagged revision of ${tag}`);
8574 }
86- // GitHub's two-dot comparison shows the endpoint trees even across divergent histories.
87- const changelogUrl = previous
88- ? `${baseUrl}/compare/${previous}${divergent ? ".." : "..."}${tag}`
89- : `${baseUrl}/commits/${tag}`;
90- notes.push(`**${divergent ? "Release tree diff" : "Full changelog"}**: ${changelogUrl}`, "");
91- return notes.join("\n");
75+ return extractChangelogSection(changelog, tag.slice(1));
9276 }
9377
9478 if (process.argv[1] === fileURLToPath(import.meta.url)) {
9579 writeFileSync("release-notes.md", generateReleaseNotes({
9680 tag: process.env.GITHUB_REF_NAME,
9781 revision: process.env.GITHUB_SHA,
98- repository: process.env.GITHUB_REPOSITORY,
9982 }));
10083 }
@@ -3,98 +3,81 @@ import { writeFileSync } from "node:fs";
3 import { fileURLToPath } from "node:url";3 import { fileURLToPath } from "node:url";
4 4
5 const STABLE_TAG = /^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/u;5 const STABLE_TAG = /^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/u;
6+const FENCED_LINE = /^ {0,3}(`{3,}|~{3,})(.*)$/u;
6 7
7-function compareVersions(left, right) {8+// Advance the fenced-code state for one line. Returns null when no block is
8- for (let index = 0; index < 3; index += 1) {9+// open, or { char, length } for the currently open fence marker.
9- if (left[index] < right[index]) return -1;10+function nextFence(line, open) {
10- if (left[index] > right[index]) return 1;11+ const match = line.match(FENCED_LINE);
12+ if (!match) return open;
13+ const [, marker, info] = match;
14+ if (open) {
15+ // A closing fence reuses the character, is at least as long, and has no info string.
16+ return marker[0] === open.char && marker.length >= open.length && info.trim() === "" ? null : open;
11 }17 }
12- return 0;18+ // A backtick fence's info string cannot contain backticks.
19+ if (marker[0] === "`" && info.includes("`")) return null;
20+ return { char: marker[0], length: marker.length };
13 }21 }
14 22
15-function previousStableTag(tags, currentTag) {23+// Return the body of the `## [<version>] - date` section, without the heading
16- const current = currentTag?.match(STABLE_TAG)?.slice(1).map(BigInt);24+// itself. Headings and section boundaries inside fenced code blocks are
17- if (!current) throw new Error("Release tag must be a strict stable version: vMAJOR.MINOR.PATCH");25+// ignored so code examples can neither duplicate nor truncate a section.
18- let previous = null;26+export function extractChangelogSection(changelog, version) {
19- for (const tag of tags) {27+ const escaped = version.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
20- const version = tag.match(STABLE_TAG)?.slice(1).map(BigInt);28+ const heading = new RegExp(`^ {0,3}## \\[${escaped}\\](?:[ \\t].*)?$`, "u");
21- if (!version || compareVersions(version, current) >= 0) continue;29+ const lines = changelog.replace(/\r\n?/gu, "\n").split("\n");
22- if (!previous || compareVersions(version, previous.version) > 0) previous = { tag, version };30+ let open = null;
31+ let sections = 0;
32+ let collected = null;
33+ let collecting = false;
34+ for (const line of lines) {
35+ if (FENCED_LINE.test(line)) open = nextFence(line, open);
36+ if (open) {
37+ // Fenced content is part of the section; only heading detection ignores it.
38+ if (collecting) collected.push(line);
39+ continue;
40+ }
41+ if (heading.test(line)) {
42+ sections += 1;
43+ collecting = sections === 1;
44+ if (collecting) collected = [];
45+ continue;
46+ }
47+ if (!collecting) continue;
48+ if (/^ {0,3}## /u.test(line)) {
49+ // Another section began; keep scanning so a duplicated version heading
50+ // cannot hide after it.
51+ collecting = false;
52+ continue;
53+ }
54+ collected.push(line);
23 }55 }
24- return previous?.tag ?? null;56+ if (sections === 0) throw new Error(`CHANGELOG.md has no section for version ${version}; expected a "## [${version}] - date" heading`);
57+ if (sections > 1) throw new Error(`CHANGELOG.md has ${sections} sections for version ${version}; expected exactly one`);
58+ const content = collected.join("\n").replace(/^\n+/u, "").replace(/\s+$/u, "");
59+ if (!content) throw new Error(`CHANGELOG.md section for version ${version} is empty`);
60+ return `${content}\n`;
25 }61 }
26 62
27-export function generateReleaseNotes({ tag, revision = tag, repository, cwd = process.cwd() }) {63+export function generateReleaseNotes({ tag, revision = tag, cwd = process.cwd() }) {
64+ if (!STABLE_TAG.test(tag)) throw new Error("Release tag must be a strict stable version: vMAJOR.MINOR.PATCH");
28 const git = (...args) => execFileSync("git", args, { cwd, encoding: "utf8" });65 const git = (...args) => execFileSync("git", args, { cwd, encoding: "utf8" });
29- if (!repository || !/^[\w.-]+\/[\w.-]+$/u.test(repository)) throw new Error("A GitHub owner/repository is required");
30- const previous = previousStableTag(git("tag", "--list").trim().split("\n"), tag);
31 const currentCommit = git("rev-parse", "--verify", "--end-of-options", `${revision}^{commit}`).trim();66 const currentCommit = git("rev-parse", "--verify", "--end-of-options", `${revision}^{commit}`).trim();
32 const taggedCommit = git("rev-parse", "--verify", `refs/tags/${tag}^{commit}`).trim();67 const taggedCommit = git("rev-parse", "--verify", `refs/tags/${tag}^{commit}`).trim();
33 if (currentCommit !== taggedCommit) throw new Error("Release revision does not match its tag");68 if (currentCommit !== taggedCommit) throw new Error("Release revision does not match its tag");
34- const baseUrl = `https://github.com/${repository}`;69+ let changelog;
35- const notes = [];70+ try {
36- let baseline = null;71+ changelog = git("show", `${currentCommit}:CHANGELOG.md`);
37- let divergent = false;72+ } catch {
38- if (previous) {73+ throw new Error(`CHANGELOG.md is missing at the tagged revision of ${tag}`);
39- baseline = git("rev-parse", "--verify", `refs/tags/${previous}^{commit}`).trim();
40- try {
41- git("merge-base", "--is-ancestor", baseline, currentCommit);
42- } catch (error) {
43- if (error.status !== 1) throw error;
44- divergent = true;
45- baseline = null;
46- }
47- }
48-
49- if (divergent) {
50- // Two endpoints, not a merge-base diff: old feature commits must not look new.
51- const statuses = git("diff", "--no-renames", "--name-status", "-z", `refs/tags/${previous}`, currentCommit, "--")
52- .split("\0");
53- const counts = { A: 0, M: 0, D: 0, T: 0 };
54- for (let index = 0; index < statuses.length - 1; index += 2) counts[statuses[index]] += 1;
55- notes.push(
56- "## Net file changes",
57- "",
58- `The history of ${previous} is not an ancestor of ${tag}. This summary compares the two release trees directly; historical commit titles are omitted to avoid presenting previously shipped work as new.`,
59- "",
60- `- ${counts.A} files added, ${counts.M} modified, ${counts.D} deleted, ${counts.T} changed type.`,
61- "",
62- );
63- } else {
64- const groups = [
65- { key: "feat", title: "Features", commits: [] },
66- { key: "change", title: "Changes", commits: [] },
67- { key: "fix", title: "Fixes", commits: [] },
68- { key: "ci", title: "CI", commits: [] },
69- ];
70- const byKey = new Map(groups.map((group) => [group.key, group]));
71- const fields = git("log", "--first-parent", "-z", "--format=%s%x00%h", baseline ? `${baseline}..${currentCommit}` : currentCommit, "--")
72- .split("\0");
73- fields.pop(); // Remove only the record terminator; empty subjects are valid fields.
74- for (let index = 0; index < fields.length; index += 2) {
75- const subject = fields[index].trim() || "(no commit subject)";
76- const hash = fields[index + 1].trim();
77- const type = subject.match(/^([a-z]+)(?:\([^)]*\))?!?:\s+/i)?.[1].toLowerCase();
78- const key = ["feat", "fix", "ci"].includes(type) ? type : "change";
79- byKey.get(key).commits.push(`- ${subject} (\`${hash}\`)`);
80- }
81- for (const group of groups) {
82- if (group.commits.length === 0) continue;
83- notes.push(`## ${group.title}`, "", ...group.commits, "");
84- }
85 }74 }
86- // GitHub's two-dot comparison shows the endpoint trees even across divergent histories.75+ return extractChangelogSection(changelog, tag.slice(1));
87- const changelogUrl = previous
88- ? `${baseUrl}/compare/${previous}${divergent ? ".." : "..."}${tag}`
89- : `${baseUrl}/commits/${tag}`;
90- notes.push(`**${divergent ? "Release tree diff" : "Full changelog"}**: ${changelogUrl}`, "");
91- return notes.join("\n");
92 }76 }
93 77
94 if (process.argv[1] === fileURLToPath(import.meta.url)) {78 if (process.argv[1] === fileURLToPath(import.meta.url)) {
95 writeFileSync("release-notes.md", generateReleaseNotes({79 writeFileSync("release-notes.md", generateReleaseNotes({
96 tag: process.env.GITHUB_REF_NAME,80 tag: process.env.GITHUB_REF_NAME,
97 revision: process.env.GITHUB_SHA,81 revision: process.env.GITHUB_SHA,
98- repository: process.env.GITHUB_REPOSITORY,
99 }));82 }));
100 }83 }