fix(tier-check): strip package-name prefixes from monorepo release tags (#463)
Version detection stripped only a leading `v`, so the monorepo tags Cargo workspaces publish (`rmcp-v3.0.1`, `rmcp-macros-v3.0.1`) parsed to NaN and scored `stable_release: fail`. rust-sdk was reported as having no stable release despite a non-prerelease `rmcp-v3.0.1`. Match the version at the end of the tag instead, so any package-name prefix is ignored while bare (`1.2.3`), `v`-prefixed and prerelease (`v2.0.0-rc1`) tags parse as they did before. Tags carrying no recognizable version are returned unchanged rather than dropped, so they still surface in the check output. Fixes #425
This commit is contained in:
parent
48561a1aec
commit
6b6e41a86d
2 changed files with 133 additions and 1 deletions
119
src/tier-check/checks/release.test.ts
Normal file
119
src/tier-check/checks/release.test.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { Octokit } from '@octokit/rest';
|
||||||
|
import { checkStableRelease } from './release';
|
||||||
|
|
||||||
|
type Release = {
|
||||||
|
tag_name: string;
|
||||||
|
draft?: boolean;
|
||||||
|
prerelease?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** An Octokit stub that returns a fixed release list. */
|
||||||
|
function octokitWith(releases: Release[]): Octokit {
|
||||||
|
return {
|
||||||
|
repos: {
|
||||||
|
listReleases: async () => ({
|
||||||
|
data: releases.map((r) => ({
|
||||||
|
draft: false,
|
||||||
|
prerelease: false,
|
||||||
|
...r
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} as unknown as Octokit;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function check(releases: Release[]) {
|
||||||
|
return checkStableRelease(
|
||||||
|
octokitWith(releases),
|
||||||
|
'modelcontextprotocol',
|
||||||
|
'sdk'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('checkStableRelease', () => {
|
||||||
|
it('accepts a bare semver tag', async () => {
|
||||||
|
expect(await check([{ tag_name: '1.2.3' }])).toMatchObject({
|
||||||
|
status: 'pass',
|
||||||
|
version: '1.2.3',
|
||||||
|
is_stable: true
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a v-prefixed tag', async () => {
|
||||||
|
expect(await check([{ tag_name: 'v1.2.3' }])).toMatchObject({
|
||||||
|
status: 'pass',
|
||||||
|
version: '1.2.3',
|
||||||
|
is_stable: true
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// https://github.com/modelcontextprotocol/conformance/issues/425
|
||||||
|
it('accepts a monorepo tag carrying a package-name prefix', async () => {
|
||||||
|
expect(await check([{ tag_name: 'rmcp-v3.0.1' }])).toMatchObject({
|
||||||
|
status: 'pass',
|
||||||
|
version: '3.0.1',
|
||||||
|
is_stable: true
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a monorepo tag whose package name contains a hyphen', async () => {
|
||||||
|
expect(await check([{ tag_name: 'rmcp-macros-v3.0.1' }])).toMatchObject({
|
||||||
|
status: 'pass',
|
||||||
|
version: '3.0.1',
|
||||||
|
is_stable: true
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a pre-1.0 release', async () => {
|
||||||
|
expect(await check([{ tag_name: 'rmcp-v0.9.0' }])).toMatchObject({
|
||||||
|
status: 'fail',
|
||||||
|
version: '0.9.0',
|
||||||
|
is_stable: false
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a prerelease named in the tag', async () => {
|
||||||
|
expect(await check([{ tag_name: 'rmcp-v3.0.1-alpha.1' }])).toMatchObject({
|
||||||
|
status: 'fail',
|
||||||
|
version: '3.0.1-alpha.1',
|
||||||
|
is_stable: false,
|
||||||
|
is_prerelease: true
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a release GitHub flags as a prerelease', async () => {
|
||||||
|
expect(
|
||||||
|
await check([{ tag_name: 'rmcp-v3.0.1', prerelease: true }])
|
||||||
|
).toMatchObject({
|
||||||
|
status: 'fail',
|
||||||
|
is_stable: false,
|
||||||
|
is_prerelease: true
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips drafts and scores the first published release', async () => {
|
||||||
|
expect(
|
||||||
|
await check([
|
||||||
|
{ tag_name: 'rmcp-v4.0.0', draft: true },
|
||||||
|
{ tag_name: 'rmcp-v3.0.1' }
|
||||||
|
])
|
||||||
|
).toMatchObject({ status: 'pass', version: '3.0.1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports an unparseable tag unchanged rather than dropping it', async () => {
|
||||||
|
expect(await check([{ tag_name: 'nightly' }])).toMatchObject({
|
||||||
|
status: 'fail',
|
||||||
|
version: 'nightly',
|
||||||
|
is_stable: false
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails when the repo has no releases', async () => {
|
||||||
|
expect(await check([])).toMatchObject({
|
||||||
|
status: 'fail',
|
||||||
|
version: null,
|
||||||
|
is_stable: false
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -33,7 +33,7 @@ export async function checkStableRelease(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const version = latest.tag_name.replace(/^v/, '');
|
const version = versionFromTag(latest.tag_name);
|
||||||
const isPrerelease =
|
const isPrerelease =
|
||||||
latest.prerelease ||
|
latest.prerelease ||
|
||||||
/-(alpha|beta|rc|dev|preview|snapshot)/i.test(version);
|
/-(alpha|beta|rc|dev|preview|snapshot)/i.test(version);
|
||||||
|
|
@ -57,3 +57,16 @@ export async function checkStableRelease(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the semantic version from a release tag.
|
||||||
|
*
|
||||||
|
* Handles bare tags (`1.2.3`), `v`-prefixed tags (`v1.2.3`), and the
|
||||||
|
* package-prefixed tags monorepos publish (`rmcp-v3.0.1`,
|
||||||
|
* `rmcp-macros-v3.0.1`). Tags that carry no recognizable version are
|
||||||
|
* returned unchanged so they still surface in the check output.
|
||||||
|
*/
|
||||||
|
function versionFromTag(tag: string): string {
|
||||||
|
const match = tag.match(/(?:^|-)v?(\d+(?:\.\d+)*(?:[-+].*)?)$/);
|
||||||
|
return match ? match[1] : tag;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue