mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-10-26 05:30:18 +00:00
b731258314
* ci: keep pre-1.0 features on the patch line release-please now treats a feat commit as a patch bump while the version is below 1.0.0, so compatible work ships in the next 0.98.N release and only an approved version transition moves the minor or major digit. A new pull request check detects the markers release-please reads as a version transition and requires the version-transition-approved label. The release PR is judged by its proposed manifest against origin/main, so an accidental minor or major step fails before it can merge. CONTRIBUTING.md and the pull request template state the compatibility rules the patch line depends on. * ci: accept every version marker the release parser reads The marker scan only accepted a colon separator, so the " #" forms of the BREAKING CHANGE, BREAKING-CHANGE and Release-As footers passed the pull request gate even though release-please bumps the version for them. Accept ":" and " #", the two separators the bundled parser defines, and match the type text it accepts before the bang. The release pull request decision now applies the patch line only while the base manifest is below 1.0.0, so a feature release above 1.0 passes without the transition label. The step into 1.0 and any later major move stay a maintainer decision, and an unreadable manifest still fails closed.
377 lines
12 KiB
JavaScript
377 lines
12 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
/**
|
|
* Fails a pull request that moves the release off the patch line without an
|
|
* approval.
|
|
*
|
|
* release-please computes the next version from conventional commits on main.
|
|
* This repository ships pre-1.0 with `bump-patch-for-minor-pre-major`, so a
|
|
* `feat:` stays on the patch line (0.98.0 to 0.98.1). Only a breaking change or
|
|
* an explicit `Release-As` moves the minor or major digit, and that is a
|
|
* maintainer decision rather than something a commit can do on its own.
|
|
*
|
|
* This check detects exactly the markers release-please treats as a version
|
|
* transition and requires the `version-transition-approved` label before one
|
|
* can pass. It deliberately does not calculate the next version: release-please
|
|
* stays the only version engine.
|
|
*
|
|
* The release PR itself is the exception. release-please derives its version
|
|
* from the commits already on main, not from markers in its own body, so for
|
|
* that PR the check compares the base manifest with the proposed manifest.
|
|
* While the base is below 1.0.0 a minor or major step needs the label. From
|
|
* 1.0.0 a feature release proposes the next minor and passes, while a major
|
|
* step stays an incompatible change and needs the label at every version. This
|
|
* also closes the gap where a `BEGIN_COMMIT_OVERRIDE` added to an already-merged
|
|
* PR can add a breaking note with no new commit to review.
|
|
*
|
|
* Usage:
|
|
* PR_TITLE=... PR_BODY=... PR_AUTHOR=... PR_HEAD_REF=... PR_APPROVED=...
|
|
* PR_COMMITS_FILE=... node scripts/check-version-transition.mjs
|
|
*
|
|
* Exits 0 when the pull request stays on the patch line or carries an approval,
|
|
* and 1 with an explanation when it does not, when a marker is unapproved, or
|
|
* when an input it needs cannot be read.
|
|
*/
|
|
|
|
import { execFileSync } from 'node:child_process'
|
|
import { readFileSync } from 'node:fs'
|
|
import { dirname, resolve } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
|
|
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
|
|
|
const MANIFEST_FILE = '.release-please-manifest.json'
|
|
const MANIFEST_REF = 'origin/main'
|
|
const RELEASE_PR_AUTHOR = 'sencho-quartermaster[bot]'
|
|
const RELEASE_BRANCH_PREFIX = 'release-please--'
|
|
const APPROVAL_LABEL = 'version-transition-approved'
|
|
|
|
// A conventional subject with a `!` immediately before the colon. Any type and
|
|
// any scope counts, including `feature!`, which release-please aliases to
|
|
// `feat`, and any type text the bundled parser accepts before the bang.
|
|
const BREAKING_SUBJECT = /^[^\s()!:]+(?:\([^()]*\))?!:/
|
|
|
|
// The two footer spellings the Conventional Commits specification allows. A
|
|
// footer separates its token with ":" or " #", the two forms the parser
|
|
// release-please bundles accepts, so a valid footer cannot slip past the scan.
|
|
const BREAKING_FOOTER = /^BREAKING[ -]CHANGE(?::| #)/i
|
|
|
|
// release-please's exact-version override note.
|
|
const RELEASE_AS = /^Release[- ]As(?::| #)/i
|
|
|
|
// One leading list marker, so a bullet cannot hide a footer from the scan.
|
|
const LIST_MARKER = /^(?:[-*]\s*|\d+\.\s*)/
|
|
|
|
function collectMarkers(text, where, found) {
|
|
for (const rawLine of String(text ?? '').split(/\r?\n/)) {
|
|
const line = rawLine.trim().replace(LIST_MARKER, '')
|
|
if (
|
|
BREAKING_SUBJECT.test(line) ||
|
|
BREAKING_FOOTER.test(line) ||
|
|
RELEASE_AS.test(line)
|
|
) {
|
|
found.push({ where, line })
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Scan the title, the body, and every full commit message for the markers
|
|
* release-please reads as a minor bump, a major bump, or an exact version.
|
|
*
|
|
* The body is scanned in full, including `BEGIN_COMMIT_OVERRIDE` blocks and
|
|
* fenced text: release-please reads the override block as the commit, so a
|
|
* marker inside one counts. Each line is trimmed and one leading `-`, `*`, or
|
|
* numbered list marker is stripped, so formatting cannot hide a marker.
|
|
*/
|
|
export function findMarkers({ title = '', body = '', commits = [] } = {}) {
|
|
const found = []
|
|
|
|
collectMarkers(title, 'title', found)
|
|
collectMarkers(body, 'body', found)
|
|
|
|
for (const commit of commits ?? []) {
|
|
collectMarkers(commit, 'commit', found)
|
|
}
|
|
|
|
return found
|
|
}
|
|
|
|
/**
|
|
* A release PR is one opened by the release app on a release branch. Both
|
|
* halves are required: a conventional release title alone is not enough, and
|
|
* neither is a matching branch or a matching author on its own.
|
|
*/
|
|
export function isReleasePr({ author = '', headRef = '' } = {}) {
|
|
return (
|
|
author === RELEASE_PR_AUTHOR &&
|
|
typeof headRef === 'string' &&
|
|
headRef.startsWith(RELEASE_BRANCH_PREFIX)
|
|
)
|
|
}
|
|
|
|
/**
|
|
* The root package version from a parsed release-please manifest, or null when
|
|
* it cannot be read. Every caller treats null as a hard failure.
|
|
*/
|
|
export function manifestVersion(manifest) {
|
|
const version = manifest?.['.']
|
|
return typeof version === 'string' ? version : null
|
|
}
|
|
|
|
function parseVersion(version) {
|
|
const match = /^(\d+)\.(\d+)\.(\d+)(?:[-+][0-9A-Za-z.-]+)?$/.exec(
|
|
String(version ?? '').trim(),
|
|
)
|
|
|
|
if (!match) {
|
|
return null
|
|
}
|
|
|
|
return { major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3]) }
|
|
}
|
|
|
|
/**
|
|
* Which digit moved between two versions: `major`, `minor`, `patch`, or `none`.
|
|
* Returns null when either version cannot be parsed, so an unreadable manifest
|
|
* can never be mistaken for a compatible step.
|
|
*/
|
|
export function versionTransition(baseVersion, headVersion) {
|
|
const base = parseVersion(baseVersion)
|
|
const head = parseVersion(headVersion)
|
|
|
|
if (!base || !head) {
|
|
return null
|
|
}
|
|
|
|
if (head.major !== base.major) {
|
|
return 'major'
|
|
}
|
|
|
|
if (head.minor !== base.minor) {
|
|
return 'minor'
|
|
}
|
|
|
|
if (head.patch !== base.patch) {
|
|
return 'patch'
|
|
}
|
|
|
|
return 'none'
|
|
}
|
|
|
|
function isApproved(approved) {
|
|
return approved === true || approved === 'true'
|
|
}
|
|
|
|
function markerFailure(markers) {
|
|
const lines = markers.map((marker) => ` ${marker.where}: ${marker.line}`)
|
|
|
|
return [
|
|
'This pull request moves the release off the patch line, and no maintainer',
|
|
'has approved a version transition.',
|
|
'',
|
|
'Found:',
|
|
...lines,
|
|
'',
|
|
'release-please reads these markers as a minor bump, a major bump, or an',
|
|
'exact version:',
|
|
' a conventional subject with "!" before the colon, such as feat!: or fix(x)!:',
|
|
' a BREAKING CHANGE or BREAKING-CHANGE footer, separated by ":" or " #"',
|
|
' a Release-As or Release As override, separated by ":" or " #"',
|
|
'',
|
|
'For a compatible change, reword the title, the body, and the commits so none',
|
|
'of them carries a marker. If this change really is a version transition, a',
|
|
`maintainer applies the ${APPROVAL_LABEL} label to this pull request.`,
|
|
].join('\n')
|
|
}
|
|
|
|
function releaseTransitionFailure(baseVersion, headVersion, transition) {
|
|
return [
|
|
`This release pull request proposes ${headVersion} from ${baseVersion}, a`,
|
|
`${transition} version transition, and no maintainer has approved it.`,
|
|
'',
|
|
'A version transition is a maintainer decision: below 1.0.0 a compatible',
|
|
'change stays on the patch line, and an incompatible change moves the major',
|
|
'digit at any version. If the transition is intended, a maintainer applies',
|
|
`the ${APPROVAL_LABEL} label to this pull request.`,
|
|
].join('\n')
|
|
}
|
|
|
|
const MANIFEST_FAILURE = [
|
|
`The release version could not be read from ${MANIFEST_FILE}.`,
|
|
`The base is read from ${MANIFEST_REF} and the head from the pull request.`,
|
|
'This check fails closed, so it cannot pass until both are readable.',
|
|
].join('\n')
|
|
|
|
/**
|
|
* True below 1.0.0, where a compatible change ships as a patch, so moving the
|
|
* minor digit is a maintainer decision. From 1.0.0 a feature release proposes
|
|
* the next minor and passes.
|
|
*/
|
|
function isPreMajor(version) {
|
|
const parsed = parseVersion(version)
|
|
return parsed !== null && parsed.major === 0
|
|
}
|
|
|
|
/**
|
|
* The release PR decision. Below 1.0.0 the release line is patch-only, so a
|
|
* minor step needs the approval label. A major step is an incompatible change
|
|
* and needs it at every version, including the step into 1.0.0. From 1.0.0 a
|
|
* feature release proposes the next minor and passes. An unreadable manifest
|
|
* fails closed.
|
|
*/
|
|
function checkReleaseTransition({ approved, baseManifest, headManifest }) {
|
|
const baseVersion = manifestVersion(baseManifest)
|
|
const headVersion = manifestVersion(headManifest)
|
|
|
|
if (!baseVersion || !headVersion) {
|
|
return MANIFEST_FAILURE
|
|
}
|
|
|
|
const transition = versionTransition(baseVersion, headVersion)
|
|
|
|
if (!transition) {
|
|
return [
|
|
`The manifest versions could not be compared: ${baseVersion} to ${headVersion}.`,
|
|
'This check fails closed.',
|
|
].join('\n')
|
|
}
|
|
|
|
if (transition !== 'major' && transition !== 'minor') {
|
|
return null
|
|
}
|
|
|
|
// A feature release from 1.0.0 onward is ordinary semver and passes without a
|
|
// label. The step into 1.0 and every later major move stays a maintainer
|
|
// decision, at every version.
|
|
const needsApproval = transition === 'major' || isPreMajor(baseVersion)
|
|
|
|
if (!needsApproval) {
|
|
return null
|
|
}
|
|
|
|
if (isApproved(approved)) {
|
|
return null
|
|
}
|
|
|
|
return releaseTransitionFailure(baseVersion, headVersion, transition)
|
|
}
|
|
|
|
/**
|
|
* The whole decision, as one pure function.
|
|
*
|
|
* A release PR is judged against its manifests. Any other PR is judged against
|
|
* its markers: none passes, and any marker needs the approval label. An input
|
|
* that could not be read fails closed, even when the label is present.
|
|
*/
|
|
export function checkVersionTransition({
|
|
title = '',
|
|
body = '',
|
|
commits = [],
|
|
approved = false,
|
|
author = '',
|
|
headRef = '',
|
|
baseManifest = null,
|
|
headManifest = null,
|
|
commitsRead = true,
|
|
} = {}) {
|
|
if (!commitsRead) {
|
|
return [
|
|
'The commit messages for this pull request could not be read.',
|
|
'This check fails closed, so it cannot pass until they are.',
|
|
].join('\n')
|
|
}
|
|
|
|
if (isReleasePr({ author, headRef })) {
|
|
return checkReleaseTransition({ approved, baseManifest, headManifest })
|
|
}
|
|
|
|
const markers = findMarkers({ title, body, commits })
|
|
|
|
if (markers.length === 0 || isApproved(approved)) {
|
|
return null
|
|
}
|
|
|
|
return markerFailure(markers)
|
|
}
|
|
|
|
/**
|
|
* Read the release manifest at a git ref. Any failure returns null, which the
|
|
* decision reports as a closed gate rather than an approval.
|
|
*/
|
|
function readManifestFromGit(ref) {
|
|
try {
|
|
return JSON.parse(
|
|
execFileSync('git', ['show', `${ref}:${MANIFEST_FILE}`], {
|
|
cwd: repoRoot,
|
|
encoding: 'utf8',
|
|
}),
|
|
)
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Read the release manifest from the working tree. Any failure returns null,
|
|
* which the decision reports as a closed gate rather than an approval.
|
|
*/
|
|
function readManifestFromWorkspace() {
|
|
try {
|
|
return JSON.parse(readFileSync(resolve(repoRoot, MANIFEST_FILE), 'utf8'))
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
function readCommits(commitsFile) {
|
|
if (!commitsFile) {
|
|
return { commits: [], commitsRead: false }
|
|
}
|
|
|
|
try {
|
|
const commits = readFileSync(commitsFile, 'utf8')
|
|
.split('\u0000')
|
|
.map((commit) => commit.trim())
|
|
.filter((commit) => commit !== '')
|
|
|
|
return { commits, commitsRead: true }
|
|
} catch {
|
|
return { commits: [], commitsRead: false }
|
|
}
|
|
}
|
|
|
|
export function main(env = process.env) {
|
|
const input = {
|
|
title: env.PR_TITLE ?? '',
|
|
body: env.PR_BODY ?? '',
|
|
approved: env.PR_APPROVED ?? '',
|
|
author: env.PR_AUTHOR ?? '',
|
|
headRef: env.PR_HEAD_REF ?? '',
|
|
...readCommits(env.PR_COMMITS_FILE ?? ''),
|
|
}
|
|
|
|
if (isReleasePr(input)) {
|
|
input.baseManifest = readManifestFromGit(MANIFEST_REF)
|
|
input.headManifest = readManifestFromWorkspace()
|
|
}
|
|
|
|
const failure = checkVersionTransition(input)
|
|
|
|
if (failure) {
|
|
console.error(failure)
|
|
process.exit(1)
|
|
}
|
|
|
|
console.log('OK: the version transition check passed.')
|
|
}
|
|
|
|
const runningDirectly =
|
|
process.argv[1] &&
|
|
(process.argv[1].endsWith('check-version-transition.mjs') ||
|
|
process.argv[1].endsWith('check-version-transition'))
|
|
|
|
if (runningDirectly) {
|
|
main()
|
|
}
|