From 3ecb8e893fda1c0929dfca8ca4c8f69662b73b9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Moreno?= Date: Thu, 15 Jan 2026 18:16:53 +0100 Subject: [PATCH] Add compatibility checks for Copilot Chat extension in release build script (#287807) * Add compatibility checks for Copilot Chat extension in release build script * add tests * address comment --- build/azure-pipelines/common/releaseBuild.ts | 85 +++++ .../common/versionCompatibility.ts | 347 ++++++++++++++++++ 2 files changed, 432 insertions(+) create mode 100644 build/azure-pipelines/common/versionCompatibility.ts diff --git a/build/azure-pipelines/common/releaseBuild.ts b/build/azure-pipelines/common/releaseBuild.ts index 01792fd22e1..92b6d22614d 100644 --- a/build/azure-pipelines/common/releaseBuild.ts +++ b/build/azure-pipelines/common/releaseBuild.ts @@ -4,7 +4,12 @@ *--------------------------------------------------------------------------------------------*/ import { CosmosClient } from '@azure/cosmos'; +import path from 'path'; +import fs from 'fs'; import { retry } from './retry.ts'; +import { type IExtensionManifest, parseApiProposalsFromSource, checkExtensionCompatibility } from './versionCompatibility.ts'; + +const root = path.dirname(path.dirname(path.dirname(import.meta.dirname))); function getEnv(name: string): string { const result = process.env[name]; @@ -16,6 +21,80 @@ function getEnv(name: string): string { return result; } +async function fetchLatestExtensionManifest(extensionId: string): Promise { + // Use the vscode-unpkg service to get the latest extension package.json + const [publisher, name] = extensionId.split('.'); + + // First, get the latest version from the gallery endpoint + const galleryUrl = `https://main.vscode-unpkg.net/_gallery/${publisher}/${name}/latest`; + const galleryResponse = await fetch(galleryUrl, { + headers: { 'User-Agent': 'VSCode Build' } + }); + + if (!galleryResponse.ok) { + throw new Error(`Failed to fetch latest version for ${extensionId}: ${galleryResponse.status} ${galleryResponse.statusText}`); + } + + const galleryData = await galleryResponse.json() as { versions: { version: string }[] }; + const version = galleryData.versions[0].version; + + // Now fetch the package.json using the actual version + const url = `https://${publisher}.vscode-unpkg.net/${publisher}/${name}/${version}/extension/package.json`; + + const response = await fetch(url, { + headers: { 'User-Agent': 'VSCode Build' } + }); + + if (!response.ok) { + throw new Error(`Failed to fetch extension ${extensionId} from unpkg: ${response.status} ${response.statusText}`); + } + + return await response.json() as IExtensionManifest; +} + +async function checkCopilotChatCompatibility(): Promise { + const extensionId = 'github.copilot-chat'; + + console.log(`Checking compatibility of ${extensionId}...`); + + // Get product version from package.json + const packageJson = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); + const productVersion = packageJson.version; + + console.log(`Product version: ${productVersion}`); + + // Get API proposals from the generated file + const apiProposalsPath = path.join(root, 'src/vs/platform/extensions/common/extensionsApiProposals.ts'); + const apiProposalsContent = fs.readFileSync(apiProposalsPath, 'utf8'); + const allApiProposals = parseApiProposalsFromSource(apiProposalsContent); + + const proposalCount = Object.keys(allApiProposals).length; + if (proposalCount === 0) { + throw new Error('Failed to load API proposals from source'); + } + + console.log(`Loaded ${proposalCount} API proposals from source`); + + // Fetch the latest extension manifest + const manifest = await retry(() => fetchLatestExtensionManifest(extensionId)); + + console.log(`Extension ${extensionId}@${manifest.version}:`); + console.log(` engines.vscode: ${manifest.engines.vscode}`); + console.log(` enabledApiProposals:\n ${manifest.enabledApiProposals?.join('\n ') || 'none'}`); + + // Check compatibility + const result = checkExtensionCompatibility(productVersion, allApiProposals, manifest); + if (!result.compatible) { + throw new Error(`Compatibility check failed:\n ${result.errors.join('\n ')}`); + } + + console.log(` ✓ Engine version compatible`); + if (manifest.enabledApiProposals?.length) { + console.log(` ✓ API proposals compatible`); + } + console.log(`✓ ${extensionId} is compatible with this build`); +} + interface Config { id: string; frozen: boolean; @@ -43,6 +122,12 @@ async function getConfig(client: CosmosClient, quality: string): Promise async function main(force: boolean): Promise { const commit = getEnv('BUILD_SOURCEVERSION'); const quality = getEnv('VSCODE_QUALITY'); + + // Check Copilot Chat compatibility before releasing insider builds + if (quality === 'insider') { + await checkCopilotChatCompatibility(); + } + const { cosmosDBAccessToken } = JSON.parse(getEnv('PUBLISH_AUTH_TOKENS')); const client = new CosmosClient({ endpoint: process.env['AZURE_DOCUMENTDB_ENDPOINT']!, tokenProvider: () => Promise.resolve(`type=aad&ver=1.0&sig=${cosmosDBAccessToken.token}`) }); diff --git a/build/azure-pipelines/common/versionCompatibility.ts b/build/azure-pipelines/common/versionCompatibility.ts new file mode 100644 index 00000000000..3246ef04df5 --- /dev/null +++ b/build/azure-pipelines/common/versionCompatibility.ts @@ -0,0 +1,347 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; + +export interface IExtensionManifest { + name: string; + publisher: string; + version: string; + engines: { vscode: string }; + main?: string; + browser?: string; + enabledApiProposals?: string[]; +} + +export function isEngineCompatible(productVersion: string, engineVersion: string): { compatible: boolean; error?: string } { + if (engineVersion === '*') { + return { compatible: true }; + } + + const versionMatch = engineVersion.match(/^(\^|>=)?(\d+)\.(\d+)\.(\d+)/); + if (!versionMatch) { + return { compatible: false, error: `Could not parse engines.vscode value: ${engineVersion}` }; + } + + const [, prefix, major, minor, patch] = versionMatch; + const productMatch = productVersion.match(/^(\d+)\.(\d+)\.(\d+)/); + if (!productMatch) { + return { compatible: false, error: `Could not parse product version: ${productVersion}` }; + } + + const [, prodMajor, prodMinor, prodPatch] = productMatch; + + const reqMajor = parseInt(major); + const reqMinor = parseInt(minor); + const reqPatch = parseInt(patch); + const pMajor = parseInt(prodMajor); + const pMinor = parseInt(prodMinor); + const pPatch = parseInt(prodPatch); + + if (prefix === '>=') { + // Minimum version check + if (pMajor > reqMajor) { return { compatible: true }; } + if (pMajor < reqMajor) { return { compatible: false, error: `Extension requires VS Code >=${engineVersion}, but product version is ${productVersion}` }; } + if (pMinor > reqMinor) { return { compatible: true }; } + if (pMinor < reqMinor) { return { compatible: false, error: `Extension requires VS Code >=${engineVersion}, but product version is ${productVersion}` }; } + if (pPatch >= reqPatch) { return { compatible: true }; } + return { compatible: false, error: `Extension requires VS Code >=${engineVersion}, but product version is ${productVersion}` }; + } + + // Caret or exact version check + if (pMajor !== reqMajor) { + return { compatible: false, error: `Extension requires VS Code ${engineVersion}, but product version is ${productVersion} (major version mismatch)` }; + } + + if (prefix === '^') { + // Caret: same major, minor and patch must be >= required + if (pMinor > reqMinor) { return { compatible: true }; } + if (pMinor < reqMinor) { return { compatible: false, error: `Extension requires VS Code ${engineVersion}, but product version is ${productVersion}` }; } + if (pPatch >= reqPatch) { return { compatible: true }; } + return { compatible: false, error: `Extension requires VS Code ${engineVersion}, but product version is ${productVersion}` }; + } + + // Exact or default behavior + if (pMinor < reqMinor) { return { compatible: false, error: `Extension requires VS Code ${engineVersion}, but product version is ${productVersion}` }; } + if (pMinor > reqMinor) { return { compatible: true }; } + if (pPatch >= reqPatch) { return { compatible: true }; } + return { compatible: false, error: `Extension requires VS Code ${engineVersion}, but product version is ${productVersion}` }; +} + +export function parseApiProposals(enabledApiProposals: string[]): { proposalName: string; version?: number }[] { + return enabledApiProposals.map(proposal => { + const [proposalName, version] = proposal.split('@'); + return { proposalName, version: version ? parseInt(version) : undefined }; + }); +} + +export function areApiProposalsCompatible( + apiProposals: string[], + productApiProposals: Readonly<{ [proposalName: string]: Readonly<{ proposal: string; version?: number }> }> +): { compatible: boolean; errors: string[] } { + if (apiProposals.length === 0) { + return { compatible: true, errors: [] }; + } + + const errors: string[] = []; + const parsedProposals = parseApiProposals(apiProposals); + + for (const { proposalName, version } of parsedProposals) { + if (!version) { + continue; + } + const existingProposal = productApiProposals[proposalName]; + if (!existingProposal) { + errors.push(`API proposal '${proposalName}' does not exist in this version of VS Code`); + } else if (existingProposal.version !== version) { + errors.push(`API proposal '${proposalName}' version mismatch: extension requires version ${version}, but VS Code has version ${existingProposal.version ?? 'unversioned'}`); + } + } + + return { compatible: errors.length === 0, errors }; +} + +export function parseApiProposalsFromSource(content: string): { [proposalName: string]: { proposal: string; version?: number } } { + const allApiProposals: { [proposalName: string]: { proposal: string; version?: number } } = {}; + + // Match proposal blocks like: proposalName: {\n\t\tproposal: '...',\n\t\tversion: N\n\t} + // or: proposalName: {\n\t\tproposal: '...',\n\t} + const proposalBlockRegex = /\t(\w+):\s*\{([^}]+)\}/g; + const versionRegex = /version:\s*(\d+)/; + + let match; + while ((match = proposalBlockRegex.exec(content)) !== null) { + const [, name, block] = match; + const versionMatch = versionRegex.exec(block); + allApiProposals[name] = { + proposal: '', + version: versionMatch ? parseInt(versionMatch[1]) : undefined + }; + } + + return allApiProposals; +} + +export function checkExtensionCompatibility( + productVersion: string, + productApiProposals: Readonly<{ [proposalName: string]: Readonly<{ proposal: string; version?: number }> }>, + manifest: IExtensionManifest +): { compatible: boolean; errors: string[] } { + const errors: string[] = []; + + // Check engine compatibility + const engineResult = isEngineCompatible(productVersion, manifest.engines.vscode); + if (!engineResult.compatible) { + errors.push(engineResult.error!); + } + + // Check API proposals compatibility + if (manifest.enabledApiProposals?.length) { + const apiResult = areApiProposalsCompatible(manifest.enabledApiProposals, productApiProposals); + if (!apiResult.compatible) { + errors.push(...apiResult.errors); + } + } + + return { compatible: errors.length === 0, errors }; +} + +if (import.meta.main) { + console.log('Running version compatibility tests...\n'); + + // isEngineCompatible tests + console.log('Testing isEngineCompatible...'); + + // Wildcard + assert.strictEqual(isEngineCompatible('1.50.0', '*').compatible, true); + + // Invalid engine version + assert.strictEqual(isEngineCompatible('1.50.0', 'invalid').compatible, false); + + // Invalid product version + assert.strictEqual(isEngineCompatible('invalid', '1.50.0').compatible, false); + + // >= prefix + assert.strictEqual(isEngineCompatible('1.50.0', '>=1.50.0').compatible, true); + assert.strictEqual(isEngineCompatible('1.50.1', '>=1.50.0').compatible, true); + assert.strictEqual(isEngineCompatible('1.51.0', '>=1.50.0').compatible, true); + assert.strictEqual(isEngineCompatible('2.0.0', '>=1.50.0').compatible, true); + assert.strictEqual(isEngineCompatible('1.49.0', '>=1.50.0').compatible, false); + assert.strictEqual(isEngineCompatible('1.50.0', '>=1.50.1').compatible, false); + assert.strictEqual(isEngineCompatible('0.50.0', '>=1.50.0').compatible, false); + + // ^ prefix (caret) + assert.strictEqual(isEngineCompatible('1.50.0', '^1.50.0').compatible, true); + assert.strictEqual(isEngineCompatible('1.50.1', '^1.50.0').compatible, true); + assert.strictEqual(isEngineCompatible('1.51.0', '^1.50.0').compatible, true); + assert.strictEqual(isEngineCompatible('1.49.0', '^1.50.0').compatible, false); + assert.strictEqual(isEngineCompatible('1.50.0', '^1.50.1').compatible, false); + assert.strictEqual(isEngineCompatible('2.0.0', '^1.50.0').compatible, false); + + // Exact/default (no prefix) + assert.strictEqual(isEngineCompatible('1.50.0', '1.50.0').compatible, true); + assert.strictEqual(isEngineCompatible('1.50.1', '1.50.0').compatible, true); + assert.strictEqual(isEngineCompatible('1.51.0', '1.50.0').compatible, true); + assert.strictEqual(isEngineCompatible('1.49.0', '1.50.0').compatible, false); + assert.strictEqual(isEngineCompatible('1.50.0', '1.50.1').compatible, false); + assert.strictEqual(isEngineCompatible('2.0.0', '1.50.0').compatible, false); + + console.log(' ✓ isEngineCompatible tests passed\n'); + + // parseApiProposals tests + console.log('Testing parseApiProposals...'); + + assert.deepStrictEqual(parseApiProposals([]), []); + assert.deepStrictEqual(parseApiProposals(['proposalA']), [{ proposalName: 'proposalA', version: undefined }]); + assert.deepStrictEqual(parseApiProposals(['proposalA@1']), [{ proposalName: 'proposalA', version: 1 }]); + assert.deepStrictEqual(parseApiProposals(['proposalA@1', 'proposalB', 'proposalC@3']), [ + { proposalName: 'proposalA', version: 1 }, + { proposalName: 'proposalB', version: undefined }, + { proposalName: 'proposalC', version: 3 } + ]); + + console.log(' ✓ parseApiProposals tests passed\n'); + + // areApiProposalsCompatible tests + console.log('Testing areApiProposalsCompatible...'); + + const productProposals = { + proposalA: { proposal: '', version: 1 }, + proposalB: { proposal: '', version: 2 }, + proposalC: { proposal: '' } // unversioned + }; + + // Empty proposals + assert.strictEqual(areApiProposalsCompatible([], productProposals).compatible, true); + + // Unversioned extension proposals (always compatible) + assert.strictEqual(areApiProposalsCompatible(['proposalA', 'proposalB'], productProposals).compatible, true); + assert.strictEqual(areApiProposalsCompatible(['unknownProposal'], productProposals).compatible, true); + + // Versioned proposals - matching + assert.strictEqual(areApiProposalsCompatible(['proposalA@1'], productProposals).compatible, true); + assert.strictEqual(areApiProposalsCompatible(['proposalA@1', 'proposalB@2'], productProposals).compatible, true); + + // Versioned proposals - version mismatch + assert.strictEqual(areApiProposalsCompatible(['proposalA@2'], productProposals).compatible, false); + assert.strictEqual(areApiProposalsCompatible(['proposalB@1'], productProposals).compatible, false); + + // Versioned proposals - missing proposal + assert.strictEqual(areApiProposalsCompatible(['unknownProposal@1'], productProposals).compatible, false); + + // Versioned proposals - product has unversioned + assert.strictEqual(areApiProposalsCompatible(['proposalC@1'], productProposals).compatible, false); + + // Mixed versioned and unversioned + assert.strictEqual(areApiProposalsCompatible(['proposalA@1', 'proposalB'], productProposals).compatible, true); + assert.strictEqual(areApiProposalsCompatible(['proposalA@2', 'proposalB'], productProposals).compatible, false); + + console.log(' ✓ areApiProposalsCompatible tests passed\n'); + + // parseApiProposalsFromSource tests + console.log('Testing parseApiProposalsFromSource...'); + + const sampleSource = ` +export const allApiProposals = { + authSession: { + proposal: 'vscode.proposed.authSession.d.ts', + }, + chatParticipant: { + proposal: 'vscode.proposed.chatParticipant.d.ts', + version: 2 + }, + testProposal: { + proposal: 'vscode.proposed.testProposal.d.ts', + version: 15 + } +}; +`; + + const parsedSource = parseApiProposalsFromSource(sampleSource); + assert.strictEqual(Object.keys(parsedSource).length, 3); + assert.strictEqual(parsedSource['authSession']?.version, undefined); + assert.strictEqual(parsedSource['chatParticipant']?.version, 2); + assert.strictEqual(parsedSource['testProposal']?.version, 15); + + // Empty source + assert.strictEqual(Object.keys(parseApiProposalsFromSource('')).length, 0); + + console.log(' ✓ parseApiProposalsFromSource tests passed\n'); + + // checkExtensionCompatibility tests + console.log('Testing checkExtensionCompatibility...'); + + const testApiProposals = { + authSession: { proposal: '', version: undefined }, + chatParticipant: { proposal: '', version: 2 }, + testProposal: { proposal: '', version: 15 } + }; + + // Compatible extension - matching engine and proposals + assert.strictEqual(checkExtensionCompatibility('1.90.0', testApiProposals, { + name: 'test-ext', + publisher: 'test', + version: '1.0.0', + engines: { vscode: '^1.90.0' }, + enabledApiProposals: ['chatParticipant@2'] + }).compatible, true); + + // Compatible - no API proposals + assert.strictEqual(checkExtensionCompatibility('1.90.0', testApiProposals, { + name: 'test-ext', + publisher: 'test', + version: '1.0.0', + engines: { vscode: '^1.90.0' } + }).compatible, true); + + // Compatible - unversioned API proposals + assert.strictEqual(checkExtensionCompatibility('1.90.0', testApiProposals, { + name: 'test-ext', + publisher: 'test', + version: '1.0.0', + engines: { vscode: '^1.90.0' }, + enabledApiProposals: ['authSession', 'chatParticipant'] + }).compatible, true); + + // Incompatible - engine version too new + assert.strictEqual(checkExtensionCompatibility('1.89.0', testApiProposals, { + name: 'test-ext', + publisher: 'test', + version: '1.0.0', + engines: { vscode: '^1.90.0' }, + enabledApiProposals: ['chatParticipant@2'] + }).compatible, false); + + // Incompatible - API proposal version mismatch + assert.strictEqual(checkExtensionCompatibility('1.90.0', testApiProposals, { + name: 'test-ext', + publisher: 'test', + version: '1.0.0', + engines: { vscode: '^1.90.0' }, + enabledApiProposals: ['chatParticipant@3'] + }).compatible, false); + + // Incompatible - missing API proposal + assert.strictEqual(checkExtensionCompatibility('1.90.0', testApiProposals, { + name: 'test-ext', + publisher: 'test', + version: '1.0.0', + engines: { vscode: '^1.90.0' }, + enabledApiProposals: ['unknownProposal@1'] + }).compatible, false); + + // Incompatible - both engine and API proposal issues + assert.strictEqual(checkExtensionCompatibility('1.89.0', testApiProposals, { + name: 'test-ext', + publisher: 'test', + version: '1.0.0', + engines: { vscode: '^1.90.0' }, + enabledApiProposals: ['chatParticipant@3'] + }).compatible, false); + + console.log(' ✓ checkExtensionCompatibility tests passed\n'); + + console.log('All tests passed! ✓'); +}