diff --git a/README.md b/README.md index ea327fd..23feafc 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ steps: - uses: actions/checkout@master - uses: actions/setup-dotnet@v1 with: - dotnet-version: '3.1.100' # SDK Version to use. + dotnet-version: '3.1.x' # SDK Version to use; x will use the latest version of the 3.1 channel - run: dotnet build ``` @@ -31,7 +31,7 @@ jobs: runs-on: ubuntu-16.04 strategy: matrix: - dotnet: [ '2.2.103', '3.0.100', '3.1.100' ] + dotnet: [ '2.2.103', '3.0', '3.1.x' ] name: Dotnet ${{ matrix.dotnet }} sample steps: - uses: actions/checkout@master @@ -49,7 +49,7 @@ steps: # Authenticates packages to push to GPR - uses: actions/setup-dotnet@v1 with: - dotnet-version: '3.1.100' # SDK Version to use. + dotnet-version: '3.1.x' # SDK Version to use. source-url: https://nuget.pkg.github.com//index.json env: NUGET_AUTH_TOKEN: ${{secrets.GITHUB_TOKEN}} diff --git a/__tests__/installer.test.ts b/__tests__/installer.test.ts index 2da3f76..bafd74c 100644 --- a/__tests__/installer.test.ts +++ b/__tests__/installer.test.ts @@ -4,6 +4,8 @@ import os = require('os'); import path = require('path'); import hc = require('@actions/http-client'); +import each from 'jest-each'; + const toolDir = path.join(__dirname, 'runner', 'tools'); const tempDir = path.join(__dirname, 'runner', 'temp'); @@ -14,6 +16,61 @@ import * as installer from '../src/installer'; const IS_WINDOWS = process.platform === 'win32'; +describe('version tests', () => { + each(['3.1.999', '3.1.101-preview.3']).test( + "Exact version '%s' should be the same", + vers => { + let versInfo = new installer.DotNetVersionInfo(vers); + + expect(versInfo.isExactVersion()).toBe(true); + expect(versInfo.version()).toBe(vers); + } + ); + + each([['3.1.x', '3.1'], ['1.1.*', '1.1'], ['2.0', '2.0']]).test( + "Generic version '%s' should be '%s'", + (vers, resVers) => { + let versInfo = new installer.DotNetVersionInfo(vers); + + expect(versInfo.isExactVersion()).toBe(false); + expect(versInfo.version()).toBe(resVers); + } + ); + + each([ + '', + '.', + '..', + ' . ', + '. ', + ' .', + ' . . ', + ' .. ', + ' . ', + '-1.-1', + '-1', + '-1.-1.-1', + '..3', + '1..3', + '1..', + '.2.3', + '.2.x', + '1', + '2.x', + '*.*.1', + '*.1', + '*.', + '1.2.', + '1.2.-abc', + 'a.b', + 'a.b.c', + 'a.b.c-preview', + ' 0 . 1 . 2 ' + ]).test("Malformed version '%s' should throw", vers => { + expect(() => new installer.DotNetVersionInfo(vers)).toThrow(); + }); +}); + describe('installer tests', () => { beforeAll(async () => { await io.rmRF(toolDir); @@ -29,6 +86,51 @@ describe('installer tests', () => { } }, 100000); + it('Resolving a normal generic version works', async () => { + const dotnetInstaller = new installer.DotnetCoreInstaller('3.1.x'); + let versInfo = await dotnetInstaller.resolveInfos( + ['win-x64'], + new installer.DotNetVersionInfo('3.1.x') + ); + + expect(versInfo.resolvedVersion.startsWith('3.1.')); + }, 100000); + + it('Resolving a nonexistent generic version fails', async () => { + const dotnetInstaller = new installer.DotnetCoreInstaller('999.1.x'); + try { + await dotnetInstaller.resolveInfos( + ['win-x64'], + new installer.DotNetVersionInfo('999.1.x') + ); + fail(); + } catch { + expect(true); + } + }, 100000); + + it('Resolving a exact stable version works', async () => { + const dotnetInstaller = new installer.DotnetCoreInstaller('3.1.201'); + let versInfo = await dotnetInstaller.resolveInfos( + ['win-x64'], + new installer.DotNetVersionInfo('3.1.201') + ); + + expect(versInfo.resolvedVersion).toBe('3.1.201'); + }, 100000); + + it('Resolving a exact preview version works', async () => { + const dotnetInstaller = new installer.DotnetCoreInstaller( + '5.0.0-preview.4' + ); + let versInfo = await dotnetInstaller.resolveInfos( + ['win-x64'], + new installer.DotNetVersionInfo('5.0.0-preview.4') + ); + + expect(versInfo.resolvedVersion).toBe('5.0.0-preview.4'); + }, 100000); + it('Acquires version of dotnet if no matching version is installed', async () => { await getDotnet('2.2.205'); const dotnetDir = path.join(toolDir, 'dncs', '2.2.205', os.arch()); @@ -39,7 +141,7 @@ describe('installer tests', () => { } else { expect(fs.existsSync(path.join(dotnetDir, 'dotnet'))).toBe(true); } - }, 100000); + }, 400000); //This needs some time to download on "slower" internet connections it('Acquires version of dotnet if no matching version is installed', async () => { const dotnetDir = path.join(toolDir, 'dncs', '2.2.105', os.arch()); diff --git a/action.yml b/action.yml index 242387b..3f702f2 100644 --- a/action.yml +++ b/action.yml @@ -6,7 +6,7 @@ branding: color: green inputs: dotnet-version: - description: 'SDK version to use. Example: 2.2.104' + description: 'SDK version to use. Examples: 2.2.104, 3.1, 3.1.x' source-url: description: 'Optional package source for which to set up authentication. Will consult any existing NuGet.config in the root of the repo and provide a temporary NuGet.config using the NUGET_AUTH_TOKEN environment variable as a ClearTextPassword' owner: diff --git a/src/installer.ts b/src/installer.ts index cd5d64d..8d9e6cc 100644 --- a/src/installer.ts +++ b/src/installer.ts @@ -27,31 +27,125 @@ if (!tempDirectory) { tempDirectory = path.join(baseLocation, 'actions', 'temp'); } +/** + * Represents the inputted version information + */ +export class DotNetVersionInfo { + private fullversion: string; + private isExactVersionSet: boolean = false; + + constructor(version: string) { + // Check for exact match + if (semver.valid(semver.clean(version) || '') != null) { + this.fullversion = semver.clean(version) as string; + this.isExactVersionSet = true; + + return; + } + + //Note: No support for previews when using generic + let parts: string[] = version.split('.'); + + if (parts.length < 2 || parts.length > 3) this.throwInvalidVersionFormat(); + + if (parts.length == 3 && parts[2] !== 'x' && parts[2] !== '*') { + this.throwInvalidVersionFormat(); + } + + let major = this.getVersionNumberOrThrow(parts[0]); + let minor = this.getVersionNumberOrThrow(parts[1]); + + this.fullversion = major + '.' + minor; + } + + private getVersionNumberOrThrow(input: string): number { + try { + if (!input || input.trim() === '') this.throwInvalidVersionFormat(); + + let number = Number(input); + + if (Number.isNaN(number) || number < 0) this.throwInvalidVersionFormat(); + + return number; + } catch { + this.throwInvalidVersionFormat(); + return -1; + } + } + + private throwInvalidVersionFormat() { + throw 'Invalid version format! Supported: 1.2.3, 1.2, 1.2.x, 1.2.*'; + } + + /** + * If true exacatly one version should be resolved + */ + public isExactVersion(): boolean { + return this.isExactVersionSet; + } + + public version(): string { + return this.fullversion; + } +} + +/** + * Represents a resolved version from the Web-Api + */ +class ResolvedVersionInfo { + downloadUrls: string[]; + resolvedVersion: string; + + constructor(downloadUrls: string[], resolvedVersion: string) { + if (downloadUrls.length === 0) { + throw 'DownloadUrls can not be empty'; + } + + if (!resolvedVersion) { + throw 'Resolved version is invalid'; + } + + this.downloadUrls = downloadUrls; + this.resolvedVersion = resolvedVersion; + } +} + export class DotnetCoreInstaller { constructor(version: string) { - if (semver.valid(semver.clean(version) || '') == null) { - throw 'Implicit version not permitted'; - } - this.version = version; + this.versionInfo = new DotNetVersionInfo(version); this.cachedToolName = 'dncs'; this.arch = 'x64'; } public async installDotnet() { // Check cache - let toolPath: string; + let toolPath: string = ''; let osSuffixes = await this.detectMachineOS(); let parts = osSuffixes[0].split('-'); if (parts.length > 1) { this.arch = parts[1]; } - toolPath = this.getLocalTool(); + + // If version is not generic -> look up cache + if (this.versionInfo.isExactVersion()) + toolPath = this.getLocalTool(this.versionInfo.version()); if (!toolPath) { // download, extract, cache - console.log('Getting a download url', this.version); - let downloadUrls = await this.getDownloadUrls(osSuffixes, this.version); - toolPath = await this.downloadAndInstall(downloadUrls); + console.log('Getting a download url', this.versionInfo.version()); + let resolvedVersionInfo = await this.resolveInfos( + osSuffixes, + this.versionInfo + ); + + //Check if cache exists for resolved version + toolPath = this.getLocalTool(resolvedVersionInfo.resolvedVersion); + if (!toolPath) { + //If not exists install it + toolPath = await this.downloadAndInstall(resolvedVersionInfo); + } else { + console.log('Using cached tool'); + } } else { console.log('Using cached tool'); } @@ -63,9 +157,9 @@ export class DotnetCoreInstaller { core.addPath(toolPath); } - private getLocalTool(): string { - console.log('Checking tool cache'); - return tc.find(this.cachedToolName, this.version, this.arch); + private getLocalTool(version: string): string { + console.log('Checking tool cache', version); + return tc.find(this.cachedToolName, version, this.arch); } private async detectMachineOS(): Promise { @@ -141,16 +235,16 @@ export class DotnetCoreInstaller { return osSuffix; } - private async downloadAndInstall(downloadUrls: string[]) { + private async downloadAndInstall(resolvedVersionInfo: ResolvedVersionInfo) { let downloaded = false; let downloadPath = ''; - for (const url of downloadUrls) { + for (const url of resolvedVersionInfo.downloadUrls) { try { downloadPath = await tc.downloadTool(url); downloaded = true; break; } catch (error) { - console.log('Could Not Download', url, JSON.stringify(error)); + console.log('Could not Download', url, JSON.stringify(error)); } } @@ -169,30 +263,29 @@ export class DotnetCoreInstaller { let cachedDir = await tc.cacheDir( extPath, this.cachedToolName, - this.version, + resolvedVersionInfo.resolvedVersion, this.arch ); - console.log('Successfully installed', this.version); + console.log('Successfully installed', resolvedVersionInfo.resolvedVersion); return cachedDir; } // OsSuffixes - The suffix which is a part of the file name ex- linux-x64, windows-x86 // Type - SDK / Runtime - // Version - Version of the SDK/Runtime - private async getDownloadUrls( + // versionInfo - versionInfo of the SDK/Runtime + async resolveInfos( osSuffixes: string[], - version: string - ): Promise { - let downloadUrls: string[] = []; - + versionInfo: DotNetVersionInfo + ): Promise { const httpClient = new hc.HttpClient('actions/setup-dotnet', [], { allowRetries: true, maxRetries: 3 }); + const releasesJsonUrl: string = await this.getReleasesJsonUrl( httpClient, - version.split('.') + versionInfo.version().split('.') ); const releasesResponse = await httpClient.getJson(releasesJsonUrl); @@ -200,13 +293,39 @@ export class DotnetCoreInstaller { let releasesInfo: any[] = releasesResult['releases']; releasesInfo = releasesInfo.filter((releaseInfo: any) => { return ( - releaseInfo['sdk']['version'] === version || - releaseInfo['sdk']['version-display'] === version + semver.satisfies( + releaseInfo['sdk']['version'], + versionInfo.version() + ) || + semver.satisfies( + releaseInfo['sdk']['version-display'], + versionInfo.version() + ) ); }); + // Exclude versions that are newer than the latest if using not exact + if (!versionInfo.isExactVersion()) { + let latestSdk: string = releasesResult['latest-sdk']; + + releasesInfo = releasesInfo.filter((releaseInfo: any) => + semver.lte(releaseInfo['sdk']['version'], latestSdk) + ); + } + + // Sort for latest version + releasesInfo = releasesInfo.sort((a, b) => + semver.rcompare(a['sdk']['version'], b['sdk']['version']) + ); + + let downloadedVersion: string = ''; + let downloadUrls: string[] = []; + if (releasesInfo.length != 0) { let release = releasesInfo[0]; + + downloadedVersion = release['sdk']['version']; + let files: any[] = release['sdk']['files']; files = files.filter((file: any) => { if (file['rid'] == osSuffixes[0] || file['rid'] == osSuffixes[1]) { @@ -225,18 +344,28 @@ export class DotnetCoreInstaller { } } else { console.log( - `Could not fetch download information for version ${version}` + `Could not fetch download information for version ${versionInfo.version()}` ); - downloadUrls = await this.getFallbackDownloadUrls(version); + + if (versionInfo.isExactVersion()) { + console.log('Using fallback'); + + downloadUrls = await this.getFallbackDownloadUrls( + versionInfo.version() + ); + downloadedVersion = versionInfo.version(); + } else { + console.log('Unable to use fallback, version is generic!'); + } } if (downloadUrls.length == 0) { - throw `Could not construct download URL. Please ensure that specified version ${version} is valid.`; + throw `Could not construct download URL. Please ensure that specified version ${versionInfo.version()}/${downloadedVersion} is valid.`; } core.debug(`Got download urls ${downloadUrls}`); - return downloadUrls; + return new ResolvedVersionInfo(downloadUrls, downloadedVersion); } private async getReleasesJsonUrl( @@ -361,7 +490,7 @@ export class DotnetCoreInstaller { return [primaryUrl, legacyUrl]; } - private version: string; + private versionInfo: DotNetVersionInfo; private cachedToolName: string; private arch: string; }