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
This commit is contained in:
João Moreno
2026-01-15 18:16:53 +01:00
committed by GitHub
parent 448f171f94
commit 3ecb8e893f
2 changed files with 432 additions and 0 deletions

View File

@@ -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<IExtensionManifest> {
// 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<void> {
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<Config>
async function main(force: boolean): Promise<void> {
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}`) });

View File

@@ -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! ✓');
}