mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-10-25 13:11:05 +00:00
1e52f3f134
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.
207 lines
7.2 KiB
JavaScript
207 lines
7.2 KiB
JavaScript
/**
|
|
* Tests for scripts/check-pr-title.mjs
|
|
*
|
|
* Run: node --test scripts/check-pr-title.test.mjs
|
|
*
|
|
* The tests assert against the literal types listed in commitlint.config.js
|
|
* rather than a copy of them, so widening or narrowing the allow-list is
|
|
* visible here as a test change instead of a silent divergence.
|
|
*/
|
|
|
|
import { describe, it } from 'node:test'
|
|
import assert from 'node:assert/strict'
|
|
|
|
import { allowedTypes, parseTitle, checkTitle } from './check-pr-title.mjs'
|
|
|
|
const TYPES = [
|
|
'feat',
|
|
'fix',
|
|
'perf',
|
|
'revert',
|
|
'docs',
|
|
'style',
|
|
'refactor',
|
|
'test',
|
|
'build',
|
|
'ci',
|
|
'chore',
|
|
'security',
|
|
]
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// allowedTypes
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('allowedTypes', () => {
|
|
it('reads the real commitlint allow-list', () => {
|
|
const types = allowedTypes('commitlint.config.js')
|
|
|
|
assert.deepEqual(types, TYPES)
|
|
})
|
|
|
|
it('survives the config being reformatted, not just the types changing', () => {
|
|
// The allow-list is loaded, not pattern-matched out of the source, so a
|
|
// re-quoting pass in commitlint.config.js cannot silently break this check.
|
|
const reformatted = { rules: { 'type-enum': [2, 'always', ['feat', 'fix']] } }
|
|
|
|
assert.deepEqual(allowedTypes(reformatted), ['feat', 'fix'])
|
|
})
|
|
|
|
it('reads the allow-list out of the [level, applicability, value] rule form', () => {
|
|
assert.deepEqual(
|
|
allowedTypes({ rules: { 'type-enum': [2, 'always', ['feat', 'fix']] } }),
|
|
['feat', 'fix'],
|
|
)
|
|
assert.deepEqual(allowedTypes({ rules: { 'type-enum': [2, ['feat']] } }), ['feat'])
|
|
})
|
|
|
|
it('fails closed when there is no type-enum rule', () => {
|
|
assert.throws(() => allowedTypes({ rules: {} }), /type-enum/)
|
|
assert.throws(() => allowedTypes({}), /type-enum/)
|
|
})
|
|
|
|
it('fails closed when the allow-list is empty', () => {
|
|
assert.throws(() => allowedTypes({ rules: { 'type-enum': [] } }), /type-enum/)
|
|
assert.throws(
|
|
() => allowedTypes({ rules: { 'type-enum': [2, 'always', []] } }),
|
|
/type-enum/,
|
|
)
|
|
})
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// parseTitle
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('parseTitle', () => {
|
|
it('parses a plain type and subject', () => {
|
|
assert.deepEqual(parseTitle('fix(dashboard): keep the column readable', TYPES), {
|
|
type: 'fix',
|
|
scope: 'dashboard',
|
|
breaking: false,
|
|
subject: 'keep the column readable',
|
|
})
|
|
})
|
|
|
|
it('parses a title with no scope', () => {
|
|
assert.deepEqual(parseTitle('ci: shard the e2e suite', TYPES), {
|
|
type: 'ci',
|
|
scope: null,
|
|
breaking: false,
|
|
subject: 'shard the e2e suite',
|
|
})
|
|
})
|
|
|
|
it('parses a breaking-change marker', () => {
|
|
const parsed = parseTitle('feat(app-store)!: replace the legacy picker', TYPES)
|
|
|
|
assert.equal(parsed.breaking, true)
|
|
assert.equal(parsed.type, 'feat')
|
|
})
|
|
|
|
it('returns null for a title with no conventional prefix', () => {
|
|
assert.equal(parseTitle('Surface runtime build identity across About, shell, and Admiral Account', TYPES), null)
|
|
})
|
|
|
|
it('rejects an uppercase type, which release-please would not map to a section', () => {
|
|
assert.equal(parseTitle('FEAT: uppercase type', TYPES), null)
|
|
})
|
|
|
|
it('tolerates spaces and punctuation inside a scope, as the parser does', () => {
|
|
// @conventional-commits/parser 0.4 accepts all of these, so rejecting them
|
|
// here would block titles that release-please reads perfectly well.
|
|
assert.equal(parseTitle('feat(scope with spaces): x', TYPES).scope, 'scope with spaces')
|
|
assert.equal(parseTitle('feat(a$b.c-d*e): x', TYPES).scope, 'a$b.c-d*e')
|
|
assert.equal(parseTitle('fix(dash\tboard): x', TYPES).scope, 'dash\tboard')
|
|
})
|
|
|
|
it('rejects a nested paren in the scope', () => {
|
|
// release-please's parser (@conventional-commits/parser 0.4.x) throws on
|
|
// this shape and then drops the commit, so accepting it here would pass a
|
|
// title that never reaches the changelog.
|
|
assert.equal(parseTitle('fix(foo(bar)): nested parens', TYPES), null)
|
|
})
|
|
|
|
it('rejects text between the scope and the colon', () => {
|
|
assert.equal(parseTitle('feat(sco pe)extra: x', TYPES), null)
|
|
})
|
|
|
|
it('rejects a space before the colon', () => {
|
|
assert.equal(parseTitle('feat : x', TYPES), null)
|
|
})
|
|
|
|
it('returns null for a type outside the allow-list', () => {
|
|
assert.equal(parseTitle('wip: try the new picker', TYPES), null)
|
|
})
|
|
|
|
it('returns null when there is no subject', () => {
|
|
assert.equal(parseTitle('fix(dashboard): ', TYPES), null)
|
|
})
|
|
|
|
it('returns null for a title that is only a type', () => {
|
|
assert.equal(parseTitle('fix:', TYPES), null)
|
|
})
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// checkTitle
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('checkTitle', () => {
|
|
it('passes every type the repo actually uses', () => {
|
|
const real = [
|
|
'feat: surface the running build identity in About, the shell chip, and Admiral Account',
|
|
'fix(dashboard): keep the Stack Health name column readable at 1280',
|
|
'security(deps): bump @grpc/grpc-js to 1.14.5',
|
|
'chore(deps): bump ip-address to 10.7.2 in the backend',
|
|
'docs: correct the cosign verification identity in release artifact guides',
|
|
'refactor(fleet): flatten the Readiness tab chrome and start its check when Fleet opens',
|
|
'test(e2e): stop a failed two-factor spec from cascading the whole suite',
|
|
'perf(gitops): bound the Blueprint detail reachability probe with a verdict window',
|
|
'ci: shard e2e, skip docs-only PRs, and drop the inert visual-regression gate',
|
|
'revert: undo the marquee change',
|
|
'build: add a reproducible timestamp',
|
|
'style: normalise the table spacing',
|
|
]
|
|
|
|
for (const title of real) {
|
|
assert.equal(checkTitle(title, TYPES), null, title)
|
|
}
|
|
})
|
|
|
|
it('rejects the untyped title that dropped a feature from the release notes', () => {
|
|
const failure = checkTitle(
|
|
'Surface runtime build identity across About, shell, and Admiral Account',
|
|
TYPES,
|
|
)
|
|
|
|
assert.ok(failure)
|
|
assert.match(failure, /not a Conventional Commit/)
|
|
assert.match(failure, /squash/i)
|
|
})
|
|
|
|
it('names the allowed types and an example when it fails', () => {
|
|
const failure = checkTitle('nope: missing the colon', TYPES)
|
|
|
|
assert.match(failure, /security/)
|
|
assert.match(failure, /fix\(dashboard\): /)
|
|
})
|
|
|
|
it('rejects an empty title', () => {
|
|
assert.match(checkTitle(' ', TYPES), /empty/)
|
|
})
|
|
|
|
it('accepts a title longer than the local commit hook allows', () => {
|
|
// Dependabot produces group titles over the 120-character commit header
|
|
// limit. Retyping the commit is the maintainer's job at merge time, and
|
|
// failing here would only block the merge behind a cosmetic rule.
|
|
const long =
|
|
'chore(deps): bump the all-npm-backend group across 1 directory with 14 updates and a rather long trailing description to exceed the limit'
|
|
|
|
assert.equal(checkTitle(long, TYPES), null)
|
|
})
|
|
|
|
it('treats surrounding whitespace as absent', () => {
|
|
assert.match(checkTitle('\n\t ', TYPES), /empty/)
|
|
})
|
|
}) |