Files
sencho/scripts/check-pr-title.mjs
Anso 1e52f3f134 ci(release-notes): require a conventional PR title (#2123)
release-please derives the changelog and the version bump from
conventional-commit subjects read off main. Squash-merging takes the
subject from the PR title, so a title the parser cannot read drops the
change from the release notes with no warning. The build-identity
feature (#1899) merged with an untyped title and never appeared in any
release notes.

The local commit-msg hook cannot catch this: it runs against the PR
branch commit and never against the squash that actually lands. Add a
pull_request workflow that validates the title, and read the allowed
types from commitlint.config.js so the two cannot drift.

The check enforces the conventional shape only, not the length or
casing rules the local hook applies to a full commit. Those would only
add noise here: Dependabot generates group titles longer than the
commit header limit, and blocking on that would fail a PR whose title is
otherwise fine.

Correct CONTRIBUTING.md while here. It claimed that hand edits to the
Release PR survive a regeneration; release-please replaces the PR body
and force-pushes the release branch, so they do not. Point the guidance
at the override block, and record that a dependency PR clearing an
advisory must not merge as chore(deps), which is conventional and
therefore passes the new check while staying out of the changelog.
2026-10-12 10:54:22 -04:00

150 lines
5.2 KiB
JavaScript

#!/usr/bin/env node
/**
* Validates a pull request title against the Conventional Commits shape.
*
* release-please computes the version bump and the changelog by parsing
* conventional commit subjects off `main`. Squash-merges take the subject from
* the PR title, so a title the parser cannot read is a change that vanishes
* from the release notes without any warning. That happened once already: the
* build-identity feature (#1899) merged with the title "Surface runtime build
* identity across About, shell, and Admiral Account" and never appeared in any
* release notes.
*
* commitlint only runs as a local commit-msg hook, which fires on the PR branch
* and never on the squash that actually lands. This check closes that gap for
* pull requests.
*
* The allowed types are read from commitlint.config.js so the local hook and
* this check cannot drift apart. Only the conventional shape is enforced here:
* the rules that matter for the release pipeline (a parsable type, and an empty
* subject). Length, casing, and full commitlint validation stay in the local
* hook, which sees the whole commit; this runs against a one-line title where a
* stricter rule set would only produce noise.
*
* Usage:
* node scripts/check-pr-title.mjs "<title>"
*
* Exits 0 when the title parses, 1 with an explanation when it does not.
*/
import { createRequire } from 'node:module'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
/**
* Pull the allowed type list out of commitlint.config.js.
*
* The config is CommonJS and lives in this repo, so it is loaded rather than
* pattern-matched out of its source: reading the file with a regex would break
* the check on an unrelated reformat (double quotes, a trailing comma), and a
* silently broken allow-list is worse than no check. Loading it also means the
* local commit-msg hook and this check can never disagree about the types.
*
* A config that cannot be read, or that carries no type-enum, is a hard
* failure, so the check fails closed instead of approving every title. Takes
* either a path or an already-loaded config object.
*/
export function allowedTypes(commitlintConfig) {
const config =
typeof commitlintConfig === 'string'
? createRequire(import.meta.url)(resolve(repoRoot, commitlintConfig))
: commitlintConfig
const rule = config?.rules?.['type-enum']
if (!Array.isArray(rule) || rule.length === 0) {
throw new Error('No type-enum rule found in commitlint.config.js')
}
// commitlint writes a rule as [level, applicability, value], so the allow-list
// is the trailing array; the shorter [level, value] form drops the middle.
const types = rule[rule.length - 1]
if (!Array.isArray(types) || types.length === 0) {
throw new Error('No type-enum allow-list found in commitlint.config.js')
}
return types.filter((type) => typeof type === 'string')
}
/**
* Parse a title into its conventional-commit parts, or return null when the
* title does not match `<type>[(<scope>)][!]: <subject>`.
*
* The scope character class mirrors what release-please's parser accepts rather
* than what the spec nominally allows. It tolerates spaces, tabs, and
* punctuation inside the scope, because the parser does, but it rejects a
* nested `(`: the parser throws on that shape and release-please catches the
* throw and drops the commit, so accepting it here would pass a title that
* silently never reaches the changelog. The same goes for text between the
* closing paren and the colon.
*/
export function parseTitle(title, types) {
const match = title.match(/^([a-z]+)(?:\(([^()]+)\))?(!)?: (.+)$/)
if (!match) {
return null
}
const [, type, scope, breaking, subject] = match
if (!types.includes(type)) {
return null
}
if (subject.trim() === '') {
return null
}
return { type, scope: scope ?? null, breaking: breaking === '!', subject }
}
export function checkTitle(title, types) {
const trimmed = (title ?? '').trim()
if (trimmed === '') {
return 'The pull request title is empty.'
}
if (parseTitle(trimmed, types)) {
return null
}
return [
`The pull request title is not a Conventional Commit: "${trimmed}"`,
'',
'Squash-merging uses the PR title as the commit subject, and release-please',
'parses those subjects to build the changelog and decide the version bump.',
'A title it cannot parse drops the change from the release notes entirely.',
'',
`Use <type>: <subject>, optionally <type>(<scope>): <subject>.`,
`Allowed types: ${types.join(', ')}.`,
'',
'Examples:',
' fix(dashboard): keep the Stack Health name column readable at 1280',
' security(deps): bump @grpc/grpc-js to 1.14.5',
].join('\n')
}
export function main(argv) {
const title = argv[2] ?? process.env.PR_TITLE ?? ''
const types = allowedTypes('commitlint.config.js')
const failure = checkTitle(title, types)
if (failure) {
console.error(failure)
process.exit(1)
}
console.log(`OK: "${title}"`)
}
const runningDirectly =
process.argv[1] &&
(process.argv[1].endsWith('check-pr-title.mjs') ||
process.argv[1].endsWith('check-pr-title'))
if (runningDirectly) {
main(process.argv)
}