Skip to content

Commit c349f5e

Browse files
committed
πŸ‘· add CI check enforcing gitmoji PR title convention
- Add `scripts/check-pr-title.ts` with the gitmoji prefix validation logic - Extract the canonical gitmoji list into `scripts/lib/gitmoji.ts` and reuse it from the changelog generator - Wire a `lint-pr-title` GitHub Actions workflow that runs on PR open/edit/reopen/synchronize - Update AGENTS.md to reflect the new requirement
1 parent b7df5f9 commit c349f5e

7 files changed

Lines changed: 143 additions & 18 deletions

File tree

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
name: 'Lint PR title'
2+
3+
on:
4+
pull_request:
5+
types: [opened, edited, reopened, synchronize]
6+
7+
permissions:
8+
contents: read
9+
10+
jobs:
11+
lint:
12+
if: github.event.pull_request.user.type != 'Bot'
13+
runs-on: ubuntu-latest
14+
steps:
15+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
16+
17+
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
18+
with:
19+
node-version-file: 'package.json'
20+
cache: 'yarn'
21+
22+
- name: Install dependencies
23+
run: yarn install --immutable
24+
25+
- name: Check PR title follows gitmoji convention
26+
env:
27+
# Passing via env (not inline ${{ }}) avoids shell injection via PR titles.
28+
PR_TITLE: ${{ github.event.pull_request.title }}
29+
run: node scripts/check-pr-title.ts

β€ŽAGENTS.mdβ€Ž

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,5 +130,5 @@ To test with specific config options (e.g. `forwardErrorsToLogs: true`), just ed
130130

131131
- Branch naming: `<username>/<feature>` (e.g., `john.doe/fix-session-bug`)
132132
- Always branch from `main` unless explicitly decided otherwise
133-
- PR title follows commit message convention (used when squashing to main)
133+
- PR title **must** follows commit message convention (see @docs/DEVELOPMENT.md)
134134
- PR template at `.github/PULL_REQUEST_TEMPLATE.md` - use it for all PRs
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import assert from 'node:assert/strict'
2+
import { describe, it } from 'node:test'
3+
import { isValidPrTitle } from './check-pr-title.ts'
4+
5+
describe('isValidPrTitle', () => {
6+
it('accepts titles starting with an allowed emoji', () => {
7+
assert.equal(isValidPrTitle('✨ Add new feature'), true)
8+
assert.equal(isValidPrTitle('πŸ› Fix bug'), true)
9+
assert.equal(isValidPrTitle('πŸ‘· Update CI'), true)
10+
assert.equal(isValidPrTitle('♻️ Refactor module'), true)
11+
})
12+
13+
it('accepts the performance emoji with or without the variation selector', () => {
14+
assert.equal(isValidPrTitle('⚑️ Speed up'), true)
15+
assert.equal(isValidPrTitle('⚑ Speed up'), true)
16+
})
17+
18+
it('rejects titles without any allowed emoji prefix', () => {
19+
assert.equal(isValidPrTitle('Add new feature'), false)
20+
assert.equal(isValidPrTitle('feat: add thing'), false)
21+
assert.equal(isValidPrTitle(''), false)
22+
})
23+
24+
it('rejects titles where the emoji is not at the start', () => {
25+
assert.equal(isValidPrTitle('Fix ✨ thing'), false)
26+
})
27+
28+
it('rejects emojis that are not in the allowed list', () => {
29+
assert.equal(isValidPrTitle('πŸš€ Launch'), false)
30+
assert.equal(isValidPrTitle('πŸ“¦ Package'), false)
31+
})
32+
})

β€Žscripts/check-pr-title.tsβ€Ž

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { printError, printLog, runMain } from './lib/executionUtils.ts'
2+
import { GITMOJI, normalizeGitmoji } from './lib/gitmoji.ts'
3+
4+
export function isValidPrTitle(title: string): boolean {
5+
const normalized = normalizeGitmoji(title)
6+
return GITMOJI.some(({ emoji }) => normalized.startsWith(normalizeGitmoji(emoji)))
7+
}
8+
9+
export function formatAllowedPrefixes(): string {
10+
return GITMOJI.map(({ emoji, label }) => ` ${emoji} ${label}`).join('\n')
11+
}
12+
13+
if (!process.env.NODE_TEST_CONTEXT) {
14+
runMain(() => {
15+
const title = process.env.PR_TITLE
16+
17+
if (title === undefined) {
18+
throw new Error('PR_TITLE environment variable is not set.')
19+
}
20+
21+
if (isValidPrTitle(title)) {
22+
printLog(`PR title OK: ${title}`)
23+
return
24+
}
25+
26+
printError(
27+
'PR title must start with one of the allowed gitmoji prefixes.\n\n' +
28+
`Current title: ${title}\n\n` +
29+
`Allowed prefixes:\n${formatAllowedPrefixes()}\n\n` +
30+
'See docs/DEVELOPMENT.md for the full convention.'
31+
)
32+
process.exit(1)
33+
})
34+
}

