mirror of
https://github.com/actions/setup-dotnet.git
synced 2026-05-10 08:58:17 +01:00
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:
124
src/installer.ts
124
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<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()) {
|
||||
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<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> {
|
||||
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<string | null> {
|
||||
const versionResolver = new DotnetVersionResolver(this.version);
|
||||
const versionResolver = new DotnetVersionResolver(
|
||||
this.version,
|
||||
this.quality,
|
||||
this.dotnetChannel
|
||||
);
|
||||
const dotnetVersion = await versionResolver.createDotnetVersion();
|
||||
|
||||
const architectureArguments =
|
||||
|
||||
@@ -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(/^(?<major>\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<string>(versions);
|
||||
const uniqueVersions = new Set<string>(
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user