mirror of
https://github.com/renovatebot/renovate.git
synced 2026-10-30 01:08:06 +00:00
710913e218
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>
123 lines
3.6 KiB
TypeScript
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();
|
|
}
|