Files
renovate/tools/check-release-dry-run.ts
Jamie Tanna 710913e218 ci: don't allow breaking changes in PR commit messages (#44944)
Renovate 44.0.0 was incorrectly released from #44004 due to the
`BREAKING ...` reference in the commit message.

Renovate uses Squash Merge as our method for merging commits into
`main`, and when enabled with the Merge Queue[0], this doesn't provide
maintainers with the ability to inspect the commit messages before
merging (which would be the case if not using the Merge Queue, we would
likely rewrite the commit message prior to merge.

With this in mind, Renovate maintainers often don't inspect the commit
messages, allowing them to stay as they are, as they'll be squashed into
the final commit.

In the case of #44004, this led to us missing the `BREAKING ...`
trailing message - from changes that were split into #44939 - and
because of this, semantic-release published this as a major release.

As a means to prevent this happening again, we can introduce a CI check
that performs a full `semantic-release --dry-run` programatically, so we
can avoid relying on log lines from `semantic-release`.

This then flags if there is a suggested major release, and if so,
rejecting it.

Co-authored-by: Claude Opus 4.8 (1M context) <jamie.tanna+claude-code@mend.io>
2026-07-29 14:39:20 +00:00

123 lines
3.6 KiB
TypeScript

import { argv, env } from 'node:process';
import { pathToFileURL } from 'node:url';
import semanticRelease from 'semantic-release';
// Release types that introduce a new major version. `premajor` is what the
// `next` prerelease branch reports for the same breaking change.
const MAJOR_TYPES = new Set(['major', 'premajor']);
// Structural subset of semantic-release's `Result` that we care about. The real
// result (`false | { lastRelease, nextRelease, ... }`) is assignable to this.
export interface DryRunResultLike {
nextRelease?: { type: string; version: string } | null;
lastRelease?: { version: string } | null;
}
export interface Verdict {
willRelease: boolean;
type: string | null;
version: string | null;
lastVersion: string | null;
isMajor: boolean;
}
export function evaluateResult(result: DryRunResultLike | false): Verdict {
if (!result || !result.nextRelease) {
return {
willRelease: false,
type: null,
version: null,
lastVersion: null,
isMajor: false,
};
}
const { type, version } = result.nextRelease;
return {
willRelease: true,
type,
version,
lastVersion: result.lastRelease?.version ?? null,
isMajor: MAJOR_TYPES.has(type),
};
}
/**
* Run a full, offline semantic-release dry-run for the state that would exist
* right after a PR merges, and return its structured result.
*
* - `repositoryUrl` is a local bare mirror, so no network or token is needed
* (works for fork PRs).
* - Only the analysis plugins run; the publish plugins are dropped, which does
* not change the computed version.
* - The spoofed `GITHUB_*` env presents the run as a push to `baseRef`, so
* `env-ci` does not treat it as a (non-releasing) pull request.
*/
function runDryRun(
repositoryUrl: string,
baseRef: string,
headSha: string,
): Promise<DryRunResultLike | false> {
return semanticRelease(
{
dryRun: true,
repositoryUrl,
plugins: [
'@semantic-release/commit-analyzer',
'@semantic-release/release-notes-generator',
],
},
{
env: {
...env,
GITHUB_ACTIONS: 'true',
GITHUB_EVENT_NAME: 'push',
GITHUB_REF: `refs/heads/${baseRef}`,
GITHUB_SHA: headSha,
},
},
);
}
async function main(): Promise<void> {
const [repositoryUrl, baseRef, headSha] = argv.slice(2);
if (!repositoryUrl || !baseRef || !headSha) {
throw new Error(
'Usage: check-release-dry-run <repositoryUrl> <baseRef> <headSha>',
);
}
const { willRelease, type, version, lastVersion, isMajor } = evaluateResult(
await runDryRun(repositoryUrl, baseRef, headSha),
);
if (!willRelease) {
console.log('semantic-release would not publish a new version.');
return;
}
console.log(
`semantic-release would publish ${version} (${type}); previous release: ${lastVersion ?? 'none'}.`,
);
if (!isMajor) {
console.log('✓ Not a new major version.');
return;
}
const title = 'Unexpected major release';
const hint =
`Merging this PR would make semantic-release publish a new major version ` +
`(${lastVersion ?? '?'} → ${version}), because a commit carries a breaking-change ` +
'marker (`!` in the header, or a `BREAKING CHANGE:`/`BREAKING-CHANGE:` note). ' +
'Remove the marker, or — if a major bump is intended (e.g. preparing the next ' +
'major) — override this check manually.';
console.log(env.CI ? `::error title=${title}::${hint}` : `${title}: ${hint}`);
process.exitCode = 1;
}
// Only run when executed directly, so tests can import the helpers.
if (argv[1] && import.meta.url === pathToFileURL(argv[1]).href) {
await main();
}