diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 98b0c18..20f4f0d 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -738,3 +738,34 @@ jobs: - name: Verify dotnet shell: pwsh run: __tests__/verify-dotnet.ps1 -Patterns "^9\.0\.1\d{2}" + + test-check-latest-false: + runs-on: ${{ matrix.operating-system }} + strategy: + fail-fast: false + matrix: + operating-system: [ubuntu-latest, windows-latest, macos-latest] + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - name: Clear toolcache + shell: pwsh + run: __tests__/clear-toolcache.ps1 ${{ runner.os }} + # Oldest 9.0.1xx SDK: resolving '9.0.1xx' online would return a newer patch. + - name: Setup dotnet 9.0.100 + uses: ./ + with: + dotnet-version: '9.0.100' + - name: Setup dotnet 9.0.1xx with check-latest false + id: reuse + uses: ./ + with: + dotnet-version: '9.0.1xx' + check-latest: false + - name: Verify dotnet + shell: pwsh + run: | + if ('${{ steps.reuse.outputs.dotnet-version }}' -ne '9.0.100') { + throw "Expected 9.0.100 to be reused, got '${{ steps.reuse.outputs.dotnet-version }}'." + } + __tests__/verify-dotnet.ps1 -Patterns "^9\.0\.100$" diff --git a/README.md b/README.md index bdd0d50..47667f9 100644 --- a/README.md +++ b/README.md @@ -323,6 +323,29 @@ steps: > **Note**: Ensure workloads are compatible with your runner's OS, architecture, and .NET SDK version before enabling workload installation. Some workloads may require additional installation time due to large toolchain downloads. +## Using the `check-latest` input + +By default (`check-latest: true`) the action resolves and installs the latest version matching the request online, which is the historical behavior. With `check-latest: false` it first looks for an SDK already installed under [`DOTNET_INSTALL_DIR`](#environment-variables) and reuses it, skipping SDK resolution and download — useful for air-gapped or preloaded runners (the `cache` and `workloads` inputs still use the network). If none matches, it installs online as usual. + +```yaml +steps: +- uses: actions/checkout@v7 +- name: Reuse a preinstalled SDK when available + uses: actions/setup-dotnet@v6 + with: + dotnet-version: '8.0.x' + check-latest: false +- run: dotnet build +``` + +An installed SDK is reused only when it matches the requested spec (`A.B.C`, `A.B`, `A.B.x`, `A.B.Cxx`, `A`, `A.x` or `latest`; `x`, `X` and `*` interchange except in a feature band, which is lowercase `Cxx`), satisfies `dotnet-quality`, is not older than the `sdk.version` declared in `global.json`, and sits next to a runnable `dotnet` executable. As online, `dotnet-quality` is ignored for a full `A.B.C` request, so such a request always resolves to a GA SDK. Cross-architecture requests, `latest` with an `LTS` or `STS` channel, and a bare wildcard always install online. + +When the request comes from `global.json`, the SDK is picked the way the .NET resolver picks one: `latestPatch`, `latestFeature`, `latestMinor` and `latestMajor` take the **highest** SDK the policy allows, while `feature`, `minor` and `major` roll forward to the **nearest** higher feature band, minor or major and then take its latest patch. `patch` — the default when `rollForward` is omitted — prefers the declared version, otherwise the latest patch in its band. `disable` and a prerelease `sdk.version` require an exact match, and `allowPrerelease` is not used: `dotnet-quality` is the only prerelease control. + +### Setting `check-latest` from the environment + +When the input is not set, the action reads the `DOTNET_CHECK_LATEST` environment variable (`true` or `false`, case-insensitive; an unsupported value warns and is ignored), which covers workflows you cannot edit. Setting it on a self-hosted runner alongside SDKs preinstalled under [`DOTNET_INSTALL_DIR`](#environment-variables) lets those workflows run without reaching `aka.ms`, `builds.dotnet.microsoft.com` or `ci.dot.net`. It applies to every job on that runner, so a dedicated runner is recommended; any workflow can still override it with an explicit `check-latest` input. + # Outputs and environment variables ## Outputs @@ -381,6 +404,7 @@ Some environment variables may be necessary for your particular case or to impro | **Env.variable** | **Description** | **Default value** | | ----------- | ----------- | ----------- | | DOTNET_INSTALL_DIR |Specifies a directory where .NET SDKs should be installed by the action.|*default value for each OS* | +| DOTNET_CHECK_LATEST |Fallback for the [`check-latest`](#setting-check-latest-from-the-environment) input, for workflows that cannot set it. Ignored when the input is set.|*true*| | DOTNET_NOLOGO |Removes logo and telemetry message from first run of dotnet cli|*false*| | DOTNET_CLI_TELEMETRY_OPTOUT |Opt-out of telemetry being sent to Microsoft|*false*| | DOTNET_MULTILEVEL_LOOKUP |Configures whether the global install location is used as a fall-back|*true*| diff --git a/__tests__/installer.test.ts b/__tests__/installer.test.ts index 141e072..734a9e7 100644 --- a/__tests__/installer.test.ts +++ b/__tests__/installer.test.ts @@ -1,5 +1,6 @@ import { afterAll, + afterEach, beforeAll, beforeEach, describe, @@ -34,10 +35,25 @@ jest.unstable_mockModule('@actions/io', () => ({ jest.unstable_mockModule('fs', () => { const actual = jest.requireActual('fs') as typeof import('fs'); const chmodSync = jest.fn(); + const readdirSync = jest.fn(); + const existsSync = jest.fn(() => true); + const statSync = jest.fn(() => ({isFile: () => true})); + const accessSync = jest.fn(); return { ...actual, chmodSync, - default: {...actual, chmodSync} + readdirSync, + existsSync, + statSync, + accessSync, + default: { + ...actual, + chmodSync, + readdirSync, + existsSync, + statSync, + accessSync + } }; }); @@ -514,6 +530,696 @@ describe('installer tests', () => { }); }); + describe('check-latest: false (local SDK reuse) tests', () => { + const readdirSyncSpy = fs.readdirSync as unknown as jest.Mock; + const statSyncSpy = fs.statSync as unknown as jest.Mock; + const accessSyncSpy = fs.accessSync as unknown as jest.Mock; + + const asFile = {isFile: () => true}; + const muxerName = IS_WINDOWS ? 'dotnet.exe' : 'dotnet'; + const isMuxer = (target: unknown) => String(target).endsWith(muxerName); + const throwEnoent = () => { + throw new Error('ENOENT'); + }; + + const makeDirents = (names: string[]): any => + names.map(name => ({ + name, + isDirectory: () => true, + isSymbolicLink: () => false + })); + + const makeSymlinks = (names: string[]): any => + names.map(name => ({ + name, + isDirectory: () => false, + isSymbolicLink: () => true + })); + + beforeEach(() => { + getExecOutputSpy.mockClear(); + getExecOutputSpy.mockImplementation(() => + Promise.resolve({ + exitCode: 0, + stdout: 'Fictitious dotnet version 1.2.3 is installed', + stderr: '' + }) + ); + statSyncSpy.mockReturnValue(asFile); + accessSyncSpy.mockImplementation(() => undefined); + }); + + afterEach(() => { + readdirSyncSpy.mockReset(); + statSyncSpy.mockReset(); + accessSyncSpy.mockReset(); + }); + + interface LocalReuseCase { + name: string; + version: string; + quality?: string; + dotnetChannel?: string; + minimumVersion?: string; + installed: string[]; + expected: string | null; + } + + const localReuseCases: LocalReuseCase[] = [ + { + name: 'reuses a locally installed pinned SDK and skips all install scripts', + version: '8.0.422', + installed: ['8.0.100', '8.0.422'], + expected: '8.0.422' + }, + { + name: 'falls back to online install when no local SDK matches the pinned request', + version: '8.0.422', + installed: ['8.0.100'], + expected: null + }, + { + name: 'reuses the highest matching patch for a floating A.B.x request', + version: '8.0.x', + installed: ['8.0.100', '8.0.412', '8.0.205'], + expected: '8.0.412' + }, + { + name: 'matches the correct feature band for an A.B.Cxx request', + version: '8.0.1xx', + installed: ['8.0.100', '8.0.105', '8.0.203'], + expected: '8.0.105' + }, + { + name: 'picks the highest installed SDK for a channel-less latest request', + version: 'latest', + installed: ['8.0.412', '9.0.101'], + expected: '9.0.101' + }, + { + name: 'ignores prerelease SDKs when quality is not preview/daily', + version: '8.0.x', + installed: ['8.0.100-preview.1', '8.0.100'], + expected: '8.0.100' + }, + { + name: 'reuses the highest installed SDK for a major-only request', + version: '8', + installed: ['8.0.100', '8.0.412', '8.0.205'], + expected: '8.0.412' + }, + { + name: 'reuses a prerelease SDK when quality is preview', + version: '8.0.x', + quality: 'preview', + installed: ['8.0.100-preview.1', '8.0.100-preview.2'], + expected: '8.0.100-preview.2' + }, + { + name: 'falls back to online install when no local SDK matches a floating request', + version: '9.0.x', + installed: ['8.0.412'], + expected: null + }, + { + name: 'installs online for a latest request with an LTS channel', + version: 'latest', + dotnetChannel: 'LTS', + installed: ['8.0.412', '9.0.101'], + expected: null + }, + { + name: 'reuses the SDK of the requested channel for a latest request', + version: 'latest', + dotnetChannel: '8.0', + installed: ['8.0.412', '9.0.101'], + expected: '8.0.412' + }, + { + name: 'installs online for a wildcard request instead of guessing', + version: 'x', + installed: ['8.0.412', '9.0.101'], + expected: null + }, + { + name: 'does not reuse a local SDK below the global.json minimum version', + version: '8.0', + minimumVersion: '8.0.400', + installed: ['8.0.100', '8.0.205'], + expected: null + }, + { + name: 'reuses a local SDK at or above the global.json minimum version', + version: '8.0', + minimumVersion: '8.0.400', + installed: ['8.0.100', '8.0.412'], + expected: '8.0.412' + }, + { + name: 'installs online when quality is preview but only GA SDKs are local', + version: '8.0.x', + quality: 'preview', + installed: ['8.0.412'], + expected: null + }, + { + name: 'ignores the quality input for majors below 6 when matching locally', + version: '3.1', + quality: 'preview', + installed: ['3.1.426', '3.1.500-preview.1'], + expected: '3.1.426' + } + ]; + + each(localReuseCases).it( + '$name', + async ({ + version, + quality = '', + dotnetChannel, + minimumVersion, + installed, + expected + }: LocalReuseCase) => { + readdirSyncSpy.mockReturnValue(makeDirents(installed)); + maxSatisfyingSpy.mockImplementation(() => '8.0.412'); + + const installedVersion = await new installer.DotnetCoreInstaller( + version, + quality as any, + undefined, + dotnetChannel, + false, + minimumVersion + ).installDotnet(); + + if (expected === null) { + expect(getExecOutputSpy).toHaveBeenCalledTimes(2); + } else { + expect(installedVersion).toBe(expected); + expect(getExecOutputSpy).not.toHaveBeenCalled(); + } + } + ); + + it('rejects an A.B.Cxx request below .NET 5 instead of reusing a local SDK', async () => { + readdirSyncSpy.mockReturnValue(makeDirents(['3.1.100'])); + + const dotnetInstaller = new installer.DotnetCoreInstaller( + '3.1.1xx', + '', + undefined, + undefined, + false + ); + + await expect(dotnetInstaller.installDotnet()).rejects.toThrow( + `The 'dotnet-version' was supplied in invalid format: 3.1.1xx! The A.B.Cxx syntax is available since the .NET 5.0 release.` + ); + expect(getExecOutputSpy).not.toHaveBeenCalled(); + }); + + each(['preview', 'daily'] as const).it( + "reuses the highest local prerelease SDK for a channel-less latest request with '%s' quality", + async quality => { + readdirSyncSpy.mockReturnValue( + makeDirents(['8.0.412', '9.0.100-preview.1', '9.0.100-preview.2']) + ); + + const dotnetInstaller = new installer.DotnetCoreInstaller( + 'latest', + quality, + undefined, + undefined, + false + ); + const installedVersion = await dotnetInstaller.installDotnet(); + + expect(installedVersion).toBe('9.0.100-preview.2'); + expect(getExecOutputSpy).not.toHaveBeenCalled(); + } + ); + + it('does NOT reuse a local SDK for a cross-architecture request', async () => { + readdirSyncSpy.mockReturnValue(makeDirents(['8.0.412'])); + maxSatisfyingSpy.mockImplementation(() => '8.0.412'); + const archSpy = jest.spyOn(os, 'arch').mockReturnValue('arm64'); + + const dotnetInstaller = new installer.DotnetCoreInstaller( + '8.0.x', + '', + 'x64', + undefined, + false + ); + await dotnetInstaller.installDotnet(); + + expect(getExecOutputSpy).toHaveBeenCalledTimes(2); + archSpy.mockRestore(); + }); + + it('installs online when check-latest defaults to true even if SDK is local', async () => { + readdirSyncSpy.mockReturnValue(makeDirents(['8.0.422'])); + maxSatisfyingSpy.mockImplementation(() => '8.0.422'); + + const dotnetInstaller = new installer.DotnetCoreInstaller( + '8.0.422', + '' + ); + await dotnetInstaller.installDotnet(); + + expect(getExecOutputSpy).toHaveBeenCalledTimes(2); + }); + + it('installs online when the dotnet muxer is missing', async () => { + readdirSyncSpy.mockReturnValue(makeDirents(['8.0.412'])); + statSyncSpy.mockImplementation((target: string) => { + if (isMuxer(target)) { + throwEnoent(); + } + return asFile; + }); + maxSatisfyingSpy.mockImplementation(() => '8.0.412'); + + const dotnetInstaller = new installer.DotnetCoreInstaller( + '8.0.x', + '', + undefined, + undefined, + false + ); + await dotnetInstaller.installDotnet(); + + expect(getExecOutputSpy).toHaveBeenCalledTimes(2); + }); + + const itOnPosix = IS_WINDOWS ? it.skip : it; + + itOnPosix( + 'installs online when the dotnet muxer is not executable', + async () => { + readdirSyncSpy.mockReturnValue(makeDirents(['8.0.412'])); + accessSyncSpy.mockImplementation((target: string) => { + if (isMuxer(target)) { + throw new Error('EACCES'); + } + }); + maxSatisfyingSpy.mockImplementation(() => '8.0.412'); + + const dotnetInstaller = new installer.DotnetCoreInstaller( + '8.0.x', + '', + undefined, + undefined, + false + ); + await dotnetInstaller.installDotnet(); + + expect(getExecOutputSpy).toHaveBeenCalledTimes(2); + } + ); + + it('installs online when the dotnet muxer path is a directory', async () => { + readdirSyncSpy.mockReturnValue(makeDirents(['8.0.412'])); + statSyncSpy.mockImplementation((target: string) => + isMuxer(target) ? {isFile: () => false} : asFile + ); + maxSatisfyingSpy.mockImplementation(() => '8.0.412'); + + const dotnetInstaller = new installer.DotnetCoreInstaller( + '8.0.x', + '', + undefined, + undefined, + false + ); + await dotnetInstaller.installDotnet(); + + expect(getExecOutputSpy).toHaveBeenCalledTimes(2); + }); + + it('reuses an SDK exposed as a symbolic link', async () => { + readdirSyncSpy.mockReturnValue(makeSymlinks(['8.0.412'])); + + const dotnetInstaller = new installer.DotnetCoreInstaller( + '8.0.x', + '', + undefined, + undefined, + false + ); + const installedVersion = await dotnetInstaller.installDotnet(); + + expect(installedVersion).toBe('8.0.412'); + expect(getExecOutputSpy).not.toHaveBeenCalled(); + }); + + it('matches the online channel mapping for legacy major-only requests', async () => { + readdirSyncSpy.mockReturnValue(makeDirents(['3.0.103', '3.1.426'])); + + const dotnetInstaller = new installer.DotnetCoreInstaller( + '3', + '', + undefined, + undefined, + false + ); + const installedVersion = await dotnetInstaller.installDotnet(); + + expect(installedVersion).toBe('3.1.426'); + expect(getExecOutputSpy).not.toHaveBeenCalled(); + }); + + each(['8.0.X', '8.0.x', '8.0.*', '8.0']).it( + 'reuses a local SDK for the floating request %s', + async (version: string) => { + readdirSyncSpy.mockReturnValue(makeDirents(['8.0.100', '8.0.412'])); + + const dotnetInstaller = new installer.DotnetCoreInstaller( + version, + '', + undefined, + undefined, + false + ); + const installedVersion = await dotnetInstaller.installDotnet(); + + expect(installedVersion).toBe('8.0.412'); + expect(getExecOutputSpy).not.toHaveBeenCalled(); + } + ); + + each(['8.X', '8.x', '8.*', '8']).it( + 'reuses a local SDK for the major-only request %s', + async (version: string) => { + readdirSyncSpy.mockReturnValue(makeDirents(['8.0.100', '8.0.412'])); + + const dotnetInstaller = new installer.DotnetCoreInstaller( + version, + '', + undefined, + undefined, + false + ); + const installedVersion = await dotnetInstaller.installDotnet(); + + expect(installedVersion).toBe('8.0.412'); + expect(getExecOutputSpy).not.toHaveBeenCalled(); + } + ); + + it('does not reuse a local SDK for an uppercase feature band request', async () => { + readdirSyncSpy.mockReturnValue(makeDirents(['8.0.105'])); + + const dotnetInstaller = new installer.DotnetCoreInstaller( + '8.0.1XX', + '', + undefined, + undefined, + false + ); + + await expect(dotnetInstaller.installDotnet()).rejects.toThrow( + `The 'dotnet-version' was supplied in invalid format: 8.0.1XX!` + ); + }); + + each(['08.0.x', '8.00.x', '08']).it( + 'does not reuse a local SDK for the invalid request %s', + async (version: string) => { + readdirSyncSpy.mockReturnValue(makeDirents(['8.0.412'])); + + const dotnetInstaller = new installer.DotnetCoreInstaller( + version, + '', + undefined, + undefined, + false + ); + + await expect(dotnetInstaller.installDotnet()).rejects.toThrow( + `The 'dotnet-version' was supplied in invalid format: ${version}!` + ); + } + ); + + it('ignores an sdk folder that does not contain an SDK', async () => { + readdirSyncSpy.mockReturnValue(makeDirents(['8.0.412'])); + statSyncSpy.mockImplementation((target: string) => { + if (String(target).includes('dotnet.dll')) { + throwEnoent(); + } + return asFile; + }); + maxSatisfyingSpy.mockImplementation(() => '8.0.412'); + + const dotnetInstaller = new installer.DotnetCoreInstaller( + '8.0.x', + '', + undefined, + undefined, + false + ); + await dotnetInstaller.installDotnet(); + + expect(getExecOutputSpy).toHaveBeenCalledTimes(2); + }); + + describe('global.json rollForward tests', () => { + interface RollForwardCase { + name: string; + version?: string; + quality?: string; + minimumVersion?: string; + rollForward?: string; + installed: string[]; + expected: string | null; + } + + const rollForwardCases: RollForwardCase[] = [ + { + name: 'latestMajor reuses the highest local SDK across majors', + minimumVersion: '8.0.100', + rollForward: 'latestMajor', + installed: ['8.0.412', '9.0.101'], + expected: '9.0.101' + }, + { + name: 'latestMajor ignores local SDKs below the declared version', + minimumVersion: '8.0.412', + rollForward: 'latestMajor', + installed: ['8.0.100', '7.0.400'], + expected: null + }, + { + name: 'latestMajor keeps requiring GA builds when quality is preview', + quality: 'preview', + minimumVersion: '8.0.100', + rollForward: 'latestMajor', + installed: ['9.0.200-preview.1'], + expected: null + }, + { + name: 'latestMinor reuses a higher local minor of the same major', + version: '8', + minimumVersion: '8.1.100', + rollForward: 'latestMinor', + installed: ['8.1.200', '8.2.300'], + expected: '8.2.300' + }, + { + name: 'latestMinor does not cross into another major', + version: '8', + minimumVersion: '8.1.100', + rollForward: 'latestMinor', + installed: ['9.0.101'], + expected: null + }, + { + name: 'latestMinor ignores local SDKs below the declared version', + version: '8', + minimumVersion: '8.1.100', + rollForward: 'latestMinor', + installed: ['8.0.412'], + expected: null + }, + { + name: 'latestFeature takes the highest band of the declared major.minor', + version: '8.0', + minimumVersion: '8.0.100', + rollForward: 'latestFeature', + installed: ['8.0.105', '8.0.400'], + expected: '8.0.400' + }, + { + name: 'latestPatch stays inside the declared feature band', + version: '8.0.2xx', + minimumVersion: '8.0.200', + rollForward: 'latestPatch', + installed: ['8.0.205', '8.0.412'], + expected: '8.0.205' + }, + { + name: 'latestPatch takes the highest patch even when the declared version is installed', + version: '8.0.1xx', + minimumVersion: '8.0.100', + rollForward: 'latestPatch', + installed: ['8.0.100', '8.0.105'], + expected: '8.0.105' + }, + { + name: 'patch prefers the exact declared version', + version: '8.0.100', + minimumVersion: '8.0.100', + rollForward: 'patch', + installed: ['8.0.100', '8.0.105'], + expected: '8.0.100' + }, + { + name: 'patch stays inside the declared feature band', + version: '8.0.100', + minimumVersion: '8.0.100', + rollForward: 'patch', + installed: ['8.0.105', '8.0.200'], + expected: '8.0.105' + }, + { + name: 'feature prefers the declared band over a higher band', + version: '8.0.100', + minimumVersion: '8.0.100', + rollForward: 'feature', + installed: ['8.0.105', '8.0.400'], + expected: '8.0.105' + }, + { + name: 'feature rolls to the next higher band, not the highest', + version: '8.0.100', + minimumVersion: '8.0.100', + rollForward: 'feature', + installed: ['8.0.200', '8.0.300', '8.0.400'], + expected: '8.0.200' + }, + { + name: 'feature takes the latest patch of the selected band', + version: '8.0.100', + minimumVersion: '8.0.100', + rollForward: 'feature', + installed: ['8.0.201', '8.0.205', '8.0.400'], + expected: '8.0.205' + }, + { + name: 'feature does not cross into another minor', + version: '8.0.100', + minimumVersion: '8.0.100', + rollForward: 'feature', + installed: ['8.1.100'], + expected: null + }, + { + name: 'minor prefers a higher band of the declared minor over a higher minor', + version: '8.0.100', + minimumVersion: '8.0.100', + rollForward: 'minor', + installed: ['8.0.400', '8.1.100'], + expected: '8.0.400' + }, + { + name: 'minor rolls to the next higher minor, not the highest', + version: '8.0.100', + minimumVersion: '8.0.100', + rollForward: 'minor', + installed: ['8.1.100', '8.2.100'], + expected: '8.1.100' + }, + { + name: 'major prefers the declared major over a higher major', + version: '8.0.100', + minimumVersion: '8.0.100', + rollForward: 'major', + installed: ['8.0.400', '9.0.100'], + expected: '8.0.400' + }, + { + name: 'major rolls to the next higher major, not the highest', + version: '8.0.100', + minimumVersion: '8.0.100', + rollForward: 'major', + installed: ['9.0.100', '10.0.100'], + expected: '9.0.100' + }, + { + name: 'non-latest policies ignore dotnet-quality, as the online path does', + version: '8.0.100', + quality: 'preview', + minimumVersion: '8.0.100', + rollForward: 'patch', + installed: ['8.0.105'], + expected: '8.0.105' + }, + { + name: 'non-latest policies never prefer a prerelease over a GA build', + version: '8.0.100', + quality: 'preview', + minimumVersion: '8.0.100', + rollForward: 'major', + installed: ['8.0.100', '9.0.100-rc.2'], + expected: '8.0.100' + }, + { + name: 'roll-forward policies never select below the declared version', + version: '8.0.200', + minimumVersion: '8.0.200', + rollForward: 'major', + installed: ['8.0.100'], + expected: null + }, + { + name: 'disable requires the exact declared version', + version: '8.0.100', + installed: ['8.0.200'], + expected: null + }, + { + name: 'installs online for an empty version without a rollForward floor', + installed: ['8.0.412'], + expected: null + } + ]; + + each(rollForwardCases).it( + '$name', + async ({ + version = '', + quality = '', + minimumVersion, + rollForward, + installed, + expected + }: RollForwardCase) => { + readdirSyncSpy.mockReturnValue(makeDirents(installed)); + maxSatisfyingSpy.mockImplementation(() => '8.0.412'); + + const installedVersion = await new installer.DotnetCoreInstaller( + version, + quality as any, + undefined, + undefined, + false, + minimumVersion, + rollForward + ).installDotnet(); + + if (expected === null) { + expect(getExecOutputSpy).toHaveBeenCalledTimes(2); + } else { + expect(installedVersion).toBe(expected); + expect(getExecOutputSpy).not.toHaveBeenCalled(); + } + } + ); + }); + }); + describe('addToPath() tests', () => { it(`should export DOTNET_ROOT env.var with value from DOTNET_INSTALL_DIR env.var`, async () => { process.env['DOTNET_INSTALL_DIR'] = 'fictitious/dotnet/install/dir'; diff --git a/__tests__/setup-dotnet.test.ts b/__tests__/setup-dotnet.test.ts index e159b48..1e1f149 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'); @@ -85,11 +86,17 @@ describe('setup-dotnet tests', () => { DotnetInstallDir.addToPath = jest.fn(); getMultilineInputSpy.mockImplementation(input => inputs[input as string]); getInputSpy.mockImplementation(input => inputs[input as string]); - getBooleanInputSpy.mockImplementation(input => inputs[input as string]); + getBooleanInputSpy.mockImplementation(input => { + const value = inputs[input as string]; + return typeof value === 'string' + ? value.toLowerCase() === 'true' + : !!value; + }); }); afterEach(() => { DotnetInstallDir.addToPath = addToPathOriginal; + delete process.env['DOTNET_CHECK_LATEST']; jest.clearAllMocks(); jest.resetAllMocks(); }); @@ -289,6 +296,77 @@ describe('setup-dotnet tests', () => { expect(DotnetInstallDir.addToPath).toHaveBeenCalledTimes(1); }); + it("should read the 'check-latest' input and pass it to DotnetCoreInstaller", async () => { + inputs['dotnet-version'] = ['10.0.101']; + inputs['dotnet-quality'] = ''; + inputs['architecture'] = ''; + inputs['check-latest'] = 'false'; + + let capturedCheckLatest: boolean | undefined; + installDotnetSpy.mockImplementation(function (this: any) { + capturedCheckLatest = this.checkLatest; + return Promise.resolve(''); + }); + + await setup.run(); + + expect(getBooleanInputSpy).toHaveBeenCalledWith('check-latest'); + expect(installDotnetSpy).toHaveBeenCalledTimes(1); + expect(capturedCheckLatest).toBe(false); + }); + + describe("'check-latest' resolution", () => { + const captureCheckLatest = async () => { + inputs['dotnet-version'] = ['10.0.101']; + inputs['dotnet-quality'] = ''; + inputs['architecture'] = ''; + + let capturedCheckLatest: boolean | undefined; + installDotnetSpy.mockImplementation(function (this: any) { + capturedCheckLatest = this.checkLatest; + return Promise.resolve(''); + }); + + await setup.run(); + return capturedCheckLatest; + }; + + it('defaults to true when neither the input nor the environment variable is set', async () => { + inputs['check-latest'] = ''; + + expect(await captureCheckLatest()).toBe(true); + expect(getBooleanInputSpy).not.toHaveBeenCalledWith('check-latest'); + }); + + it.each(['false', 'FALSE', 'False'])( + "reads 'check-latest' from the DOTNET_CHECK_LATEST environment variable ('%s')", + async envValue => { + inputs['check-latest'] = ''; + process.env['DOTNET_CHECK_LATEST'] = envValue; + + expect(await captureCheckLatest()).toBe(false); + } + ); + + it('lets an explicit input win over the environment variable', async () => { + inputs['check-latest'] = 'true'; + process.env['DOTNET_CHECK_LATEST'] = 'false'; + + expect(await captureCheckLatest()).toBe(true); + }); + + it('warns and falls back to true when the environment variable is not a boolean', async () => { + inputs['check-latest'] = ''; + process.env['DOTNET_CHECK_LATEST'] = 'yes'; + + expect(await captureCheckLatest()).toBe(true); + expect(warningSpy).toHaveBeenCalledWith( + `Value 'yes' is not supported for the DOTNET_CHECK_LATEST environment variable. Supported values are: true, false. The 'check-latest' option falls back to 'true'.` + ); + expect(setFailedSpy).not.toHaveBeenCalled(); + }); + }); + it('should fail the action if unsupported architecture is provided', async () => { inputs['dotnet-version'] = ['10.0.101']; inputs['dotnet-quality'] = ''; @@ -389,5 +467,241 @@ describe('setup-dotnet tests', () => { `The 'dotnet-channel' input is only supported when 'dotnet-version' is set to 'latest'.` ); }); + + it('should pass the global.json rollForward minimum version to DotnetCoreInstaller', async () => { + inputs['dotnet-version'] = []; + inputs['dotnet-quality'] = ''; + inputs['dotnet-channel'] = ''; + inputs['architecture'] = ''; + inputs['check-latest'] = 'false'; + inputs['global-json-file'] = 'global.json'; + + existsSyncSpy.mockReturnValue(true); + readFileSyncSpy.mockReturnValue( + JSON.stringify({ + sdk: {version: '8.0.400', rollForward: 'latestFeature'} + }) + ); + + let capturedVersion: string | undefined; + let capturedMinimumVersion: string | undefined; + installDotnetSpy.mockImplementation(function (this: any) { + capturedVersion = this.version; + capturedMinimumVersion = this.minimumVersion; + return Promise.resolve('8.0.412'); + }); + + await setup.run(); + inputs['global-json-file'] = ''; + + expect(capturedVersion).toBe('8.0'); + expect(capturedMinimumVersion).toBe('8.0.400'); + }); + + it('should pass non-latest rollForward policies to DotnetCoreInstaller', async () => { + inputs['dotnet-version'] = []; + inputs['dotnet-quality'] = ''; + inputs['dotnet-channel'] = ''; + inputs['architecture'] = ''; + inputs['check-latest'] = 'false'; + inputs['global-json-file'] = 'global.json'; + + existsSyncSpy.mockReturnValue(true); + readFileSyncSpy.mockReturnValue( + JSON.stringify({sdk: {version: '8.0.100', rollForward: 'feature'}}) + ); + + let capturedVersion: string | undefined; + let capturedMinimumVersion: string | undefined; + let capturedRollForward: string | undefined; + installDotnetSpy.mockImplementation(function (this: any) { + capturedVersion = this.version; + capturedMinimumVersion = this.minimumVersion; + capturedRollForward = this.rollForward; + return Promise.resolve('8.0.200'); + }); + + await setup.run(); + inputs['global-json-file'] = ''; + + expect(capturedVersion).toBe('8.0.100'); + expect(capturedMinimumVersion).toBe('8.0.100'); + expect(capturedRollForward).toBe('feature'); + }); + + it('should default an omitted rollForward to patch', async () => { + inputs['dotnet-version'] = []; + inputs['dotnet-quality'] = ''; + inputs['dotnet-channel'] = ''; + inputs['architecture'] = ''; + inputs['check-latest'] = 'false'; + inputs['global-json-file'] = 'global.json'; + + existsSyncSpy.mockReturnValue(true); + readFileSyncSpy.mockReturnValue( + JSON.stringify({sdk: {version: '8.0.100'}}) + ); + + let capturedVersion: string | undefined; + let capturedMinimumVersion: string | undefined; + let capturedRollForward: string | undefined; + installDotnetSpy.mockImplementation(function (this: any) { + capturedVersion = this.version; + capturedMinimumVersion = this.minimumVersion; + capturedRollForward = this.rollForward; + return Promise.resolve('8.0.100'); + }); + + await setup.run(); + inputs['global-json-file'] = ''; + + expect(capturedVersion).toBe('8.0.100'); + expect(capturedMinimumVersion).toBe('8.0.100'); + expect(capturedRollForward).toBe('patch'); + }); + + it('should not default to patch for a prerelease sdk version', async () => { + inputs['dotnet-version'] = []; + inputs['dotnet-quality'] = ''; + inputs['dotnet-channel'] = ''; + inputs['architecture'] = ''; + inputs['check-latest'] = 'false'; + inputs['global-json-file'] = 'global.json'; + + existsSyncSpy.mockReturnValue(true); + readFileSyncSpy.mockReturnValue( + JSON.stringify({sdk: {version: '8.0.100-preview.1'}}) + ); + + let capturedMinimumVersion: string | undefined; + let capturedRollForward: string | undefined; + installDotnetSpy.mockImplementation(function (this: any) { + capturedMinimumVersion = this.minimumVersion; + capturedRollForward = this.rollForward; + return Promise.resolve('8.0.100-preview.1'); + }); + + await setup.run(); + inputs['global-json-file'] = ''; + + expect(capturedMinimumVersion).toBeUndefined(); + expect(capturedRollForward).toBeUndefined(); + }); + + it('should not default to patch for a version that is not a full SDK version', async () => { + inputs['dotnet-version'] = []; + inputs['dotnet-quality'] = ''; + inputs['dotnet-channel'] = ''; + inputs['architecture'] = ''; + inputs['check-latest'] = 'false'; + inputs['global-json-file'] = 'global.json'; + + existsSyncSpy.mockReturnValue(true); + readFileSyncSpy.mockReturnValue( + JSON.stringify({sdk: {version: '8.0.0'}}) + ); + + let capturedMinimumVersion: string | undefined; + let capturedRollForward: string | undefined; + installDotnetSpy.mockImplementation(function (this: any) { + capturedMinimumVersion = this.minimumVersion; + capturedRollForward = this.rollForward; + return Promise.resolve('8.0.0'); + }); + + await setup.run(); + inputs['global-json-file'] = ''; + + expect(capturedMinimumVersion).toBeUndefined(); + expect(capturedRollForward).toBeUndefined(); + }); + + it('should not roll forward a version pinned by the dotnet-version input', async () => { + inputs['dotnet-version'] = ['8.0.100']; + inputs['dotnet-quality'] = ''; + inputs['dotnet-channel'] = ''; + inputs['architecture'] = ''; + inputs['check-latest'] = 'false'; + inputs['global-json-file'] = 'global.json'; + + existsSyncSpy.mockReturnValue(true); + readFileSyncSpy.mockReturnValue( + JSON.stringify({sdk: {version: '8.0.100'}}) + ); + + let capturedMinimumVersion: string | undefined; + let capturedRollForward: string | undefined; + installDotnetSpy.mockImplementation(function (this: any) { + capturedMinimumVersion = this.minimumVersion; + capturedRollForward = this.rollForward; + return Promise.resolve('8.0.100'); + }); + + await setup.run(); + inputs['global-json-file'] = ''; + + expect(installDotnetSpy).toHaveBeenCalledTimes(1); + expect(capturedMinimumVersion).toBeUndefined(); + expect(capturedRollForward).toBeUndefined(); + }); + + it('should keep the global.json floor when a floating dotnet-version matches', async () => { + inputs['dotnet-version'] = ['8.0']; + inputs['dotnet-quality'] = ''; + inputs['dotnet-channel'] = ''; + inputs['architecture'] = ''; + inputs['check-latest'] = 'false'; + inputs['global-json-file'] = 'global.json'; + + existsSyncSpy.mockReturnValue(true); + readFileSyncSpy.mockReturnValue( + JSON.stringify({ + sdk: {version: '8.0.400', rollForward: 'latestFeature'} + }) + ); + + let capturedMinimumVersion: string | undefined; + let capturedRollForward: string | undefined; + installDotnetSpy.mockImplementation(function (this: any) { + capturedMinimumVersion = this.minimumVersion; + capturedRollForward = this.rollForward; + return Promise.resolve('8.0.400'); + }); + + await setup.run(); + inputs['global-json-file'] = ''; + + expect(installDotnetSpy).toHaveBeenCalledTimes(1); + expect(capturedMinimumVersion).toBe('8.0.400'); + expect(capturedRollForward).toBe('latestFeature'); + }); + + it('should not set a rollForward policy for disable', async () => { + inputs['dotnet-version'] = []; + inputs['dotnet-quality'] = ''; + inputs['dotnet-channel'] = ''; + inputs['architecture'] = ''; + inputs['check-latest'] = 'false'; + inputs['global-json-file'] = 'global.json'; + + existsSyncSpy.mockReturnValue(true); + readFileSyncSpy.mockReturnValue( + JSON.stringify({sdk: {version: '8.0.100', rollForward: 'disable'}}) + ); + + let capturedMinimumVersion: string | undefined; + let capturedRollForward: string | undefined; + installDotnetSpy.mockImplementation(function (this: any) { + capturedMinimumVersion = this.minimumVersion; + capturedRollForward = this.rollForward; + return Promise.resolve('8.0.100'); + }); + + await setup.run(); + inputs['global-json-file'] = ''; + + expect(capturedMinimumVersion).toBeUndefined(); + expect(capturedRollForward).toBeUndefined(); + }); }); }); diff --git a/action.yml b/action.yml index e2ac19d..191393b 100644 --- a/action.yml +++ b/action.yml @@ -39,6 +39,9 @@ inputs: architecture: description: 'Optional architecture for the .NET install. Supported values: x64, x86, arm64, amd64, arm, s390x, ppc64le, riscv64. If not set, the installer auto-detects the current system architecture.' required: false + check-latest: + description: 'Optional, defaults to true. When false, a locally installed SDK that satisfies the requested version is reused without any network calls, otherwise the latest matching version is installed online. Cross-architecture requests always install online. Falls back to the DOTNET_CHECK_LATEST environment variable when this input is not set.' + required: false outputs: cache-hit: description: 'A boolean value to indicate if a cache was hit.' diff --git a/dist/setup/index.js b/dist/setup/index.js index 56b1747..2e18523 100644 --- a/dist/setup/index.js +++ b/dist/setup/index.js @@ -45084,6 +45084,20 @@ const PLATFORM = (() => { const QUALITY_INPUT_MINIMAL_MAJOR_TAG = 6; const LATEST_PATCH_SYNTAX_MINIMAL_MAJOR_TAG = 5; +function channelForMajor(major) { + // Starting with .NET 5, the minor version is always zero. + // Hardcode the earlier versions because they will not get new releases. + switch (major) { + case '1': + return '1.1'; + case '2': + return '2.2'; + case '3': + return '3.1'; + default: + return `${major}.0`; + } +} class DotnetVersionResolver { quality; dotnetChannel; @@ -45163,22 +45177,7 @@ class DotnetVersionResolver { this.resolvedArgument.value = `${major}.${minor}`; } else if (this.isNumericTag(major)) { - // Starting with .NET 5, the minor version is always zero. - // Hardcode the earlier versions because they will not get new releases. - switch (major) { - case '1': - this.resolvedArgument.value = '1.1'; - break; - case '2': - this.resolvedArgument.value = '2.2'; - break; - case '3': - this.resolvedArgument.value = '3.1'; - break; - default: - this.resolvedArgument.value = `${major}.0`; - break; - } + this.resolvedArgument.value = channelForMajor(major); } else { // If "dotnet-version" is specified as *, x or X resolve latest version of .NET explicitly from LTS channel. The version argument will default to "latest" by install-dotnet script. @@ -45352,25 +45351,240 @@ function normalizeArch(arch) { return arch.toLowerCase(); } } +function isFile(filePath) { + try { + return (0,external_fs_namespaceObject.statSync)(filePath).isFile(); + } + catch { + return false; + } +} +function isExecutableFile(filePath) { + if (!isFile(filePath)) { + return false; + } + if (utils_IS_WINDOWS) { + return true; + } + try { + (0,external_fs_namespaceObject.accessSync)(filePath, external_fs_namespaceObject.constants.X_OK); + return true; + } + catch { + return false; + } +} class DotnetCoreInstaller { version; quality; architecture; dotnetChannel; + checkLatest; + minimumVersion; + rollForward; + static FeatureBandSyntax = /^(\d+)\.(\d+)\.(\d)xx$/; static { DotnetInstallDir.setEnvironmentVariable(); } - constructor(version, quality, architecture, dotnetChannel) { + constructor(version, quality, architecture, dotnetChannel, checkLatest = true, minimumVersion, rollForward) { this.version = version; this.quality = quality; this.architecture = architecture; this.dotnetChannel = dotnetChannel; + this.checkLatest = checkLatest; + this.minimumVersion = minimumVersion; + this.rollForward = rollForward; + this.version = version.trim(); + } + getInstalledSdkVersions() { + const sdkDir = external_path_default().join(DotnetInstallDir.dirPath, 'sdk'); + try { + const versions = (0,external_fs_namespaceObject.readdirSync)(sdkDir, { withFileTypes: true }) + .filter(entry => entry.isDirectory() || entry.isSymbolicLink()) + .map(entry => entry.name) + .filter(name => semver_default().valid(name) !== null) + .filter(name => isFile(external_path_default().join(sdkDir, name, 'dotnet.dll'))); + core_debug(`Locally installed .NET SDKs in '${sdkDir}': ${versions.join(', ') || ''}`); + return versions; + } + catch { + core_debug(`Unable to read the SDK directory '${sdkDir}'.`); + return []; + } + } + hasDotnetMuxer() { + return isExecutableFile(external_path_default().join(DotnetInstallDir.dirPath, utils_IS_WINDOWS ? 'dotnet.exe' : 'dotnet')); + } + qualityApplies() { + if (this.version.toLowerCase() === 'latest') { + const major = (this.dotnetChannel || '').trim().match(/^(\d+)/)?.[1]; + return major ? Number(major) >= QUALITY_INPUT_MINIMAL_MAJOR_TAG : true; + } + if (semver_default().valid(this.version)) { + return false; + } + const major = this.version.match(/^(\d+)/)?.[1]; + return major ? Number(major) >= QUALITY_INPUT_MINIMAL_MAJOR_TAG : false; + } + findByScope(candidates, major, minor, band) { + return (candidates.find(version => { + const parsed = semver_default().parse(version); + return (parsed && + parsed.major === Number(major) && + parsed.minor === Number(minor) && + (band === undefined || + Math.floor(parsed.patch / 100) === Number(band))); + }) ?? null); + } + static isInRollForwardScope(version, policy, declared) { + const parsed = semver_default().parse(version); + if (!parsed) { + return false; + } + switch (policy) { + case 'patch': + case 'latestPatch': + return (parsed.major === declared.major && + parsed.minor === declared.minor && + Math.floor(parsed.patch / 100) === Math.floor(declared.patch / 100)); + case 'feature': + case 'latestFeature': + return (parsed.major === declared.major && parsed.minor === declared.minor); + case 'minor': + case 'latestMinor': + return parsed.major === declared.major; + case 'major': + case 'latestMajor': + return true; + default: + return false; + } + } + static highestVersion(versions) { + return versions.reduce((best, version) => semver_default().gt(version, best) ? version : best); + } + static nearestBandVersion(versions) { + return versions.reduce((best, version) => { + const delta = semver_default().major(version) - semver_default().major(best) || + semver_default().minor(version) - semver_default().minor(best) || + Math.floor(semver_default().patch(version) / 100) - + Math.floor(semver_default().patch(best) / 100); + return delta < 0 || (delta === 0 && semver_default().gt(version, best)) + ? version + : best; + }); + } + findByRollForward(candidates, policy, declaredVersion) { + const declared = semver_default().parse(declaredVersion); + if (!declared) { + return null; + } + const scoped = candidates.filter(version => DotnetCoreInstaller.isInRollForwardScope(version, policy, declared)); + if (!scoped.length) { + return null; + } + switch (policy) { + case 'patch': + return (scoped.find(version => semver_default().eq(version, declared)) ?? + DotnetCoreInstaller.highestVersion(scoped)); + case 'feature': + case 'minor': + case 'major': + return DotnetCoreInstaller.nearestBandVersion(scoped); + default: + return DotnetCoreInstaller.highestVersion(scoped); + } + } + filterByQuality(allowed) { + const wantsPrerelease = ['preview', 'daily'].includes((this.quality || '').toLowerCase()) && + this.qualityApplies(); + return allowed + .filter(version => wantsPrerelease + ? semver_default().prerelease(version) !== null + : semver_default().prerelease(version) === null) + .sort((semver_default()).rcompare); + } + findLocalSdkVersion() { + const installed = this.getInstalledSdkVersions(); + if (!installed.length) { + return null; + } + if (!this.hasDotnetMuxer()) { + core_debug(`The 'dotnet' executable was not found in '${DotnetInstallDir.dirPath}'. Locally installed SDKs are ignored.`); + return null; + } + const minimumVersion = this.minimumVersion; + const allowed = minimumVersion + ? installed.filter(version => semver_default().gte(version, minimumVersion)) + : installed; + if (!allowed.length) { + core_debug(`No locally installed .NET SDK satisfies the global.json minimum version '${minimumVersion}'.`); + return null; + } + if (this.rollForward && minimumVersion) { + return this.findByRollForward(this.filterByQuality(allowed), this.rollForward, minimumVersion); + } + if (semver_default().valid(this.version)) { + return allowed.find(version => version === this.version) ?? null; + } + if (this.version.toLowerCase() !== 'latest' && + !DotnetCoreInstaller.FeatureBandSyntax.test(this.version) && + !semver_default().validRange(this.version)) { + core_debug(`The requested version '${this.version}' is not a valid version spec. Locally installed SDKs are ignored.`); + return null; + } + const candidates = this.filterByQuality(allowed); + if (!candidates.length) { + return null; + } + const input = this.version.toLowerCase(); + if (input === 'latest') { + const channel = (this.dotnetChannel || '').trim(); + if (!channel) { + return candidates[0]; + } + const channelMinor = channel.match(/^(\d+)\.(\d+)$/); + if (channelMinor) { + return this.findByScope(candidates, channelMinor[1], channelMinor[2]); + } + const channelBand = channel.match(/^(\d+)\.(\d+)\.(\d)xx$/); + if (channelBand) { + return this.findByScope(candidates, channelBand[1], channelBand[2], channelBand[3]); + } + return null; + } + const bandMatch = this.version.match(DotnetCoreInstaller.FeatureBandSyntax); + if (bandMatch) { + if (Number(bandMatch[1]) < LATEST_PATCH_SYNTAX_MINIMAL_MAJOR_TAG) { + return null; + } + return this.findByScope(candidates, bandMatch[1], bandMatch[2], bandMatch[3]); + } + const minorMatch = this.version.match(/^(\d+)\.(\d+)(?:\.[xX*])?$/); + if (minorMatch) { + return this.findByScope(candidates, minorMatch[1], minorMatch[2]); + } + const majorMatch = this.version.match(/^(\d+)(?:\.[xX*])?$/); + if (majorMatch) { + const [major, minor] = channelForMajor(majorMatch[1]).split('.'); + return this.findByScope(candidates, major, minor); + } + return null; } async installDotnet() { + const isCrossArch = !!this.architecture && + normalizeArch(this.architecture) !== normalizeArch(external_os_default().arch()); + if (!this.checkLatest && !isCrossArch) { + const localVersion = this.findLocalSdkVersion(); + if (localVersion) { + info(`'check-latest' is false and a locally installed .NET SDK (${localVersion}) satisfies the '${this.version}' request. Skipping download.`); + return localVersion; + } + info(`'check-latest' is false but no locally installed .NET SDK satisfies the '${this.version}' request. Falling back to online installation.`); + } const versionResolver = new DotnetVersionResolver(this.version, this.quality, this.dotnetChannel); const dotnetVersion = await versionResolver.createDotnetVersion(); - const architectureArguments = this.architecture && - normalizeArch(this.architecture) !== normalizeArch(external_os_default().arch()) + const architectureArguments = isCrossArch ? [ utils_IS_WINDOWS ? '-InstallDir' : '--install-dir', utils_IS_WINDOWS @@ -106580,6 +106794,7 @@ const supportedArchitectures = [ 'ppc64le', 'riscv64' ]; +const CHECK_LATEST_ENV_VAR = 'DOTNET_CHECK_LATEST'; function isValidChannel(channel) { const upper = channel.toUpperCase(); if (upper === 'LTS' || upper === 'STS') @@ -106605,8 +106820,19 @@ async function run() { // Proxy, auth, (etc) are still set up, even if no version is identified // const versions = getMultilineInput('dotnet-version'); + const explicitVersions = new Set(versions); + const globalJsonConstraints = new Map(); + const addVersionFromGlobalJson = (globalJsonPath) => { + const { version, minimumVersion, rollForward } = getVersionFromGlobalJson(globalJsonPath); + versions.push(version); + const isRedundantExplicitPin = explicitVersions.has(version) && version === minimumVersion; + if (minimumVersion && !isRedundantExplicitPin) { + globalJsonConstraints.set(version, { minimumVersion, rollForward }); + } + }; const installedDotnetVersions = []; const architecture = getArchitectureInput(); + const checkLatest = getCheckLatestInput(); let dotnetChannel = getInput('dotnet-channel'); const isLatestRequested = versions.some(version => version && version.toLowerCase() === 'latest'); if (dotnetChannel && !isValidChannel(dotnetChannel)) { @@ -106628,14 +106854,14 @@ async function run() { if (!external_fs_namespaceObject.existsSync(globalJsonPath)) { throw new Error(`The specified global.json file '${globalJsonFileInput}' does not exist`); } - versions.push(getVersionFromGlobalJson(globalJsonPath)); + addVersionFromGlobalJson(globalJsonPath); } if (!versions.length) { // Try to fall back to global.json core_debug('No version found, trying to find version from global.json'); const globalJsonPath = external_path_default().join(process.cwd(), 'global.json'); if (external_fs_namespaceObject.existsSync(globalJsonPath)) { - versions.push(getVersionFromGlobalJson(globalJsonPath)); + addVersionFromGlobalJson(globalJsonPath); } else { info(`The global.json wasn't found in the root directory. No .NET version will be installed.`); @@ -106649,7 +106875,8 @@ async function run() { let dotnetInstaller; const uniqueVersions = new Set(versions.map(v => (v.toLowerCase() === 'latest' ? 'latest' : v))); for (const version of uniqueVersions) { - dotnetInstaller = new DotnetCoreInstaller(version, quality, architecture, version.toLowerCase() === 'latest' ? dotnetChannel : undefined); + const constraint = globalJsonConstraints.get(version); + dotnetInstaller = new DotnetCoreInstaller(version, quality, architecture, version.toLowerCase() === 'latest' ? dotnetChannel : undefined, checkLatest, constraint?.minimumVersion, constraint?.rollForward); const installedVersion = await dotnetInstaller.installDotnet(); installedDotnetVersions.push(installedVersion); } @@ -106704,8 +106931,36 @@ function getArchitectureInput() { } throw new Error(`Value '${raw}' is not supported for the 'architecture' option. Supported values are: ${supportedArchitectures.join(', ')}.`); } +function getCheckLatestInput() { + if ((getInput('check-latest') || '').trim()) { + return getBooleanInput('check-latest'); + } + const rawEnvValue = (process.env[CHECK_LATEST_ENV_VAR] || '').trim(); + if (rawEnvValue) { + const envValue = rawEnvValue.toLowerCase(); + if (envValue === 'true' || envValue === 'false') { + core_debug(`The 'check-latest' option is set to '${envValue}' by the ${CHECK_LATEST_ENV_VAR} environment variable.`); + return envValue === 'true'; + } + warning(`Value '${rawEnvValue}' is not supported for the ${CHECK_LATEST_ENV_VAR} environment variable. Supported values are: true, false. The 'check-latest' option falls back to 'true'.`); + } + return true; +} +const ROLL_FORWARD_POLICIES = [ + 'patch', + 'feature', + 'minor', + 'major', + 'latestPatch', + 'latestFeature', + 'latestMinor', + 'latestMajor' +]; +const versionPattern = /^\d+\.\d+\.[1-9]\d{2,}$/; function getVersionFromGlobalJson(globalJsonPath) { let version = ''; + let minimumVersion; + let rollForwardPolicy; const globalJson = json5_lib_default().parse( // .trim() is necessary to strip BOM https://github.com/nodejs/node/issues/20649 external_fs_namespaceObject.readFileSync(globalJsonPath, { encoding: 'utf8' }).trim(), @@ -106719,7 +106974,6 @@ function getVersionFromGlobalJson(globalJsonPath) { version = globalJson.sdk.version; const rollForward = globalJson.sdk.rollForward; if (rollForward && !semver_default().prerelease(version)) { - const versionPattern = /^\d+\.\d+\.[1-9]\d{2,}$/; if (!versionPattern.test(version)) { throw new Error(`Version '${version}' is not valid for the 'sdk.version' value in global.json. ` + `When 'rollForward' is specified, a full SDK version is required. ` + @@ -106741,9 +106995,19 @@ function getVersionFromGlobalJson(globalJsonPath) { version = `${major}.${minor}.${feature}xx`; break; } + if (ROLL_FORWARD_POLICIES.includes(rollForward)) { + minimumVersion = globalJson.sdk.version; + rollForwardPolicy = rollForward; + } + } + else if (!rollForward && + !semver_default().prerelease(version) && + versionPattern.test(version)) { + minimumVersion = version; + rollForwardPolicy = 'patch'; } } - return version; + return { version, minimumVersion, rollForward: rollForwardPolicy }; } function outputInstalledVersion(installedVersions, globalJsonFileInput) { if (!installedVersions.length) { diff --git a/src/installer.ts b/src/installer.ts index 47953c1..3b1e36b 100644 --- a/src/installer.ts +++ b/src/installer.ts @@ -3,7 +3,13 @@ import * as core from '@actions/core'; import * as exec from '@actions/exec'; import * as io from '@actions/io'; import * as hc from '@actions/http-client'; -import {chmodSync} from 'fs'; +import { + accessSync, + chmodSync, + constants as fsConstants, + readdirSync, + statSync +} from 'fs'; import path from 'path'; import {fileURLToPath} from 'url'; import os from 'os'; @@ -29,6 +35,22 @@ interface ReleaseIndexResponse { const QUALITY_INPUT_MINIMAL_MAJOR_TAG = 6; const LATEST_PATCH_SYNTAX_MINIMAL_MAJOR_TAG = 5; + +function channelForMajor(major: string): string { + // Starting with .NET 5, the minor version is always zero. + // Hardcode the earlier versions because they will not get new releases. + switch (major) { + case '1': + return '1.1'; + case '2': + return '2.2'; + case '3': + return '3.1'; + default: + return `${major}.0`; + } +} + export class DotnetVersionResolver { private inputVersion: string; private resolvedArgument: DotnetVersion; @@ -126,22 +148,7 @@ export class DotnetVersionResolver { } else if (this.isNumericTag(major) && this.isNumericTag(minor)) { this.resolvedArgument.value = `${major}.${minor}`; } else if (this.isNumericTag(major)) { - // Starting with .NET 5, the minor version is always zero. - // Hardcode the earlier versions because they will not get new releases. - switch (major) { - case '1': - this.resolvedArgument.value = '1.1'; - break; - case '2': - this.resolvedArgument.value = '2.2'; - break; - case '3': - this.resolvedArgument.value = '3.1'; - break; - default: - this.resolvedArgument.value = `${major}.0`; - break; - } + this.resolvedArgument.value = channelForMajor(major); } else { // If "dotnet-version" is specified as *, x or X resolve latest version of .NET explicitly from LTS channel. The version argument will default to "latest" by install-dotnet script. this.resolvedArgument.value = 'LTS'; @@ -377,7 +384,32 @@ export function normalizeArch(arch: string): string { } } +function isFile(filePath: string): boolean { + try { + return statSync(filePath).isFile(); + } catch { + return false; + } +} + +function isExecutableFile(filePath: string): boolean { + if (!isFile(filePath)) { + return false; + } + if (IS_WINDOWS) { + return true; + } + try { + accessSync(filePath, fsConstants.X_OK); + return true; + } catch { + return false; + } +} + export class DotnetCoreInstaller { + private static readonly FeatureBandSyntax = /^(\d+)\.(\d+)\.(\d)xx$/; + static { DotnetInstallDir.setEnvironmentVariable(); } @@ -386,10 +418,291 @@ export class DotnetCoreInstaller { private version: string, private quality: QualityOptions, private architecture?: string, - private dotnetChannel?: string - ) {} + private dotnetChannel?: string, + private checkLatest: boolean = true, + private minimumVersion?: string, + private rollForward?: string + ) { + this.version = version.trim(); + } + + private getInstalledSdkVersions(): string[] { + const sdkDir = path.join(DotnetInstallDir.dirPath, 'sdk'); + try { + const versions = readdirSync(sdkDir, {withFileTypes: true}) + .filter(entry => entry.isDirectory() || entry.isSymbolicLink()) + .map(entry => entry.name) + .filter(name => semver.valid(name) !== null) + .filter(name => isFile(path.join(sdkDir, name, 'dotnet.dll'))); + core.debug( + `Locally installed .NET SDKs in '${sdkDir}': ${ + versions.join(', ') || '' + }` + ); + return versions; + } catch { + core.debug(`Unable to read the SDK directory '${sdkDir}'.`); + return []; + } + } + + private hasDotnetMuxer(): boolean { + return isExecutableFile( + path.join(DotnetInstallDir.dirPath, IS_WINDOWS ? 'dotnet.exe' : 'dotnet') + ); + } + + private qualityApplies(): boolean { + if (this.version.toLowerCase() === 'latest') { + const major = (this.dotnetChannel || '').trim().match(/^(\d+)/)?.[1]; + return major ? Number(major) >= QUALITY_INPUT_MINIMAL_MAJOR_TAG : true; + } + if (semver.valid(this.version)) { + return false; + } + const major = this.version.match(/^(\d+)/)?.[1]; + return major ? Number(major) >= QUALITY_INPUT_MINIMAL_MAJOR_TAG : false; + } + + private findByScope( + candidates: string[], + major: string, + minor: string, + band?: string + ): string | null { + return ( + candidates.find(version => { + const parsed = semver.parse(version); + return ( + parsed && + parsed.major === Number(major) && + parsed.minor === Number(minor) && + (band === undefined || + Math.floor(parsed.patch / 100) === Number(band)) + ); + }) ?? null + ); + } + + private static isInRollForwardScope( + version: string, + policy: string, + declared: semver.SemVer + ): boolean { + const parsed = semver.parse(version); + if (!parsed) { + return false; + } + switch (policy) { + case 'patch': + case 'latestPatch': + return ( + parsed.major === declared.major && + parsed.minor === declared.minor && + Math.floor(parsed.patch / 100) === Math.floor(declared.patch / 100) + ); + case 'feature': + case 'latestFeature': + return ( + parsed.major === declared.major && parsed.minor === declared.minor + ); + case 'minor': + case 'latestMinor': + return parsed.major === declared.major; + case 'major': + case 'latestMajor': + return true; + default: + return false; + } + } + + private static highestVersion(versions: string[]): string { + return versions.reduce((best, version) => + semver.gt(version, best) ? version : best + ); + } + + private static nearestBandVersion(versions: string[]): string { + return versions.reduce((best, version) => { + const delta = + semver.major(version) - semver.major(best) || + semver.minor(version) - semver.minor(best) || + Math.floor(semver.patch(version) / 100) - + Math.floor(semver.patch(best) / 100); + return delta < 0 || (delta === 0 && semver.gt(version, best)) + ? version + : best; + }); + } + + private findByRollForward( + candidates: string[], + policy: string, + declaredVersion: string + ): string | null { + const declared = semver.parse(declaredVersion); + if (!declared) { + return null; + } + + const scoped = candidates.filter(version => + DotnetCoreInstaller.isInRollForwardScope(version, policy, declared) + ); + if (!scoped.length) { + return null; + } + + switch (policy) { + case 'patch': + return ( + scoped.find(version => semver.eq(version, declared)) ?? + DotnetCoreInstaller.highestVersion(scoped) + ); + case 'feature': + case 'minor': + case 'major': + return DotnetCoreInstaller.nearestBandVersion(scoped); + default: + return DotnetCoreInstaller.highestVersion(scoped); + } + } + + private filterByQuality(allowed: string[]): string[] { + const wantsPrerelease = + ['preview', 'daily'].includes((this.quality || '').toLowerCase()) && + this.qualityApplies(); + return allowed + .filter(version => + wantsPrerelease + ? semver.prerelease(version) !== null + : semver.prerelease(version) === null + ) + .sort(semver.rcompare); + } + + private findLocalSdkVersion(): string | null { + const installed = this.getInstalledSdkVersions(); + if (!installed.length) { + return null; + } + + if (!this.hasDotnetMuxer()) { + core.debug( + `The 'dotnet' executable was not found in '${DotnetInstallDir.dirPath}'. Locally installed SDKs are ignored.` + ); + return null; + } + + const minimumVersion = this.minimumVersion; + const allowed = minimumVersion + ? installed.filter(version => semver.gte(version, minimumVersion)) + : installed; + + if (!allowed.length) { + core.debug( + `No locally installed .NET SDK satisfies the global.json minimum version '${minimumVersion}'.` + ); + return null; + } + + if (this.rollForward && minimumVersion) { + return this.findByRollForward( + this.filterByQuality(allowed), + this.rollForward, + minimumVersion + ); + } + + if (semver.valid(this.version)) { + return allowed.find(version => version === this.version) ?? null; + } + + if ( + this.version.toLowerCase() !== 'latest' && + !DotnetCoreInstaller.FeatureBandSyntax.test(this.version) && + !semver.validRange(this.version) + ) { + core.debug( + `The requested version '${this.version}' is not a valid version spec. Locally installed SDKs are ignored.` + ); + return null; + } + + const candidates = this.filterByQuality(allowed); + + if (!candidates.length) { + return null; + } + + const input = this.version.toLowerCase(); + + if (input === 'latest') { + const channel = (this.dotnetChannel || '').trim(); + if (!channel) { + return candidates[0]; + } + const channelMinor = channel.match(/^(\d+)\.(\d+)$/); + if (channelMinor) { + return this.findByScope(candidates, channelMinor[1], channelMinor[2]); + } + const channelBand = channel.match(/^(\d+)\.(\d+)\.(\d)xx$/); + if (channelBand) { + return this.findByScope( + candidates, + channelBand[1], + channelBand[2], + channelBand[3] + ); + } + return null; + } + + const bandMatch = this.version.match(DotnetCoreInstaller.FeatureBandSyntax); + if (bandMatch) { + if (Number(bandMatch[1]) < LATEST_PATCH_SYNTAX_MINIMAL_MAJOR_TAG) { + return null; + } + return this.findByScope( + candidates, + bandMatch[1], + bandMatch[2], + bandMatch[3] + ); + } + + const minorMatch = this.version.match(/^(\d+)\.(\d+)(?:\.[xX*])?$/); + if (minorMatch) { + return this.findByScope(candidates, minorMatch[1], minorMatch[2]); + } + + const majorMatch = this.version.match(/^(\d+)(?:\.[xX*])?$/); + if (majorMatch) { + const [major, minor] = channelForMajor(majorMatch[1]).split('.'); + return this.findByScope(candidates, major, minor); + } + + return null; + } public async installDotnet(): Promise { + const isCrossArch = + !!this.architecture && + normalizeArch(this.architecture) !== normalizeArch(os.arch()); + + if (!this.checkLatest && !isCrossArch) { + const localVersion = this.findLocalSdkVersion(); + if (localVersion) { + core.info( + `'check-latest' is false and a locally installed .NET SDK (${localVersion}) satisfies the '${this.version}' request. Skipping download.` + ); + return localVersion; + } + core.info( + `'check-latest' is false but no locally installed .NET SDK satisfies the '${this.version}' request. Falling back to online installation.` + ); + } + const versionResolver = new DotnetVersionResolver( this.version, this.quality, @@ -397,16 +710,14 @@ export class DotnetCoreInstaller { ); const dotnetVersion = await versionResolver.createDotnetVersion(); - const architectureArguments = - this.architecture && - normalizeArch(this.architecture) !== normalizeArch(os.arch()) - ? [ - IS_WINDOWS ? '-InstallDir' : '--install-dir', - IS_WINDOWS - ? `"${path.join(DotnetInstallDir.dirPath, this.architecture)}"` - : path.join(DotnetInstallDir.dirPath, this.architecture) - ] - : []; + const architectureArguments = isCrossArch + ? [ + IS_WINDOWS ? '-InstallDir' : '--install-dir', + IS_WINDOWS + ? `"${path.join(DotnetInstallDir.dirPath, this.architecture!)}"` + : path.join(DotnetInstallDir.dirPath, this.architecture!) + ] + : []; /** * Install dotnet runtime first in order to get * the latest stable version of dotnet CLI diff --git a/src/setup-dotnet.ts b/src/setup-dotnet.ts index 70e5ab5..34faf77 100644 --- a/src/setup-dotnet.ts +++ b/src/setup-dotnet.ts @@ -31,6 +31,8 @@ type SupportedArchitecture = (typeof supportedArchitectures)[number]; export type QualityOptions = (typeof qualityOptions)[number] | ''; +const CHECK_LATEST_ENV_VAR = 'DOTNET_CHECK_LATEST'; + function isValidChannel(channel: string): boolean { const upper = channel.toUpperCase(); if (upper === 'LTS' || upper === 'STS') return true; @@ -54,8 +56,24 @@ export async function run() { // Proxy, auth, (etc) are still set up, even if no version is identified // const versions = core.getMultilineInput('dotnet-version'); + const explicitVersions = new Set(versions); + const globalJsonConstraints = new Map< + string, + {minimumVersion: string; rollForward?: string} + >(); + const addVersionFromGlobalJson = (globalJsonPath: string) => { + const {version, minimumVersion, rollForward} = + getVersionFromGlobalJson(globalJsonPath); + versions.push(version); + const isRedundantExplicitPin = + explicitVersions.has(version) && version === minimumVersion; + if (minimumVersion && !isRedundantExplicitPin) { + globalJsonConstraints.set(version, {minimumVersion, rollForward}); + } + }; const installedDotnetVersions: (string | null)[] = []; const architecture = getArchitectureInput(); + const checkLatest = getCheckLatestInput(); let dotnetChannel = core.getInput('dotnet-channel'); const isLatestRequested = versions.some( @@ -87,7 +105,7 @@ export async function run() { `The specified global.json file '${globalJsonFileInput}' does not exist` ); } - versions.push(getVersionFromGlobalJson(globalJsonPath)); + addVersionFromGlobalJson(globalJsonPath); } if (!versions.length) { @@ -95,7 +113,7 @@ export async function run() { core.debug('No version found, trying to find version from global.json'); const globalJsonPath = path.join(process.cwd(), 'global.json'); if (fs.existsSync(globalJsonPath)) { - versions.push(getVersionFromGlobalJson(globalJsonPath)); + addVersionFromGlobalJson(globalJsonPath); } else { core.info( `The global.json wasn't found in the root directory. No .NET version will be installed.` @@ -117,11 +135,15 @@ export async function run() { versions.map(v => (v.toLowerCase() === 'latest' ? 'latest' : v)) ); for (const version of uniqueVersions) { + const constraint = globalJsonConstraints.get(version); dotnetInstaller = new DotnetCoreInstaller( version, quality, architecture, - version.toLowerCase() === 'latest' ? dotnetChannel : undefined + version.toLowerCase() === 'latest' ? dotnetChannel : undefined, + checkLatest, + constraint?.minimumVersion, + constraint?.rollForward ); const installedVersion = await dotnetInstaller.installDotnet(); installedDotnetVersions.push(installedVersion); @@ -200,8 +222,51 @@ function getArchitectureInput(): SupportedArchitecture | '' { ); } -function getVersionFromGlobalJson(globalJsonPath: string): string { +function getCheckLatestInput(): boolean { + if ((core.getInput('check-latest') || '').trim()) { + return core.getBooleanInput('check-latest'); + } + + const rawEnvValue = (process.env[CHECK_LATEST_ENV_VAR] || '').trim(); + if (rawEnvValue) { + const envValue = rawEnvValue.toLowerCase(); + if (envValue === 'true' || envValue === 'false') { + core.debug( + `The 'check-latest' option is set to '${envValue}' by the ${CHECK_LATEST_ENV_VAR} environment variable.` + ); + return envValue === 'true'; + } + core.warning( + `Value '${rawEnvValue}' is not supported for the ${CHECK_LATEST_ENV_VAR} environment variable. Supported values are: true, false. The 'check-latest' option falls back to 'true'.` + ); + } + + return true; +} + +interface GlobalJsonVersion { + version: string; + minimumVersion?: string; + rollForward?: string; +} + +const ROLL_FORWARD_POLICIES = [ + 'patch', + 'feature', + 'minor', + 'major', + 'latestPatch', + 'latestFeature', + 'latestMinor', + 'latestMajor' +]; + +const versionPattern = /^\d+\.\d+\.[1-9]\d{2,}$/; + +function getVersionFromGlobalJson(globalJsonPath: string): GlobalJsonVersion { let version = ''; + let minimumVersion: string | undefined; + let rollForwardPolicy: string | undefined; const globalJson = JSON5.parse( // .trim() is necessary to strip BOM https://github.com/nodejs/node/issues/20649 fs.readFileSync(globalJsonPath, {encoding: 'utf8'}).trim(), @@ -215,7 +280,6 @@ function getVersionFromGlobalJson(globalJsonPath: string): string { version = globalJson.sdk.version; const rollForward = globalJson.sdk.rollForward; if (rollForward && !semver.prerelease(version)) { - const versionPattern = /^\d+\.\d+\.[1-9]\d{2,}$/; if (!versionPattern.test(version)) { throw new Error( `Version '${version}' is not valid for the 'sdk.version' value in global.json. ` + @@ -244,9 +308,21 @@ function getVersionFromGlobalJson(globalJsonPath: string): string { version = `${major}.${minor}.${feature}xx`; break; } + + if (ROLL_FORWARD_POLICIES.includes(rollForward)) { + minimumVersion = globalJson.sdk.version; + rollForwardPolicy = rollForward; + } + } else if ( + !rollForward && + !semver.prerelease(version) && + versionPattern.test(version) + ) { + minimumVersion = version; + rollForwardPolicy = 'patch'; } } - return version; + return {version, minimumVersion, rollForward: rollForwardPolicy}; } function outputInstalledVersion(