From df77ff66158cf8fca2a9dc945ce661df1899c9ef Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Fri, 23 Jan 2026 12:31:14 -0800 Subject: [PATCH] build: mix in the quality for release so product.json matches (#290007) * build: mix in the quality for release so product.json matches * Reapply "feat: add allowlist compatibility check for API proposals in extensions" (#290003) This reverts commit abf64deb34478a88ad555038b30bba1593ea081d. --- build/azure-pipelines/common/releaseBuild.ts | 29 +++++- .../common/versionCompatibility.ts | 91 +++++++++++++++++++ build/azure-pipelines/product-release.yml | 5 + 3 files changed, 124 insertions(+), 1 deletion(-) diff --git a/build/azure-pipelines/common/releaseBuild.ts b/build/azure-pipelines/common/releaseBuild.ts index 92b6d22614d..ee5df457b58 100644 --- a/build/azure-pipelines/common/releaseBuild.ts +++ b/build/azure-pipelines/common/releaseBuild.ts @@ -7,7 +7,7 @@ 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'; +import { type IExtensionManifest, parseApiProposalsFromSource, checkExtensionCompatibility, areAllowlistedApiProposalsMatching } from './versionCompatibility.ts'; const root = path.dirname(path.dirname(path.dirname(import.meta.dirname))); @@ -75,6 +75,25 @@ async function checkCopilotChatCompatibility(): Promise { 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 productAllowlistedProposals = productJson?.extensionEnabledApiProposals?.[extensionId]; + + 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)); @@ -92,6 +111,14 @@ async function checkCopilotChatCompatibility(): Promise { 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`); } diff --git a/build/azure-pipelines/common/versionCompatibility.ts b/build/azure-pipelines/common/versionCompatibility.ts index 3246ef04df5..e4004d78f29 100644 --- a/build/azure-pipelines/common/versionCompatibility.ts +++ b/build/azure-pipelines/common/versionCompatibility.ts @@ -124,6 +124,50 @@ export function parseApiProposalsFromSource(content: string): { [proposalName: s return allApiProposals; } +export function areAllowlistedApiProposalsMatching( + extensionId: string, + productAllowlistedProposals: string[] | undefined, + manifestEnabledProposals: string[] | undefined +): { compatible: boolean; errors: string[] } { + // Normalize undefined to empty arrays for easier comparison + const productProposals = productAllowlistedProposals || []; + const manifestProposals = manifestEnabledProposals || []; + + // If extension doesn't declare any proposals, it's always compatible + // (product.json can allowlist more than the extension uses) + if (manifestProposals.length === 0) { + return { compatible: true, errors: [] }; + } + + // If extension declares API proposals but product.json doesn't allowlist them + if (productProposals.length === 0) { + return { + compatible: false, + errors: [ + `Extension '${extensionId}' declares API proposals in package.json (${manifestProposals.join(', ')}) ` + + `but product.json does not allowlist any API proposals for this extension` + ] + }; + } + + // Check that all proposals in manifest are allowlisted in product.json + // (product.json can have extra proposals that the extension doesn't use) + // Note: Strip version suffixes from manifest proposals (e.g., "chatParticipant@2" -> "chatParticipant") + // because product.json only contains base proposal names + const productSet = new Set(productProposals); + const errors: string[] = []; + + for (const proposal of manifestProposals) { + // Strip version suffix if present (e.g., "chatParticipant@2" -> "chatParticipant") + const proposalName = proposal.split('@')[0]; + if (!productSet.has(proposalName)) { + errors.push(`API proposal '${proposal}' is declared in extension '${extensionId}' package.json but is not allowlisted in product.json`); + } + } + + return { compatible: errors.length === 0, errors }; +} + export function checkExtensionCompatibility( productVersion: string, productApiProposals: Readonly<{ [proposalName: string]: Readonly<{ proposal: string; version?: number }> }>, @@ -343,5 +387,52 @@ export const allApiProposals = { console.log(' ✓ checkExtensionCompatibility tests passed\n'); + // areAllowlistedApiProposalsMatching tests + console.log('Testing areAllowlistedApiProposalsMatching...'); + + // Both undefined - compatible + assert.strictEqual(areAllowlistedApiProposalsMatching('test.ext', undefined, undefined).compatible, true); + + // Both empty arrays - compatible + assert.strictEqual(areAllowlistedApiProposalsMatching('test.ext', [], []).compatible, true); + + // Exact match - compatible + assert.strictEqual(areAllowlistedApiProposalsMatching('test.ext', ['proposalA', 'proposalB'], ['proposalA', 'proposalB']).compatible, true); + + // Match regardless of order - compatible + assert.strictEqual(areAllowlistedApiProposalsMatching('test.ext', ['proposalB', 'proposalA'], ['proposalA', 'proposalB']).compatible, true); + + // Extension declares but product.json doesn't allowlist - incompatible + assert.strictEqual(areAllowlistedApiProposalsMatching('test.ext', undefined, ['proposalA']).compatible, false); + assert.strictEqual(areAllowlistedApiProposalsMatching('test.ext', [], ['proposalA']).compatible, false); + + // Product.json allowlists but extension doesn't declare - COMPATIBLE (product.json can have extras) + assert.strictEqual(areAllowlistedApiProposalsMatching('test.ext', ['proposalA'], undefined).compatible, true); + assert.strictEqual(areAllowlistedApiProposalsMatching('test.ext', ['proposalA'], []).compatible, true); + + // Extension declares more than allowlisted - incompatible + assert.strictEqual(areAllowlistedApiProposalsMatching('test.ext', ['proposalA'], ['proposalA', 'proposalB']).compatible, false); + + // Product.json allowlists more than declared - COMPATIBLE (product.json can have extras) + assert.strictEqual(areAllowlistedApiProposalsMatching('test.ext', ['proposalA', 'proposalB'], ['proposalA']).compatible, true); + + // Completely different sets - incompatible (manifest has proposals not in allowlist) + assert.strictEqual(areAllowlistedApiProposalsMatching('test.ext', ['proposalA'], ['proposalB']).compatible, false); + + // Product.json has extras and manifest matches subset - compatible + assert.strictEqual(areAllowlistedApiProposalsMatching('test.ext', ['proposalA', 'proposalB', 'proposalC'], ['proposalA', 'proposalB']).compatible, true); + + // Versioned proposals - should strip version and match base name + assert.strictEqual(areAllowlistedApiProposalsMatching('test.ext', ['chatParticipant'], ['chatParticipant@2']).compatible, true); + assert.strictEqual(areAllowlistedApiProposalsMatching('test.ext', ['proposalA', 'proposalB'], ['proposalA@1', 'proposalB@3']).compatible, true); + + // Versioned proposal not in allowlist - incompatible + assert.strictEqual(areAllowlistedApiProposalsMatching('test.ext', ['proposalA'], ['proposalB@2']).compatible, false); + + // Mix of versioned and unversioned proposals + assert.strictEqual(areAllowlistedApiProposalsMatching('test.ext', ['proposalA', 'proposalB'], ['proposalA', 'proposalB@2']).compatible, true); + + console.log(' ✓ areAllowlistedApiProposalsMatching tests passed\n'); + console.log('All tests passed! ✓'); } diff --git a/build/azure-pipelines/product-release.yml b/build/azure-pipelines/product-release.yml index 72b33a78ad1..00821eb41a6 100644 --- a/build/azure-pipelines/product-release.yml +++ b/build/azure-pipelines/product-release.yml @@ -10,6 +10,11 @@ steps: versionSource: fromFile versionFilePath: .nvmrc + - template: ./distro/download-distro.yml@self + + - script: node build/azure-pipelines/distro/mixin-quality.ts + displayName: Mixin distro quality + - task: AzureCLI@2 displayName: Fetch secrets inputs: