From d926083d06e68d0b9bc715c388a1337580a38d14 Mon Sep 17 00:00:00 2001 From: Ali Hashemi Date: Sun, 26 Jul 2026 08:31:59 -0300 Subject: [PATCH 1/2] fix(tier-check): parse semver from monorepo release tags, add --tag-prefix --- src/tier-check/checks/release.test.ts | 208 +++++++++++++++++++++++++ src/tier-check/checks/release.ts | 75 ++++++--- src/tier-check/checks/spec-tracking.ts | 7 +- src/tier-check/index.ts | 16 +- 4 files changed, 277 insertions(+), 29 deletions(-) create mode 100644 src/tier-check/checks/release.test.ts diff --git a/src/tier-check/checks/release.test.ts b/src/tier-check/checks/release.test.ts new file mode 100644 index 00000000..d50c1c5e --- /dev/null +++ b/src/tier-check/checks/release.test.ts @@ -0,0 +1,208 @@ +import { describe, test, expect, vi } from 'vitest'; +import { Octokit } from '@octokit/rest'; +import { checkStableRelease, extractSemver } from './release'; + +function mockOctokit( + releases: Array<{ tag_name: string; draft?: boolean; prerelease?: boolean }> +) { + return { + repos: { + listReleases: vi.fn().mockResolvedValue({ data: releases }) + } + } as unknown as Octokit; +} + +describe('extractSemver', () => { + test('plain tag', () => { + expect(extractSemver('v1.2.3')).toEqual({ + version: '1.2.3', + major: 1, + prerelease: null + }); + }); + + test('no leading v', () => { + expect(extractSemver('1.2.3')).toEqual({ + version: '1.2.3', + major: 1, + prerelease: null + }); + }); + + test('crate prefix', () => { + expect(extractSemver('rust-mcp-sdk-v1.0.1')).toEqual({ + version: '1.0.1', + major: 1, + prerelease: null + }); + }); + + test('npm-style prefix with prerelease', () => { + expect(extractSemver('mcp-use@1.18.0-canary.3')).toEqual({ + version: '1.18.0-canary.3', + major: 1, + prerelease: 'canary.3' + }); + }); + + test('semver prerelease', () => { + expect(extractSemver('pkg-v2.0.0-rc.1')).toEqual({ + version: '2.0.0-rc.1', + major: 2, + prerelease: 'rc.1' + }); + }); + + test('0.x version', () => { + expect(extractSemver('v0.9.1')).toEqual({ + version: '0.9.1', + major: 0, + prerelease: null + }); + }); + + test('no semver returns null', () => { + expect(extractSemver('nightly')).toBeNull(); + }); + + test('empty string returns null', () => { + expect(extractSemver('')).toBeNull(); + }); +}); + +describe('checkStableRelease', () => { + test('plain tag passes', async () => { + const octokit = mockOctokit([{ tag_name: 'v1.2.3' }]); + const result = await checkStableRelease(octokit, 'owner', 'repo'); + expect(result).toEqual({ + status: 'pass', + version: '1.2.3', + is_stable: true, + is_prerelease: false + }); + }); + + test('no leading v passes', async () => { + const octokit = mockOctokit([{ tag_name: '1.2.3' }]); + const result = await checkStableRelease(octokit, 'owner', 'repo'); + expect(result.status).toBe('pass'); + expect(result.version).toBe('1.2.3'); + }); + + test('crate prefix passes', async () => { + const octokit = mockOctokit([{ tag_name: 'rust-mcp-sdk-v1.0.1' }]); + const result = await checkStableRelease(octokit, 'owner', 'repo'); + expect(result.status).toBe('pass'); + expect(result.version).toBe('1.0.1'); + }); + + test('npm-style prefix with canary is prerelease', async () => { + const octokit = mockOctokit([{ tag_name: 'mcp-use@1.18.0-canary.3' }]); + const result = await checkStableRelease(octokit, 'owner', 'repo'); + expect(result.is_prerelease).toBe(true); + expect(result.is_stable).toBe(false); + expect(result.status).toBe('fail'); + expect(result.version).toBe('1.18.0-canary.3'); + }); + + test('prefix filter picks correct tag', async () => { + const octokit = mockOctokit([ + { tag_name: 'mcp-use@1.18.0-canary.3' }, + { tag_name: 'python-v1.5.2' } + ]); + const result = await checkStableRelease( + octokit, + 'owner', + 'repo', + 'python-v' + ); + expect(result.status).toBe('pass'); + expect(result.version).toBe('1.5.2'); + expect(result.is_stable).toBe(true); + }); + + test('0.x version fails', async () => { + const octokit = mockOctokit([{ tag_name: 'v0.9.1' }]); + const result = await checkStableRelease(octokit, 'owner', 'repo'); + expect(result.is_stable).toBe(false); + expect(result.status).toBe('fail'); + }); + + test('semver prerelease is prerelease', async () => { + const octokit = mockOctokit([{ tag_name: 'pkg-v2.0.0-rc.1' }]); + const result = await checkStableRelease(octokit, 'owner', 'repo'); + expect(result.is_prerelease).toBe(true); + expect(result.is_stable).toBe(false); + expect(result.status).toBe('fail'); + }); + + test('unparseable tags are skipped in favor of a parseable one', async () => { + const octokit = mockOctokit([ + { tag_name: 'nightly' }, + { tag_name: 'v1.0.0' } + ]); + const result = await checkStableRelease(octokit, 'owner', 'repo'); + expect(result.status).toBe('pass'); + expect(result.version).toBe('1.0.0'); + }); + + test('drafts are ignored when a published release exists', async () => { + const octokit = mockOctokit([ + { tag_name: 'v2.0.0', draft: true }, + { tag_name: 'v1.0.0' } + ]); + const result = await checkStableRelease(octokit, 'owner', 'repo'); + expect(result.status).toBe('pass'); + expect(result.version).toBe('1.0.0'); + }); + + test('empty releases fails', async () => { + const octokit = mockOctokit([]); + const result = await checkStableRelease(octokit, 'owner', 'repo'); + expect(result.status).toBe('fail'); + expect(result.version).toBeNull(); + expect(result.is_stable).toBe(false); + }); + + test('candidate with prerelease flag on GitHub is prerelease', async () => { + const octokit = mockOctokit([{ tag_name: 'v1.0.0', prerelease: true }]); + const result = await checkStableRelease(octokit, 'owner', 'repo'); + expect(result.is_prerelease).toBe(true); + expect(result.is_stable).toBe(false); + expect(result.status).toBe('fail'); + }); + + test('tag with prerelease markers in non-semver portion is prerelease', async () => { + const octokit = mockOctokit([{ tag_name: 'canary-v1.0.0' }]); + const result = await checkStableRelease(octokit, 'owner', 'repo'); + expect(result.is_prerelease).toBe(true); + expect(result.is_stable).toBe(false); + }); + + test('tagPrefix filters out non-matching releases', async () => { + const octokit = mockOctokit([ + { tag_name: 'rust-mcp-transport-v1.0.0' }, + { tag_name: 'rust-mcp-sdk-v1.0.1' } + ]); + const result = await checkStableRelease( + octokit, + 'owner', + 'repo', + 'rust-mcp-sdk-v' + ); + expect(result.status).toBe('pass'); + expect(result.version).toBe('1.0.1'); + }); + + test('tagPrefix with no matching releases fails', async () => { + const octokit = mockOctokit([{ tag_name: 'other-pkg-v1.0.0' }]); + const result = await checkStableRelease( + octokit, + 'owner', + 'repo', + 'my-pkg-v' + ); + expect(result.status).toBe('fail'); + expect(result.version).toBeNull(); + }); +}); diff --git a/src/tier-check/checks/release.ts b/src/tier-check/checks/release.ts index 3eb53f1d..1b5185bf 100644 --- a/src/tier-check/checks/release.ts +++ b/src/tier-check/checks/release.ts @@ -1,16 +1,40 @@ import { Octokit } from '@octokit/rest'; import { ReleaseResult } from '../types'; +/** + * Extracts the first semver from a release tag, tolerating monorepo tag + * prefixes such as `rust-mcp-sdk-v1.0.1`, `mcp-use@1.18.0-canary.3`, + * or plain `v1.2.3`. Returns null when the tag contains no semver. + */ +export function extractSemver( + tag: string +): { version: string; major: number; prerelease: string | null } | null { + const match = tag.match(/(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/); + if (!match) return null; + return { + version: match[0], + major: parseInt(match[1], 10), + prerelease: match[4] ?? null + }; +} + +// Word-boundary match so markers are caught both at the start of a tag +// (e.g. `canary-v1.0.0`) and after a `-` separator (e.g. `v1.0.0-alpha`). +const PRERELEASE_MARKERS = /\b(alpha|beta|rc|dev|preview|snapshot|canary)\b/i; + export async function checkStableRelease( octokit: Octokit, owner: string, - repo: string + repo: string, + tagPrefix?: string ): Promise { try { const { data: releases } = await octokit.repos.listReleases({ owner, repo, - per_page: 20 + // With a prefix filter, widen the window so the package's latest + // release isn't pushed out by other packages' releases. + per_page: tagPrefix ? 100 : 20 }); if (releases.length === 0) { @@ -22,31 +46,38 @@ export async function checkStableRelease( }; } - // Find latest non-draft release - const latest = releases.find((r) => !r.draft); - if (!latest) { + const candidates = releases.filter( + (r) => !r.draft && (!tagPrefix || r.tag_name.startsWith(tagPrefix)) + ); + + // First release (API order) whose tag contains a parseable semver. + // Tags with no semver are skipped , previously they produced a fail with + // the raw tag as version, which broke selection in mixed-tag monorepos. + for (const release of candidates) { + const parsed = extractSemver(release.tag_name); + if (!parsed) continue; + + const isPrerelease = + release.prerelease || + (parsed.prerelease !== null && + PRERELEASE_MARKERS.test(`-${parsed.prerelease}`)) || + PRERELEASE_MARKERS.test(release.tag_name); + + const isStable = !isPrerelease && parsed.major >= 1; + return { - status: 'fail', - version: null, - is_stable: false, - is_prerelease: false + status: isStable ? 'pass' : 'fail', + version: parsed.version, + is_stable: isStable, + is_prerelease: isPrerelease }; } - const version = latest.tag_name.replace(/^v/, ''); - const isPrerelease = - latest.prerelease || - /-(alpha|beta|rc|dev|preview|snapshot)/i.test(version); - - // Check if version is >= 1.0.0 - const parts = version.split('.').map((p) => parseInt(p, 10)); - const isStable = !isPrerelease && parts.length >= 2 && parts[0] >= 1; - return { - status: isStable ? 'pass' : 'fail', - version, - is_stable: isStable, - is_prerelease: isPrerelease + status: 'fail', + version: null, + is_stable: false, + is_prerelease: false }; } catch { return { diff --git a/src/tier-check/checks/spec-tracking.ts b/src/tier-check/checks/spec-tracking.ts index ce4c101c..62726b45 100644 --- a/src/tier-check/checks/spec-tracking.ts +++ b/src/tier-check/checks/spec-tracking.ts @@ -4,7 +4,8 @@ import { SpecTrackingResult } from '../types'; export async function checkSpecTracking( octokit: Octokit, owner: string, - repo: string + repo: string, + tagPrefix?: string ): Promise { try { // Get latest spec release from modelcontextprotocol/modelcontextprotocol @@ -21,7 +22,9 @@ export async function checkSpecTracking( repo, per_page: 50 }); - const nonDraftSdkReleases = sdkReleases.filter((r) => !r.draft); + const nonDraftSdkReleases = sdkReleases.filter( + (r) => !r.draft && (!tagPrefix || r.tag_name.startsWith(tagPrefix)) + ); if (!latestSpec || nonDraftSdkReleases.length === 0) { return { diff --git a/src/tier-check/index.ts b/src/tier-check/index.ts index 1cd32c19..17a2a704 100644 --- a/src/tier-check/index.ts +++ b/src/tier-check/index.ts @@ -53,6 +53,10 @@ export function createTierCheckCommand(): Command { '--spec-version ', 'Only run conformance scenarios for this spec version' ) + .option( + '--tag-prefix ', + 'Only consider releases whose tag starts with this prefix (monorepos with per-package tags, e.g. "rust-mcp-sdk-v")' + ) .action(async (options) => { const { owner, repo } = parseRepo(options.repo); let token = options.token || process.env.GITHUB_TOKEN; @@ -125,15 +129,17 @@ export function createTierCheckCommand(): Command { console.error(' \u2713 P0 Resolution'); return r; }), - checkStableRelease(octokit, owner, repo).then((r) => { - console.error(' \u2713 Stable Release'); - return r; - }), + checkStableRelease(octokit, owner, repo, options.tagPrefix).then( + (r) => { + console.error(' \u2713 Stable Release'); + return r; + } + ), checkPolicySignals(octokit, owner, repo, options.branch).then((r) => { console.error(' \u2713 Policy Signals'); return r; }), - checkSpecTracking(octokit, owner, repo).then((r) => { + checkSpecTracking(octokit, owner, repo, options.tagPrefix).then((r) => { console.error(' \u2713 Spec Tracking'); return r; }) From fbebbbdabe3f181b027a6279578c27367e552725 Mon Sep 17 00:00:00 2001 From: Ali Hashemi Date: Sun, 26 Jul 2026 09:29:29 -0300 Subject: [PATCH 2/2] chore: add --tag-prefix to the cli options --- .claude/skills/mcp-sdk-tier-audit/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.claude/skills/mcp-sdk-tier-audit/README.md b/.claude/skills/mcp-sdk-tier-audit/README.md index 77fc8cf8..7c4a4c5b 100644 --- a/.claude/skills/mcp-sdk-tier-audit/README.md +++ b/.claude/skills/mcp-sdk-tier-audit/README.md @@ -44,6 +44,7 @@ For public repos, any authenticated token works (no special scopes needed — au --days Limit triage analysis to last N days --output json | markdown | terminal (default: terminal) --token GitHub token (defaults to GITHUB_TOKEN or gh auth token) +--tag-prefix Only consider releases whose tag starts with this prefix (monorepos with per-package tags, e.g. "package-name-v") ``` ### What the CLI Checks