β€Žscripts/lib/gitmoji.tsβ€Ž

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// Canonical gitmoji prefix convention. Must stay in sync with docs/DEVELOPMENT.md.
2+
// The order within each category is the priority used by the changelog generator.
3+
4+
export type GitmojiCategory = 'public' | 'internal'
5+
6+
export interface Gitmoji {
7+
emoji: string
8+
label: string
9+
category: GitmojiCategory
10+
}
11+
12+
export const GITMOJI: readonly Gitmoji[] = [
13+
// User-facing changes
14+
{ emoji: 'πŸ’₯', label: 'Breaking change', category: 'public' },
15+
{ emoji: '✨', label: 'New feature', category: 'public' },
16+
{ emoji: 'πŸ›', label: 'Bug fix', category: 'public' },
17+
{ emoji: '⚑️', label: 'Performance', category: 'public' },
18+
{ emoji: 'πŸ“', label: 'Documentation', category: 'public' },
19+
{ emoji: 'βš—οΈ', label: 'Experimental', category: 'public' },
20+
21+
// Internal changes
22+
{ emoji: 'πŸ‘·', label: 'Build/CI', category: 'internal' },
23+
{ emoji: '♻️', label: 'Refactor', category: 'internal' },
24+
{ emoji: '🎨', label: 'Code structure', category: 'internal' },
25+
{ emoji: 'βœ…', label: 'Tests', category: 'internal' },
26+
{ emoji: 'πŸ”§', label: 'Configuration', category: 'internal' },
27+
{ emoji: 'πŸ”₯', label: 'Removal', category: 'internal' },
28+
{ emoji: 'πŸ‘Œ', label: 'Code review', category: 'internal' },
29+
{ emoji: '🚨', label: 'Linting', category: 'internal' },
30+
{ emoji: '🧹', label: 'Cleanup', category: 'internal' },
31+
{ emoji: 'πŸ”Š', label: 'Logging', category: 'internal' },
32+
]
33+
34+
// Strip the Unicode variation selector (U+FE0F) so '⚑' and '⚑️' compare equal.
35+
const VARIATION_SELECTOR = /️/g
36+
export const normalizeGitmoji = (value: string): string => value.replace(VARIATION_SELECTOR, '')
37+
38+
export const PUBLIC_EMOJI_PRIORITY: readonly string[] = GITMOJI.filter((g) => g.category === 'public').map(
39+
(g) => g.emoji
40+
)
41+
export const INTERNAL_EMOJI_PRIORITY: readonly string[] = GITMOJI.filter((g) => g.category === 'internal').map(
42+
(g) => g.emoji
43+
)

β€Žscripts/release/generate-changelog/lib/addNewChangesToChangelog.tsβ€Ž

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,15 +98,15 @@ function getLastReleaseTagName(): string {
9898
return match[1]
9999
}
100100

101-
function sortByEmojiPriority(a: string, b: string, priorityList: string[]): number {
101+
function sortByEmojiPriority(a: string, b: string, priorityList: readonly string[]): number {
102102
const getFirstRelevantEmojiIndex = (text: string): number => {
103103
const emoji = findFirstEmoji(text)
104104
return emoji && priorityList.includes(emoji) ? priorityList.indexOf(emoji) : Number.MAX_VALUE
105105
}
106106
return getFirstRelevantEmojiIndex(a) - getFirstRelevantEmojiIndex(b)
107107
}
108108

109-
function formatChangeList(title: string, changes: string[], priority: string[]): string {
109+
function formatChangeList(title: string, changes: string[], priority: readonly string[]): string {
110110
if (!changes.length) {
111111
return ''
112112
}
Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,4 @@
1+
export { PUBLIC_EMOJI_PRIORITY, INTERNAL_EMOJI_PRIORITY } from '../../../lib/gitmoji.ts'
2+
13
export const CONTRIBUTING_FILE = 'docs/DEVELOPMENT.md'
24
export const CHANGELOG_FILE = 'CHANGELOG.md'
3-
export const PUBLIC_EMOJI_PRIORITY: string[] = ['πŸ’₯', '✨', 'πŸ›', '⚑', 'πŸ“']
4-
export const INTERNAL_EMOJI_PRIORITY: string[] = [
5-
'πŸ‘·',
6-
'πŸ”§',
7-
'πŸ“¦', // build conf
8-
'♻️',
9-
'🎨', // refactoring
10-
'πŸ§ͺ',
11-
'βœ…', // tests
12-
'πŸ”‡',
13-
'πŸ”Š', // telemetry
14-
'πŸ‘Œ',
15-
'πŸ“„',
16-
'βš—οΈ', // experiment
17-
]

0 commit comments

Comments
Β (0)