diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml new file mode 100644 index 0000000..5b73e5a --- /dev/null +++ b/.github/workflows/pr-title.yml @@ -0,0 +1,36 @@ +name: Check PR Title + +on: + pull_request: + types: [opened, edited] + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + lint-pr-title: + name: Lint PR title + runs-on: ubuntu-latest + + if: ${{ (github.event.action == 'opened' || github.event.changes.title != null) && github.actor != 'renovate[bot]' }} + + steps: + - name: Checkout + uses: actions/checkout@v5 + with: + persist-credentials: false + # Only fetch the config file from the repository + sparse-checkout-cone-mode: false + sparse-checkout: commitlint.config.ts + + - name: Install dependencies + run: npm install -D @commitlint/cli @commitlint/config-conventional + + - name: Validate PR title with commitlint + run: echo "$PR_TITLE" | npx commitlint + env: + PR_TITLE: ${{ github.event.pull_request.title }} diff --git a/commitlint.config.ts b/commitlint.config.ts new file mode 100644 index 0000000..6f8709b --- /dev/null +++ b/commitlint.config.ts @@ -0,0 +1,38 @@ +import type { Rule, UserConfig } from '@commitlint/types' +import { RuleConfigSeverity } from '@commitlint/types' + +// #region Rules + +/** + * Rule to ensure the first letter of the commit subject is lowercase. + * + * @param parsed - Parsed commit object containing commit message parts. + * @returns A tuple where the first element is a boolean indicating + * if the rule passed, and the second is an optional error message. + */ +const subjectLowercaseFirst: Rule = async (parsed) => { + const firstChar = parsed.subject!.match(/[a-z]/i)?.[0] + if (firstChar && firstChar === firstChar.toUpperCase()) { + return [false, 'Subject must start with a lowercase letter'] + } + return [true] +} + +// #endregion + +const Configuration: UserConfig = { + extends: ['@commitlint/config-conventional'], + rules: { + 'subject-case': [RuleConfigSeverity.Disabled], + 'subject-lowercase-first': [RuleConfigSeverity.Error, 'always'], + }, + plugins: [ + { + rules: { + 'subject-lowercase-first': subjectLowercaseFirst, + }, + }, + ], +} + +export default Configuration diff --git a/package.json b/package.json index 752db5b..ab106ea 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ }, "devDependencies": { "@antfu/eslint-config": "^6.2.0", + "@commitlint/types": "^20.0.0", "@types/node": "^24.10.1", "automd": "^0.4.2", "bumpp": "^10.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6b2acef..ffa4198 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@antfu/eslint-config': specifier: ^6.2.0 version: 6.2.0(@vue/compiler-sfc@3.5.24)(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)(vitest@4.0.13(@opentelemetry/api@1.9.0)(@types/debug@4.1.12)(@types/node@24.10.1)(jiti@2.6.1)(tsx@4.20.6)(yaml@2.8.1)) + '@commitlint/types': + specifier: ^20.0.0 + version: 20.0.0 '@types/node': specifier: ^24.10.1 version: 24.10.1 @@ -365,6 +368,10 @@ packages: '@clack/prompts@0.11.0': resolution: {integrity: sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw==} + '@commitlint/types@20.0.0': + resolution: {integrity: sha512-bVUNBqG6aznYcYjTjnc3+Cat/iBgbgpflxbIBTnsHTX0YVpnmINPEkSRWymT2Q8aSH3Y7aKnEbunilkYe8TybA==} + engines: {node: '>=v18'} + '@docsearch/css@3.8.2': resolution: {integrity: sha512-y05ayQFyUmCXze79+56v/4HpycYF3uFqB78pLPrSV5ZKAlDuIAAJNhaRi8tTdRNXh05yxX/TyNnzD6LwSM89vQ==} @@ -1173,6 +1180,9 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/conventional-commits-parser@5.0.2': + resolution: {integrity: sha512-BgT2szDXnVypgpNxOK8aL5SGjUdaQbC++WZNjF1Qge3Og2+zhHj+RWhmehLhYyvQwqAmvezruVfOf8+3m74W+g==} + '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} @@ -1660,6 +1670,10 @@ packages: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + change-case@5.4.4: resolution: {integrity: sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==} @@ -3837,6 +3851,11 @@ snapshots: picocolors: 1.1.1 sisteransi: 1.0.5 + '@commitlint/types@20.0.0': + dependencies: + '@types/conventional-commits-parser': 5.0.2 + chalk: 5.6.2 + '@docsearch/css@3.8.2': {} '@docsearch/js@3.8.2(@algolia/client-search@5.44.0)(search-insights@2.17.3)': @@ -4442,6 +4461,10 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/conventional-commits-parser@5.0.2': + dependencies: + '@types/node': 24.10.1 + '@types/debug@4.1.12': dependencies: '@types/ms': 2.1.0 @@ -5068,6 +5091,8 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 + chalk@5.6.2: {} + change-case@5.4.4: {} character-entities-html4@2.1.0: {}