diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index d6708136b..4bda39d82 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -649,3 +649,44 @@ jobs: - name: Verify dotnet shell: pwsh run: __tests__/verify-dotnet.ps1 -Patterns "^8.0.416$", "^9.0.308$", "^10.0.101$", "^8.0" + + test-setup-latest-version: + runs-on: ${{ matrix.operating-system }} + strategy: + fail-fast: false + matrix: + operating-system: [ubuntu-latest] + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Clear toolcache + shell: pwsh + run: __tests__/clear-toolcache.ps1 ${{ runner.os }} + - name: Setup dotnet latest + uses: ./ + with: + dotnet-version: latest + - name: Verify dotnet + shell: pwsh + run: __tests__/verify-dotnet.ps1 -Patterns "^\d+\.\d+\.\d+" + + test-setup-latest-with-channel-abcxx: + runs-on: ${{ matrix.operating-system }} + strategy: + fail-fast: false + matrix: + operating-system: [macos-latest] + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Clear toolcache + shell: pwsh + run: __tests__/clear-toolcache.ps1 ${{ runner.os }} + - name: Setup dotnet latest with A.B.Cxx channel + uses: ./ + with: + dotnet-version: latest + dotnet-channel: '9.0.1xx' + - name: Verify dotnet + shell: pwsh + run: __tests__/verify-dotnet.ps1 -Patterns "^9\.0\.1\d{2}" diff --git a/README.md b/README.md index 4465ee746..ee6b18181 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,30 @@ The `dotnet-version` input supports following syntax: - **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** 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). +- **latest** - dynamically resolves to the highest active .NET SDK version. By default, it installs the latest **stable (GA)** version (excluding previews and end-of-life releases). Can be combined with `dotnet-channel` and `dotnet-quality`. +## Using with `dotnet-channel` input + +The optional `dotnet-channel` input specifies the source channel for the installation. Supported values: + +| Value | Description | +|-------|-------------| +| `STS` | The most recent Standard Term Support release | +| `LTS` | The most recent Long Term Support release | +| `A.B` (e.g. `8.0`) | A specific release channel | +| `A.B.Cxx` (e.g. `8.0.1xx`) | A specific SDK release (available since 5.0) | + +> **Note**: The `dotnet-channel` input is only applied when `dotnet-version` is set to `latest`. If used with a specific version, a warning will be logged and the channel input will be ignored. + +**Install latest LTS version:** +```yaml +steps: +- uses: actions/checkout@v6 +- uses: actions/setup-dotnet@v5 + with: + dotnet-version: latest + dotnet-channel: LTS +``` ## Using the `architecture` input Using the architecture input, it is possible to specify the required .NET SDK architecture. Possible values: `x64`, `x86`, `arm64`, `amd64`, `arm`, `s390x`, `ppc64le`, `riscv64`. If the input is not specified, the architecture defaults to the host OS architecture (not all of the architectures are available on all platforms). @@ -77,9 +100,10 @@ steps: ``` ## 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. +The `dotnet-quality` input installs the latest build of the specified quality in the channel. Supported values: `daily`, `preview`, `ga`. + +> **Note**: When used with a specific SDK version, `dotnet-quality` supports only `A.B`, `A.B.x`, `A`, `A.x`, and `A.B.Cxx` formats where the major version is higher than 5. For all other formats, `dotnet-quality` will be ignored. ```yml steps: @@ -91,6 +115,18 @@ steps: - run: dotnet build ``` +`dotnet-quality` can also be combined with `dotnet-version: latest` and `dotnet-channel` to target specific builds such as the latest `daily` build from the `LTS` channel. + +```yaml +steps: +- uses: actions/checkout@v6 +- uses: actions/setup-dotnet@v5 + with: + dotnet-version: latest + dotnet-channel: LTS + dotnet-quality: daily +``` + ## Using the `global-json-file` input `setup-dotnet` action can read .NET SDK version from a `global.json` file. Input `global-json-file` is used for specifying the path to the `global.json`. If the file that was supplied to `global-json-file` input doesn't exist, the action will fail with error. @@ -371,4 +407,4 @@ The scripts and documentation in this project are released under the [MIT Licens ## Contributions -Contributions are welcome! See [Contributor's Guide](docs/contributors.md) +Contributions are welcome! See [Contributor's Guide](docs/contributors.md) \ No newline at end of file diff --git a/__tests__/installer.test.ts b/__tests__/installer.test.ts index 11a57bebb..851c327ae 100644 --- a/__tests__/installer.test.ts +++ b/__tests__/installer.test.ts @@ -9,7 +9,6 @@ import * as io from '@actions/io'; import * as installer from '../src/installer'; import {IS_WINDOWS} from '../src/utils'; -import {QualityOptions} from '../src/setup-dotnet'; describe('installer tests', () => { const env = process.env; @@ -40,7 +39,7 @@ describe('installer tests', () => { it('should throw the error in case of non-zero exit code of the installation script. The error message should contain logs.', async () => { const inputVersion = '10.0.101'; - const inputQuality = '' as QualityOptions; + const inputQuality = ''; const errorMessage = 'fictitious error message!'; getExecOutputSpy.mockImplementation(() => { @@ -62,7 +61,7 @@ describe('installer tests', () => { it('should return version of .NET SDK after installation complete', async () => { const inputVersion = '10.0.101'; - const inputQuality = '' as QualityOptions; + const inputQuality = ''; const stdout = `Fictitious dotnet version ${inputVersion} is installed`; getExecOutputSpy.mockImplementation(() => { return Promise.resolve({ @@ -84,7 +83,7 @@ describe('installer tests', () => { it(`should supply 'version' argument to the installation script if supplied version is in A.B.C syntax`, async () => { const inputVersion = '10.0.101'; - const inputQuality = '' as QualityOptions; + const inputQuality = ''; const stdout = `Fictitious dotnet version ${inputVersion} is installed`; getExecOutputSpy.mockImplementation(() => { @@ -122,7 +121,7 @@ describe('installer tests', () => { it(`should warn if the 'quality' input is set and the supplied version is in A.B.C syntax`, async () => { const inputVersion = '10.0.101'; - const inputQuality = 'ga' as QualityOptions; + const inputQuality = 'ga'; const stdout = `Fictitious dotnet version ${inputVersion} is installed`; getExecOutputSpy.mockImplementation(() => { return Promise.resolve({ @@ -147,7 +146,7 @@ describe('installer tests', () => { it(`should warn if the 'quality' input is set and version isn't in A.B.C syntax but major tag is lower then 6`, async () => { const inputVersion = '3.1'; - const inputQuality = 'ga' as QualityOptions; + const inputQuality = 'ga'; const stdout = `Fictitious dotnet version ${inputVersion} is installed`; getExecOutputSpy.mockImplementation(() => { @@ -174,7 +173,7 @@ describe('installer tests', () => { each(['10', '10.0', '10.0.x', '10.0.*', '10.0.X']).test( `should supply 'quality' argument to the installation script if quality input is set and version (%s) is not in A.B.C syntax`, async inputVersion => { - const inputQuality = 'ga' as QualityOptions; + const inputQuality = 'ga'; const exitCode = 0; const stdout = `Fictitious dotnet version ${inputVersion} is installed`; getExecOutputSpy.mockImplementation(() => { @@ -214,7 +213,7 @@ describe('installer tests', () => { each(['10', '10.0', '10.0.x', '10.0.*', '10.0.X']).test( `should supply 'channel' argument to the installation script if version (%s) isn't in A.B.C syntax`, async inputVersion => { - const inputQuality = '' as QualityOptions; + const inputQuality = ''; const exitCode = 0; const stdout = `Fictitious dotnet version ${inputVersion} is installed`; getExecOutputSpy.mockImplementation(() => { @@ -255,7 +254,7 @@ describe('installer tests', () => { it(`should supply '-ProxyAddress' argument to the installation script if env.variable 'https_proxy' is set`, async () => { process.env['https_proxy'] = 'https://proxy.com'; const inputVersion = '10.0.101'; - const inputQuality = '' as QualityOptions; + const inputQuality = ''; const stdout = `Fictitious dotnet version ${inputVersion} is installed`; getExecOutputSpy.mockImplementation(() => { @@ -293,7 +292,7 @@ describe('installer tests', () => { it(`should supply '-ProxyBypassList' argument to the installation script if env.variable 'no_proxy' is set`, async () => { process.env['no_proxy'] = 'first.url,second.url'; const inputVersion = '10.0.101'; - const inputQuality = '' as QualityOptions; + const inputQuality = ''; const stdout = `Fictitious dotnet version ${inputVersion} is installed`; getExecOutputSpy.mockImplementation(() => { @@ -331,7 +330,7 @@ describe('installer tests', () => { it(`should supply 'architecture' argument to the installation script when architecture is provided`, async () => { const inputVersion = '10.0.101'; - const inputQuality = '' as QualityOptions; + const inputQuality = ''; const inputArchitecture = 'x64'; const stdout = `Fictitious dotnet version ${inputVersion} is installed`; @@ -365,7 +364,7 @@ describe('installer tests', () => { it(`should NOT supply 'architecture' argument when architecture is not provided`, async () => { const inputVersion = '10.0.101'; - const inputQuality = '' as QualityOptions; + const inputQuality = ''; const stdout = `Fictitious dotnet version ${inputVersion} is installed`; getExecOutputSpy.mockImplementation(() => { @@ -395,7 +394,7 @@ describe('installer tests', () => { it(`should supply 'install-dir' with arch subdirectory for cross-arch install`, async () => { const inputVersion = '10.0.101'; - const inputQuality = '' as QualityOptions; + const inputQuality = ''; const inputArchitecture = 'x64'; const stdout = `Fictitious dotnet version ${inputVersion} is installed`; @@ -436,7 +435,7 @@ describe('installer tests', () => { it(`should NOT supply 'install-dir' when architecture matches runner's native arch`, async () => { const inputVersion = '10.0.101'; - const inputQuality = '' as QualityOptions; + const inputQuality = ''; const nativeArch = os.arch().toLowerCase(); const stdout = `Fictitious dotnet version ${inputVersion} is installed`; diff --git a/__tests__/latest-version.test.ts b/__tests__/latest-version.test.ts new file mode 100644 index 000000000..e5e637a7c --- /dev/null +++ b/__tests__/latest-version.test.ts @@ -0,0 +1,223 @@ +import {DotnetVersionResolver} from '../src/installer'; +import * as hc from '@actions/http-client'; +import * as core from '@actions/core'; + +// Mock http-client +jest.mock('@actions/http-client'); + +describe('DotnetVersionResolver with latest', () => { + let getJsonMock: jest.Mock; + let warningSpy: jest.SpyInstance; + + beforeEach(() => { + getJsonMock = jest.fn(); + (hc.HttpClient as any).mockImplementation(() => { + return { + getJson: getJsonMock + }; + }); + warningSpy = jest.spyOn(core, 'warning').mockImplementation(() => {}); + }); + + afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + const mockReleases = { + 'releases-index': [ + { + 'channel-version': '10.0', + 'support-phase': 'preview', + 'release-type': 'lts' + }, + { + 'channel-version': '9.0', + 'support-phase': 'active', + 'release-type': 'sts' + }, + { + 'channel-version': '8.0', + 'support-phase': 'active', + 'release-type': 'lts' + }, + { + 'channel-version': '7.0', + 'support-phase': 'eol', + 'release-type': 'sts' + } + ] + }; + + it('should resolve "latest" to highest stable version by default', async () => { + getJsonMock.mockResolvedValue({result: mockReleases}); + + const resolver = new DotnetVersionResolver('latest'); + const version = await resolver.createDotnetVersion(); + + expect(version.value).toBe('9.0'); + expect(version.type.toLowerCase()).toContain('channel'); + expect(version.qualityFlag).toBe(true); + }); + + it('should resolve "LATEST" (uppercase) to highest stable version', async () => { + getJsonMock.mockResolvedValue({result: mockReleases}); + + const resolver = new DotnetVersionResolver('LATEST'); + const version = await resolver.createDotnetVersion(); + + expect(version.value).toBe('9.0'); + expect(version.type.toLowerCase()).toContain('channel'); + expect(version.qualityFlag).toBe(true); + }); + + it('should resolve "latest" to highest preview version if quality is preview', async () => { + getJsonMock.mockResolvedValue({result: mockReleases}); + + const resolver = new DotnetVersionResolver('latest', 'preview'); + const version = await resolver.createDotnetVersion(); + + expect(version.value).toBe('10.0'); + }); + + it('should resolve "latest" with channel filter LTS', async () => { + getJsonMock.mockResolvedValue({result: mockReleases}); + + const resolver = new DotnetVersionResolver('latest', '', 'LTS'); + const version = await resolver.createDotnetVersion(); + + expect(version.value).toBe('8.0'); + }); + + it('should resolve "latest" with channel filter STS', async () => { + getJsonMock.mockResolvedValue({result: mockReleases}); + + const resolver = new DotnetVersionResolver('latest', '', 'STS'); + const version = await resolver.createDotnetVersion(); + + expect(version.value).toBe('9.0'); + }); + + it('should resolve "latest" with channel filter STS and preview quality', async () => { + getJsonMock.mockResolvedValue({result: mockReleases}); + + const resolver = new DotnetVersionResolver('latest', 'preview', 'STS'); + const version = await resolver.createDotnetVersion(); + + // preview quality includes all support-phases; STS filter → 9.0 (active, sts) + expect(version.value).toBe('9.0'); + }); + + it('should warn if channel is provided but version is not latest', async () => { + getJsonMock.mockResolvedValue({result: mockReleases}); + + const resolver = new DotnetVersionResolver('8.0', '', 'LTS'); + await resolver.createDotnetVersion(); + + expect(warningSpy).toHaveBeenCalledWith( + `The 'dotnet-channel' input is only supported when 'dotnet-version' is set to 'latest'.` + ); + }); + + it('should throw when releases-index API returns empty active releases', async () => { + const emptyReleases = { + 'releases-index': [ + { + 'channel-version': '7.0', + 'support-phase': 'eol', + 'release-type': 'sts' + } + ] + }; + getJsonMock.mockResolvedValue({result: emptyReleases}); + + const resolver = new DotnetVersionResolver('latest'); + + await expect(resolver.createDotnetVersion()).rejects.toThrow( + /Could not find any active releases/ + ); + }); + + it('should throw when releases-index response has unexpected format', async () => { + getJsonMock.mockResolvedValue({result: {}}); + + const resolver = new DotnetVersionResolver('latest'); + + await expect(resolver.createDotnetVersion()).rejects.toThrow( + /Unexpected response format/ + ); + }); + + it('should throw when releases-index response is null', async () => { + getJsonMock.mockResolvedValue({result: null}); + + const resolver = new DotnetVersionResolver('latest'); + + await expect(resolver.createDotnetVersion()).rejects.toThrow( + /Unexpected response format/ + ); + }); + + it('should resolve "latest" with ga quality same as default (no previews)', async () => { + getJsonMock.mockResolvedValue({result: mockReleases}); + + const resolver = new DotnetVersionResolver('latest', 'ga'); + const version = await resolver.createDotnetVersion(); + + // ga should behave like no quality — skip preview (10.0), pick 9.0 + expect(version.value).toBe('9.0'); + }); + + it('should resolve "latest" with LTS channel and daily quality', async () => { + getJsonMock.mockResolvedValue({result: mockReleases}); + + const resolver = new DotnetVersionResolver('latest', 'daily', 'LTS'); + const version = await resolver.createDotnetVersion(); + + // daily allows previews, LTS filter applies — 10.0 (preview, lts) is the highest LTS + expect(version.value).toBe('10.0'); + expect(version.qualityFlag).toBe(true); + }); + + it('should resolve "latest" with A.B channel directly without API call', async () => { + const resolver = new DotnetVersionResolver('latest', '', '8.0'); + const version = await resolver.createDotnetVersion(); + + expect(version.value).toBe('8.0'); + expect(version.type.toLowerCase()).toContain('channel'); + expect(version.qualityFlag).toBe(true); + // Should NOT call the API + expect(getJsonMock).not.toHaveBeenCalled(); + }); + + it('should resolve "latest" with A.B.Cxx channel directly without API call', async () => { + const resolver = new DotnetVersionResolver('latest', '', '8.0.1xx'); + const version = await resolver.createDotnetVersion(); + + expect(version.value).toBe('8.0.1xx'); + expect(version.type.toLowerCase()).toContain('channel'); + expect(version.qualityFlag).toBe(true); + // Should NOT call the API + expect(getJsonMock).not.toHaveBeenCalled(); + }); + + it('should resolve "latest" with A.B channel for older version with qualityFlag false', async () => { + const resolver = new DotnetVersionResolver('latest', '', '3.1'); + const version = await resolver.createDotnetVersion(); + + expect(version.value).toBe('3.1'); + expect(version.type.toLowerCase()).toContain('channel'); + // major 3 < 6 → qualityFlag false + expect(version.qualityFlag).toBe(false); + expect(getJsonMock).not.toHaveBeenCalled(); + }); + + it('should resolve "latest" with A.B.Cxx channel and quality', async () => { + const resolver = new DotnetVersionResolver('latest', 'ga', '8.0.2xx'); + const version = await resolver.createDotnetVersion(); + + expect(version.value).toBe('8.0.2xx'); + expect(version.qualityFlag).toBe(true); + expect(getJsonMock).not.toHaveBeenCalled(); + }); +}); diff --git a/__tests__/setup-dotnet.test.ts b/__tests__/setup-dotnet.test.ts index 5f01d5553..7c05d3802 100644 --- a/__tests__/setup-dotnet.test.ts +++ b/__tests__/setup-dotnet.test.ts @@ -84,7 +84,7 @@ describe('setup-dotnet tests', () => { inputs['dotnet-version'] = ['10.0']; inputs['dotnet-quality'] = 'fictitiousQuality'; - const expectedErrorMessage = `Value '${inputs['dotnet-quality']}' is not supported for the 'dotnet-quality' option. Supported values are: daily, signed, validated, preview, ga.`; + const expectedErrorMessage = `Value '${inputs['dotnet-quality']}' is not supported for the 'dotnet-quality' option. Supported values are: daily, preview, ga.`; await setup.run(); expect(setFailedSpy).toHaveBeenCalledWith(expectedErrorMessage); @@ -256,5 +256,95 @@ describe('setup-dotnet tests', () => { await setup.run(); expect(setFailedSpy).toHaveBeenCalledWith(expectedErrorMessage); }); + + it('should fail the action if unsupported dotnet-channel value is provided with latest', async () => { + inputs['dotnet-version'] = ['latest']; + inputs['dotnet-quality'] = ''; + inputs['dotnet-channel'] = 'invalid'; + inputs['architecture'] = ''; + + const expectedErrorMessage = `Value 'invalid' is not supported for the 'dotnet-channel' option. Supported values are: LTS, STS, A.B (e.g. 8.0), A.B.Cxx (e.g. 8.0.1xx).`; + + await setup.run(); + expect(setFailedSpy).toHaveBeenCalledWith(expectedErrorMessage); + }); + + it('should warn but not fail if unsupported dotnet-channel value is provided with a specific version', async () => { + inputs['dotnet-version'] = ['8.0.x']; + inputs['dotnet-quality'] = ''; + inputs['dotnet-channel'] = 'invalid'; + inputs['architecture'] = ''; + + installDotnetSpy.mockImplementation(() => Promise.resolve('')); + + await setup.run(); + expect(setFailedSpy).not.toHaveBeenCalled(); + expect(warningSpy).toHaveBeenCalledWith( + `Value 'invalid' is not supported for the 'dotnet-channel' option and will be ignored because 'dotnet-version' is not set to 'latest'. Supported values are: LTS, STS, A.B (e.g. 8.0), A.B.Cxx (e.g. 8.0.1xx).` + ); + }); + + it('should pass valid dotnet-channel value through without error', async () => { + inputs['dotnet-version'] = ['latest']; + inputs['dotnet-quality'] = ''; + inputs['dotnet-channel'] = 'LTS'; + inputs['architecture'] = ''; + + installDotnetSpy.mockImplementation(() => Promise.resolve('')); + + await setup.run(); + expect(setFailedSpy).not.toHaveBeenCalled(); + }); + + it('should pass A.B channel value through without error when used with latest', async () => { + inputs['dotnet-version'] = ['latest']; + inputs['dotnet-quality'] = ''; + inputs['dotnet-channel'] = '8.0'; + inputs['architecture'] = ''; + + installDotnetSpy.mockImplementation(() => Promise.resolve('')); + + await setup.run(); + expect(setFailedSpy).not.toHaveBeenCalled(); + }); + + it('should pass A.B.Cxx channel value through without error when used with latest', async () => { + inputs['dotnet-version'] = ['latest']; + inputs['dotnet-quality'] = ''; + inputs['dotnet-channel'] = '8.0.1xx'; + inputs['architecture'] = ''; + + installDotnetSpy.mockImplementation(() => Promise.resolve('')); + + await setup.run(); + expect(setFailedSpy).not.toHaveBeenCalled(); + }); + + it('should fail with A.B.Cxx channel if major version is below 5', async () => { + inputs['dotnet-version'] = ['latest']; + inputs['dotnet-quality'] = ''; + inputs['dotnet-channel'] = '3.1.1xx'; + inputs['architecture'] = ''; + + const expectedErrorMessage = `Value '3.1.1xx' is not supported for the 'dotnet-channel' option. Supported values are: LTS, STS, A.B (e.g. 8.0), A.B.Cxx (e.g. 8.0.1xx).`; + + await setup.run(); + expect(setFailedSpy).toHaveBeenCalledWith(expectedErrorMessage); + }); + + it('should warn and not fail if valid dotnet-channel is provided with a non-latest version', async () => { + inputs['dotnet-version'] = ['8.0.x']; + inputs['dotnet-quality'] = ''; + inputs['dotnet-channel'] = 'LTS'; + inputs['architecture'] = ''; + + installDotnetSpy.mockImplementation(() => Promise.resolve('')); + + await setup.run(); + expect(setFailedSpy).not.toHaveBeenCalled(); + expect(warningSpy).toHaveBeenCalledWith( + `The 'dotnet-channel' input is only supported when 'dotnet-version' is set to 'latest'.` + ); + }); }); }); diff --git a/action.yml b/action.yml index 861fb9d75..315bc8f53 100644 --- a/action.yml +++ b/action.yml @@ -6,9 +6,11 @@ branding: color: green 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' + 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, latest' dotnet-quality: - description: 'Optional quality of the build. The possible values are: daily, signed, validated, preview, ga.' + description: 'Optional quality of the build. The possible values are: daily, preview, ga.' + dotnet-channel: + description: 'Optional channel for the installation. The possible values are: STS, LTS, A.B (e.g. 8.0), A.B.Cxx (e.g. 8.0.1xx, available since 5.0). To be used with "dotnet-version: latest".' global-json-file: description: 'Optional global.json location, if your global.json isn''t located in the root of the repo.' source-url: @@ -39,4 +41,4 @@ runs: using: 'node24' main: 'dist/setup/index.js' post: 'dist/cache-save/index.js' - post-if: success() + post-if: success() \ No newline at end of file diff --git a/dist/setup/index.js b/dist/setup/index.js index df697240d..0e9a8b114 100644 --- a/dist/setup/index.js +++ b/dist/setup/index.js @@ -54786,15 +54786,51 @@ const utils_1 = __nccwpck_require__(71314); const QUALITY_INPUT_MINIMAL_MAJOR_TAG = 6; const LATEST_PATCH_SYNTAX_MINIMAL_MAJOR_TAG = 5; class DotnetVersionResolver { + quality; + dotnetChannel; inputVersion; resolvedArgument; - constructor(version) { + constructor(version, quality = '', dotnetChannel) { + this.quality = quality; + this.dotnetChannel = dotnetChannel; this.inputVersion = version.trim(); this.resolvedArgument = { type: '', value: '', qualityFlag: false }; } + isVersionChannel(channel) { + // A.B format (e.g., 3.1, 8.0) + if (/^\d+\.\d+$/.test(channel)) + return true; + // A.B.Cxx format (e.g., 8.0.1xx) is supported only for .NET 5.0+ + const latestPatchMatch = channel.match(/^(\d+)\.\d+\.\d{1}xx$/); + if (latestPatchMatch) { + const major = Number(latestPatchMatch[1]); + return (!Number.isNaN(major) && major >= LATEST_PATCH_SYNTAX_MINIMAL_MAJOR_TAG); + } + return false; + } async resolveVersionInput() { + if (this.inputVersion.toLowerCase() === 'latest') { + const channel = this.dotnetChannel || ''; + if (this.isVersionChannel(channel)) { + // A.B or A.B.Cxx channels are passed directly to the install script + this.resolvedArgument.value = channel; + } + else { + // LTS, STS, or empty — resolve via releases index API + this.resolvedArgument.value = await this.getLatestVersion(channel); + } + this.resolvedArgument.type = 'channel'; + const latestChannelMajorTag = Number(this.resolvedArgument.value.split('.')[0]); + this.resolvedArgument.qualityFlag = + !Number.isNaN(latestChannelMajorTag) && + latestChannelMajorTag >= QUALITY_INPUT_MINIMAL_MAJOR_TAG; + return; + } + if (this.dotnetChannel) { + core.warning(`The 'dotnet-channel' input is only supported when 'dotnet-version' is set to 'latest'.`); + } if (!semver_1.default.validRange(this.inputVersion) && !this.isLatestPatchSyntax()) { - throw new Error(`The 'dotnet-version' was supplied in invalid format: ${this.inputVersion}! Supported syntax: A.B.C, A.B, A.B.x, A, A.x, A.B.Cxx`); + throw new Error(`The 'dotnet-version' was supplied in invalid format: ${this.inputVersion}! Supported syntax: A.B.C, A.B, A.B.x, A, A.x, A.B.Cxx, latest`); } if (semver_1.default.valid(this.inputVersion)) { this.createVersionArgument(); @@ -54852,6 +54888,46 @@ class DotnetVersionResolver { } return this.resolvedArgument; } + async getLatestVersion(channelFilter) { + const httpClient = new hc.HttpClient('actions/setup-dotnet', [], { + allowRetries: true, + maxRetries: 3 + }); + const response = await httpClient.getJson(DotnetVersionResolver.DotnetCoreIndexUrl); + const result = response.result; + const rawReleasesInfo = result?.['releases-index']; + if (!Array.isArray(rawReleasesInfo)) { + throw new Error('Unexpected response format from .NET releases index.'); + } + let releasesInfo = rawReleasesInfo; + // Filter out EOL versions + releasesInfo = releasesInfo.filter(info => info['support-phase'] !== 'eol'); + // Filter out preview versions if quality is not 'preview' or 'daily' + // If quality is not specified, we assume strict stability (GA only) + const normalizedQuality = (this.quality || '').toLowerCase(); + if (!['preview', 'daily'].includes(normalizedQuality)) { + releasesInfo = releasesInfo.filter(info => info['support-phase'] !== 'preview'); + } + // Apply channel filter (LTS/STS) + if (channelFilter) { + const type = channelFilter.toLowerCase(); + releasesInfo = releasesInfo.filter(info => info['release-type'] === type); + } + releasesInfo.sort((a, b) => { + const partsA = a['channel-version'].split('.').map(Number); + const partsB = b['channel-version'].split('.').map(Number); + for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) { + const diff = (partsB[i] || 0) - (partsA[i] || 0); + if (diff !== 0) + return diff; + } + return 0; + }); + if (releasesInfo.length === 0) { + throw new Error(`Could not find any active releases matching channel '${channelFilter || 'any'}'`); + } + return releasesInfo[0]['channel-version']; + } async getLatestByMajorTag(majorTag) { const httpClient = new hc.HttpClient('actions/setup-dotnet', [], { allowRetries: true, @@ -54987,16 +55063,18 @@ class DotnetCoreInstaller { version; quality; architecture; + dotnetChannel; static { DotnetInstallDir.setEnvironmentVariable(); } - constructor(version, quality, architecture) { + constructor(version, quality, architecture, dotnetChannel) { this.version = version; this.quality = quality; this.architecture = architecture; + this.dotnetChannel = dotnetChannel; } async installDotnet() { - const versionResolver = new DotnetVersionResolver(this.version); + const versionResolver = new DotnetVersionResolver(this.version, this.quality, this.dotnetChannel); const dotnetVersion = await versionResolver.createDotnetVersion(); const architectureArguments = this.architecture && normalizeArch(this.architecture) !== normalizeArch(os_1.default.arch()) @@ -55115,13 +55193,7 @@ const cache_utils_1 = __nccwpck_require__(41678); const cache_restore_1 = __nccwpck_require__(19517); const constants_1 = __nccwpck_require__(69042); const json5_1 = __importDefault(__nccwpck_require__(86904)); -const qualityOptions = [ - 'daily', - 'signed', - 'validated', - 'preview', - 'ga' -]; +const qualityOptions = ['daily', 'preview', 'ga']; const supportedArchitectures = [ 'x64', 'x86', @@ -55132,6 +55204,19 @@ const supportedArchitectures = [ 'ppc64le', 'riscv64' ]; +function isValidChannel(channel) { + const upper = channel.toUpperCase(); + if (upper === 'LTS' || upper === 'STS') + return true; + // A.B format (e.g., 3.1, 8.0) + if (/^\d+\.\d+$/.test(channel)) + return true; + // A.B.Cxx format (e.g., 8.0.1xx) - available since 5.0 + const match = channel.match(/^(?\d+)\.\d+\.\d{1}xx$/); + if (match && parseInt(match.groups.major) >= 5) + return true; + return false; +} async function run() { try { // @@ -55146,6 +55231,21 @@ async function run() { const versions = core.getMultilineInput('dotnet-version'); const installedDotnetVersions = []; const architecture = getArchitectureInput(); + let dotnetChannel = core.getInput('dotnet-channel'); + const isLatestRequested = versions.some(version => version && version.toLowerCase() === 'latest'); + if (dotnetChannel && !isValidChannel(dotnetChannel)) { + if (isLatestRequested) { + throw new Error(`Value '${dotnetChannel}' is not supported for the 'dotnet-channel' option. Supported values are: LTS, STS, A.B (e.g. 8.0), A.B.Cxx (e.g. 8.0.1xx).`); + } + else { + core.warning(`Value '${dotnetChannel}' is not supported for the 'dotnet-channel' option and will be ignored because 'dotnet-version' is not set to 'latest'. Supported values are: LTS, STS, A.B (e.g. 8.0), A.B.Cxx (e.g. 8.0.1xx).`); + dotnetChannel = ''; + } + } + else if (dotnetChannel && !isLatestRequested) { + core.warning(`The 'dotnet-channel' input is only supported when 'dotnet-version' is set to 'latest'.`); + dotnetChannel = ''; + } const globalJsonFileInput = core.getInput('global-json-file'); if (globalJsonFileInput) { const globalJsonPath = path_1.default.resolve(process.cwd(), globalJsonFileInput); @@ -55168,12 +55268,12 @@ async function run() { 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.`); + throw new Error(`Value '${quality}' is not supported for the 'dotnet-quality' option. Supported values are: daily, preview, ga.`); } let dotnetInstaller; - const uniqueVersions = new Set(versions); + const uniqueVersions = new Set(versions.map(v => (v.toLowerCase() === 'latest' ? 'latest' : v))); for (const version of uniqueVersions) { - dotnetInstaller = new installer_1.DotnetCoreInstaller(version, quality, architecture); + dotnetInstaller = new installer_1.DotnetCoreInstaller(version, quality, architecture, version.toLowerCase() === 'latest' ? dotnetChannel : undefined); const installedVersion = await dotnetInstaller.installDotnet(); installedDotnetVersions.push(installedVersion); } diff --git a/src/installer.ts b/src/installer.ts index 451c66c48..24023160f 100644 --- a/src/installer.ts +++ b/src/installer.ts @@ -16,21 +16,74 @@ export interface DotnetVersion { qualityFlag: boolean; } +interface ReleaseIndexEntry { + 'channel-version': string; + 'support-phase': string; + 'release-type': string; +} + +interface ReleaseIndexResponse { + 'releases-index': ReleaseIndexEntry[]; +} + const QUALITY_INPUT_MINIMAL_MAJOR_TAG = 6; const LATEST_PATCH_SYNTAX_MINIMAL_MAJOR_TAG = 5; export class DotnetVersionResolver { private inputVersion: string; private resolvedArgument: DotnetVersion; - constructor(version: string) { + constructor( + version: string, + private quality: QualityOptions = '', + private dotnetChannel?: string + ) { this.inputVersion = version.trim(); this.resolvedArgument = {type: '', value: '', qualityFlag: false}; } + private isVersionChannel(channel: string): boolean { + // A.B format (e.g., 3.1, 8.0) + if (/^\d+\.\d+$/.test(channel)) return true; + // A.B.Cxx format (e.g., 8.0.1xx) is supported only for .NET 5.0+ + const latestPatchMatch = channel.match(/^(\d+)\.\d+\.\d{1}xx$/); + if (latestPatchMatch) { + const major = Number(latestPatchMatch[1]); + return ( + !Number.isNaN(major) && major >= LATEST_PATCH_SYNTAX_MINIMAL_MAJOR_TAG + ); + } + return false; + } + private async resolveVersionInput(): Promise { + if (this.inputVersion.toLowerCase() === 'latest') { + const channel = this.dotnetChannel || ''; + if (this.isVersionChannel(channel)) { + // A.B or A.B.Cxx channels are passed directly to the install script + this.resolvedArgument.value = channel; + } else { + // LTS, STS, or empty — resolve via releases index API + this.resolvedArgument.value = await this.getLatestVersion(channel); + } + this.resolvedArgument.type = 'channel'; + const latestChannelMajorTag = Number( + this.resolvedArgument.value.split('.')[0] + ); + this.resolvedArgument.qualityFlag = + !Number.isNaN(latestChannelMajorTag) && + latestChannelMajorTag >= QUALITY_INPUT_MINIMAL_MAJOR_TAG; + return; + } + + if (this.dotnetChannel) { + core.warning( + `The 'dotnet-channel' input is only supported when 'dotnet-version' is set to 'latest'.` + ); + } + if (!semver.validRange(this.inputVersion) && !this.isLatestPatchSyntax()) { throw new Error( - `The 'dotnet-version' was supplied in invalid format: ${this.inputVersion}! Supported syntax: A.B.C, A.B, A.B.x, A, A.x, A.B.Cxx` + `The 'dotnet-version' was supplied in invalid format: ${this.inputVersion}! Supported syntax: A.B.C, A.B, A.B.x, A, A.x, A.B.Cxx, latest` ); } if (semver.valid(this.inputVersion)) { @@ -96,6 +149,64 @@ export class DotnetVersionResolver { return this.resolvedArgument; } + private async getLatestVersion(channelFilter: string): Promise { + const httpClient = new hc.HttpClient('actions/setup-dotnet', [], { + allowRetries: true, + maxRetries: 3 + }); + + const response = await httpClient.getJson( + DotnetVersionResolver.DotnetCoreIndexUrl + ); + + const result = response.result; + const rawReleasesInfo = result?.['releases-index']; + + if (!Array.isArray(rawReleasesInfo)) { + throw new Error('Unexpected response format from .NET releases index.'); + } + + let releasesInfo = rawReleasesInfo; + + // Filter out EOL versions + releasesInfo = releasesInfo.filter(info => info['support-phase'] !== 'eol'); + + // Filter out preview versions if quality is not 'preview' or 'daily' + // If quality is not specified, we assume strict stability (GA only) + const normalizedQuality = (this.quality || '').toLowerCase(); + if (!['preview', 'daily'].includes(normalizedQuality)) { + releasesInfo = releasesInfo.filter( + info => info['support-phase'] !== 'preview' + ); + } + + // Apply channel filter (LTS/STS) + if (channelFilter) { + const type = channelFilter.toLowerCase(); + releasesInfo = releasesInfo.filter(info => info['release-type'] === type); + } + + releasesInfo.sort((a, b) => { + const partsA = a['channel-version'].split('.').map(Number); + const partsB = b['channel-version'].split('.').map(Number); + for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) { + const diff = (partsB[i] || 0) - (partsA[i] || 0); + if (diff !== 0) return diff; + } + return 0; + }); + + if (releasesInfo.length === 0) { + throw new Error( + `Could not find any active releases matching channel '${ + channelFilter || 'any' + }'` + ); + } + + return releasesInfo[0]['channel-version']; + } + private async getLatestByMajorTag(majorTag: string): Promise { const httpClient = new hc.HttpClient('actions/setup-dotnet', [], { allowRetries: true, @@ -279,11 +390,16 @@ export class DotnetCoreInstaller { constructor( private version: string, private quality: QualityOptions, - private architecture?: string + private architecture?: string, + private dotnetChannel?: string ) {} public async installDotnet(): Promise { - const versionResolver = new DotnetVersionResolver(this.version); + const versionResolver = new DotnetVersionResolver( + this.version, + this.quality, + this.dotnetChannel + ); const dotnetVersion = await versionResolver.createDotnetVersion(); const architectureArguments = diff --git a/src/setup-dotnet.ts b/src/setup-dotnet.ts index 5a576805a..4950f8fb3 100644 --- a/src/setup-dotnet.ts +++ b/src/setup-dotnet.ts @@ -15,13 +15,7 @@ import {restoreCache} from './cache-restore'; import {Outputs} from './constants'; import JSON5 from 'json5'; -const qualityOptions = [ - 'daily', - 'signed', - 'validated', - 'preview', - 'ga' -] as const; +const qualityOptions = ['daily', 'preview', 'ga'] as const; const supportedArchitectures = [ 'x64', 'x86', @@ -34,7 +28,18 @@ const supportedArchitectures = [ ] as const; type SupportedArchitecture = (typeof supportedArchitectures)[number]; -export type QualityOptions = (typeof qualityOptions)[number]; +export type QualityOptions = (typeof qualityOptions)[number] | ''; + +function isValidChannel(channel: string): boolean { + const upper = channel.toUpperCase(); + if (upper === 'LTS' || upper === 'STS') return true; + // A.B format (e.g., 3.1, 8.0) + if (/^\d+\.\d+$/.test(channel)) return true; + // A.B.Cxx format (e.g., 8.0.1xx) - available since 5.0 + const match = channel.match(/^(?\d+)\.\d+\.\d{1}xx$/); + if (match && parseInt(match.groups!.major) >= 5) return true; + return false; +} export async function run() { try { @@ -50,6 +55,28 @@ export async function run() { const versions = core.getMultilineInput('dotnet-version'); const installedDotnetVersions: (string | null)[] = []; const architecture = getArchitectureInput(); + let dotnetChannel = core.getInput('dotnet-channel'); + + const isLatestRequested = versions.some( + version => version && version.toLowerCase() === 'latest' + ); + if (dotnetChannel && !isValidChannel(dotnetChannel)) { + if (isLatestRequested) { + throw new Error( + `Value '${dotnetChannel}' is not supported for the 'dotnet-channel' option. Supported values are: LTS, STS, A.B (e.g. 8.0), A.B.Cxx (e.g. 8.0.1xx).` + ); + } else { + core.warning( + `Value '${dotnetChannel}' is not supported for the 'dotnet-channel' option and will be ignored because 'dotnet-version' is not set to 'latest'. Supported values are: LTS, STS, A.B (e.g. 8.0), A.B.Cxx (e.g. 8.0.1xx).` + ); + dotnetChannel = ''; + } + } else if (dotnetChannel && !isLatestRequested) { + core.warning( + `The 'dotnet-channel' input is only supported when 'dotnet-version' is set to 'latest'.` + ); + dotnetChannel = ''; + } const globalJsonFileInput = core.getInput('global-json-file'); if (globalJsonFileInput) { @@ -80,17 +107,20 @@ export async function run() { 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.` + `Value '${quality}' is not supported for the 'dotnet-quality' option. Supported values are: daily, preview, ga.` ); } let dotnetInstaller: DotnetCoreInstaller; - const uniqueVersions = new Set(versions); + const uniqueVersions = new Set( + versions.map(v => (v.toLowerCase() === 'latest' ? 'latest' : v)) + ); for (const version of uniqueVersions) { dotnetInstaller = new DotnetCoreInstaller( version, quality, - architecture + architecture, + version.toLowerCase() === 'latest' ? dotnetChannel : undefined ); const installedVersion = await dotnetInstaller.installDotnet(); installedDotnetVersions.push(installedVersion);