From 98d51db1cd559bae93d19544c3093de24ce1a9db Mon Sep 17 00:00:00 2001 From: Soumya Ranjan Mahunt Date: Sun, 4 May 2025 08:27:43 +0000 Subject: [PATCH 1/2] refactor: updated Visual Studio setup process for required components installation --- __tests__/utils/visual_studio/setup.test.ts | 61 ++++++++++++++++- dist/index.js | 76 +++++++++++++++------ src/installer/windows/installation/index.ts | 12 ++-- src/utils/visual_studio/base.ts | 63 +++++++++++++++++ src/utils/visual_studio/setup.ts | 50 +++++++++----- 5 files changed, 217 insertions(+), 45 deletions(-) diff --git a/__tests__/utils/visual_studio/setup.test.ts b/__tests__/utils/visual_studio/setup.test.ts index fb1783b..26e7c2c 100644 --- a/__tests__/utils/visual_studio/setup.test.ts +++ b/__tests__/utils/visual_studio/setup.test.ts @@ -1,8 +1,12 @@ import * as path from 'path' import {promises as fs} from 'fs' import os from 'os' +import crypto from 'crypto' import * as exec from '@actions/exec' -import {VisualStudio} from '../../../src/utils/visual_studio' +import { + VisualStudio, + VisualStudioConfig +} from '../../../src/utils/visual_studio' describe('visual studio setup validation', () => { const env = process.env @@ -21,11 +25,13 @@ describe('visual studio setup validation', () => { beforeEach(() => { process.env = {...env} + VisualStudio.shared = undefined }) afterEach(() => { jest.restoreAllMocks() process.env = env + VisualStudio.shared = undefined }) it('tests visual studio setup fails when invalid path', async () => { @@ -58,6 +64,59 @@ describe('visual studio setup validation', () => { await expect( VisualStudio.setup({version: '16', components: visualStudio.components}) ).resolves.toMatchObject(visualStudio) + expect(VisualStudio.shared).toStrictEqual(visualStudio) + }) + + it('tests visual studio duplicate setup', async () => { + VisualStudio.shared = visualStudio + const fsAccessSpy = jest.spyOn(fs, 'access') + const execSpy = jest.spyOn(exec, 'exec') + const getExecOutputSpy = jest.spyOn(exec, 'getExecOutput') + await expect( + VisualStudio.setup({version: '16', components: visualStudio.components}) + ).resolves.toMatchObject(visualStudio) + expect(VisualStudio.shared).toBe(visualStudio) + for (const spy of [fsAccessSpy, execSpy, getExecOutputSpy]) { + expect(spy).not.toHaveBeenCalled() + } + }) + + it('tests visual studio setup successfully skipping components installation', async () => { + process.env.VSWHERE_PATH = path.join('C:', 'Visual Studio') + const ucrtVersion = '1' + const ucrtSdkDir = path.join('C:', 'UniversalCRTSdkDir') + const vcToolsInstallDir = path.join('C:', 'VCToolsInstallDir') + const tmpDir = process.env.RUNNER_TEMP || os.tmpdir() + const configId = '792a1d5c-ef88-45da-858c-baf3e6e0d048' + const configFileName = `swift-setup-installation-${configId}.vsconfig` + const vsConfig: VisualStudioConfig = { + version: visualStudio.installationVersion, + components: visualStudio.components + } + + jest.spyOn(fs, 'access').mockResolvedValue() + jest.spyOn(exec, 'exec').mockResolvedValue(0) + jest.spyOn(crypto, 'randomUUID').mockReturnValue(configId) + jest + .spyOn(exec, 'getExecOutput') + .mockResolvedValueOnce({ + exitCode: 0, + stdout: JSON.stringify([visualStudio]), + stderr: '' + }) + .mockResolvedValueOnce({ + exitCode: 0, + stdout: `UniversalCRTSdkDir=${ucrtSdkDir}\nUCRTVersion=${ucrtVersion}\nVCToolsInstallDir=${vcToolsInstallDir}`, + stderr: '' + }) + const readFileSpy = jest.spyOn(fs, 'readFile') + readFileSpy.mockResolvedValue(JSON.stringify(vsConfig)) + + await expect( + VisualStudio.setup({version: '16', components: visualStudio.components}) + ).resolves.toMatchObject(visualStudio) + expect(readFileSpy.mock.calls[0][0]).toBe(path.join(tmpDir, configFileName)) + expect(VisualStudio.shared).toStrictEqual(visualStudio) }) it('tests visual studio environment setup', async () => { diff --git a/dist/index.js b/dist/index.js index ca18485..2677f98 100644 --- a/dist/index.js +++ b/dist/index.js @@ -872,9 +872,9 @@ const exec_1 = __nccwpck_require__(5236); const base_1 = __nccwpck_require__(2349); const approach_1 = __nccwpck_require__(5655); const fallback_1 = __nccwpck_require__(3494); -base_1.Installation.get = async (install) => { +base_1.Installation.get = async function (install) { if (!(install?.length ?? 1)) { - return lastInstallation; + return this.lastInstallation; } const approaches = [ async () => (0, approach_1.secondDirectoryLayout)(install), @@ -898,14 +898,13 @@ base_1.Installation.get = async (install) => { } return undefined; }; -let lastInstallation; -base_1.Installation.install = async (exe) => { +base_1.Installation.install = async function (exe) { core.debug(`Installing toolchain from "${exe}"`); const oldEnv = await (0, fallback_1.env)(); await (0, exec_1.exec)(`"${exe}"`, ['-q']); const newEnv = await (0, fallback_1.env)(); - lastInstallation = await base_1.Installation.detect(oldEnv, newEnv); - return lastInstallation; + this.lastInstallation = await base_1.Installation.detect(oldEnv, newEnv); + return this.lastInstallation; }; base_1.Installation.detect = async (oldEnv, newEnv) => { const installation = await base_1.Installation.get(); @@ -2123,19 +2122,35 @@ exports.VISUAL_STUDIO_WINSDK_COMPONENT_REGEX = /Microsoft\.VisualStudio\.Compone class VisualStudio { installationPath; installationVersion; + productId; + channelId; catalog; properties; components; - constructor(installationPath, installationVersion, catalog, properties, components) { + constructor(installationPath, installationVersion, productId, channelId, catalog, properties, components) { this.installationPath = installationPath; this.installationVersion = installationVersion; + this.productId = productId; + this.channelId = channelId; this.catalog = catalog; this.properties = properties; this.components = components; } // eslint-disable-next-line @typescript-eslint/no-explicit-any static createFromJSON(json) { - return new VisualStudio(json.installationPath, json.installationVersion, json.catalog, json.properties, json.components); + return new VisualStudio(json.installationPath, json.installationVersion, json.productId, json.channelId, json.catalog, json.properties, json.components); + } + get defaultOptions() { + return [ + '--productId', + this.productId, + '--channelId', + this.channelId, + '--installPath', + this.installationPath, + '--noUpdateInstaller', + '--quiet' + ]; } async env() { /// https://docs.microsoft.com/en-us/cpp/build/building-on-the-command-line?view=msvc-170 @@ -2242,15 +2257,18 @@ var __importStar = (this && this.__importStar) || (function () { }; })(); Object.defineProperty(exports, "__esModule", ({ value: true })); +const os = __importStar(__nccwpck_require__(857)); +const path = __importStar(__nccwpck_require__(6928)); +const fs_1 = __nccwpck_require__(9896); +const crypto_1 = __nccwpck_require__(6982); const core = __importStar(__nccwpck_require__(7484)); const exec_1 = __nccwpck_require__(5236); const base_1 = __nccwpck_require__(3093); const vswhere_1 = __nccwpck_require__(6536); -let shared; /// set up required visual studio tools for swift on windows base_1.VisualStudio.setup = async function (requirement) { - if (shared) { - return shared; + if (this.shared) { + return this.shared; } /// https://github.com/microsoft/vswhere/wiki/Find-MSBuild /// get visual studio properties @@ -2275,28 +2293,42 @@ base_1.VisualStudio.setup = async function (requirement) { throw new Error(`Unable to find any Visual Studio installation for version: ${requirement.version}.`); } const vsEnv = await vs.env(); - const comps = requirement.components; + let comps = requirement.components; if (vsEnv.UCRTVersion && vsEnv.UniversalCRTSdkDir && - vsEnv.VCToolsInstallDir && - comps.length < 3) { - core.debug('VS components already setup, skipping installation'); - shared = vs; - return vs; + vsEnv.VCToolsInstallDir) { + const tmpDir = process.env.RUNNER_TEMP || os.tmpdir(); + const configFileName = `swift-setup-installation-${(0, crypto_1.randomUUID)()}.vsconfig`; + const configPath = path.join(tmpDir, configFileName); + core.debug(`Exporting VS installation config to "${configPath}"`); + await (0, exec_1.exec)(`"${vs.properties.setupEngineFilePath}"`, [ + 'export', + ...vs.defaultOptions, + '--config', + configPath + ]); + const configContent = await fs_1.promises.readFile(configPath, 'utf-8'); + core.debug(`Exported configuration data: "${configContent}"`); + const vsConfig = JSON.parse(configContent); + const installedComponents = new Set(vsConfig.components); + comps = comps.filter(comp => !installedComponents.has(comp)); + if (comps.length == 0) { + core.debug('VS components already setup, skipping installation'); + this.shared = vs; + return vs; + } } /// https://docs.microsoft.com/en-us/visualstudio/install/use-command-line-parameters-to-install-visual-studio?view=vs-2022 /// install required visual studio components core.debug(`Installing VS components "${comps}" at "${vs.installationPath}"`); await (0, exec_1.exec)(`"${vs.properties.setupEngineFilePath}"`, [ 'modify', - '--installPath', - vs.installationPath, + ...vs.defaultOptions, ...requirement.components.flatMap(component => ['--add', component]), '--installWhileDownloading', - '--force', - '--quiet' + '--force' ]); - shared = vs; + this.shared = vs; return vs; }; diff --git a/src/installer/windows/installation/index.ts b/src/installer/windows/installation/index.ts index 262f64a..b14d04f 100644 --- a/src/installer/windows/installation/index.ts +++ b/src/installer/windows/installation/index.ts @@ -7,6 +7,7 @@ import {env, fallback} from './fallback' declare module './base' { // eslint-disable-next-line no-shadow, @typescript-eslint/no-namespace export namespace Installation { + let lastInstallation: Installation | CustomInstallation export function get( install?: string ): Promise @@ -20,9 +21,9 @@ declare module './base' { } } -Installation.get = async (install?: string) => { +Installation.get = async function (install?: string) { if (!(install?.length ?? 1)) { - return lastInstallation + return this.lastInstallation } const approaches = [ @@ -47,14 +48,13 @@ Installation.get = async (install?: string) => { return undefined } -let lastInstallation: Installation | CustomInstallation -Installation.install = async (exe: string) => { +Installation.install = async function (exe: string) { core.debug(`Installing toolchain from "${exe}"`) const oldEnv = await env() await exec(`"${exe}"`, ['-q']) const newEnv = await env() - lastInstallation = await Installation.detect(oldEnv, newEnv) - return lastInstallation + this.lastInstallation = await Installation.detect(oldEnv, newEnv) + return this.lastInstallation } Installation.detect = async ( diff --git a/src/utils/visual_studio/base.ts b/src/utils/visual_studio/base.ts index c7d28c8..9dbd4cb 100644 --- a/src/utils/visual_studio/base.ts +++ b/src/utils/visual_studio/base.ts @@ -9,6 +9,8 @@ export class VisualStudio { private constructor( readonly installationPath: string, readonly installationVersion: string, + readonly productId: string, + readonly channelId: string, readonly catalog: VisualStudioCatalog, readonly properties: VisualStudioProperties, readonly components: string[] @@ -19,12 +21,27 @@ export class VisualStudio { return new VisualStudio( json.installationPath, json.installationVersion, + json.productId, + json.channelId, json.catalog, json.properties, json.components ) } + get defaultOptions() { + return [ + '--productId', + this.productId, + '--channelId', + this.channelId, + '--installPath', + this.installationPath, + '--noUpdateInstaller', + '--quiet' + ] + } + async env() { /// https://docs.microsoft.com/en-us/cpp/build/building-on-the-command-line?view=msvc-170 const nativeToolsScriptx86 = path.join( @@ -92,3 +109,49 @@ export interface VisualStudioEnv { readonly VCToolsInstallDir?: string readonly [name: string]: string | undefined } + +/** + * Represents the structure of a Visual Studio .vsconfig file + */ +export interface VisualStudioConfig { + /** + * The version of the .vsconfig file format + */ + version: string + + /** + * List of workloads and components to be installed + */ + components: string[] + + /** + * Optional installation channel URI + * Example: https://aka.ms/vs/17/release/channel + */ + installChannelUri?: string + + /** + * Runtime components to be installed (e.g., .NET Core, ASP.NET Core runtimes) + */ + runtimeComponents?: string[] + + /** + * Installation-related properties + */ + properties?: { + /** + * A custom name for the configuration + */ + nickname?: string + + /** + * Channel ID to use for installation, e.g., VisualStudio.17.Release + */ + channelId?: string + + /** + * Installation path for Visual Studio + */ + installPath?: string + } +} diff --git a/src/utils/visual_studio/setup.ts b/src/utils/visual_studio/setup.ts index 24c3250..a53ea89 100644 --- a/src/utils/visual_studio/setup.ts +++ b/src/utils/visual_studio/setup.ts @@ -1,21 +1,24 @@ +import * as os from 'os' +import * as path from 'path' +import {promises as fs} from 'fs' +import {randomUUID} from 'crypto' import * as core from '@actions/core' import {exec, getExecOutput} from '@actions/exec' -import {VisualStudio, VisualStudioRequirement} from './base' +import {VisualStudio, VisualStudioRequirement, VisualStudioConfig} from './base' import {VSWhere} from './vswhere' declare module './base' { // eslint-disable-next-line no-shadow, @typescript-eslint/no-namespace export namespace VisualStudio { + let shared: VisualStudio | undefined function setup(requirement: VisualStudioRequirement): Promise } } -let shared: VisualStudio - /// set up required visual studio tools for swift on windows VisualStudio.setup = async function (requirement: VisualStudioRequirement) { - if (shared) { - return shared + if (this.shared) { + return this.shared } /// https://github.com/microsoft/vswhere/wiki/Find-MSBuild /// get visual studio properties @@ -46,16 +49,33 @@ VisualStudio.setup = async function (requirement: VisualStudioRequirement) { } const vsEnv = await vs.env() - const comps = requirement.components + let comps = requirement.components if ( vsEnv.UCRTVersion && vsEnv.UniversalCRTSdkDir && - vsEnv.VCToolsInstallDir && - comps.length < 3 + vsEnv.VCToolsInstallDir ) { - core.debug('VS components already setup, skipping installation') - shared = vs - return vs + const tmpDir = process.env.RUNNER_TEMP || os.tmpdir() + const configFileName = `swift-setup-installation-${randomUUID()}.vsconfig` + const configPath = path.join(tmpDir, configFileName) + core.debug(`Exporting VS installation config to "${configPath}"`) + await exec(`"${vs.properties.setupEngineFilePath}"`, [ + 'export', + ...vs.defaultOptions, + '--config', + configPath + ]) + + const configContent = await fs.readFile(configPath, 'utf-8') + core.debug(`Exported configuration data: "${configContent}"`) + const vsConfig: VisualStudioConfig = JSON.parse(configContent) + const installedComponents = new Set(vsConfig.components) + comps = comps.filter(comp => !installedComponents.has(comp)) + if (comps.length == 0) { + core.debug('VS components already setup, skipping installation') + this.shared = vs + return vs + } } /// https://docs.microsoft.com/en-us/visualstudio/install/use-command-line-parameters-to-install-visual-studio?view=vs-2022 @@ -63,13 +83,11 @@ VisualStudio.setup = async function (requirement: VisualStudioRequirement) { core.debug(`Installing VS components "${comps}" at "${vs.installationPath}"`) await exec(`"${vs.properties.setupEngineFilePath}"`, [ 'modify', - '--installPath', - vs.installationPath, + ...vs.defaultOptions, ...requirement.components.flatMap(component => ['--add', component]), '--installWhileDownloading', - '--force', - '--quiet' + '--force' ]) - shared = vs + this.shared = vs return vs } From 30e8447c173b89bf1f2d4f3cbff913ed6a527936 Mon Sep 17 00:00:00 2001 From: Soumya Ranjan Mahunt Date: Sun, 4 May 2025 12:46:07 +0000 Subject: [PATCH 2/2] perf: improved gpg verification for new windows toolchains --- dist/index.js | 18 ++++++++---------- src/installer/verify.ts | 18 ++++++++---------- 2 files changed, 16 insertions(+), 20 deletions(-) diff --git a/dist/index.js b/dist/index.js index 2677f98..4c80f7c 100644 --- a/dist/index.js +++ b/dist/index.js @@ -414,9 +414,11 @@ class VerifyingToolchainInstaller extends base_1.ToolchainInstaller { } async downloadSignature() { try { - return this.signatureUrl - ? await toolCache.downloadTool(this.signatureUrl) - : undefined; + if (this.signatureUrl) { + core.debug(`Downloading snapshot signature from "${this.signatureUrl}"`); + return await toolCache.downloadTool(this.signatureUrl); + } + return undefined; } catch (error) { if (error instanceof toolCache.HTTPError && @@ -429,17 +431,13 @@ class VerifyingToolchainInstaller extends base_1.ToolchainInstaller { } async download(arch) { const sigUrl = this.signatureUrl; + const signature = await this.downloadSignature(); async function setupKeys() { - if (sigUrl) { + if (sigUrl && signature) { await gpg.setupKeys(); } } - core.debug(`Downloading snapshot signature from "${sigUrl}"`); - const [, toolchain, signature] = await Promise.all([ - setupKeys(), - super.download(arch), - this.downloadSignature() - ]); + const [, toolchain] = await Promise.all([setupKeys(), super.download(arch)]); if (signature) { await gpg.verify(signature, toolchain); } diff --git a/src/installer/verify.ts b/src/installer/verify.ts index 1e05aff..0ee80bc 100644 --- a/src/installer/verify.ts +++ b/src/installer/verify.ts @@ -15,9 +15,11 @@ export abstract class VerifyingToolchainInstaller< private async downloadSignature() { try { - return this.signatureUrl - ? await toolCache.downloadTool(this.signatureUrl) - : undefined + if (this.signatureUrl) { + core.debug(`Downloading snapshot signature from "${this.signatureUrl}"`) + return await toolCache.downloadTool(this.signatureUrl) + } + return undefined } catch (error) { if ( error instanceof toolCache.HTTPError && @@ -32,18 +34,14 @@ export abstract class VerifyingToolchainInstaller< protected async download(arch: string) { const sigUrl = this.signatureUrl + const signature = await this.downloadSignature() async function setupKeys() { - if (sigUrl) { + if (sigUrl && signature) { await gpg.setupKeys() } } - core.debug(`Downloading snapshot signature from "${sigUrl}"`) - const [, toolchain, signature] = await Promise.all([ - setupKeys(), - super.download(arch), - this.downloadSignature() - ]) + const [, toolchain] = await Promise.all([setupKeys(), super.download(arch)]) if (signature) { await gpg.verify(signature, toolchain) }