diff --git a/build/azure-pipelines/common/checkCopilotChatCompatibility.ts b/build/azure-pipelines/common/checkCopilotChatCompatibility.ts new file mode 100644 index 00000000000..9c4c12943b9 --- /dev/null +++ b/build/azure-pipelines/common/checkCopilotChatCompatibility.ts @@ -0,0 +1,137 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import path from 'path'; +import fs from 'fs'; +import { retry } from './retry.ts'; +import { type IExtensionManifest, parseApiProposalsFromSource, checkExtensionCompatibility, areAllowlistedApiProposalsMatching } from './versionCompatibility.ts'; + +const root = path.dirname(path.dirname(path.dirname(import.meta.dirname))); + +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; +} + +export 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`); + + // Load product.json to check allowlisted API proposals + const productJsonPath = path.join(root, 'product.json'); + let productJson; + try { + productJson = JSON.parse(fs.readFileSync(productJsonPath, 'utf8')); + } catch (error) { + throw new Error(`Failed to load or parse product.json: ${error}`); + } + const extensionEnabledApiProposals = productJson?.extensionEnabledApiProposals; + const extensionIdKey = extensionEnabledApiProposals ? Object.keys(extensionEnabledApiProposals).find(key => key.toLowerCase() === extensionId.toLowerCase()) : undefined; + const productAllowlistedProposals = extensionIdKey ? extensionEnabledApiProposals[extensionIdKey] : undefined; + + if (productAllowlistedProposals) { + console.log(`Product.json allowlisted proposals for ${extensionId}:`); + for (const proposal of productAllowlistedProposals) { + console.log(` ${proposal}`); + } + } else { + console.log(`Product.json allowlisted proposals for ${extensionId}: none`); + } + + // 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`); + } + + // Check that product.json allowlist matches package.json declarations + const allowlistResult = areAllowlistedApiProposalsMatching(extensionId, productAllowlistedProposals, manifest.enabledApiProposals); + if (!allowlistResult.compatible) { + throw new Error(`Allowlist check failed:\n ${allowlistResult.errors.join('\n ')}`); + } + + console.log(` ✓ Product.json allowlist matches package.json`); + console.log(`✓ ${extensionId} is compatible with this build`); +} + +if (import.meta.main) { + const warnOnly = process.argv.includes('--warn-only'); + + checkCopilotChatCompatibility().then(() => { + console.log('Copilot Chat compatibility check passed'); + process.exit(0); + }, err => { + if (warnOnly) { + // Issue a warning using Azure DevOps logging commands but don't fail the build + console.log(`##vso[task.logissue type=warning]Copilot Chat compatibility check failed: ${err.message}`); + console.log(`##vso[task.complete result=SucceededWithIssues;]Copilot Chat compatibility check failed`); + console.log(''); + console.log(`⚠️ WARNING: ${err.message}`); + console.log(''); + console.log('The build will continue, but the release step will fail if this is not resolved.'); + process.exit(0); + } else { + console.error(err); + process.exit(1); + } + }); +} diff --git a/build/azure-pipelines/common/checkDistroCommit.ts b/build/azure-pipelines/common/checkDistroCommit.ts new file mode 100644 index 00000000000..4003a10df31 --- /dev/null +++ b/build/azure-pipelines/common/checkDistroCommit.ts @@ -0,0 +1,112 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import path from 'path'; +import fs from 'fs'; +import { retry } from './retry.ts'; + +const root = path.dirname(path.dirname(path.dirname(import.meta.dirname))); + +function getEnv(name: string): string { + const result = process.env[name]; + + if (typeof result === 'undefined') { + throw new Error('Missing env: ' + name); + } + + return result; +} + +interface GitHubBranchResponse { + commit: { + sha: string; + }; +} + +async function getDistroBranchHead(branch: string, token: string): Promise { + const url = `https://api.github.com/repos/microsoft/vscode-distro/branches/${encodeURIComponent(branch)}`; + + const response = await fetch(url, { + headers: { + 'Accept': 'application/vnd.github+json', + 'Authorization': `Bearer ${token}`, + 'X-GitHub-Api-Version': '2022-11-28', + 'User-Agent': 'VSCode Build' + } + }); + + if (!response.ok) { + throw new Error(`Failed to fetch branch ${branch} from vscode-distro: ${response.status} ${response.statusText}`); + } + + const data = await response.json() as GitHubBranchResponse; + return data.commit.sha; +} + +async function checkDistroCommit(): Promise { + // Get the distro commit from package.json + const packageJsonPath = path.join(root, 'package.json'); + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + const distroCommit: string = packageJson.distro; + + if (!distroCommit) { + console.log('No distro commit found in package.json, skipping check'); + return; + } + + console.log(`Distro commit in package.json: ${distroCommit}`); + + // Get the current branch from Azure DevOps + // BUILD_SOURCEBRANCH is in format refs/heads/main or refs/heads/release/1.90 + const sourceBranch = getEnv('BUILD_SOURCEBRANCH'); + const branchMatch = sourceBranch.match(/^refs\/heads\/(.+)$/); + + if (!branchMatch) { + console.log(`Cannot determine branch from BUILD_SOURCEBRANCH: ${sourceBranch}, skipping check`); + return; + } + + const branch = branchMatch[1]; + console.log(`Current branch: ${branch}`); + + // Get the GitHub token + const token = getEnv('GITHUB_TOKEN'); + + // Fetch the HEAD of the matching branch in vscode-distro + let distroBranchHead: string; + try { + distroBranchHead = await retry(() => getDistroBranchHead(branch, token)); + } catch (error) { + // If the branch doesn't exist in distro, that's expected for feature branches + console.log(`Could not fetch branch '${branch}' from vscode-distro: ${error}`); + console.log('This is expected for feature branches that have not been merged to distro'); + return; + } + + console.log(`Distro branch '${branch}' HEAD: ${distroBranchHead}`); + + // Compare the commits + if (distroCommit === distroBranchHead) { + console.log(`✓ Distro commit matches branch HEAD`); + } else { + // Issue a warning using Azure DevOps logging commands + console.log(`##vso[task.logissue type=warning]Distro commit mismatch: package.json has ${distroCommit.substring(0, 8)} but ${branch} HEAD is ${distroBranchHead.substring(0, 8)}`); + console.log(`##vso[task.complete result=SucceededWithIssues;]Distro commit does not match branch HEAD`); + console.log(''); + console.log(`⚠️ WARNING: Distro commit in package.json does not match the HEAD of branch '${branch}' in vscode-distro`); + console.log(` package.json distro: ${distroCommit}`); + console.log(` ${branch} HEAD: ${distroBranchHead}`); + console.log(''); + console.log(' To update, run: npm run update-distro'); + } +} + +checkDistroCommit().then(() => { + console.log('Distro commit check completed'); + process.exit(0); +}, err => { + console.error(err); + process.exit(1); +}); diff --git a/build/azure-pipelines/common/releaseBuild.ts b/build/azure-pipelines/common/releaseBuild.ts index 37e869a42bc..3cd8082308e 100644 --- a/build/azure-pipelines/common/releaseBuild.ts +++ b/build/azure-pipelines/common/releaseBuild.ts @@ -4,12 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import { CosmosClient } from '@azure/cosmos'; -import path from 'path'; -import fs from 'fs'; import { retry } from './retry.ts'; -import { type IExtensionManifest, parseApiProposalsFromSource, checkExtensionCompatibility, areAllowlistedApiProposalsMatching } from './versionCompatibility.ts'; - -const root = path.dirname(path.dirname(path.dirname(import.meta.dirname))); +import { checkCopilotChatCompatibility } from './checkCopilotChatCompatibility.ts'; function getEnv(name: string): string { const result = process.env[name]; @@ -21,109 +17,6 @@ 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`); - - // Load product.json to check allowlisted API proposals - const productJsonPath = path.join(root, 'product.json'); - let productJson; - try { - productJson = JSON.parse(fs.readFileSync(productJsonPath, 'utf8')); - } catch (error) { - throw new Error(`Failed to load or parse product.json: ${error}`); - } - const extensionEnabledApiProposals = productJson?.extensionEnabledApiProposals; - const extensionIdKey = extensionEnabledApiProposals ? Object.keys(extensionEnabledApiProposals).find(key => key.toLowerCase() === extensionId.toLowerCase()) : undefined; - const productAllowlistedProposals = extensionIdKey ? extensionEnabledApiProposals[extensionIdKey] : undefined; - - if (productAllowlistedProposals) { - console.log(`Product.json allowlisted proposals for ${extensionId}:`); - for (const proposal of productAllowlistedProposals) { - console.log(` ${proposal}`); - } - } else { - console.log(`Product.json allowlisted proposals for ${extensionId}: none`); - } - - // 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`); - } - - // Check that product.json allowlist matches package.json declarations - const allowlistResult = areAllowlistedApiProposalsMatching(extensionId, productAllowlistedProposals, manifest.enabledApiProposals); - if (!allowlistResult.compatible) { - throw new Error(`Allowlist check failed:\n ${allowlistResult.errors.join('\n ')}`); - } - - console.log(` ✓ Product.json allowlist matches package.json`); - console.log(`✓ ${extensionId} is compatible with this build`); -} - interface Config { id: string; frozen: boolean; diff --git a/build/azure-pipelines/product-build.yml b/build/azure-pipelines/product-build.yml index 3ff40d1d941..897c27e680d 100644 --- a/build/azure-pipelines/product-build.yml +++ b/build/azure-pipelines/product-build.yml @@ -203,6 +203,15 @@ extends: jobs: - template: build/azure-pipelines/product-compile.yml@self + - ${{ if eq(variables['VSCODE_PUBLISH'], 'true') }}: + - stage: ValidationChecks + dependsOn: [] + pool: + name: 1es-ubuntu-22.04-x64 + os: linux + jobs: + - template: build/azure-pipelines/product-validation-checks.yml@self + - ${{ if or(eq(parameters.VSCODE_BUILD_LINUX, true),eq(parameters.VSCODE_BUILD_LINUX_ARMHF, true),eq(parameters.VSCODE_BUILD_LINUX_ARM64, true),eq(parameters.VSCODE_BUILD_ALPINE, true),eq(parameters.VSCODE_BUILD_ALPINE_ARM64, true),eq(parameters.VSCODE_BUILD_MACOS, true),eq(parameters.VSCODE_BUILD_MACOS_ARM64, true),eq(parameters.VSCODE_BUILD_WIN32, true),eq(parameters.VSCODE_BUILD_WIN32_ARM64, true)) }}: - stage: CompileCLI dependsOn: [] diff --git a/build/azure-pipelines/product-validation-checks.yml b/build/azure-pipelines/product-validation-checks.yml new file mode 100644 index 00000000000..adf61f33c42 --- /dev/null +++ b/build/azure-pipelines/product-validation-checks.yml @@ -0,0 +1,40 @@ +jobs: + - job: ValidationChecks + displayName: Distro and Extension Validation + timeoutInMinutes: 15 + steps: + - template: ./common/checkout.yml@self + + - task: NodeTool@0 + inputs: + versionSource: fromFile + versionFilePath: .nvmrc + + - template: ./distro/download-distro.yml@self + + - script: node build/azure-pipelines/distro/mixin-quality.ts + displayName: Mixin distro quality + + - task: AzureKeyVault@2 + displayName: "Azure Key Vault: Get Secrets" + inputs: + azureSubscription: vscode + KeyVaultName: vscode-build-secrets + SecretsFilter: "github-distro-mixin-password" + + - script: npm ci + workingDirectory: build + env: + GITHUB_TOKEN: "$(github-distro-mixin-password)" + displayName: Install build dependencies + + - script: node build/azure-pipelines/common/checkDistroCommit.ts + displayName: Check distro commit + env: + GITHUB_TOKEN: "$(github-distro-mixin-password)" + BUILD_SOURCEBRANCH: "$(Build.SourceBranch)" + continueOnError: true + + - script: node build/azure-pipelines/common/checkCopilotChatCompatibility.ts --warn-only + displayName: Check Copilot Chat compatibility + continueOnError: true