diff --git a/README.md b/README.md index 9531d61a4..84c5e85d2 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,32 @@ steps: > **Important**: When using `rollForward` in `global.json`, the `sdk.version` field must be a fully-qualified SDK version (e.g., `8.0.100`, `10.0.100`). Wildcard versions (e.g., `10.0.*`) and runtime-style versions (e.g., `8.0.0`) are not supported. See the [.NET SDK version specification](https://learn.microsoft.com/en-us/dotnet/core/tools/global-json#version) for details. +## Using the `dotnet-version-file` input +`setup-dotnet` can read the .NET SDK version from a version file via the `dotnet-version-file` input. This is handy for sharing a single source of truth with tools such as [asdf](https://asdf-vm.com/) and [mise](https://mise.jdx.dev/). The input accepts: + +- a [`.tool-versions`](https://asdf-vm.com/manage/configuration.html#tool-versions) file — the version is read from its `dotnet` entry; +- a `global.json` file — the version is read from its `sdk.version` field. + +If the file supplied to `dotnet-version-file` doesn't exist, the action fails. If the file exists but contains no .NET SDK version, a warning is logged and no version is installed from it. + +```yml +steps: +- uses: actions/checkout@v6 +- uses: actions/setup-dotnet@v5 + with: + dotnet-version-file: .tool-versions +- run: dotnet build +``` + +A `.tool-versions` file may pin several tools; only the `dotnet` entry is used: + +``` +nodejs 20.11.0 +dotnet 8.0.100 +``` + +>**Note**: In case both `dotnet-version` and `dotnet-version-file` inputs are used, versions from both inputs will be installed. + ## Caching NuGet Packages The action has a built-in functionality for caching and restoring dependencies. It uses [toolkit/cache](https://github.com/actions/toolkit/tree/main/packages/cache) under the hood for caching global packages data but requires less configuration settings. The `cache` input is optional, and caching is turned off by default. diff --git a/__tests__/setup-dotnet.test.ts b/__tests__/setup-dotnet.test.ts index e159b48a7..4c4525369 100644 --- a/__tests__/setup-dotnet.test.ts +++ b/__tests__/setup-dotnet.test.ts @@ -66,6 +66,7 @@ describe('setup-dotnet tests', () => { const setOutputSpy = core.setOutput as jest.Mock; const existsSyncSpy = fs.existsSync as jest.Mock; + const readFileSyncSpy = fs.readFileSync as jest.Mock; const maxSatisfyingSpy = jest.spyOn(semver, 'maxSatisfying'); @@ -122,6 +123,80 @@ describe('setup-dotnet tests', () => { expect(infoSpy).toHaveBeenCalledWith(expectedInfoMessage); }); + it('should fail the action if dotnet-version-file input is present, but the file does not exist in the file system', async () => { + inputs['dotnet-version'] = []; + inputs['global-json-file'] = ''; + inputs['dotnet-version-file'] = '.tool-versions'; + + existsSyncSpy.mockReturnValue(false); + + const expectedErrorMessage = `The specified dotnet-version-file '${inputs['dotnet-version-file']}' does not exist`; + + await setup.run(); + expect(setFailedSpy).toHaveBeenCalledWith(expectedErrorMessage); + + inputs['dotnet-version-file'] = ''; + }); + + it('should read the .NET SDK version from a .tool-versions file supplied via dotnet-version-file', async () => { + inputs['dotnet-version'] = []; + inputs['global-json-file'] = ''; + inputs['dotnet-quality'] = ''; + inputs['dotnet-version-file'] = '.tool-versions'; + + existsSyncSpy.mockReturnValue(true); + readFileSyncSpy.mockReturnValue('nodejs 20.0.0\ndotnet 8.0.100\n'); + installDotnetSpy.mockImplementation(() => Promise.resolve('8.0.100')); + setOutputSpy.mockImplementation(() => {}); + + await setup.run(); + + expect(installDotnetSpy).toHaveBeenCalledTimes(1); + expect(setOutputSpy).toHaveBeenCalledWith('dotnet-version', '8.0.100'); + + inputs['dotnet-version-file'] = ''; + }); + + it('should read the .NET SDK version from a global.json supplied via dotnet-version-file', async () => { + inputs['dotnet-version'] = []; + inputs['global-json-file'] = ''; + inputs['dotnet-quality'] = ''; + inputs['dotnet-version-file'] = 'csharp/global.json'; + + existsSyncSpy.mockReturnValue(true); + readFileSyncSpy.mockReturnValue('{"sdk":{"version":"8.0.100"}}'); + installDotnetSpy.mockImplementation(() => Promise.resolve('8.0.100')); + setOutputSpy.mockImplementation(() => {}); + + await setup.run(); + + expect(installDotnetSpy).toHaveBeenCalledTimes(1); + expect(setOutputSpy).toHaveBeenCalledWith('dotnet-version', '8.0.100'); + + inputs['dotnet-version-file'] = ''; + }); + + it('should warn and not install if dotnet-version-file has no dotnet entry', async () => { + inputs['dotnet-version'] = []; + inputs['global-json-file'] = ''; + inputs['dotnet-version-file'] = '.tool-versions'; + + // The version file exists, but the fallback global.json in the repo root does not. + existsSyncSpy.mockImplementation(filePath => + String(filePath).endsWith('.tool-versions') + ); + readFileSyncSpy.mockReturnValue('nodejs 20.0.0\n# no dotnet here\n'); + + await setup.run(); + + expect(warningSpy).toHaveBeenCalledWith( + `No .NET SDK version was found in '${inputs['dotnet-version-file']}'. Make sure the file contains a 'dotnet' entry (.tool-versions) or an 'sdk.version' field (global.json).` + ); + expect(installDotnetSpy).not.toHaveBeenCalled(); + + inputs['dotnet-version-file'] = ''; + }); + it('should fail the action if quality is supplied but its value is not supported', async () => { inputs['global-json-file'] = ''; inputs['dotnet-version'] = ['10.0']; diff --git a/action.yml b/action.yml index e2ac19d07..53082d5e3 100644 --- a/action.yml +++ b/action.yml @@ -17,6 +17,9 @@ inputs: global-json-file: description: 'Optional global.json location, if your global.json isn''t located in the root of the repo.' required: false + dotnet-version-file: + description: 'Optional path to a file with the .NET SDK version to install. Supports a .tool-versions file (asdf/mise format, reads the "dotnet" entry) or a global.json file.' + required: false source-url: description: 'Optional package source for which to set up authentication. Will consult any existing NuGet.config in the root of the repo and provide a temporary NuGet.config using the NUGET_AUTH_TOKEN environment variable as a ClearTextPassword' required: false diff --git a/dist/setup/index.js b/dist/setup/index.js index 14d6048a7..1560d808c 100644 --- a/dist/setup/index.js +++ b/dist/setup/index.js @@ -105747,6 +105747,20 @@ async function run() { } versions.push(getVersionFromGlobalJson(globalJsonPath)); } + const versionFileInput = getInput('dotnet-version-file'); + if (versionFileInput) { + const versionFilePath = external_path_default().resolve(process.cwd(), versionFileInput); + if (!external_fs_namespaceObject.existsSync(versionFilePath)) { + throw new Error(`The specified dotnet-version-file '${versionFileInput}' does not exist`); + } + const versionFromFile = getVersionFromFile(versionFilePath); + if (versionFromFile) { + versions.push(versionFromFile); + } + else { + warning(`No .NET SDK version was found in '${versionFileInput}'. Make sure the file contains a 'dotnet' entry (.tool-versions) or an 'sdk.version' field (global.json).`); + } + } if (!versions.length) { // Try to fall back to global.json core_debug('No version found, trying to find version from global.json'); @@ -105799,7 +105813,7 @@ async function run() { if (sourceUrl) { configAuthentication(sourceUrl, configFile); } - outputInstalledVersion(installedDotnetVersions, globalJsonFileInput); + outputInstalledVersion(installedDotnetVersions, globalJsonFileInput || versionFileInput); if (getBooleanInput('cache') && isCacheFeatureAvailable()) { const cacheDependencyPath = getInput('cache-dependency-path'); await cache_restore_restoreCache(cacheDependencyPath); @@ -105862,7 +105876,29 @@ function getVersionFromGlobalJson(globalJsonPath) { } return version; } -function outputInstalledVersion(installedVersions, globalJsonFileInput) { +function getVersionFromToolVersions(toolVersionsPath) { + const content = external_fs_namespaceObject.readFileSync(toolVersionsPath, { encoding: 'utf8' }); + for (const line of content.split(/\r?\n/)) { + // A .tool-versions entry is ` `; lines starting with `#` are comments. + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) + continue; + const [tool, version] = trimmed.split(/\s+/); + if (tool === 'dotnet' && version) { + return version; + } + } + return ''; +} +function getVersionFromFile(versionFilePath) { + // Dispatch by file: a global.json is parsed as JSON, anything else + // (e.g. .tool-versions) is parsed in the asdf/mise ` ` format. + if (external_path_default().basename(versionFilePath).toLowerCase().endsWith('.json')) { + return getVersionFromGlobalJson(versionFilePath); + } + return getVersionFromToolVersions(versionFilePath); +} +function outputInstalledVersion(installedVersions, versionFileInput) { if (!installedVersions.length) { info(`The '${Outputs.DotnetVersion}' output will not be set.`); return; @@ -105871,8 +105907,8 @@ function outputInstalledVersion(installedVersions, globalJsonFileInput) { warning(`Failed to output the installed version of .NET. The '${Outputs.DotnetVersion}' output will not be set.`); return; } - if (globalJsonFileInput) { - const versionToOutput = installedVersions.at(-1); // .NET SDK version parsed from the global.json file is installed last + if (versionFileInput) { + const versionToOutput = installedVersions.at(-1); // .NET SDK version parsed from the version file is installed last setOutput(Outputs.DotnetVersion, versionToOutput); return; } diff --git a/src/setup-dotnet.ts b/src/setup-dotnet.ts index 70e5ab5e8..a6bb591aa 100644 --- a/src/setup-dotnet.ts +++ b/src/setup-dotnet.ts @@ -90,6 +90,24 @@ export async function run() { versions.push(getVersionFromGlobalJson(globalJsonPath)); } + const versionFileInput = core.getInput('dotnet-version-file'); + if (versionFileInput) { + const versionFilePath = path.resolve(process.cwd(), versionFileInput); + if (!fs.existsSync(versionFilePath)) { + throw new Error( + `The specified dotnet-version-file '${versionFileInput}' does not exist` + ); + } + const versionFromFile = getVersionFromFile(versionFilePath); + if (versionFromFile) { + versions.push(versionFromFile); + } else { + core.warning( + `No .NET SDK version was found in '${versionFileInput}'. Make sure the file contains a 'dotnet' entry (.tool-versions) or an 'sdk.version' field (global.json).` + ); + } + } + if (!versions.length) { // Try to fall back to global.json core.debug('No version found, trying to find version from global.json'); @@ -167,7 +185,10 @@ export async function run() { auth.configAuthentication(sourceUrl, configFile); } - outputInstalledVersion(installedDotnetVersions, globalJsonFileInput); + outputInstalledVersion( + installedDotnetVersions, + globalJsonFileInput || versionFileInput + ); if (core.getBooleanInput('cache') && isCacheFeatureAvailable()) { const cacheDependencyPath = core.getInput('cache-dependency-path'); @@ -249,9 +270,32 @@ function getVersionFromGlobalJson(globalJsonPath: string): string { return version; } +function getVersionFromToolVersions(toolVersionsPath: string): string { + const content = fs.readFileSync(toolVersionsPath, {encoding: 'utf8'}); + for (const line of content.split(/\r?\n/)) { + // A .tool-versions entry is ` `; lines starting with `#` are comments. + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const [tool, version] = trimmed.split(/\s+/); + if (tool === 'dotnet' && version) { + return version; + } + } + return ''; +} + +function getVersionFromFile(versionFilePath: string): string { + // Dispatch by file: a global.json is parsed as JSON, anything else + // (e.g. .tool-versions) is parsed in the asdf/mise ` ` format. + if (path.basename(versionFilePath).toLowerCase().endsWith('.json')) { + return getVersionFromGlobalJson(versionFilePath); + } + return getVersionFromToolVersions(versionFilePath); +} + function outputInstalledVersion( installedVersions: (string | null)[], - globalJsonFileInput: string + versionFileInput: string ): void { if (!installedVersions.length) { core.info(`The '${Outputs.DotnetVersion}' output will not be set.`); @@ -265,8 +309,8 @@ function outputInstalledVersion( return; } - if (globalJsonFileInput) { - const versionToOutput = installedVersions.at(-1); // .NET SDK version parsed from the global.json file is installed last + if (versionFileInput) { + const versionToOutput = installedVersions.at(-1); // .NET SDK version parsed from the version file is installed last core.setOutput(Outputs.DotnetVersion, versionToOutput); return; }