diff --git a/README.md b/README.md index 879ebeb..c2de94f 100644 --- a/README.md +++ b/README.md @@ -49,20 +49,42 @@ steps: 9.0.x - run: dotnet build ``` + +## Installing additional runtimes + +The `dotnet-runtime` input allows you to install .NET runtimes separately from SDKs. This is useful for multi-targeting scenarios where you need one SDK version but multiple runtime versions. + +When `dotnet-runtime` is specified, both the .NET Runtime (Microsoft.NETCore.App) and the ASP.NET Core Runtime (Microsoft.AspNetCore.App) are installed for each specified version. + +**Example: Install SDK 10 with runtimes 8 and 9**: +```yml +steps: +- uses: actions/checkout@v5 +- name: Setup dotnet + uses: actions/setup-dotnet@v5 + with: + dotnet-version: '10.0.x' + dotnet-runtime: | + 8.0.x + 9.0.x +- run: dotnet build +- run: dotnet test +``` + ## Supported version syntax -The `dotnet-version` input supports following syntax: +The `dotnet-version` and `dotnet-runtime` inputs support following syntax: -- **A.B.C** (e.g 6.0.400, 7.0.100-preview.7.22377.5) - installs exact version of .NET SDK -- **A.B** or **A.B.x** (e.g. 8.0, 8.0.x) - installs the latest patch version of .NET SDK on the channel `8.0`, including prerelease versions (preview, rc) +- **A.B.C** (e.g 6.0.400, 7.0.100-preview.7.22377.5) - installs exact version of .NET SDK or runtime +- **A.B** or **A.B.x** (e.g. 8.0, 8.0.x) - installs the latest patch version on the channel `8.0`, including prerelease versions (preview, rc) - **A** or **A.x** (e.g. 8, 8.x) - installs the latest minor version of the specified major tag, including prerelease versions (preview, rc) -- **A.B.Cxx** (e.g. 8.0.4xx) - available since `.NET 5.0` release. Installs the latest version of the specific SDK release, including prerelease versions (preview, rc). +- **A.B.Cxx** (e.g. 8.0.4xx) - available since `.NET 5.0` release. Installs the latest version of the specific release, including prerelease versions (preview, rc). ## Using the `dotnet-quality` input This input sets up the action to install the latest build of the specified quality in the channel. The possible values of `dotnet-quality` are: **daily**, **signed**, **validated**, **preview**, **ga**. -> **Note**: `dotnet-quality` input can be used only with .NET SDK version in 'A.B', 'A.B.x', 'A', 'A.x' and 'A.B.Cxx' formats where the major version is higher than 5. In other cases, `dotnet-quality` input will be ignored. +> **Note**: `dotnet-quality` input can be used only with .NET SDK or runtime version in 'A.B', 'A.B.x', 'A', 'A.x' and 'A.B.Cxx' formats where the major version is higher than 5. In other cases, `dotnet-quality` input will be ignored. The quality setting applies to both SDK and runtime installations. ```yml steps: diff --git a/__tests__/installer.test.ts b/__tests__/installer.test.ts index 84aea32..e0fc54a 100644 --- a/__tests__/installer.test.ts +++ b/__tests__/installer.test.ts @@ -344,6 +344,171 @@ describe('installer tests', () => { expect(path).toContain(process.env['DOTNET_INSTALL_DIR']); }); }); + + describe('installRuntime() tests', () => { + beforeAll(() => { + whichSpy.mockImplementation(() => Promise.resolve('PathToShell')); + chmodSyncSpy.mockImplementation(() => {}); + readdirSpy.mockImplementation(() => Promise.resolve([])); + }); + + afterAll(() => { + jest.resetAllMocks(); + }); + + it('should throw the error in case of non-zero exit code of the .NET runtime installation script. The error message should contain logs.', async () => { + const inputVersion = '8.0.402'; + const inputQuality = '' as QualityOptions; + const errorMessage = 'fictitious error message!'; + + getExecOutputSpy.mockImplementation(() => { + return Promise.resolve({ + exitCode: 1, + stdout: '', + stderr: errorMessage + }); + }); + + const dotnetInstaller = new installer.DotnetCoreInstaller( + inputVersion, + inputQuality + ); + await expect(dotnetInstaller.installRuntime()).rejects.toThrow( + `Failed to install dotnet runtime, exit code: 1. ${errorMessage}` + ); + }); + + it('should throw the error in case of non-zero exit code of the ASP.NET Core runtime installation script. The error message should contain logs.', async () => { + const inputVersion = '8.0.402'; + const inputQuality = '' as QualityOptions; + const errorMessage = 'fictitious aspnetcore error message!'; + + getExecOutputSpy + .mockImplementationOnce(() => { + return Promise.resolve({ + exitCode: 0, + stdout: `Fictitious dotnet runtime version ${inputVersion} is installed`, + stderr: '' + }); + }) + .mockImplementationOnce(() => { + return Promise.resolve({ + exitCode: 1, + stdout: '', + stderr: errorMessage + }); + }); + + const dotnetInstaller = new installer.DotnetCoreInstaller( + inputVersion, + inputQuality + ); + await expect(dotnetInstaller.installRuntime()).rejects.toThrow( + `Failed to install aspnetcore runtime, exit code: 1. ${errorMessage}` + ); + }); + + it('should return version of .NET runtime after installation complete', async () => { + const inputVersion = '8.0.402'; + const inputQuality = '' as QualityOptions; + const stdout = `Fictitious dotnet runtime version ${inputVersion} is installed`; + getExecOutputSpy.mockImplementation(() => { + return Promise.resolve({ + exitCode: 0, + stdout: `${stdout}`, + stderr: '' + }); + }); + maxSatisfyingSpy.mockImplementation(() => inputVersion); + + const dotnetInstaller = new installer.DotnetCoreInstaller( + inputVersion, + inputQuality + ); + const installedVersion = await dotnetInstaller.installRuntime(); + + expect(installedVersion).toBe(inputVersion); + }); + + it(`should supply '--runtime dotnet' and '--runtime aspnetcore' arguments to the installation script`, async () => { + const inputVersion = '8.0.402'; + const inputQuality = '' as QualityOptions; + const stdout = `Fictitious dotnet runtime version ${inputVersion} is installed`; + + getExecOutputSpy.mockImplementation(() => { + return Promise.resolve({ + exitCode: 0, + stdout: `${stdout}`, + stderr: '' + }); + }); + maxSatisfyingSpy.mockImplementation(() => inputVersion); + + const dotnetInstaller = new installer.DotnetCoreInstaller( + inputVersion, + inputQuality + ); + + await dotnetInstaller.installRuntime(); + + // Check first call installs .NET runtime + const dotnetScriptArguments = ( + getExecOutputSpy.mock.calls[0][1] as string[] + ).join(' '); + const expectedDotnetArgument = IS_WINDOWS + ? `-Runtime dotnet` + : `--runtime dotnet`; + + expect(dotnetScriptArguments).toContain(expectedDotnetArgument); + + // Check second call installs ASP.NET Core runtime + const aspnetcoreScriptArguments = ( + getExecOutputSpy.mock.calls[1][1] as string[] + ).join(' '); + const expectedAspnetcoreArgument = IS_WINDOWS + ? `-Runtime aspnetcore` + : `--runtime aspnetcore`; + + expect(aspnetcoreScriptArguments).toContain(expectedAspnetcoreArgument); + }); + + it(`should supply 'version' argument to both runtime installation scripts if supplied version is in A.B.C syntax`, async () => { + const inputVersion = '8.0.402'; + const inputQuality = '' as QualityOptions; + const stdout = `Fictitious dotnet runtime version ${inputVersion} is installed`; + + getExecOutputSpy.mockImplementation(() => { + return Promise.resolve({ + exitCode: 0, + stdout: `${stdout}`, + stderr: '' + }); + }); + maxSatisfyingSpy.mockImplementation(() => inputVersion); + + const dotnetInstaller = new installer.DotnetCoreInstaller( + inputVersion, + inputQuality + ); + + await dotnetInstaller.installRuntime(); + + const expectedArgument = IS_WINDOWS + ? `-Version ${inputVersion}` + : `--version ${inputVersion}`; + + // Check both calls contain version argument + const dotnetScriptArguments = ( + getExecOutputSpy.mock.calls[0][1] as string[] + ).join(' '); + expect(dotnetScriptArguments).toContain(expectedArgument); + + const aspnetcoreScriptArguments = ( + getExecOutputSpy.mock.calls[1][1] as string[] + ).join(' '); + expect(aspnetcoreScriptArguments).toContain(expectedArgument); + }); + }); }); describe('DotnetVersionResolver tests', () => { diff --git a/action.yml b/action.yml index 73bd19d..a3e29af 100644 --- a/action.yml +++ b/action.yml @@ -7,6 +7,8 @@ branding: inputs: dotnet-version: description: 'Optional SDK version(s) to use. If not provided, will install global.json version when available. Examples: 2.2.104, 3.1, 3.1.x, 3.x, 6.0.2xx' + dotnet-runtime: + description: 'Optional runtime version(s) to install. Supports same version syntax as dotnet-version. Examples: 8.0.x, 9.0.x, 8.0.402' dotnet-quality: description: 'Optional quality of the build. The possible values are: daily, signed, validated, preview, ga.' global-json-file: diff --git a/dist/setup/index.js b/dist/setup/index.js index e638606..e5fe526 100644 --- a/dist/setup/index.js +++ b/dist/setup/index.js @@ -100716,6 +100716,42 @@ class DotnetCoreInstaller { } return this.parseInstalledVersion(dotnetInstallOutput.stdout); } + async installRuntime() { + const versionResolver = new DotnetVersionResolver(this.version); + const dotnetVersion = await versionResolver.createDotnetVersion(); + /** + * Install .NET runtime (Microsoft.NETCore.App) + * Skip non-versioned files to avoid overwriting CLI + */ + const dotnetRuntimeOutput = await new DotnetInstallScript() + // If dotnet CLI is already installed - avoid overwriting it + .useArguments(utils_1.IS_WINDOWS ? '-SkipNonVersionedFiles' : '--skip-non-versioned-files') + // Install .NET runtime (Microsoft.NETCore.App) + .useArguments(utils_1.IS_WINDOWS ? '-Runtime' : '--runtime', 'dotnet') + // Use version provided by user + .useVersion(dotnetVersion, this.quality) + .execute(); + if (dotnetRuntimeOutput.exitCode) { + throw new Error(`Failed to install dotnet runtime, exit code: ${dotnetRuntimeOutput.exitCode}. ${dotnetRuntimeOutput.stderr}`); + } + /** + * Install ASP.NET Core runtime (Microsoft.AspNetCore.App) + * Skip non-versioned files to avoid overwriting CLI + */ + const aspnetcoreRuntimeOutput = await new DotnetInstallScript() + // If dotnet CLI is already installed - avoid overwriting it + .useArguments(utils_1.IS_WINDOWS ? '-SkipNonVersionedFiles' : '--skip-non-versioned-files') + // Install ASP.NET Core runtime (Microsoft.AspNetCore.App) + .useArguments(utils_1.IS_WINDOWS ? '-Runtime' : '--runtime', 'aspnetcore') + // Use version provided by user + .useVersion(dotnetVersion, this.quality) + .execute(); + if (aspnetcoreRuntimeOutput.exitCode) { + throw new Error(`Failed to install aspnetcore runtime, exit code: ${aspnetcoreRuntimeOutput.exitCode}. ${aspnetcoreRuntimeOutput.stderr}`); + } + // Return the .NET runtime version (both should be the same version) + return this.parseInstalledVersion(dotnetRuntimeOutput.stdout); + } parseInstalledVersion(stdout) { const regex = /(?\d+\.\d+\.\d+[a-z0-9._-]*)/gm; const matchedResult = regex.exec(stdout); @@ -100796,6 +100832,7 @@ async function run() { // // dotnet-version is optional, but needs to be provided for most use cases. // If supplied, install / use from the tool cache. + // dotnet-runtime is optional and allows installing runtime-only versions. // global-version-file may be specified to point to a specific global.json // and will be used to install an additional version. // If not supplied, look for version in ./global.json. @@ -100803,7 +100840,9 @@ async function run() { // Proxy, auth, (etc) are still set up, even if no version is identified // const versions = core.getMultilineInput('dotnet-version'); + const runtimeVersions = core.getMultilineInput('dotnet-runtime'); const installedDotnetVersions = []; + const installedRuntimeVersions = []; const globalJsonFileInput = core.getInput('global-json-file'); if (globalJsonFileInput) { const globalJsonPath = path_1.default.resolve(process.cwd(), globalJsonFileInput); @@ -100823,11 +100862,11 @@ async function run() { core.info(`The global.json wasn't found in the root directory. No .NET version will be installed.`); } } + const quality = core.getInput('dotnet-quality'); + if (quality && !qualityOptions.includes(quality)) { + throw new Error(`Value '${quality}' is not supported for the 'dotnet-quality' option. Supported values are: daily, signed, validated, preview, ga.`); + } if (versions.length) { - const quality = core.getInput('dotnet-quality'); - if (quality && !qualityOptions.includes(quality)) { - throw new Error(`Value '${quality}' is not supported for the 'dotnet-quality' option. Supported values are: daily, signed, validated, preview, ga.`); - } let dotnetInstaller; const uniqueVersions = new Set(versions); for (const version of uniqueVersions) { @@ -100837,6 +100876,19 @@ async function run() { } installer_1.DotnetInstallDir.addToPath(); } + if (runtimeVersions.length) { + let dotnetInstaller; + const uniqueRuntimeVersions = new Set(runtimeVersions); + for (const runtimeVersion of uniqueRuntimeVersions) { + dotnetInstaller = new installer_1.DotnetCoreInstaller(runtimeVersion, quality); + const installedRuntimeVersion = await dotnetInstaller.installRuntime(); + installedRuntimeVersions.push(installedRuntimeVersion); + } + // Ensure PATH is set (may have been set already by SDK installation) + if (!versions.length) { + installer_1.DotnetInstallDir.addToPath(); + } + } const sourceUrl = core.getInput('source-url'); const configFile = core.getInput('config-file'); if (sourceUrl) { diff --git a/package-lock.json b/package-lock.json index 52bb56f..ee8d781 100644 --- a/package-lock.json +++ b/package-lock.json @@ -360,6 +360,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.23.3.tgz", "integrity": "sha512-Jg+msLuNuCJDyBvFv5+OKOUjWMZgd85bKjbICd3zWrKAo+bJ49HJufi7CQE0q0uR8NGyO6xkCACScNqyjHSZew==", "dev": true, + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.22.13", @@ -1416,6 +1417,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/@octokit/core/-/core-5.0.1.tgz", "integrity": "sha512-lyeeeZyESFo+ffI801SaBKmCfsvarO+dgV8/0gD8u1d87clbEdWsP5yC+dSj3zLhb2eIf5SJrn6vDz9AheETHw==", + "peer": true, "dependencies": { "@octokit/auth-token": "^4.0.0", "@octokit/graphql": "^7.0.0", @@ -2521,6 +2523,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", "dev": true, + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2792,6 +2795,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001541", "electron-to-chromium": "^1.4.535", @@ -3248,6 +3252,7 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz", "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==", "dev": true, + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -4269,6 +4274,7 @@ "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, + "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -6038,6 +6044,7 @@ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/src/installer.ts b/src/installer.ts index 12f58d7..371e9c6 100644 --- a/src/installer.ts +++ b/src/installer.ts @@ -311,6 +311,56 @@ export class DotnetCoreInstaller { return this.parseInstalledVersion(dotnetInstallOutput.stdout); } + public async installRuntime(): Promise { + const versionResolver = new DotnetVersionResolver(this.version); + const dotnetVersion = await versionResolver.createDotnetVersion(); + + /** + * Install .NET runtime (Microsoft.NETCore.App) + * Skip non-versioned files to avoid overwriting CLI + */ + const dotnetRuntimeOutput = await new DotnetInstallScript() + // If dotnet CLI is already installed - avoid overwriting it + .useArguments( + IS_WINDOWS ? '-SkipNonVersionedFiles' : '--skip-non-versioned-files' + ) + // Install .NET runtime (Microsoft.NETCore.App) + .useArguments(IS_WINDOWS ? '-Runtime' : '--runtime', 'dotnet') + // Use version provided by user + .useVersion(dotnetVersion, this.quality) + .execute(); + + if (dotnetRuntimeOutput.exitCode) { + throw new Error( + `Failed to install dotnet runtime, exit code: ${dotnetRuntimeOutput.exitCode}. ${dotnetRuntimeOutput.stderr}` + ); + } + + /** + * Install ASP.NET Core runtime (Microsoft.AspNetCore.App) + * Skip non-versioned files to avoid overwriting CLI + */ + const aspnetcoreRuntimeOutput = await new DotnetInstallScript() + // If dotnet CLI is already installed - avoid overwriting it + .useArguments( + IS_WINDOWS ? '-SkipNonVersionedFiles' : '--skip-non-versioned-files' + ) + // Install ASP.NET Core runtime (Microsoft.AspNetCore.App) + .useArguments(IS_WINDOWS ? '-Runtime' : '--runtime', 'aspnetcore') + // Use version provided by user + .useVersion(dotnetVersion, this.quality) + .execute(); + + if (aspnetcoreRuntimeOutput.exitCode) { + throw new Error( + `Failed to install aspnetcore runtime, exit code: ${aspnetcoreRuntimeOutput.exitCode}. ${aspnetcoreRuntimeOutput.stderr}` + ); + } + + // Return the .NET runtime version (both should be the same version) + return this.parseInstalledVersion(dotnetRuntimeOutput.stdout); + } + private parseInstalledVersion(stdout: string): string | null { const regex = /(?\d+\.\d+\.\d+[a-z0-9._-]*)/gm; const matchedResult = regex.exec(stdout); diff --git a/src/setup-dotnet.ts b/src/setup-dotnet.ts index 2a628a5..c80acee 100644 --- a/src/setup-dotnet.ts +++ b/src/setup-dotnet.ts @@ -24,6 +24,7 @@ export async function run() { // // dotnet-version is optional, but needs to be provided for most use cases. // If supplied, install / use from the tool cache. + // dotnet-runtime is optional and allows installing runtime-only versions. // global-version-file may be specified to point to a specific global.json // and will be used to install an additional version. // If not supplied, look for version in ./global.json. @@ -31,7 +32,9 @@ export async function run() { // Proxy, auth, (etc) are still set up, even if no version is identified // const versions = core.getMultilineInput('dotnet-version'); + const runtimeVersions = core.getMultilineInput('dotnet-runtime'); const installedDotnetVersions: (string | null)[] = []; + const installedRuntimeVersions: (string | null)[] = []; const globalJsonFileInput = core.getInput('global-json-file'); if (globalJsonFileInput) { @@ -57,15 +60,15 @@ export async function run() { } } + const quality = core.getInput('dotnet-quality') as QualityOptions; + + if (quality && !qualityOptions.includes(quality)) { + throw new Error( + `Value '${quality}' is not supported for the 'dotnet-quality' option. Supported values are: daily, signed, validated, preview, ga.` + ); + } + if (versions.length) { - const quality = core.getInput('dotnet-quality') as QualityOptions; - - if (quality && !qualityOptions.includes(quality)) { - throw new Error( - `Value '${quality}' is not supported for the 'dotnet-quality' option. Supported values are: daily, signed, validated, preview, ga.` - ); - } - let dotnetInstaller: DotnetCoreInstaller; const uniqueVersions = new Set(versions); for (const version of uniqueVersions) { @@ -76,6 +79,20 @@ export async function run() { DotnetInstallDir.addToPath(); } + if (runtimeVersions.length) { + let dotnetInstaller: DotnetCoreInstaller; + const uniqueRuntimeVersions = new Set(runtimeVersions); + for (const runtimeVersion of uniqueRuntimeVersions) { + dotnetInstaller = new DotnetCoreInstaller(runtimeVersion, quality); + const installedRuntimeVersion = await dotnetInstaller.installRuntime(); + installedRuntimeVersions.push(installedRuntimeVersion); + } + // Ensure PATH is set (may have been set already by SDK installation) + if (!versions.length) { + DotnetInstallDir.addToPath(); + } + } + const sourceUrl: string = core.getInput('source-url'); const configFile: string = core.getInput('config-file'); if (sourceUrl) {