diff --git a/.agents/skills/codex-panel-release/SKILL.md b/.agents/skills/codex-panel-release/SKILL.md
index 4cb290d3..26688f1f 100644
--- a/.agents/skills/codex-panel-release/SKILL.md
+++ b/.agents/skills/codex-panel-release/SKILL.md
@@ -14,15 +14,16 @@ Use this skill when delegating Codex Panel release work to an agent. `docs/relea
- Do not create GitHub Releases locally with `gh release create`; the tag-triggered GitHub Actions workflow owns release creation and asset attachment.
- Keep internal validation notes, procedural details, and implementation reasoning out of release notes.
- Release notes must be short, public-facing bullets under the single `## Changes` section required by `docs/release.md`.
+- Treat the release notes produced by `release:prepare` as a draft. Conventional Commit types select candidate bullets, but the full release diff remains the source for checking completeness and user-facing accuracy.
## Delegation Procedure
1. Read `docs/release.md`, `package.json`, `manifest.json`, `versions.json`, and existing `.github/release-notes/` files.
2. Identify the target release version and the commit range since the previous released tag.
-3. Follow the preparation step in `docs/release.md`.
-4. Draft `.github/release-notes/X.Y.Z.md` from the full diff since the previous released tag, not only the latest commit.
-5. Before committing, ask the user to approve the release version, release-note bullets, and included commit range.
-6. After approval, follow `docs/release.md` for the commit, preflight, tag, and push sequence.
+3. Inspect the full diff since the previous released tag, then follow the preparation step in `docs/release.md`; it updates version files and generates `.github/release-notes/X.Y.Z.md` from Conventional Commits.
+4. Review the generated draft against the full diff. Rewrite bullets for users, combine related implementation commits, reorder by importance, add an omitted user-facing change when a commit was misclassified, and replace the empty placeholder when no candidate commit was generated.
+5. Before committing, ask the user to approve the release version, reviewed release-note bullets, and included commit range.
+6. After approval, follow `docs/release.md` for the Conventional Commit, preflight, tag, and push sequence. Do not rely on a local hook; preflight explicitly checks commits since the previous version in `versions.json`.
7. After pushing, let GitHub Actions create or update the GitHub Release.
## Failure Handling
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index 054c5db6..4b3611cc 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -4,6 +4,8 @@ updates:
directory: "/"
schedule:
interval: "weekly"
+ commit-message:
+ prefix: "ci(deps)"
cooldown:
default-days: 7
@@ -11,6 +13,9 @@ updates:
directory: "/"
schedule:
interval: "weekly"
+ commit-message:
+ prefix: "chore(deps)"
+ prefix-development: "chore(deps)"
cooldown:
default-days: 7
open-pull-requests-limit: 5
diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml
index 6112ed63..2693162f 100644
--- a/.github/workflows/check.yml
+++ b/.github/workflows/check.yml
@@ -18,6 +18,7 @@ jobs:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
+ fetch-depth: 0
persist-credentials: false
- name: Setup Node.js
@@ -32,6 +33,20 @@ jobs:
- name: Check recorded API baselines
run: npm run api:baseline -- --recorded-only
+ - name: Check pull request commit messages
+ if: github.event_name == 'pull_request'
+ env:
+ BASE_SHA: ${{ github.event.pull_request.base.sha }}
+ HEAD_SHA: ${{ github.event.pull_request.head.sha }}
+ run: npm run commitlint -- --from "$BASE_SHA" --to "$HEAD_SHA" --verbose
+
+ - name: Check pushed commit messages
+ if: github.event_name == 'push'
+ env:
+ BASE_SHA: ${{ github.event.before }}
+ HEAD_SHA: ${{ github.sha }}
+ run: npm run commitlint -- --from "$BASE_SHA" --to "$HEAD_SHA" --verbose
+
- name: Check release metadata
run: npm run release:check
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 04525b65..93d41761 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -19,6 +19,7 @@ jobs:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
+ fetch-depth: 0
persist-credentials: false
- name: Setup Node.js
@@ -35,6 +36,14 @@ jobs:
env:
RELEASE_VERSION: ${{ github.ref_name }}
+ - name: Check release commit messages
+ env:
+ TAG: ${{ github.ref_name }}
+ run: |
+ PREVIOUS_TAG="$(node -p 'Object.keys(require("./versions.json")).at(-2)')"
+ git rev-parse --verify "refs/tags/$PREVIOUS_TAG"
+ npm run commitlint -- --from "$PREVIOUS_TAG" --to "$TAG" --verbose
+
- name: Run checks and build
run: npm run check
diff --git a/AGENTS.md b/AGENTS.md
index 66041557..b17c2e0a 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -2,6 +2,8 @@ This repository contains the Codex Panel Obsidian plugin.
Jujutsu is the recommended local change-management workflow when available.
+Commits after the `5.0.0` tag must follow Conventional Commits. Use the repository's allowed types and examples in `docs/development.md`, and validate the relevant commit range explicitly because Jujutsu does not rely on local Git hooks.
+
## What To Read
- Read `README.md` for user-facing behavior, requirements, commands, privacy, and compatibility.
diff --git a/commitlint.config.mjs b/commitlint.config.mjs
new file mode 100644
index 00000000..7a082ab0
--- /dev/null
+++ b/commitlint.config.mjs
@@ -0,0 +1,9 @@
+export default {
+ defaultIgnores: false,
+ extends: ["@commitlint/config-conventional"],
+ ignores: [(message) => /^Merge pull request #\d+ from \S+/.test(message)],
+ rules: {
+ "subject-case": [0],
+ "type-enum": [2, "always", ["build", "chore", "ci", "docs", "feat", "fix", "perf", "refactor", "revert", "test"]],
+ },
+};
diff --git a/docs/development.md b/docs/development.md
index fecdb64e..233b7553 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -14,6 +14,21 @@ Use Node.js 26, matching `.node-version`, CI, and the installed Node type defini
Use this as the normal edit loop: make the change, run `npm run fix`, then run `npm run check`. Treat `npm run fix` as trusted mechanical cleanup for formatting, import ordering, and Knip safe fixes; review the resulting diff at normal change-boundary checkpoints rather than after each tool adjustment.
+## Commit Messages
+
+Commits after the `5.0.0` tag follow [Conventional Commits 1.0.0](https://www.conventionalcommits.org/en/v1.0.0/):
+
+```text
+feat(composer): add daily note context suggestions
+fix(threads): prevent manual titles from being overwritten
+refactor(chat): move session wiring into the runtime
+chore(release): 5.0.1
+```
+
+Use one of `build`, `chore`, `ci`, `docs`, `feat`, `fix`, `perf`, `refactor`, `revert`, or `test`. Scopes are optional and are not restricted to a fixed list. Keep the description concise and omit a trailing period. Both lowercase and uppercase starts are accepted so Dependabot's generated `Bump ...` descriptions follow the same validation path. Use `!` before the colon or a `BREAKING CHANGE:` footer for a disruptive change.
+
+CI checks every commit introduced by a pull request or direct push. GitHub-generated `Merge pull request ...` commits are the only format exemption; write manual reverts as `revert: ...`, and rewrite `fixup!` or `squash!` commits before publication. Run `npm run commitlint -- --from --to
--verbose` for the same local check; local Git hooks are optional and are not a substitute for CI in the Jujutsu-first workflow.
+
Use focused scripts such as `npm run typecheck`, `npm run test`, or `npm run build` only when diagnosing a specific failure or when a full check would obscure the signal while iterating. Do not treat focused scripts as a substitute for the final `npm run check`. CI and release preflight run the same `npm run check` command as local development.
Keep rule suppressions local and include the Obsidian-specific reason when a native Obsidian UI pattern intentionally diverges from a generic browser rule.
diff --git a/docs/release.md b/docs/release.md
index f382cc06..9ed0e06c 100644
--- a/docs/release.md
+++ b/docs/release.md
@@ -6,13 +6,13 @@ Release work is Jujutsu-first in a colocated Git repository; Git is still used t
Plugin versions use SemVer-shaped numbers for Obsidian distribution, but they are not a library API compatibility contract. Prefer patch releases for fixes, dependency updates, internal changes, and compatibility refreshes that preserve existing workflows, including routine Codex CLI app-server compatibility updates. Prefer minor releases for user-visible capabilities, settings, workflow additions, or supported-runtime baseline changes such as raising the minimum supported Obsidian app/API version. Reserve major releases for disruptive workflow, settings, storage, or support-policy changes.
-Create a release by preparing the next version, editing the generated release notes, committing the release changes, then running the preflight before pushing the matching tag:
+Create a release by preparing the next version, reviewing and editing the generated release notes, committing the release changes, then running the preflight before pushing the matching tag:
```sh
npm run release:prepare -- X.Y.Z
-# Edit .github/release-notes/X.Y.Z.md.
+# Review and edit .github/release-notes/X.Y.Z.md.
jj status
-jj commit -m "Bump version to X.Y.Z"
+jj commit -m "chore(release): X.Y.Z"
jj bookmark move main --to @-
npm run release:preflight
jj tag set X.Y.Z -r main
@@ -20,7 +20,9 @@ jj git push --remote origin --bookmark main
git push origin X.Y.Z
```
-`release:prepare` updates the version files and creates a `## Changes` release notes template. `release:preflight` verifies the local Jujutsu/Git state, release metadata, API baselines, lockfile, and the same `npm run check` validation used by CI after the release commit is on `main`.
+`release:prepare` updates the version files and generates a `## Changes` release notes draft from Conventional Commits after the previous version tag. It includes `feat`, `fix`, `perf`, and commits marked as breaking changes; other commit types are omitted. Review the generated bullets for user-facing wording, combine related implementation commits, and reorder them by importance before committing. If there are no included commits, preparation leaves an empty bullet to replace manually.
+
+`release:preflight` verifies all commit messages since the previous tag, the local Jujutsu/Git state, release metadata, API baselines, lockfile, and the same `npm run check` validation used by CI after the release commit is on `main`.
Release notes should normally include only user-facing changes. Internal implementation changes, validation details, and release procedure notes should be omitted when minor; when they are important enough to mention, group them into at most one concise bullet.
diff --git a/package-lock.json b/package-lock.json
index f842f5cd..45f92cd2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -18,8 +18,12 @@
},
"devDependencies": {
"@biomejs/biome": "^2.5.2",
+ "@commitlint/cli": "^21.2.1",
+ "@commitlint/config-conventional": "^21.2.0",
"@types/node": "^26.1.0",
"concurrently": "^10.0.3",
+ "conventional-changelog-conventionalcommits": "^10.2.1",
+ "conventional-commits-parser": "^7.1.0",
"esbuild": "^0.28.0",
"eslint": "^10.6.0",
"eslint-plugin-obsidianmd": "^0.4.1",
@@ -86,6 +90,31 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@babel/code-frame": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
+ "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
+ "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/@biomejs/biome": {
"version": "2.5.2",
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.5.2.tgz",
@@ -297,6 +326,328 @@
"w3c-keyname": "^2.2.4"
}
},
+ "node_modules/@commitlint/cli": {
+ "version": "21.2.1",
+ "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-21.2.1.tgz",
+ "integrity": "sha512-blsZGe29hJ72VGEFVl72IVYX+1vsfINpjA9yWQA6i7OKD/McGEOXg08sKIRKjFk4JvzhV/9n0l3i6NooPLTNfg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@commitlint/config-conventional": "^21.2.0",
+ "@commitlint/format": "^21.2.0",
+ "@commitlint/lint": "^21.2.0",
+ "@commitlint/load": "^21.2.0",
+ "@commitlint/read": "^21.2.1",
+ "@commitlint/types": "^21.2.0",
+ "tinyexec": "^1.0.0",
+ "yargs": "^18.0.0"
+ },
+ "bin": {
+ "commitlint": "cli.js"
+ },
+ "engines": {
+ "node": ">=22.12.0"
+ }
+ },
+ "node_modules/@commitlint/config-conventional": {
+ "version": "21.2.0",
+ "resolved": "https://registry.npmjs.org/@commitlint/config-conventional/-/config-conventional-21.2.0.tgz",
+ "integrity": "sha512-Qf8WRDVcyVd14if6VTWenebxFbKnVnbzPUJjlzjkyJGeHK2xCGd63Dr1XZzj0plXKQb9P0BfOxoc1HVeCo2BWQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@commitlint/types": "^21.2.0",
+ "conventional-changelog-conventionalcommits": "^10.0.0"
+ },
+ "engines": {
+ "node": ">=22.12.0"
+ }
+ },
+ "node_modules/@commitlint/config-validator": {
+ "version": "21.2.0",
+ "resolved": "https://registry.npmjs.org/@commitlint/config-validator/-/config-validator-21.2.0.tgz",
+ "integrity": "sha512-t7AzNHAKeIdo/3NRGwzpufKHsKkPHmFs/56N2Fnsh0/r0rGtnQzTxk6vnFgjaGr4hdSQKNB50/KAhR9Yk4LJKA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@commitlint/types": "^21.2.0",
+ "ajv": "^8.11.0"
+ },
+ "engines": {
+ "node": ">=22.12.0"
+ }
+ },
+ "node_modules/@commitlint/config-validator/node_modules/ajv": {
+ "version": "8.20.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
+ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.3",
+ "fast-uri": "^3.0.1",
+ "json-schema-traverse": "^1.0.0",
+ "require-from-string": "^2.0.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/@commitlint/config-validator/node_modules/json-schema-traverse": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@commitlint/ensure": {
+ "version": "21.2.0",
+ "resolved": "https://registry.npmjs.org/@commitlint/ensure/-/ensure-21.2.0.tgz",
+ "integrity": "sha512-76IF9vDNS13lAzEEik9eKwzt8f9hYhWiwVXZ2AnyLCz5/f511FsEQ3pw1X3/zSQpdRLQU7i5qDMVKyXi1GWjSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@commitlint/types": "^21.2.0",
+ "es-toolkit": "^1.46.0"
+ },
+ "engines": {
+ "node": ">=22.12.0"
+ }
+ },
+ "node_modules/@commitlint/execute-rule": {
+ "version": "21.0.1",
+ "resolved": "https://registry.npmjs.org/@commitlint/execute-rule/-/execute-rule-21.0.1.tgz",
+ "integrity": "sha512-RifH+FmImozKBE6mozhF4K3r2RRKP7SMi/Q/zLCmExtp5e05lhHOUYqGBlFBAGNHaZxU/WYw1XuugYK9jQzqnA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=22.12.0"
+ }
+ },
+ "node_modules/@commitlint/format": {
+ "version": "21.2.0",
+ "resolved": "https://registry.npmjs.org/@commitlint/format/-/format-21.2.0.tgz",
+ "integrity": "sha512-c4q64xaav2U83t7k7RyzJerBZurPer7FxUOY0RL5L/6CZijZ7K+s6HIBGIghj0ey1P2+seRX0J9XQYtDued6tg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@commitlint/types": "^21.2.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=22.12.0"
+ }
+ },
+ "node_modules/@commitlint/is-ignored": {
+ "version": "21.2.0",
+ "resolved": "https://registry.npmjs.org/@commitlint/is-ignored/-/is-ignored-21.2.0.tgz",
+ "integrity": "sha512-4/eB0vBN7L88O/oC4ajAEqi7j2ZfNgxl/+11RfAV9YosejZgDXhY2C9VcHnHJhOzPLoSy5P3Mg/46kqeyJfXKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@commitlint/types": "^21.2.0",
+ "semver": "^7.6.0"
+ },
+ "engines": {
+ "node": ">=22.12.0"
+ }
+ },
+ "node_modules/@commitlint/lint": {
+ "version": "21.2.0",
+ "resolved": "https://registry.npmjs.org/@commitlint/lint/-/lint-21.2.0.tgz",
+ "integrity": "sha512-ceO5dp9pLjEZ6y6qbq/uXWXDPykqqlTsyzoQ0NzecpisSJhK3kTy9qzQoPeJuWG/IMNdV1lO0RgmzqoAlSi1uw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@commitlint/is-ignored": "^21.2.0",
+ "@commitlint/parse": "^21.2.0",
+ "@commitlint/rules": "^21.2.0",
+ "@commitlint/types": "^21.2.0"
+ },
+ "engines": {
+ "node": ">=22.12.0"
+ }
+ },
+ "node_modules/@commitlint/load": {
+ "version": "21.2.0",
+ "resolved": "https://registry.npmjs.org/@commitlint/load/-/load-21.2.0.tgz",
+ "integrity": "sha512-RjlzWQqruRwIenJEfZtq7kG97co97nKoHpflE5YnF61tDLXxHPrdWImgzw6VL6MlFyaOcVlk74eBV8ZQmc3oIA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@commitlint/config-validator": "^21.2.0",
+ "@commitlint/execute-rule": "^21.0.1",
+ "@commitlint/resolve-extends": "^21.2.0",
+ "@commitlint/types": "^21.2.0",
+ "cosmiconfig": "^9.0.1",
+ "cosmiconfig-typescript-loader": "^6.1.0",
+ "es-toolkit": "^1.46.0",
+ "is-plain-obj": "^4.1.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=22.12.0"
+ }
+ },
+ "node_modules/@commitlint/message": {
+ "version": "21.2.0",
+ "resolved": "https://registry.npmjs.org/@commitlint/message/-/message-21.2.0.tgz",
+ "integrity": "sha512-YxGoiXD/HXNXLJPrQwE5poXa+XH0CBEm+mdvbHQP0g6MV/dmJyUFCzPNzZbxL93GvZ70TmtTK0Z0/IBpAqHv8g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=22.12.0"
+ }
+ },
+ "node_modules/@commitlint/parse": {
+ "version": "21.2.0",
+ "resolved": "https://registry.npmjs.org/@commitlint/parse/-/parse-21.2.0.tgz",
+ "integrity": "sha512-QHWxG4d0PLTF634/AdyZ0MQS+CLn5YOuJlCFhMMlSGKFxzYGUetkHBj18xgBD+6fVzUrA2lrCdi/vlS2f/oYXg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@commitlint/types": "^21.2.0",
+ "conventional-changelog-angular": "^9.0.0",
+ "conventional-commits-parser": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=22.12.0"
+ }
+ },
+ "node_modules/@commitlint/read": {
+ "version": "21.2.1",
+ "resolved": "https://registry.npmjs.org/@commitlint/read/-/read-21.2.1.tgz",
+ "integrity": "sha512-hUW7EJQnNTL0vPOmVMNK4CrnrNBN0nN+JJHReFkdHO5y4iyHeEmTBwuC15OCqUTjxWo7idnH1LftfpWVIaPWIA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@commitlint/top-level": "^21.2.0",
+ "@commitlint/types": "^21.2.0",
+ "@conventional-changelog/git-client": "^3.0.0",
+ "tinyexec": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=22.12.0"
+ }
+ },
+ "node_modules/@commitlint/resolve-extends": {
+ "version": "21.2.0",
+ "resolved": "https://registry.npmjs.org/@commitlint/resolve-extends/-/resolve-extends-21.2.0.tgz",
+ "integrity": "sha512-4O/1j51+79Wth9s/MGxt/5gs0XYLDgNlYpltQfhAvLE0itusLKs9zruxbiNg1oOkmkb9L9L4USYGjEj7n87NxA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@commitlint/config-validator": "^21.2.0",
+ "@commitlint/types": "^21.2.0",
+ "es-toolkit": "^1.46.0",
+ "global-directory": "^5.0.0",
+ "resolve-from": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=22.12.0"
+ }
+ },
+ "node_modules/@commitlint/resolve-extends/node_modules/resolve-from": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz",
+ "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@commitlint/rules": {
+ "version": "21.2.0",
+ "resolved": "https://registry.npmjs.org/@commitlint/rules/-/rules-21.2.0.tgz",
+ "integrity": "sha512-C2yXMNpiB8ETZKfx5JD8+ExgF8vTU1VQMKPSUUYwqKpw9oJWQBrlXBpdU038mj2WPjof7o9UzFpmTyBeGMZwZg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@commitlint/ensure": "^21.2.0",
+ "@commitlint/message": "^21.2.0",
+ "@commitlint/to-lines": "^21.0.1",
+ "@commitlint/types": "^21.2.0"
+ },
+ "engines": {
+ "node": ">=22.12.0"
+ }
+ },
+ "node_modules/@commitlint/to-lines": {
+ "version": "21.0.1",
+ "resolved": "https://registry.npmjs.org/@commitlint/to-lines/-/to-lines-21.0.1.tgz",
+ "integrity": "sha512-bd1BFII7p1EQZre9Kaj+kKaMFP3cFCdt21K7DItVux9XP5WjLgJ0/Uy1pJJh9aPwVJ6SKg62PxqlZaHI8hQAXw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=22.12.0"
+ }
+ },
+ "node_modules/@commitlint/top-level": {
+ "version": "21.2.0",
+ "resolved": "https://registry.npmjs.org/@commitlint/top-level/-/top-level-21.2.0.tgz",
+ "integrity": "sha512-Y5gmQ+KxzqCrBFJfLvFEPvvwD3LDiNZoTT2yeFBm96M8qhmqSzQc5DvX3rheAaAMjyIvMXOCLS/mWfdpONsjyQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "escalade": "^3.2.0"
+ },
+ "engines": {
+ "node": ">=22.12.0"
+ }
+ },
+ "node_modules/@commitlint/types": {
+ "version": "21.2.0",
+ "resolved": "https://registry.npmjs.org/@commitlint/types/-/types-21.2.0.tgz",
+ "integrity": "sha512-7zVFCDB2reMvJH5dmbKnOQPjZEvjdJTH8jc0U/PIPU1r3/+vf5pD1HlfitV2MWsWXrvu7u39iY1lyLUPOaN0Gw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "conventional-commits-parser": "^7.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=22.12.0"
+ }
+ },
+ "node_modules/@conventional-changelog/git-client": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@conventional-changelog/git-client/-/git-client-3.1.0.tgz",
+ "integrity": "sha512-Tqa/gHco2WJWa740NRjOrfKVvzIqxkZpecb8bemaQ8sKM5PXb1UK4uTyTb/1wIqNuOVaDOFxyBdhTIQZn6gdjQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@simple-libs/child-process-utils": "^2.0.0",
+ "@simple-libs/stream-utils": "^2.0.0",
+ "semver": "^7.5.2"
+ },
+ "engines": {
+ "node": ">=22"
+ },
+ "peerDependencies": {
+ "conventional-commits-filter": "^6.0.1",
+ "conventional-commits-parser": "^7.0.1"
+ },
+ "peerDependenciesMeta": {
+ "conventional-commits-filter": {
+ "optional": true
+ },
+ "conventional-commits-parser": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@conventional-changelog/template": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@conventional-changelog/template/-/template-1.2.1.tgz",
+ "integrity": "sha512-TzlTVpKPjaqW6qOYjQcYUDuGsLCNsvFHVBXkYGTAnf5V37jCWrE5haKNXzz0WZUtVHjrpV76L1buANjwXMfT8w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=22"
+ }
+ },
"node_modules/@csstools/color-helpers": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
@@ -2322,6 +2673,35 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@simple-libs/child-process-utils": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/@simple-libs/child-process-utils/-/child-process-utils-2.0.0.tgz",
+ "integrity": "sha512-dvNoRKLijXnD0XoJAz94pbNuB5GQgDr55UhpSPhffDkTT0Cmcqh9jSCOtwfT2d4H6MI9E7c4SgtMuJXZ6F3c6A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@simple-libs/stream-utils": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=22"
+ },
+ "funding": {
+ "url": "https://ko-fi.com/dangreen"
+ }
+ },
+ "node_modules/@simple-libs/stream-utils": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/@simple-libs/stream-utils/-/stream-utils-2.0.0.tgz",
+ "integrity": "sha512-fCTuZK4QBa+39Oz9l4OGfJfz+GpwCp3AqO7Zch3to99xHPgstVsRFpeQ8LNd2o1Gv8raL2mCFwiaHh7bFSp5DQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=22"
+ },
+ "funding": {
+ "url": "https://ko-fi.com/dangreen"
+ }
+ },
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
@@ -2881,6 +3261,19 @@
"dev": true,
"license": "Python-2.0"
},
+ "node_modules/argue-cli": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/argue-cli/-/argue-cli-3.1.0.tgz",
+ "integrity": "sha512-DhBpBfXL4SS2uC0N922MMajKR3CdrTG0u2or1PNYgXMsrSzViJrbtvT0nCLlLGUI0plam/ZZCs7aAauHtW9thw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=22"
+ },
+ "funding": {
+ "url": "https://ko-fi.com/dangreen"
+ }
+ },
"node_modules/array-buffer-byte-length": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz",
@@ -3341,6 +3734,49 @@
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
+ "node_modules/conventional-changelog-angular": {
+ "version": "9.2.1",
+ "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-9.2.1.tgz",
+ "integrity": "sha512-oWSL6ZhnXbYraOFTK3PgRAQJ8fADDAEv5K6AdeyQPLvjFmhG8+ejL0jZZp/R7vTmGJaBvZEE+sE7dB4bCv7sAw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "@conventional-changelog/template": "^1.2.1"
+ },
+ "engines": {
+ "node": ">=22"
+ }
+ },
+ "node_modules/conventional-changelog-conventionalcommits": {
+ "version": "10.2.1",
+ "resolved": "https://registry.npmjs.org/conventional-changelog-conventionalcommits/-/conventional-changelog-conventionalcommits-10.2.1.tgz",
+ "integrity": "sha512-n4Kr1HFMTf3iMbES0TMxKIcYtUUv4rKqyQQp2JwfOEfFCOfGT3Tq4mCyJ8S9/YPyWhydjfKrrvnyl+gCjA+mJQ==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "@conventional-changelog/template": "^1.2.1"
+ },
+ "engines": {
+ "node": ">=22"
+ }
+ },
+ "node_modules/conventional-commits-parser": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-7.1.0.tgz",
+ "integrity": "sha512-DPp6hkUjvwIivxbkrTiLXeRswNv1A/4GFA2X6scXma0AMa9632V3TwxmrlkUIEtUktiM3Ln+RrSH2xlP3/jUTw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@simple-libs/stream-utils": "^2.0.0",
+ "argue-cli": "^3.1.0"
+ },
+ "bin": {
+ "conventional-commits-parser": "dist/cli/index.js"
+ },
+ "engines": {
+ "node": ">=22"
+ }
+ },
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -3348,6 +3784,61 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/cosmiconfig": {
+ "version": "9.0.2",
+ "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.2.tgz",
+ "integrity": "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "env-paths": "^2.2.1",
+ "import-fresh": "^3.3.0",
+ "js-yaml": "^4.1.0",
+ "parse-json": "^5.2.0"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/d-fischer"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.9.5"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/cosmiconfig-typescript-loader": {
+ "version": "6.3.0",
+ "resolved": "https://registry.npmjs.org/cosmiconfig-typescript-loader/-/cosmiconfig-typescript-loader-6.3.0.tgz",
+ "integrity": "sha512-Akr82WH1Wfqatyiqpj8HDkO2o2KmJRu1FhKfSNJP3K4IdXwHfEyL7MOb62i1AGQVLtIQM+iCE9CGOtrfhR+mmA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "jiti": "2.6.1"
+ },
+ "engines": {
+ "node": ">=v18"
+ },
+ "peerDependencies": {
+ "@types/node": "*",
+ "cosmiconfig": ">=9",
+ "typescript": ">=5"
+ }
+ },
+ "node_modules/cosmiconfig-typescript-loader/node_modules/jiti": {
+ "version": "2.6.1",
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
+ "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jiti": "lib/jiti-cli.mjs"
+ }
+ },
"node_modules/crelt": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
@@ -3756,6 +4247,26 @@
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
+ "node_modules/env-paths": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
+ "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/error-ex": {
+ "version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz",
+ "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-arrayish": "^0.2.1"
+ }
+ },
"node_modules/es-abstract": {
"version": "1.24.2",
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz",
@@ -3940,6 +4451,17 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/es-toolkit": {
+ "version": "1.49.0",
+ "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.49.0.tgz",
+ "integrity": "sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==",
+ "dev": true,
+ "license": "MIT",
+ "workspaces": [
+ "docs",
+ "benchmarks"
+ ]
+ },
"node_modules/esbuild": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
@@ -5260,6 +5782,22 @@
"node": ">=10.13.0"
}
},
+ "node_modules/global-directory": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/global-directory/-/global-directory-5.0.0.tgz",
+ "integrity": "sha512-1pgFdhK3J2LeM+dVf2Pd424yHx2ou338lC0ErNP2hPx4j8eW1Sp0XqSjNxtk6Tc4Kr5wlWtSvz8cn2yb7/SG/w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ini": "6.0.0"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/globals": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
@@ -5494,6 +6032,16 @@
"node": ">=0.8.19"
}
},
+ "node_modules/ini": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz",
+ "integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
"node_modules/internal-slot": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
@@ -5527,6 +6075,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/is-arrayish": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
+ "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/is-async-function": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz",
@@ -5746,6 +6301,19 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/is-plain-obj": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz",
+ "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/is-potential-custom-element-name": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
@@ -6018,6 +6586,13 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/json-parse-even-better-errors": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
+ "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/json-schema-migrate": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/json-schema-migrate/-/json-schema-migrate-2.0.0.tgz",
@@ -6481,6 +7056,13 @@
"url": "https://opencollective.com/parcel"
}
},
+ "node_modules/lines-and-columns": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
+ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/linkedom": {
"version": "0.18.12",
"resolved": "https://registry.npmjs.org/linkedom/-/linkedom-0.18.12.tgz",
@@ -7444,6 +8026,25 @@
"node": ">=6"
}
},
+ "node_modules/parse-json": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
+ "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.0.0",
+ "error-ex": "^1.3.1",
+ "json-parse-even-better-errors": "^2.3.0",
+ "lines-and-columns": "^1.1.6"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/parse5": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
diff --git a/package.json b/package.json
index b8f71aae..330cffa0 100644
--- a/package.json
+++ b/package.json
@@ -20,6 +20,7 @@
"api:baseline": "node scripts/api-baseline.mjs",
"build": "node esbuild.config.mjs",
"build:styles": "node scripts/build-styles.mjs",
+ "commitlint": "commitlint",
"check": "concurrently --group --pad-prefix 'node:check:*' && node --run build",
"check:biome": "biome check --diagnostic-level=warn --error-on-warnings",
"check:css-usage": "node scripts/check-css-usage.mjs",
@@ -30,6 +31,7 @@
"fix": "biome check --fix --skip=plugin --diagnostic-level=warn --error-on-warnings && knip --fix --no-progress",
"generate:app-server-types": "node scripts/generate-app-server-types.mjs",
"release:check": "node scripts/release/check.mjs",
+ "release:notes": "node scripts/release/notes.mjs",
"release:preflight": "node scripts/release/preflight.mjs",
"release:prepare": "node scripts/release/prepare.mjs",
"test": "vitest run --pool threads --maxWorkers 6",
@@ -37,8 +39,12 @@
},
"devDependencies": {
"@biomejs/biome": "^2.5.2",
+ "@commitlint/cli": "^21.2.1",
+ "@commitlint/config-conventional": "^21.2.0",
"@types/node": "^26.1.0",
"concurrently": "^10.0.3",
+ "conventional-changelog-conventionalcommits": "^10.2.1",
+ "conventional-commits-parser": "^7.1.0",
"esbuild": "^0.28.0",
"eslint": "^10.6.0",
"eslint-plugin-obsidianmd": "^0.4.1",
diff --git a/scripts/release/notes.mjs b/scripts/release/notes.mjs
new file mode 100644
index 00000000..30be8384
--- /dev/null
+++ b/scripts/release/notes.mjs
@@ -0,0 +1,73 @@
+import { spawnSync } from "node:child_process";
+import { pathToFileURL } from "node:url";
+import conventionalCommits from "conventional-changelog-conventionalcommits";
+import { CommitParser } from "conventional-commits-parser";
+
+const releaseNoteTypes = new Set(["feat", "fix", "perf"]);
+const parser = new CommitParser(conventionalCommits().parser);
+
+function fail(message) {
+ throw new Error(`release notes generation failed: ${message}`);
+}
+
+function runGit(args, cwd) {
+ const result = spawnSync("git", args, {
+ cwd,
+ encoding: "utf8",
+ shell: false,
+ });
+ if (result.error) fail(result.error.message);
+ if (result.status !== 0) {
+ fail(`${args.join(" ")} exited with ${result.status}: ${result.stderr.trim()}`);
+ }
+ return result.stdout;
+}
+
+function formatSubject(subject) {
+ const sentence = subject.replace(/^([a-z])/, (letter) => letter.toUpperCase());
+ return /[.!?]$/.test(sentence) ? sentence : `${sentence}.`;
+}
+
+export function releaseNoteForCommit(message) {
+ const commit = parser.parse(message);
+ if (!commit.subject) return null;
+
+ const isBreaking = commit.notes.length > 0;
+ if (!isBreaking && !releaseNoteTypes.has(commit.type ?? "")) return null;
+
+ return formatSubject(commit.subject);
+}
+
+export function renderReleaseNotes(messages) {
+ const entries = messages.map(releaseNoteForCommit).filter((entry) => entry !== null);
+ const bullets = entries.length > 0 ? entries.map((entry) => `- ${entry}`).join("\n") : "- ";
+ return `## Changes\n\n${bullets}\n`;
+}
+
+export function readCommitMessagesSince(tag, cwd = process.cwd()) {
+ runGit(["rev-parse", "--verify", `refs/tags/${tag}`], cwd);
+ const output = runGit(["log", "--format=%B%x00", `${tag}..HEAD`], cwd);
+ return output
+ .split("\0")
+ .map((message) => message.trim())
+ .filter(Boolean);
+}
+
+export function generateReleaseNotes(tag, cwd = process.cwd()) {
+ return renderReleaseNotes(readCommitMessagesSince(tag, cwd));
+}
+
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
+ const previousTag = process.argv[2];
+ if (!previousTag) {
+ console.error("usage: npm run release:notes -- X.Y.Z");
+ process.exit(1);
+ }
+
+ try {
+ process.stdout.write(generateReleaseNotes(previousTag));
+ } catch (error) {
+ console.error(error instanceof Error ? error.message : error);
+ process.exit(1);
+ }
+}
diff --git a/scripts/release/preflight.mjs b/scripts/release/preflight.mjs
index cfd6d8be..60668091 100644
--- a/scripts/release/preflight.mjs
+++ b/scripts/release/preflight.mjs
@@ -1,4 +1,5 @@
import { spawnSync } from "node:child_process";
+import { readFileSync } from "node:fs";
function fail(message) {
console.error(`release preflight failed: ${message}`);
@@ -81,6 +82,11 @@ if (maybeRun("jj", ["root"])) {
assertGitReleaseState(packageVersion);
}
+const versionKeys = Object.keys(JSON.parse(readFileSync("versions.json", "utf8")));
+const previousTag = versionKeys.at(-2);
+if (!previousTag) fail("versions.json must contain a release before the prepared version");
+run("git", ["rev-parse", "--verify", `refs/tags/${previousTag}`], { capture: true });
+run("npm", ["run", "commitlint", "--", "--from", previousTag, "--to", "main", "--verbose"]);
run("npm", ["run", "release:check"]);
run("npm", ["run", "api:baseline"]);
run("npm", ["ci", "--dry-run"]);
diff --git a/scripts/release/prepare.mjs b/scripts/release/prepare.mjs
index 3b431283..2fba6a61 100644
--- a/scripts/release/prepare.mjs
+++ b/scripts/release/prepare.mjs
@@ -1,5 +1,6 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
+import { generateReleaseNotes } from "./notes.mjs";
import { compareVersions, isExpectedNextVersion, parseVersion } from "./versioning.mjs";
function fail(message) {
@@ -38,6 +39,13 @@ try {
if (error.code !== "ENOENT") throw error;
}
+let releaseNotes;
+try {
+ releaseNotes = generateReleaseNotes(previousVersionKey);
+} catch (error) {
+ fail(error instanceof Error ? error.message : String(error));
+}
+
packageJson.version = releaseVersion;
packageLockJson.version = releaseVersion;
if (!packageLockJson.packages?.[""]) fail('package-lock.json is missing packages[""]');
@@ -51,7 +59,7 @@ await writeFile("manifest.json", `${JSON.stringify(manifestJson, null, 2)}\n`);
await writeFile("versions.json", `${JSON.stringify(versionsJson, null, 2)}\n`);
await mkdir(notesDir, { recursive: true });
-await writeFile(notesPath, "## Changes\n\n- \n");
+await writeFile(notesPath, releaseNotes);
console.log(`prepared release ${releaseVersion}`);
-console.log(`edit ${notesPath}, then run npm run release:preflight after committing`);
+console.log(`review and edit ${notesPath}, then run npm run release:preflight after committing`);
diff --git a/tests/scripts/development-scripts.test.ts b/tests/scripts/development-scripts.test.ts
index dc3e4e60..a43342fc 100644
--- a/tests/scripts/development-scripts.test.ts
+++ b/tests/scripts/development-scripts.test.ts
@@ -14,6 +14,16 @@ afterEach(async () => {
});
describe("development scripts", () => {
+ it("enforces Conventional Commits except for GitHub merge commits", () => {
+ for (const message of ["feat: add side chats", "Merge pull request #123 from owner/feature"]) {
+ expect(runCommitlint(message), message).toBe(0);
+ }
+
+ for (const message of ['Revert "feat: add side chats"', "v5.1.0", "fixup! feat: add side chats", "squash! feat: add side chats"]) {
+ expect(runCommitlint(message), message).toBe(1);
+ }
+ });
+
it("fails style builds when CSS files are missing from the style order file", async () => {
const cwd = await styleOrderFixture();
@@ -172,6 +182,34 @@ describe("development scripts", () => {
expect(result.stderr).toContain("codex-panel__unused");
});
+ it("prepares release notes from conventional commits since the previous tag", async () => {
+ const cwd = await tempWorkspace();
+ await writeJson(path.join(cwd, "package.json"), { version: "2.3.2" });
+ await writeJson(path.join(cwd, "package-lock.json"), {
+ version: "2.3.2",
+ packages: { "": { version: "2.3.2" } },
+ });
+ await writeJson(path.join(cwd, "manifest.json"), { version: "2.3.2", minAppVersion: "1.12.0" });
+ await writeJson(path.join(cwd, "versions.json"), { "2.3.2": "1.12.0" });
+ runGit(["init"], cwd);
+ runGit(["config", "user.name", "Codex Panel Tests"], cwd);
+ runGit(["config", "user.email", "tests@example.com"], cwd);
+ runGit(["add", "."], cwd);
+ runGit(["commit", "-m", "chore: establish release baseline"], cwd);
+ runGit(["tag", "2.3.2"], cwd);
+ await writeFile(path.join(cwd, "change.txt"), "side chats\n");
+ runGit(["add", "change.txt"], cwd);
+ runGit(["commit", "-m", "feat(chat): add side chat support"], cwd);
+
+ const result = runNodeScript("scripts/release/prepare.mjs", ["2.4.0"], cwd);
+
+ expect(result.status).toBe(0);
+ await expect(readFile(path.join(cwd, ".github", "release-notes", "2.4.0.md"), "utf8")).resolves.toBe(
+ "## Changes\n\n- Add side chat support.\n",
+ );
+ await expect(readJson(path.join(cwd, "package.json"))).resolves.toMatchObject({ version: "2.4.0" });
+ });
+
it("fails release prepare before changing version files when release notes already exist", async () => {
const cwd = await tempWorkspace();
await mkdir(path.join(cwd, ".github", "release-notes"), { recursive: true });
@@ -236,6 +274,20 @@ function runNodeScript(script: string, args: string[] = [], cwd = repoRoot, env:
});
}
+function runCommitlint(message: string): number | null {
+ return spawnSync(process.execPath, [path.join(repoRoot, "node_modules", "@commitlint", "cli", "cli.js")], {
+ cwd: repoRoot,
+ encoding: "utf8",
+ input: `${message}\n`,
+ shell: false,
+ }).status;
+}
+
+function runGit(args: string[], cwd: string): void {
+ const result = spawnSync("git", args, { cwd, encoding: "utf8", shell: false });
+ if (result.status !== 0) throw new Error(`git ${args.join(" ")} failed: ${result.stderr}`);
+}
+
async function writeJson(file: string, value: unknown): Promise {
await writeFile(file, `${JSON.stringify(value, null, 2)}\n`);
}
diff --git a/tests/scripts/release-notes.test.ts b/tests/scripts/release-notes.test.ts
new file mode 100644
index 00000000..9b3bddc0
--- /dev/null
+++ b/tests/scripts/release-notes.test.ts
@@ -0,0 +1,45 @@
+import path from "node:path";
+import { pathToFileURL } from "node:url";
+import { describe, expect, it } from "vitest";
+
+const { releaseNoteForCommit, renderReleaseNotes } = (await import(
+ pathToFileURL(path.join(process.cwd(), "scripts", "release", "notes.mjs")).href
+)) as {
+ releaseNoteForCommit(message: string): string | null;
+ renderReleaseNotes(messages: string[]): string;
+};
+
+describe("release notes", () => {
+ it("renders user-facing conventional commits and breaking changes", () => {
+ expect(
+ renderReleaseNotes([
+ "feat(chat): add side chat support",
+ "fix: prevent stale titles",
+ "perf(stream): reduce rendering work",
+ "refactor!: remove the legacy settings format",
+ "docs: explain side chats",
+ "chore(deps): update development dependencies",
+ ]),
+ ).toBe(
+ [
+ "## Changes",
+ "",
+ "- Add side chat support.",
+ "- Prevent stale titles.",
+ "- Reduce rendering work.",
+ "- Remove the legacy settings format.",
+ "",
+ ].join("\n"),
+ );
+ });
+
+ it("uses a breaking footer to include an otherwise hidden type", () => {
+ expect(releaseNoteForCommit("refactor: replace settings storage\n\nBREAKING-CHANGE: existing settings are reset")).toBe(
+ "Replace settings storage.",
+ );
+ });
+
+ it("leaves an editable placeholder when no commits belong in the notes", () => {
+ expect(renderReleaseNotes(["test: cover release generation", "ci: validate commit messages"])).toBe("## Changes\n\n- \n");
+ });
+});