From 0d1f577ea9830bd6b156510354b8b26937af1e50 Mon Sep 17 00:00:00 2001 From: Ethan Arrowood Date: Wed, 7 Oct 2026 14:05:42 -0600 Subject: [PATCH 1/4] ci: lint the PR title, and match the analyzer's preset to the notes generator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Squash is the only merge method here, and `squash_merge_commit_title` is COMMIT_OR_PR_TITLE, so for any PR with more than one commit the PR title becomes the subject of the commit that lands on main — and that subject is the only thing semantic-release reads. commitlint sees the commits inside the PR, which the squash then discards, so a title with no conventional type is a silent no-release. #41 shipped inside 2.2.5 with no note of its own, and #65 produced no release at all. A title check now runs on every pull request, including on `edited`, so a retitle re-checks. `@semantic-release/commit-analyzer` also carried no `preset` and so defaulted to `angular`, while the notes generator was explicitly `conventionalcommits`. The two now match. That is not only tidiness: `angular` does not recognise the `!` breaking-change shorthand, so `feat!: …` passed commitlint and then analysed as no release at all. Verified against both presets — `feat!:` is `none` under angular and `major` under conventionalcommits; every other type agrees. CONTRIBUTING gains the rules and a type-to-release table. Co-Authored-By: Claude Opus 5 --- .github/workflows/verify-commits.yaml | 16 ++++++++++++++++ .releaserc.json | 7 ++++++- CONTRIBUTING.md | 26 ++++++++++++++++++++++++++ 3 files changed, 48 insertions(+), 1 deletion(-) diff --git a/.github/workflows/verify-commits.yaml b/.github/workflows/verify-commits.yaml index eff4609..4a8640d 100644 --- a/.github/workflows/verify-commits.yaml +++ b/.github/workflows/verify-commits.yaml @@ -7,7 +7,23 @@ on: branches: [main] jobs: + # Squash is the only merge method on this repo, and `squash_merge_commit_title` is + # COMMIT_OR_PR_TITLE — so for any PR with more than one commit, the PR title becomes the subject of + # the commit that lands on main, and that subject is what semantic-release reads to decide the + # release. commitlint below only sees the commits inside the PR, which the squash then discards. + # Without this job a non-conventional title is silently a no-release: it happened to #41 and #65. + pr-title: + name: PR Title + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + steps: + - name: Lint PR title + uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + commitlint: + name: Commitlint runs-on: ubuntu-latest steps: - name: Checkout diff --git a/.releaserc.json b/.releaserc.json index a672832..43e23df 100644 --- a/.releaserc.json +++ b/.releaserc.json @@ -1,7 +1,12 @@ { "branches": ["main"], "plugins": [ - "@semantic-release/commit-analyzer", + [ + "@semantic-release/commit-analyzer", + { + "preset": "conventionalcommits" + } + ], [ "@semantic-release/release-notes-generator", { diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 330af79..6170079 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -90,3 +90,29 @@ The `fixture()` call binds the test file to its Harper fixture and handles start As the test suite grows, repeated patterns of locator queries and multi-step interactions should be extracted into [Page Object Models](https://playwright.dev/docs/pom). A POM is a class that wraps `page` and encapsulates the selectors and actions for a specific page or feature, keeping test files focused on assertions rather than DOM mechanics. For example, testing ISR cache revalidation involves navigating to a page, reading some state, triggering revalidation, and waiting for new content — logic that would benefit from a `CachedPage` POM rather than being inlined across multiple tests. As fixture apps grow more complex and tests start sharing the same navigation and interaction patterns, that's the signal to introduce POMs. + +## Commits and releases + +Releases are cut by semantic-release from the commits on `main`, so what lands there decides whether a +release happens at all. + +**Your pull request title must be a conventional commit.** Squash is the only merge method on this +repository, and for any PR with more than one commit GitHub uses the PR *title* as the subject of the +commit that lands on `main`. That subject is the only thing semantic-release reads. A title with no +type — `Add support for X` rather than `feat: add support for X` — produces a commit it classifies as +`no release`, and the work ships silently inside whatever release comes next. This has happened twice +(#41 and #65). CI lints the title for this reason; it also lints the commits within the PR, but the +squash discards those subjects. + +Use `!` or a `BREAKING CHANGE:` footer for a breaking change (`feat!: drop Next.js 14 support`). Both +require the `conventionalcommits` preset that `.releaserc.json` sets on **both** +`@semantic-release/commit-analyzer` and `@semantic-release/release-notes-generator`. Leaving it off +the analyzer silently falls back to the `angular` preset, which does not recognise `!` — `feat!:` +passes commitlint and then yields no release at all. Keep the two presets in step. + +| Title | Release | +| --- | --- | +| `fix: …`, `perf: …`, `revert: …` | patch | +| `feat: …` | minor | +| `feat!: …`, or a `BREAKING CHANGE:` footer | major | +| `docs: …`, `test: …`, `chore: …`, `refactor: …` | none | From 38a87193b09b107ae4448b02e7fb8560e63c3b50 Mon Sep 17 00:00:00 2001 From: Ethan Arrowood Date: Thu, 8 Oct 2026 12:31:41 -0600 Subject: [PATCH 2/4] docs: record how squashing treats the two breaking-change forms The `!` is read only from the commit subject and a `BREAKING CHANGE:` footer only from the body, so under squash merging the bang counts in the PR title while the footer counts in a commit inside the PR. Both verified against the configured analyzer, including the shape GitHub actually produces. Also rewraps the section: the file keeps a paragraph on one line, and the first draft hard-wrapped at 100 columns. Co-Authored-By: Claude Opus 5 --- CONTRIBUTING.md | 48 ++++++++++++++++++++++++++++++++---------------- 1 file changed, 32 insertions(+), 16 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6170079..2290461 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -93,22 +93,9 @@ For example, testing ISR cache revalidation involves navigating to a page, readi ## Commits and releases -Releases are cut by semantic-release from the commits on `main`, so what lands there decides whether a -release happens at all. - -**Your pull request title must be a conventional commit.** Squash is the only merge method on this -repository, and for any PR with more than one commit GitHub uses the PR *title* as the subject of the -commit that lands on `main`. That subject is the only thing semantic-release reads. A title with no -type — `Add support for X` rather than `feat: add support for X` — produces a commit it classifies as -`no release`, and the work ships silently inside whatever release comes next. This has happened twice -(#41 and #65). CI lints the title for this reason; it also lints the commits within the PR, but the -squash discards those subjects. - -Use `!` or a `BREAKING CHANGE:` footer for a breaking change (`feat!: drop Next.js 14 support`). Both -require the `conventionalcommits` preset that `.releaserc.json` sets on **both** -`@semantic-release/commit-analyzer` and `@semantic-release/release-notes-generator`. Leaving it off -the analyzer silently falls back to the `angular` preset, which does not recognise `!` — `feat!:` -passes commitlint and then yields no release at all. Keep the two presets in step. +Releases are cut by semantic-release from the commits on `main`, so what lands there decides whether a release happens at all. + +**Your pull request title must be a conventional commit.** Squash is the only merge method on this repository, and for any PR with more than one commit GitHub uses the PR *title* as the subject of the commit that lands on `main`. That subject is the only thing semantic-release reads for the release type. A title with no type — `Add support for X` rather than `feat: add support for X` — produces a commit it classifies as `no release`, and the work ships silently inside whatever release comes next. This has happened twice (#41 and #65). CI lints the title for that reason; it also lints the commits within the PR, but the squash discards those subjects. | Title | Release | | --- | --- | @@ -116,3 +103,32 @@ passes commitlint and then yields no release at all. Keep the two presets in ste | `feat: …` | minor | | `feat!: …`, or a `BREAKING CHANGE:` footer | major | | `docs: …`, `test: …`, `chore: …`, `refactor: …` | none | + +### Breaking changes + +There are two ways to declare one, and squashing treats them differently. The `!` is read only from the commit *subject*; a `BREAKING CHANGE:` footer is read from the *body*. Since the PR title becomes the subject and the squashed commit messages become the body, `feat!:` counts only in the PR title — buried in a commit inside the PR it does nothing. A footer, by contrast, survives the squash even when the title has no type at all. + +| Declared as | In the PR title | In a commit body | +| --- | --- | --- | +| `feat!: …` | major | none | +| `BREAKING CHANGE: …` footer | n/a | major | + +Do both. The `!` in the title is what makes the release major; the footer is what the notes quote, so write it for someone upgrading rather than restating the subject. + +``` +feat!: drop Next.js 14 support + +Next 14 has no cacheHandlers interface, so the use-cache handler cannot be registered there. + +BREAKING CHANGE: Next.js 14 is no longer supported. Upgrade to 15 or 16. +``` + +That renders as: + +```markdown +### ⚠ BREAKING CHANGES + +* Next.js 14 is no longer supported. Upgrade to 15 or 16. +``` + +Both forms depend on the `conventionalcommits` preset that `.releaserc.json` sets on **both** `@semantic-release/commit-analyzer` and `@semantic-release/release-notes-generator`, and on `conventional-changelog-conventionalcommits` staying in `release.yaml`'s `extra_plugins`. Leave the preset off the analyzer and it falls back to `angular`, which does not recognise `!`: `feat!:` then passes commitlint and yields no release at all. Keep the two in step. From af6260887f46953db6d636555b4d2fa92ab650de Mon Sep 17 00:00:00 2001 From: Ethan Arrowood Date: Thu, 8 Oct 2026 14:19:12 -0600 Subject: [PATCH 3/4] docs: list every commit type the release config declares The no-release row named four of the seven. `style`, `build` and `ci` are declared in .releaserc.json too, and all three analyse as no release. Also notes that "none" is about triggering a release, not visibility: every declared type has its own section, so it still appears in the notes of whatever release it rides along in. Co-Authored-By: Claude Opus 5 --- CONTRIBUTING.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2290461..cd183ca 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -102,7 +102,9 @@ Releases are cut by semantic-release from the commits on `main`, so what lands t | `fix: …`, `perf: …`, `revert: …` | patch | | `feat: …` | minor | | `feat!: …`, or a `BREAKING CHANGE:` footer | major | -| `docs: …`, `test: …`, `chore: …`, `refactor: …` | none | +| `build: …`, `chore: …`, `ci: …`, `docs: …`, `refactor: …`, `style: …`, `test: …` | none | + +`none` means the commit does not trigger a release by itself, not that it is invisible: `.releaserc.json` gives every type above its own section, so it still appears in the notes of whatever release it rides along in. ### Breaking changes From d14f80b2d76ba87a734db124a81dd2bf812b9056 Mon Sep 17 00:00:00 2001 From: Ethan Arrowood Date: Thu, 8 Oct 2026 14:26:00 -0600 Subject: [PATCH 4/4] chore: drop the style and build note sections this repo does not use MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both came in with the release config and neither is used here. Removing them from the presetConfig changes nothing about release types — both still analyse as no release — but such a commit is now hidden from the notes rather than given its own section. Co-Authored-By: Claude Opus 5 --- .releaserc.json | 2 -- CONTRIBUTING.md | 4 ++-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/.releaserc.json b/.releaserc.json index 43e23df..81f3751 100644 --- a/.releaserc.json +++ b/.releaserc.json @@ -20,9 +20,7 @@ { "type": "chore", "scope": "deps", "section": "Dependency Updates", "hidden": false }, { "type": "chore", "section": "Chores", "hidden": false }, { "type": "docs", "section": "Documentation" }, - { "type": "style", "section": "Styles" }, { "type": "test", "section": "Tests" }, - { "type": "build", "section": "Build System" }, { "type": "ci", "section": "Continuous Integration" }, { "type": "revert", "section": "Reverts" } ] diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cd183ca..05dd7ee 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -102,9 +102,9 @@ Releases are cut by semantic-release from the commits on `main`, so what lands t | `fix: …`, `perf: …`, `revert: …` | patch | | `feat: …` | minor | | `feat!: …`, or a `BREAKING CHANGE:` footer | major | -| `build: …`, `chore: …`, `ci: …`, `docs: …`, `refactor: …`, `style: …`, `test: …` | none | +| `chore: …`, `ci: …`, `docs: …`, `refactor: …`, `test: …` | none | -`none` means the commit does not trigger a release by itself, not that it is invisible: `.releaserc.json` gives every type above its own section, so it still appears in the notes of whatever release it rides along in. +`none` means the commit does not trigger a release by itself, not that it is invisible: `.releaserc.json` gives every type above its own section, so it still appears in the notes of whatever release it rides along in. A type that is not listed there — `style` and `build`, which this repository does not use — is hidden from the notes instead. ### Breaking changes