From 0730549f2a57f6cb7e4b70215a29ba59c03d89df Mon Sep 17 00:00:00 2001 From: Forge Date: Tue, 22 Sep 2026 03:15:32 +0000 Subject: [PATCH] Retain semver build metadata in immutable release identities --- __tests__/extension-releases.test.ts | 7 +++++++ scripts/validation/extensions-compatibility.cjs | 5 ++++- src/plugins/releases.ts | 11 +++++++++-- 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/__tests__/extension-releases.test.ts b/__tests__/extension-releases.test.ts index ea98d96e9..5edead11f 100644 --- a/__tests__/extension-releases.test.ts +++ b/__tests__/extension-releases.test.ts @@ -20,6 +20,13 @@ describe('compatible registry selection', () => { expect(choose(rows.reverse()).version).toBe('1.10.0'); expect(choose([release('2.0.0', { engines: '>=2' }), release('1.0.0')], { engineVersion: '2.1.0' }).version).toBe('2.0.0'); }); + it('preserves build metadata identity with deterministic equal-precedence selection', () => { + const rows = [release('1.0.0+z'), release('1.0.0+a'), release('v2.0.0')]; + expect(choose(rows).version).toBe('1.0.0+a'); + expect(choose(rows.reverse()).version).toBe('1.0.0+a'); + expect(choose(rows, { version: '1.0.0+z' }).version).toBe('1.0.0+z'); + expect(() => choose(rows, { version: 'v2.0.0' })).toThrow('exact semantic version'); + }); it('skips invalid metadata and unsupported APIs instead of adopting them', () => { expect(choose([null, release('bad'), release('9.0.0', { apiVersion: 2 }), release('8.0.0', { engines: 'nonsense' }), release('7.0.0', { integrity: 'bad' }), release('6.0.0', { id: 'other' }), release('5.0.0', { capabilities: ['languages'] }), diff --git a/scripts/validation/extensions-compatibility.cjs b/scripts/validation/extensions-compatibility.cjs index d3d59691a..35ddae2f5 100644 --- a/scripts/validation/extensions-compatibility.cjs +++ b/scripts/validation/extensions-compatibility.cjs @@ -147,7 +147,10 @@ async function cli(args, expected = 0) { await cli(['install', registry + '/api/download/compatible-demo/1.9.0', '--path', roots[2]]); await expectGraph(roots[2], '1.9.0'); const file = path.join(lab, 'pinned.cgext'); fs.writeFileSync(file, pinned.bytes); await cli(['install', file, '--path', roots[2]]); await expectGraph(roots[2], '1.10.0'); - checks.push('exact artifact URL/file semantics preserved'); + await publish('1.10.0+fixture.1'); + await cli(['install', 'compatible-demo', '--registry', registry, '--version', '1.10.0+fixture.1', '--path', roots[2]]); + await expectGraph(roots[2], '1.10.0+fixture.1'); + checks.push('exact artifact URL/file semantics and build-metadata pin identity preserved'); await page.setViewportSize({ width: 390, height: 844 }); await page.locator('.compatibility').filter({ hasText: 'Selected 3.0.0' }).waitFor(); assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)); diff --git a/src/plugins/releases.ts b/src/plugins/releases.ts index 704276bff..a0a3eeb62 100644 --- a/src/plugins/releases.ts +++ b/src/plugins/releases.ts @@ -16,10 +16,17 @@ export interface ReleaseSelection { export function validateExtensionId(id: unknown): asserts id is string { if (typeof id !== 'string' || !/^[a-z0-9][a-z0-9-]{0,63}$/.test(id)) throw new Error('Expected a lowercase extension id'); } +function exactVersion(value: unknown): value is string { + if (typeof value !== 'string') return false; + const parsed = semver.parse(value); + // semver.valid()/SemVer.version omit build metadata; retain it for immutable + // artifact identity while rejecting aliases such as v1.0.0 or whitespace. + return !!parsed && value === parsed.version + (parsed.build.length ? '+' + parsed.build.join('.') : ''); +} export function resolveRelease(raw: unknown, id: string, options: ReleaseSelection): ExtensionRelease { validateExtensionId(id); if (!semver.valid(options.engineVersion)) throw new Error('Cannot determine the connected CodeGraph version'); - if (options.version !== undefined && (typeof options.version !== 'string' || semver.valid(options.version) !== options.version)) throw new Error('Request an exact semantic version, such as 1.2.3 (prereleases require an exact pin)'); + if (options.version !== undefined && !exactVersion(options.version)) throw new Error('Request an exact semantic version, such as 1.2.3 (prereleases require an exact pin)'); if (!Array.isArray(raw)) throw new Error('Registry returned an invalid release catalog'); if (options.currentVersion !== undefined && !semver.valid(options.currentVersion)) throw new Error('Installed version is invalid; request an explicit version to repair it'); const candidates: ExtensionRelease[] = [], reasons: string[] = []; @@ -28,7 +35,7 @@ export function resolveRelease(raw: unknown, id: string, options: ReleaseSelecti const r = item as ExtensionRelease; if (options.version !== undefined && r?.version !== options.version) continue; try { - if (!r || r.id !== id || typeof r.version !== 'string' || semver.valid(r.version) !== r.version || + if (!r || r.id !== id || !exactVersion(r.version) || typeof r.integrity !== 'string' || !/^[a-f0-9]{64}$/.test(r.integrity) || typeof r.engines !== 'string') throw new Error('invalid release identity, version, integrity or engine range'); if (versions.has(r.version)) throw new Error(`Registry returned duplicate immutable version ${r.version}`); versions.add(r.version);