Add dotnet-version: latest support with dotnet-channel input (#730)

* feat: add dotnet-version: latest keyword with dotnet-channel support (#497)

* restore test-proxy container image

* update e2e-tests.yml and documentation

* fix(tests): correct release-type and support-phase values in latest-version test mocks
This commit is contained in:
mahabaleshwars
2026-04-27 23:54:18 +05:30
committed by GitHub
parent df991aeaf2
commit af9211b136
9 changed files with 687 additions and 50 deletions

View File

@@ -649,3 +649,44 @@ jobs:
- name: Verify dotnet - name: Verify dotnet
shell: pwsh shell: pwsh
run: __tests__/verify-dotnet.ps1 -Patterns "^8.0.416$", "^9.0.308$", "^10.0.101$", "^8.0" 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}"

View File

@@ -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.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** or **A.x** (e.g. 8, 8.x) - installs the latest minor version of the specified major tag, including prerelease versions (preview, rc)
- **A.B.Cxx** (e.g. 8.0.4xx) - available since `.NET 5.0` release. Installs the latest version of the specific SDK release, including prerelease versions (preview, rc). - **A.B.Cxx** (e.g. 8.0.4xx) - available since `.NET 5.0` release. Installs the latest version of the specific 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
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). 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 ## 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 ```yml
steps: steps:
@@ -91,6 +115,18 @@ steps:
- run: dotnet build <my project> - run: dotnet build <my project>
``` ```
`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 ## 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. `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.

View File

