From 8fc051ebd4a5effe807bf0d5831dca885b47f5c8 Mon Sep 17 00:00:00 2001 From: murashit Date: Tue, 14 Jul 2026 15:53:19 +0900 Subject: [PATCH] chore(release): adopt Conventional Commits and generated release notes --- .agents/skills/codex-panel-release/SKILL.md | 9 +- .github/dependabot.yml | 5 + .github/workflows/check.yml | 15 + .github/workflows/release.yml | 9 + AGENTS.md | 2 + commitlint.config.mjs | 9 + docs/development.md | 15 + docs/release.md | 10 +- package-lock.json | 601 ++++++++++++++++++++ package.json | 6 + scripts/release/notes.mjs | 73 +++ scripts/release/preflight.mjs | 6 + scripts/release/prepare.mjs | 12 +- tests/scripts/development-scripts.test.ts | 52 ++ tests/scripts/release-notes.test.ts | 45 ++ 15 files changed, 859 insertions(+), 10 deletions(-) create mode 100644 commitlint.config.mjs create mode 100644 scripts/release/notes.mjs create mode 100644 tests/scripts/release-notes.test.ts 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"); + }); +});