Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 119 additions & 0 deletions src/tier-check/checks/release.test.ts
Original file line number Diff line number Diff line change
@@ -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
});
});
});
15 changes: 14 additions & 1 deletion src/tier-check/checks/release.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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;
}