@@ -9,7 +9,6 @@ import * as io from '@actions/io';
import * as installer from '../src/installer'; import * as installer from '../src/installer';
import {IS_WINDOWS} from '../src/utils'; import {IS_WINDOWS} from '../src/utils';
import {QualityOptions} from '../src/setup-dotnet';
describe('installer tests', () => { describe('installer tests', () => {
const env = process.env; 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 () => { 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 inputVersion = '10.0.101';
const inputQuality = '' as QualityOptions; const inputQuality = '';
const errorMessage = 'fictitious error message!'; const errorMessage = 'fictitious error message!';
getExecOutputSpy.mockImplementation(() => { getExecOutputSpy.mockImplementation(() => {
@@ -62,7 +61,7 @@ describe('installer tests', () => {
it('should return version of .NET SDK after installation complete', async () => { it('should return version of .NET SDK after installation complete', async () => {
const inputVersion = '10.0.101'; const inputVersion = '10.0.101';
const inputQuality = '' as QualityOptions; const inputQuality = '';
const stdout = `Fictitious dotnet version ${inputVersion} is installed`; const stdout = `Fictitious dotnet version ${inputVersion} is installed`;
getExecOutputSpy.mockImplementation(() => { getExecOutputSpy.mockImplementation(() => {
return Promise.resolve({ 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 () => { 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 inputVersion = '10.0.101';
const inputQuality = '' as QualityOptions; const inputQuality = '';
const stdout = `Fictitious dotnet version ${inputVersion} is installed`; const stdout = `Fictitious dotnet version ${inputVersion} is installed`;
getExecOutputSpy.mockImplementation(() => { 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 () => { 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 inputVersion = '10.0.101';
const inputQuality = 'ga' as QualityOptions; const inputQuality = 'ga';
const stdout = `Fictitious dotnet version ${inputVersion} is installed`; const stdout = `Fictitious dotnet version ${inputVersion} is installed`;
getExecOutputSpy.mockImplementation(() => { getExecOutputSpy.mockImplementation(() => {
return Promise.resolve({ 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 () => { 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 inputVersion = '3.1';
const inputQuality = 'ga' as QualityOptions; const inputQuality = 'ga';
const stdout = `Fictitious dotnet version ${inputVersion} is installed`; const stdout = `Fictitious dotnet version ${inputVersion} is installed`;
getExecOutputSpy.mockImplementation(() => { getExecOutputSpy.mockImplementation(() => {
@@ -174,7 +173,7 @@ describe('installer tests', () => {
each(['10', '10.0', '10.0.x', '10.0.*', '10.0.X']).test( 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`, `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 => { async inputVersion => {
const inputQuality = 'ga' as QualityOptions; const inputQuality = 'ga';
const exitCode = 0; const exitCode = 0;
const stdout = `Fictitious dotnet version ${inputVersion} is installed`; const stdout = `Fictitious dotnet version ${inputVersion} is installed`;
getExecOutputSpy.mockImplementation(() => { getExecOutputSpy.mockImplementation(() => {
@@ -214,7 +213,7 @@ describe('installer tests', () => {
each(['10', '10.0', '10.0.x', '10.0.*', '10.0.X']).test( 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`, `should supply 'channel' argument to the installation script if version (%s) isn't in A.B.C syntax`,
async inputVersion => { async inputVersion => {
const inputQuality = '' as QualityOptions; const inputQuality = '';
const exitCode = 0; const exitCode = 0;
const stdout = `Fictitious dotnet version ${inputVersion} is installed`; const stdout = `Fictitious dotnet version ${inputVersion} is installed`;
getExecOutputSpy.mockImplementation(() => { 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 () => { it(`should supply '-ProxyAddress' argument to the installation script if env.variable 'https_proxy' is set`, async () => {
process.env['https_proxy'] = 'https://proxy.com'; process.env['https_proxy'] = 'https://proxy.com';
const inputVersion = '10.0.101'; const inputVersion = '10.0.101';
const inputQuality = '' as QualityOptions; const inputQuality = '';
const stdout = `Fictitious dotnet version ${inputVersion} is installed`; const stdout = `Fictitious dotnet version ${inputVersion} is installed`;
getExecOutputSpy.mockImplementation(() => { 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 () => { 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'; process.env['no_proxy'] = 'first.url,second.url';
const inputVersion = '10.0.101'; const inputVersion = '10.0.101';
const inputQuality = '' as QualityOptions; const inputQuality = '';
const stdout = `Fictitious dotnet version ${inputVersion} is installed`; const stdout = `Fictitious dotnet version ${inputVersion} is installed`;
getExecOutputSpy.mockImplementation(() => { getExecOutputSpy.mockImplementation(() => {
@@ -331,7 +330,7 @@ describe('installer tests', () => {
it(`should supply 'architecture' argument to the installation script when architecture is provided`, async () => { it(`should supply 'architecture' argument to the installation script when architecture is provided`, async () => {
const inputVersion = '10.0.101'; const inputVersion = '10.0.101';
const inputQuality = '' as QualityOptions; const inputQuality = '';
const inputArchitecture = 'x64'; const inputArchitecture = 'x64';
const stdout = `Fictitious dotnet version ${inputVersion} is installed`; 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 () => { it(`should NOT supply 'architecture' argument when architecture is not provided`, async () => {
const inputVersion = '10.0.101'; const inputVersion = '10.0.101';
const inputQuality = '' as QualityOptions; const inputQuality = '';
const stdout = `Fictitious dotnet version ${inputVersion} is installed`; const stdout = `Fictitious dotnet version ${inputVersion} is installed`;
getExecOutputSpy.mockImplementation(() => { getExecOutputSpy.mockImplementation(() => {
@@ -395,7 +394,7 @@ describe('installer tests', () => {
it(`should supply 'install-dir' with arch subdirectory for cross-arch install`, async () => { it(`should supply 'install-dir' with arch subdirectory for cross-arch install`, async () => {
const inputVersion = '10.0.101'; const inputVersion = '10.0.101';
const inputQuality = '' as QualityOptions; const inputQuality = '';
const inputArchitecture = 'x64'; const inputArchitecture = 'x64';
const stdout = `Fictitious dotnet version ${inputVersion} is installed`; 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 () => { it(`should NOT supply 'install-dir' when architecture matches runner's native arch`, async () => {
const inputVersion = '10.0.101'; const inputVersion = '10.0.101';
const inputQuality = '' as QualityOptions; const inputQuality = '';
const nativeArch = os.arch().toLowerCase(); const nativeArch = os.arch().toLowerCase();
const stdout = `Fictitious dotnet version ${inputVersion} is installed`; const stdout = `Fictitious dotnet version ${inputVersion} is installed`;

View File

@@ -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();
});
});

View File

@@ -84,7 +84,7 @@ describe('setup-dotnet tests', () => {
inputs['dotnet-version'] = ['10.0']; inputs['dotnet-version'] = ['10.0'];
inputs['dotnet-quality'] = 'fictitiousQuality'; 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(); await setup.run();
expect(setFailedSpy).toHaveBeenCalledWith(expectedErrorMessage); expect(setFailedSpy).toHaveBeenCalledWith(expectedErrorMessage);
@@ -256,5 +256,95 @@ describe('setup-dotnet tests', () => {
await setup.run(); await setup.run();
expect(setFailedSpy).toHaveBeenCalledWith(expectedErrorMessage); 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'.`
);
});
}); });
}); });

View File

@@ -6,9 +6,11 @@ branding:
color: green color: green
inputs: inputs:
dotnet-version: 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: 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: global-json-file:
description: 'Optional global.json location, if your global.json isn''t located in the root of the repo.' description: 'Optional global.json location, if your global.json isn''t located in the root of the repo.'
source-url: source-url:

128
dist/setup/index.js vendored
View File

@@ -78690,15 +78690,51 @@ const utils_1 = __nccwpck_require__(71314);
const QUALITY_INPUT_MINIMAL_MAJOR_TAG = 6; const QUALITY_INPUT_MINIMAL_MAJOR_TAG = 6;
const LATEST_PATCH_SYNTAX_MINIMAL_MAJOR_TAG = 5; const LATEST_PATCH_SYNTAX_MINIMAL_MAJOR_TAG = 5;
class DotnetVersionResolver { class DotnetVersionResolver {
quality;
dotnetChannel;
inputVersion; inputVersion;
resolvedArgument; resolvedArgument;
constructor(version) { constructor(version, quality = '', dotnetChannel) {
this.quality = quality;
this.dotnetChannel = dotnetChannel;
this.inputVersion = version.trim(); this.inputVersion = version.trim();
this.resolvedArgument = { type: '', value: '', qualityFlag: false }; 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() { 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()) { 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)) { if (semver_1.default.valid(this.inputVersion)) {
this.createVersionArgument(); this.createVersionArgument();
@@ -78756,6 +78792,46 @@ class DotnetVersionResolver {
} }
return this.resolvedArgument; 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) { async getLatestByMajorTag(majorTag) {
const httpClient = new hc.HttpClient('actions/setup-dotnet', [], { const httpClient = new hc.HttpClient('actions/setup-dotnet', [], {
allowRetries: true, allowRetries: true,
@@ -78891,16 +78967,18 @@ class DotnetCoreInstaller {
version; version;
quality; quality;
architecture; architecture;
dotnetChannel;
static { static {
DotnetInstallDir.setEnvironmentVariable(); DotnetInstallDir.setEnvironmentVariable();
} }
constructor(version, quality, architecture) { constructor(version, quality, architecture, dotnetChannel) {
this.version = version; this.version = version;
this.quality = quality; this.quality = quality;
this.architecture = architecture; this.architecture = architecture;
this.dotnetChannel = dotnetChannel;
} }
async installDotnet() { async installDotnet() {
const versionResolver = new DotnetVersionResolver(this.version); const versionResolver = new DotnetVersionResolver(this.version, this.quality, this.dotnetChannel);
const dotnetVersion = await versionResolver.createDotnetVersion(); const dotnetVersion = await versionResolver.createDotnetVersion();
const architectureArguments = this.architecture && const architectureArguments = this.architecture &&
normalizeArch(this.architecture) !== normalizeArch(os_1.default.arch()) normalizeArch(this.architecture) !== normalizeArch(os_1.default.arch())
@@ -79019,13 +79097,7 @@ const cache_utils_1 = __nccwpck_require__(41678);
const cache_restore_1 = __nccwpck_require__(19517); const cache_restore_1 = __nccwpck_require__(19517);
const constants_1 = __nccwpck_require__(69042); const constants_1 = __nccwpck_require__(69042);
const json5_1 = __importDefault(__nccwpck_require__(86904)); const json5_1 = __importDefault(__nccwpck_require__(86904));
const qualityOptions = [ const qualityOptions = ['daily', 'preview', 'ga'];
'daily',
'signed',
'validated',
'preview',
'ga'
];
const supportedArchitectures = [ const supportedArchitectures = [
'x64', 'x64',
'x86', 'x86',
@@ -79036,6 +79108,19 @@ const supportedArchitectures = [
'ppc64le', 'ppc64le',
'riscv64' '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(/^(?<major>\d+)\.\d+\.\d{1}xx$/);
if (match && parseInt(match.groups.major) >= 5)
return true;
return false;
}
async function run() { async function run() {
try { try {
// //
@@ -79050,6 +79135,21 @@ async function run() {
const versions = core.getMultilineInput('dotnet-version'); const versions = core.getMultilineInput('dotnet-version');
const installedDotnetVersions = []; const installedDotnetVersions = [];
const architecture = getArchitectureInput(); 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'); const globalJsonFileInput = core.getInput('global-json-file');
if (globalJsonFileInput) { if (globalJsonFileInput) {
const globalJsonPath = path_1.default.resolve(process.cwd(), globalJsonFileInput); const globalJsonPath = path_1.default.resolve(process.cwd(), globalJsonFileInput);
@@ -79072,12 +79172,12 @@ async function run() {
if (versions.length) { if (versions.length) {
const quality = core.getInput('dotnet-quality'); const quality = core.getInput('dotnet-quality');
if (quality && !qualityOptions.includes(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; let dotnetInstaller;
const uniqueVersions = new Set(versions); const uniqueVersions = new Set(versions.map(v => (v.toLowerCase() === 'latest' ? 'latest' : v)));
for (const version of uniqueVersions) { 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(); const installedVersion = await dotnetInstaller.installDotnet();
installedDotnetVersions.push(installedVersion); installedDotnetVersions.push(installedVersion);
} }

View File

@@ -16,21 +16,74 @@ export interface DotnetVersion {
qualityFlag: boolean; 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 QUALITY_INPUT_MINIMAL_MAJOR_TAG = 6;
const LATEST_PATCH_SYNTAX_MINIMAL_MAJOR_TAG = 5; const LATEST_PATCH_SYNTAX_MINIMAL_MAJOR_TAG = 5;
export class DotnetVersionResolver { export class DotnetVersionResolver {
private inputVersion: string; private inputVersion: string;
private resolvedArgument: DotnetVersion; private resolvedArgument: DotnetVersion;
constructor(version: string) { constructor(
version: string,
private quality: QualityOptions = '',
private dotnetChannel?: string
) {
this.inputVersion = version.trim(); this.inputVersion = version.trim();
this.resolvedArgument = {type: '', value: '', qualityFlag: false}; 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<void> { private async resolveVersionInput(): Promise<void> {
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()) { if (!semver.validRange(this.inputVersion) && !this.isLatestPatchSyntax()) {
throw new Error( 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)) { if (semver.valid(this.inputVersion)) {
@@ -96,6 +149,64 @@ export class DotnetVersionResolver {
return this.resolvedArgument; return this.resolvedArgument;
} }
private async getLatestVersion(channelFilter: string): Promise<string> {
const httpClient = new hc.HttpClient('actions/setup-dotnet', [], {
allowRetries: true,
maxRetries: 3
});
const response = await httpClient.getJson<ReleaseIndexResponse>(
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<string> { private async getLatestByMajorTag(majorTag: string): Promise<string> {
const httpClient = new hc.HttpClient('actions/setup-dotnet', [], { const httpClient = new hc.HttpClient('actions/setup-dotnet', [], {
allowRetries: true, allowRetries: true,
@@ -279,11 +390,16 @@ export class DotnetCoreInstaller {
constructor( constructor(
private version: string, private version: string,
private quality: QualityOptions, private quality: QualityOptions,
private architecture?: string private architecture?: string,
private dotnetChannel?: string
) {} ) {}
public async installDotnet(): Promise<string | null> { public async installDotnet(): Promise<string | null> {
const versionResolver = new DotnetVersionResolver(this.version); const versionResolver = new DotnetVersionResolver(
this.version,
this.quality,
this.dotnetChannel
);
const dotnetVersion = await versionResolver.createDotnetVersion(); const dotnetVersion = await versionResolver.createDotnetVersion();
const architectureArguments = const architectureArguments =

View File

@@ -15,13 +15,7 @@ import {restoreCache} from './cache-restore';
import {Outputs} from './constants'; import {Outputs} from './constants';
import JSON5 from 'json5'; import JSON5 from 'json5';
const qualityOptions = [ const qualityOptions = ['daily', 'preview', 'ga'] as const;
'daily',
'signed',
'validated',
'preview',
'ga'
] as const;
const supportedArchitectures = [ const supportedArchitectures = [
'x64', 'x64',
'x86', 'x86',
@@ -34,7 +28,18 @@ const supportedArchitectures = [
] as const; ] as const;
type SupportedArchitecture = (typeof supportedArchitectures)[number]; 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(/^(?<major>\d+)\.\d+\.\d{1}xx$/);
if (match && parseInt(match.groups!.major) >= 5) return true;
return false;
}
export async function run() { export async function run() {
try { try {
@@ -50,6 +55,28 @@ export async function run() {
const versions = core.getMultilineInput('dotnet-version'); const versions = core.getMultilineInput('dotnet-version');
const installedDotnetVersions: (string | null)[] = []; const installedDotnetVersions: (string | null)[] = [];
const architecture = getArchitectureInput(); 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'); const globalJsonFileInput = core.getInput('global-json-file');
if (globalJsonFileInput) { if (globalJsonFileInput) {
@@ -80,17 +107,20 @@ export async function run() {
if (quality && !qualityOptions.includes(quality)) { if (quality && !qualityOptions.includes(quality)) {
throw new Error( 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; let dotnetInstaller: DotnetCoreInstaller;
const uniqueVersions = new Set<string>(versions); const uniqueVersions = new Set<string>(
versions.map(v => (v.toLowerCase() === 'latest' ? 'latest' : v))
);
for (const version of uniqueVersions) { for (const version of uniqueVersions) {
dotnetInstaller = new DotnetCoreInstaller( dotnetInstaller = new DotnetCoreInstaller(
version, version,
quality, quality,
architecture architecture,
version.toLowerCase() === 'latest' ? dotnetChannel : undefined
); );
const installedVersion = await dotnetInstaller.installDotnet(); const installedVersion = await dotnetInstaller.installDotnet();
installedDotnetVersions.push(installedVersion); installedDotnetVersions.push(installedVersion);