diff --git a/.github/workflows/lint-pr-title.yml b/.github/workflows/lint-pr-title.yml new file mode 100644 index 0000000000..fe6f7f10e3 --- /dev/null +++ b/.github/workflows/lint-pr-title.yml @@ -0,0 +1,29 @@ +name: 'Lint PR title' + +on: + pull_request: + types: [opened, edited, reopened, synchronize] + +permissions: + contents: read + +jobs: + lint: + if: github.event.pull_request.user.type != 'Bot' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version-file: 'package.json' + cache: 'yarn' + + - name: Install dependencies + run: yarn install --immutable + + - name: Check PR title follows gitmoji convention + env: + # Passing via env (not inline ${{ }}) avoids shell injection via PR titles. + PR_TITLE: ${{ github.event.pull_request.title }} + run: node scripts/check-pr-title.ts diff --git a/AGENTS.md b/AGENTS.md index 334a33404d..4ccfe52f24 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -130,5 +130,5 @@ To test with specific config options (e.g. `forwardErrorsToLogs: true`), just ed - Branch naming: `/` (e.g., `john.doe/fix-session-bug`) - Always branch from `main` unless explicitly decided otherwise -- PR title follows commit message convention (used when squashing to main) +- PR title **must** follow commit message convention (see @docs/DEVELOPMENT.md) - PR template at `.github/PULL_REQUEST_TEMPLATE.md` - use it for all PRs diff --git a/scripts/check-pr-title.spec.ts b/scripts/check-pr-title.spec.ts new file mode 100644 index 0000000000..c30173f88c --- /dev/null +++ b/scripts/check-pr-title.spec.ts @@ -0,0 +1,32 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { isValidPrTitle } from './check-pr-title.ts' + +describe('isValidPrTitle', () => { + it('accepts titles starting with an allowed emoji', () => { + assert.equal(isValidPrTitle('โœจ Add new feature'), true) + assert.equal(isValidPrTitle('๐Ÿ› Fix bug'), true) + assert.equal(isValidPrTitle('๐Ÿ‘ท Update CI'), true) + assert.equal(isValidPrTitle('โ™ป๏ธ Refactor module'), true) + }) + + it('accepts the performance emoji with or without the variation selector', () => { + assert.equal(isValidPrTitle('โšก๏ธ Speed up'), true) + assert.equal(isValidPrTitle('โšก Speed up'), true) + }) + + it('rejects titles without any allowed emoji prefix', () => { + assert.equal(isValidPrTitle('Add new feature'), false) + assert.equal(isValidPrTitle('feat: add thing'), false) + assert.equal(isValidPrTitle(''), false) + }) + + it('rejects titles where the emoji is not at the start', () => { + assert.equal(isValidPrTitle('Fix โœจ thing'), false) + }) + + it('rejects emojis that are not in the allowed list', () => { + assert.equal(isValidPrTitle('๐Ÿš€ Launch'), false) + assert.equal(isValidPrTitle('๐Ÿ“ฆ Package'), false) + }) +}) diff --git a/scripts/check-pr-title.ts b/scripts/check-pr-title.ts new file mode 100644 index 0000000000..04d0321b9e --- /dev/null +++ b/scripts/check-pr-title.ts @@ -0,0 +1,34 @@ +import { printError, printLog, runMain } from './lib/executionUtils.ts' +import { GITMOJI, normalizeGitmoji } from './lib/gitmoji.ts' + +export function isValidPrTitle(title: string): boolean { + const normalized = normalizeGitmoji(title) + return GITMOJI.some(({ emoji }) => normalized.startsWith(normalizeGitmoji(emoji))) +} + +export function formatAllowedPrefixes(): string { + return GITMOJI.map(({ emoji, label }) => ` ${emoji} ${label}`).join('\n') +} + +if (!process.env.NODE_TEST_CONTEXT) { + runMain(() => { + const title = process.env.PR_TITLE + + if (title === undefined) { + throw new Error('PR_TITLE environment variable is not set.') + } + + if (isValidPrTitle(title)) { + printLog(`PR title OK: ${title}`) + return + } + + printError( + 'PR title must start with one of the allowed gitmoji prefixes.\n\n' + + `Current title: ${title}\n\n` + + `Allowed prefixes:\n${formatAllowedPrefixes()}\n\n` + + 'See docs/DEVELOPMENT.md for the full convention.' + ) + process.exit(1) + }) +} diff --git a/scripts/lib/gitmoji.ts b/scripts/lib/gitmoji.ts new file mode 100644 index 0000000000..63a88c06fe --- /dev/null +++ b/scripts/lib/gitmoji.ts @@ -0,0 +1,45 @@ +// Canonical gitmoji prefix convention. Must stay in sync with docs/DEVELOPMENT.md. +// The order within each category is the priority used by the changelog generator. + +export type GitmojiCategory = 'public' | 'internal' + +export interface Gitmoji { + emoji: string + label: string + category: GitmojiCategory +} + +export const GITMOJI: readonly Gitmoji[] = [ + // User-facing changes + { emoji: '๐Ÿ’ฅ', label: 'Breaking change', category: 'public' }, + { emoji: 'โœจ', label: 'New feature', category: 'public' }, + { emoji: '๐Ÿ›', label: 'Bug fix', category: 'public' }, + { emoji: 'โšก๏ธ', label: 'Performance', category: 'public' }, + { emoji: '๐Ÿ“', label: 'Documentation', category: 'public' }, + { emoji: 'โš—๏ธ', label: 'Experimental', category: 'public' }, + + // Internal changes + { emoji: '๐Ÿ‘ท', label: 'Build/CI', category: 'internal' }, + { emoji: 'โ™ป๏ธ', label: 'Refactor', category: 'internal' }, + { emoji: '๐ŸŽจ', label: 'Code structure', category: 'internal' }, + { emoji: 'โœ…', label: 'Tests', category: 'internal' }, + { emoji: '๐Ÿ”ง', label: 'Configuration', category: 'internal' }, + { emoji: '๐Ÿ”ฅ', label: 'Removal', category: 'internal' }, + { emoji: '๐Ÿ‘Œ', label: 'Code review', category: 'internal' }, + { emoji: '๐Ÿšจ', label: 'Linting', category: 'internal' }, + { emoji: '๐Ÿงน', label: 'Cleanup', category: 'internal' }, + { emoji: '๐Ÿ”Š', label: 'Logging', category: 'internal' }, +] + +// Strip the Unicode variation selector (U+FE0F) so 'โšก' and 'โšก๏ธ' compare equal. +const VARIATION_SELECTOR = /\uFE0F/g +export const normalizeGitmoji = (value: string): string => value.replace(VARIATION_SELECTOR, '') + +// Exported priorities are normalized so they match the output of `\p{Extended_Pictographic}`, +// which strips the U+FE0F variation selector. +export const PUBLIC_EMOJI_PRIORITY: readonly string[] = GITMOJI.filter((g) => g.category === 'public').map((g) => + normalizeGitmoji(g.emoji) +) +export const INTERNAL_EMOJI_PRIORITY: readonly string[] = GITMOJI.filter((g) => g.category === 'internal').map((g) => + normalizeGitmoji(g.emoji) +) diff --git a/scripts/release/generate-changelog/lib/addNewChangesToChangelog.ts b/scripts/release/generate-changelog/lib/addNewChangesToChangelog.ts index aa827b1b17..117a5430a1 100644 --- a/scripts/release/generate-changelog/lib/addNewChangesToChangelog.ts +++ b/scripts/release/generate-changelog/lib/addNewChangesToChangelog.ts @@ -98,7 +98,7 @@ function getLastReleaseTagName(): string { return match[1] } -function sortByEmojiPriority(a: string, b: string, priorityList: string[]): number { +function sortByEmojiPriority(a: string, b: string, priorityList: readonly string[]): number { const getFirstRelevantEmojiIndex = (text: string): number => { const emoji = findFirstEmoji(text) return emoji && priorityList.includes(emoji) ? priorityList.indexOf(emoji) : Number.MAX_VALUE @@ -106,7 +106,7 @@ function sortByEmojiPriority(a: string, b: string, priorityList: string[]): numb return getFirstRelevantEmojiIndex(a) - getFirstRelevantEmojiIndex(b) } -function formatChangeList(title: string, changes: string[], priority: string[]): string { +function formatChangeList(title: string, changes: string[], priority: readonly string[]): string { if (!changes.length) { return '' } diff --git a/scripts/release/generate-changelog/lib/constants.ts b/scripts/release/generate-changelog/lib/constants.ts index bdf71658af..ce5390e30a 100644 --- a/scripts/release/generate-changelog/lib/constants.ts +++ b/scripts/release/generate-changelog/lib/constants.ts @@ -1,17 +1,4 @@ +export { PUBLIC_EMOJI_PRIORITY, INTERNAL_EMOJI_PRIORITY } from '../../../lib/gitmoji.ts' + export const CONTRIBUTING_FILE = 'docs/DEVELOPMENT.md' export const CHANGELOG_FILE = 'CHANGELOG.md' -export const PUBLIC_EMOJI_PRIORITY: string[] = ['๐Ÿ’ฅ', 'โœจ', '๐Ÿ›', 'โšก', '๐Ÿ“'] -export const INTERNAL_EMOJI_PRIORITY: string[] = [ - '๐Ÿ‘ท', - '๐Ÿ”ง', - '๐Ÿ“ฆ', // build conf - 'โ™ป๏ธ', - '๐ŸŽจ', // refactoring - '๐Ÿงช', - 'โœ…', // tests - '๐Ÿ”‡', - '๐Ÿ”Š', // telemetry - '๐Ÿ‘Œ', - '๐Ÿ“„', - 'โš—๏ธ', // experiment -]