Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .github/workflows/lint-pr-title.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
name: 'Lint PR title'

on:
pull_request:
types: [opened, edited, reopened]

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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,5 +130,5 @@ To test with specific config options (e.g. `forwardErrorsToLogs: true`), just ed

- Branch naming: `<username>/<feature>` (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** follows commit message convention (see @docs/DEVELOPMENT.md)
- PR template at `.github/PULL_REQUEST_TEMPLATE.md` - use it for all PRs
32 changes: 32 additions & 0 deletions scripts/check-pr-title.spec.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
33 changes: 33 additions & 0 deletions scripts/check-pr-title.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
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)
})
}
43 changes: 43 additions & 0 deletions scripts/lib/gitmoji.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// 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 = /️/g
export const normalizeGitmoji = (value: string): string => value.replace(VARIATION_SELECTOR, '')

export const PUBLIC_EMOJI_PRIORITY: readonly string[] = GITMOJI.filter((g) => g.category === 'public').map(
(g) => g.emoji
)
export const INTERNAL_EMOJI_PRIORITY: readonly string[] = GITMOJI.filter((g) => g.category === 'internal').map(
(g) => g.emoji
)
Original file line number Diff line number Diff line change
Expand Up @@ -98,15 +98,15 @@ 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
}
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 ''
}
Expand Down
17 changes: 2 additions & 15 deletions scripts/release/generate-changelog/lib/constants.ts
Original file line number Diff line number Diff line change
@@ -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
]
Loading