chore(release): prepare 1.1.0 with changelog-based release notes
7aaa70b parent: 6ae56e9 modified
.github/workflows/deploy-bunny.yml +9 -1 | @@ -48,6 +48,14 @@ jobs: | ||
| 48 | 48 | preview='^pre-v(0|[1-9][0-9]*)-(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' |
| 49 | 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 | 59 | - run: npm test |
| 52 | 60 | |
| 53 | 61 | package: |
| @@ -380,7 +388,7 @@ jobs: | ||
| 380 | 388 | - name: Generate release notes |
| 381 | 389 | run: node scripts/generate-release-notes.mjs |
| 382 | 390 | |
| 383 | - - name: Create release with categorized notes | |
| 391 | + - name: Create release with changelog notes | |
| 384 | 392 | env: |
| 385 | 393 | GH_TOKEN: ${{ github.token }} |
| 386 | 394 | 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 test | 59 | - run: npm test |
| 52 | 60 | ||
| 53 | package: | 61 | package: |
| @@ -380,7 +388,7 @@ jobs: | |||
| 380 | - name: Generate release notes | 388 | - name: Generate release notes |
| 381 | run: node scripts/generate-release-notes.mjs | 389 | run: node scripts/generate-release-notes.mjs |
| 382 | 390 | ||
| 383 | - - name: Create release with categorized notes | 391 | + - name: Create release with changelog notes |
| 384 | env: | 392 | env: |
| 385 | GH_TOKEN: ${{ github.token }} | 393 | GH_TOKEN: ${{ github.token }} |
| 386 | shell: bash | 394 | 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 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "name": "lapin-fute", |
| 3 | - "version": "1.0.4", | |
| 3 | + "version": "1.1.0", | |
| 4 | 4 | "lockfileVersion": 3, |
| 5 | 5 | "requires": true, |
| 6 | 6 | "packages": { |
| 7 | 7 | "": { |
| 8 | 8 | "name": "lapin-fute", |
| 9 | - "version": "1.0.4", | |
| 9 | + "version": "1.1.0", | |
| 10 | 10 | "license": "MIT", |
| 11 | 11 | "workspaces": [ |
| 12 | 12 | "packages/*" |
| @@ -57,7 +57,7 @@ | ||
| 57 | 57 | }, |
| 58 | 58 | "packages/catalog": { |
| 59 | 59 | "name": "@lapin-fute/catalog", |
| 60 | - "version": "1.0.4", | |
| 60 | + "version": "1.1.0", | |
| 61 | 61 | "license": "MIT", |
| 62 | 62 | "dependencies": { |
| 63 | 63 | "csv-parse": "7.0.2", |
| @@ -66,17 +66,17 @@ | ||
| 66 | 66 | }, |
| 67 | 67 | "packages/companion": { |
| 68 | 68 | "name": "@lapin-fute/companion", |
| 69 | - "version": "1.0.4", | |
| 69 | + "version": "1.1.0", | |
| 70 | 70 | "license": "MIT" |
| 71 | 71 | }, |
| 72 | 72 | "packages/config-page": { |
| 73 | 73 | "name": "@lapin-fute/config-page", |
| 74 | - "version": "1.0.4", | |
| 74 | + "version": "1.1.0", | |
| 75 | 75 | "license": "MIT" |
| 76 | 76 | }, |
| 77 | 77 | "packages/watch": { |
| 78 | 78 | "name": "lapin-fute-watch", |
| 79 | - "version": "1.0.4", | |
| 79 | + "version": "1.1.0", | |
| 80 | 80 | "license": "MIT" |
| 81 | 81 | } |
| 82 | 82 | } |
| @@ -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 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "name": "lapin-fute", |
| 3 | - "version": "1.0.4", | |
| 3 | + "version": "1.1.0", | |
| 4 | 4 | "license": "MIT", |
| 5 | 5 | "private": true, |
| 6 | 6 | "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 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "name": "@lapin-fute/catalog", |
| 3 | - "version": "1.0.4", | |
| 3 | + "version": "1.1.0", | |
| 4 | 4 | "license": "MIT", |
| 5 | 5 | "private": true, |
| 6 | 6 | "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 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "name": "@lapin-fute/companion", |
| 3 | - "version": "1.0.4", | |
| 3 | + "version": "1.1.0", | |
| 4 | 4 | "license": "MIT", |
| 5 | 5 | "private": true, |
| 6 | 6 | "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 @@ | ||
| 153 | 153 | </div> |
| 154 | 154 | |
| 155 | 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 | 157 | <h3>Publisher and contact</h3> |
| 158 | 158 | <p><strong>Author and publisher:</strong> Anthodev, a private individual acting on a non-professional basis.</p> |
| 159 | 159 | <p><strong>Publication director:</strong> Anthodev.</p> |
| @@ -175,7 +175,7 @@ | ||
| 175 | 175 | </div> |
| 176 | 176 | |
| 177 | 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 | 179 | <h3>Édition et contact</h3> |
| 180 | 180 | <p><strong>Auteur et éditeur :</strong> Anthodev, personne physique agissant à titre privé et non professionnel.</p> |
| 181 | 181 | <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 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "name": "@lapin-fute/config-page", |
| 3 | - "version": "1.0.4", | |
| 3 | + "version": "1.1.0", | |
| 4 | 4 | "license": "MIT", |
| 5 | 5 | "private": true, |
| 6 | 6 | "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"; | ||
| 4 | 4 | import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; |
| 5 | 5 | import { tmpdir } from "node:os"; |
| 6 | 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 | 126 | function fixture(t) { |
| 10 | 127 | const cwd = mkdtempSync(join(tmpdir(), "lapin-fute-release-notes-")); |
| @@ -14,91 +131,46 @@ function fixture(t) { | ||
| 14 | 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 | 132 | }).trim(); |
| 16 | 133 | 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)); | |
| 20 | 137 | const tree = git("write-tree"); |
| 21 | 138 | return git("commit-tree", tree, ...parents.flatMap((parent) => ["-p", parent]), "-m", subject); |
| 22 | 139 | } |
| 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 }) }; | |
| 28 | 142 | } |
| 29 | 143 | |
| 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) => { | |
| 70 | 145 | 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); | |
| 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 | 157 | 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); | |
| 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 | 170 | 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"]) { | |
| 100 | 172 | assert.throws(() => f.notes(tag), /strict stable version/u); |
| 101 | 173 | } |
| 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); | |
| 104 | 176 | }); |
| @@ -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 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "name": "lapin-fute-watch", |
| 3 | 3 | "author": "Anthodev", |
| 4 | - "version": "1.0.4", | |
| 4 | + "version": "1.1.0", | |
| 5 | 5 | "license": "MIT", |
| 6 | 6 | "keywords": [ |
| 7 | 7 | "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"; | ||
| 3 | 3 | import { fileURLToPath } from "node:url"; |
| 4 | 4 | |
| 5 | 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 | - 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; | |
| 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) { | |
| 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); | |
| 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 | 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 | 66 | const currentCommit = git("rev-parse", "--verify", "--end-of-options", `${revision}^{commit}`).trim(); |
| 32 | 67 | const taggedCommit = git("rev-parse", "--verify", `refs/tags/${tag}^{commit}`).trim(); |
| 33 | 68 | 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}`); | |
| 85 | 74 | } |
| 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)); | |
| 92 | 76 | } |
| 93 | 77 | |
| 94 | 78 | if (process.argv[1] === fileURLToPath(import.meta.url)) { |
| 95 | 79 | writeFileSync("release-notes.md", generateReleaseNotes({ |
| 96 | 80 | tag: process.env.GITHUB_REF_NAME, |
| 97 | 81 | revision: process.env.GITHUB_SHA, |
| 98 | - repository: process.env.GITHUB_REPOSITORY, | |
| 99 | 82 | })); |
| 100 | 83 | } |
| @@ -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 | } |