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:
hwkiem 2026-09-06 12:10:54 -07:00 • committed by GitHub
commit 6b6e41a86d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 133 additions and 1 deletions

View 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
});
});
});

View file

@ -33,7 +33,7 @@ export async function checkStableRelease(
};
}
const version = latest.tag_name.replace(/^v/, '');
const version = versionFromTag(latest.tag_name);
const isPrerelease =
latest.prerelease ||
/-(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;
}