diff --git a/README.md b/README.md index 461338a9..2986da3e 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,7 @@ Currently, the following distributions are supported: | `corretto` | [Amazon Corretto Build of OpenJDK](https://aws.amazon.com/corretto/) | [`corretto` license](https://aws.amazon.com/corretto/faqs/) | `semeru` | [IBM Semeru Runtime Open Edition](https://developer.ibm.com/languages/java/semeru-runtimes/downloads/) | [`semeru` license](https://openjdk.java.net/legal/gplv2+ce.html) | | `oracle` | [Oracle JDK](https://www.oracle.com/java/technologies/downloads/) | [`oracle` license](https://java.com/freeuselicense) +| `openjdk` | [OpenJDK](https://jdk.java.net/) | [`openjdk` license](https://openjdk.org/legal/gplv2+ce.html) | `dragonwell` | [Alibaba Dragonwell JDK](https://dragonwell-jdk.io/) | [`dragonwell` license](https://www.aliyun.com/product/dragonwell/) | `sapmachine` | [SAP SapMachine JDK/JRE](https://sapmachine.io/) | [`sapmachine` license](https://github.com/SAP/SapMachine/blob/sapmachine/LICENSE) | `graalvm` | [Oracle GraalVM](https://www.graalvm.org/) | [`graalvm` license](https://www.oracle.com/downloads/licenses/graal-free-license.html) diff --git a/__tests__/distributors/openjdk-installer.test.ts b/__tests__/distributors/openjdk-installer.test.ts new file mode 100644 index 00000000..f6714fd7 --- /dev/null +++ b/__tests__/distributors/openjdk-installer.test.ts @@ -0,0 +1,161 @@ +import {afterEach, beforeEach, describe, expect, it, jest} from '@jest/globals'; +import {HttpClient} from '@actions/http-client'; + +jest.unstable_mockModule('@actions/core', () => ({ + info: jest.fn(), + warning: jest.fn(), + debug: jest.fn(), + error: jest.fn(), + notice: jest.fn(), + setFailed: jest.fn(), + setOutput: jest.fn(), + getInput: jest.fn(), + getBooleanInput: jest.fn(), + getMultilineInput: jest.fn(), + addPath: jest.fn(), + exportVariable: jest.fn(), + saveState: jest.fn(), + getState: jest.fn(), + setSecret: jest.fn(), + isDebug: jest.fn(() => false), + startGroup: jest.fn(), + endGroup: jest.fn(), + group: jest.fn((_name: string, fn: () => Promise) => fn()), + toPlatformPath: jest.fn((value: string) => value), + toWin32Path: jest.fn((value: string) => value), + toPosixPath: jest.fn((value: string) => value) +})); + +const {OpenJdkDistribution} = + await import('../../src/distributions/openjdk/installer.js'); +const {getJavaDistribution} = + await import('../../src/distributions/distribution-factory.js'); + +const homePage = ` + JDK 26 + JDK + 27 +`; +const currentPage = ` + tar.gz + tar.gz +`; +const earlyAccessPage = ` + tar.gz +`; +const archivePage = ` + tar.gz + tar.gz +`; + +function createDistribution( + version = '26', + architecture = 'x64', + packageType = 'jdk' +) { + return new OpenJdkDistribution({ + version, + architecture, + packageType, + checkLatest: false + }); +} + +describe('OpenJdkDistribution', () => { + let getSpy: jest.SpiedFunction; + + beforeEach(() => { + getSpy = jest + .spyOn(HttpClient.prototype, 'get') + .mockImplementation(async url => { + const pages: Record = { + 'https://jdk.java.net/': homePage, + 'https://jdk.java.net/26/': currentPage, + 'https://jdk.java.net/27/': earlyAccessPage, + 'https://jdk.java.net/archive/': archivePage + }; + return { + readBody: async () => pages[url] ?? '' + } as Awaited>; + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('resolves the newest matching GA release', async () => { + const result = await createDistribution()['findPackageForDownload']('26'); + + expect(result).toEqual({ + version: '26.0.2', + url: 'https://download.java.net/java/GA/jdk26.0.2/hash/10/GPL/openjdk-26.0.2_linux-x64_bin.tar.gz' + }); + }); + + it('resolves an archived GA release', async () => { + const result = + await createDistribution('26.0.1')['findPackageForDownload']('26.0.1'); + + expect(result.version).toBe('26.0.1'); + expect(result.url).toContain('/openjdk-26.0.1_linux-x64_bin.tar.gz'); + }); + + it('resolves an early-access release without requesting the archive', async () => { + const result = + await createDistribution('27-ea')['findPackageForDownload']('27'); + + expect(result).toEqual({ + version: '27.0.0+32', + url: 'https://download.java.net/java/early_access/jdk27/32/GPL/openjdk-27-ea+32_linux-x64_bin.tar.gz' + }); + expect(getSpy).not.toHaveBeenCalledWith('https://jdk.java.net/archive/'); + }); + + it('reports available versions when no release matches', async () => { + await expect( + createDistribution()['findPackageForDownload']('24') + ).rejects.toThrow( + "No matching version found for SemVer '24'.\nDistribution: OpenJDK" + ); + }); + + it.each([ + ['jre', 'OpenJDK provides only the `jdk` package type'], + ['jdk+fx', 'OpenJDK provides only the `jdk` package type'] + ])('rejects the %s package type', async (packageType, message) => { + await expect( + createDistribution('26', 'x64', packageType)['findPackageForDownload']( + '26' + ) + ).rejects.toThrow(message); + }); + + it('rejects unsupported architectures', async () => { + await expect( + createDistribution('26', 'x86')['findPackageForDownload']('26') + ).rejects.toThrow('Unsupported architecture: x86'); + }); + + it('maps supported platforms', () => { + const distribution = createDistribution(); + + expect(distribution['getPlatform']('linux')).toBe('linux'); + expect(distribution['getPlatform']('darwin')).toBe('macos'); + expect(distribution['getPlatform']('win32')).toBe('windows'); + expect(() => distribution['getPlatform']('freebsd')).toThrow( + "Platform 'freebsd' is not supported" + ); + }); + + it('is registered in the distribution factory', () => { + const distribution = getJavaDistribution('openjdk', { + version: '26', + architecture: 'x64', + packageType: 'jdk', + checkLatest: false + }); + + expect(distribution).toBeInstanceOf(OpenJdkDistribution); + }); +}); diff --git a/dist/setup/index.js b/dist/setup/index.js index 89e5b84e..58dbe7b6 100644 --- a/dist/setup/index.js +++ b/dist/setup/index.js @@ -131874,6 +131874,94 @@ class KonaDistribution extends JavaBase { } } +;// CONCATENATED MODULE: ./src/distributions/openjdk/installer.ts + + + + + + + +const OPENJDK_BASE_URL = 'https://jdk.java.net'; +class OpenJdkDistribution extends JavaBase { + constructor(installerOptions) { + super('OpenJDK', installerOptions); + } + async findPackageForDownload(range) { + if (this.packageType !== 'jdk') { + throw new Error('OpenJDK provides only the `jdk` package type'); + } + const arch = this.distributionArchitecture(); + if (!['x64', 'aarch64'].includes(arch)) { + throw new Error(`Unsupported architecture: ${this.architecture}`); + } + const platform = this.getPlatform(); + const releases = await this.getAvailableVersions(platform, arch); + const matchingReleases = releases + .filter(release => isVersionSatisfies(range, release.version)) + .sort((left, right) => -semver_default().compareBuild(left.version, right.version)); + if (!matchingReleases.length) { + throw this.createVersionNotFoundError(range, releases.map(release => release.version), `Platform: ${platform}`); + } + return matchingReleases[0]; + } + async downloadTool(javaRelease) { + info(`Downloading Java ${javaRelease.version} (${this.distribution}) from ${javaRelease.url} ...`); + let javaArchivePath = await downloadTool(javaRelease.url); + info(`Extracting Java archive...`); + const extension = getDownloadArchiveExtension(); + if (process.platform === 'win32') { + javaArchivePath = renameWinArchive(javaArchivePath); + } + const extractedJavaPath = await extractJdkFile(javaArchivePath, extension); + const archiveName = external_fs_default().readdirSync(extractedJavaPath)[0]; + const archivePath = external_path_default().join(extractedJavaPath, archiveName); + const javaPath = await cacheDir(archivePath, this.toolcacheFolderName, this.getToolcacheVersionName(javaRelease.version), this.architecture); + return { version: javaRelease.version, path: javaPath }; + } + async getAvailableVersions(platform, arch) { + const homePage = await this.fetchPage(`${OPENJDK_BASE_URL}/`); + const releasePageUrls = Array.from(homePage.matchAll(/href="\/(\d+)\/">JDK\s+\d+/g), match => `${OPENJDK_BASE_URL}/${match[1]}/`); + const pages = await Promise.all(releasePageUrls.map(url => this.fetchPage(url))); + if (this.stable) { + pages.push(await this.fetchPage(`${OPENJDK_BASE_URL}/archive/`)); + } + const releases = pages.flatMap(page => this.parseReleases(page, platform, arch)); + return releases.filter(release => release.url.includes('/early_access/') !== this.stable); + } + async fetchPage(url) { + const response = await this.http.get(url); + return response.readBody(); + } + parseReleases(html, platform, arch) { + const extension = platform === 'windows' ? 'zip' : 'tar.gz'; + const pattern = new RegExp(`href="(https://download\\.java\\.net/[^"]+/openjdk-([^"_]+)_${platform}-${arch}_bin\\.${extension.replaceAll('.', '\\.')})"`, 'g'); + return Array.from(html.matchAll(pattern), match => ({ + version: this.toSemver(match[2]), + url: match[1] + })); + } + toSemver(version) { + const [javaVersion, build] = version.replace('-ea', '').split('+'); + const normalizedVersion = javaVersion.includes('.') + ? javaVersion + : `${javaVersion}.0.0`; + return build ? `${normalizedVersion}+${build}` : normalizedVersion; + } + getPlatform(platform = process.platform) { + switch (platform) { + case 'darwin': + return 'macos'; + case 'linux': + return 'linux'; + case 'win32': + return 'windows'; + default: + throw new Error(`Platform '${platform}' is not supported. Supported platforms: 'linux', 'macos', 'windows'`); + } + } +} + ;// CONCATENATED MODULE: ./src/distributions/distribution-factory.ts @@ -131890,6 +131978,7 @@ class KonaDistribution extends JavaBase { + var JavaDistribution; (function (JavaDistribution) { JavaDistribution["Adopt"] = "adopt"; @@ -131910,6 +131999,7 @@ var JavaDistribution; JavaDistribution["GraalVMCommunity"] = "graalvm-community"; JavaDistribution["JetBrains"] = "jetbrains"; JavaDistribution["Kona"] = "kona"; + JavaDistribution["OpenJdk"] = "openjdk"; })(JavaDistribution || (JavaDistribution = {})); function getJavaDistribution(distributionName, installerOptions, jdkFile) { switch (distributionName) { @@ -131948,6 +132038,8 @@ function getJavaDistribution(distributionName, installerOptions, jdkFile) { return new JetBrainsDistribution(installerOptions); case JavaDistribution.Kona: return new KonaDistribution(installerOptions); + case JavaDistribution.OpenJdk: + return new OpenJdkDistribution(installerOptions); default: return null; } diff --git a/src/distributions/distribution-factory.ts b/src/distributions/distribution-factory.ts index 7ad5db03..fe53b2e9 100644 --- a/src/distributions/distribution-factory.ts +++ b/src/distributions/distribution-factory.ts @@ -21,6 +21,7 @@ import { } from './graalvm/installer.js'; import {JetBrainsDistribution} from './jetbrains/installer.js'; import {KonaDistribution} from './kona/installer.js'; +import {OpenJdkDistribution} from './openjdk/installer.js'; enum JavaDistribution { Adopt = 'adopt', @@ -40,7 +41,8 @@ enum JavaDistribution { GraalVM = 'graalvm', GraalVMCommunity = 'graalvm-community', JetBrains = 'jetbrains', - Kona = 'kona' + Kona = 'kona', + OpenJdk = 'openjdk' } export function getJavaDistribution( @@ -93,6 +95,8 @@ export function getJavaDistribution( return new JetBrainsDistribution(installerOptions); case JavaDistribution.Kona: return new KonaDistribution(installerOptions); + case JavaDistribution.OpenJdk: + return new OpenJdkDistribution(installerOptions); default: return null; } diff --git a/src/distributions/openjdk/installer.ts b/src/distributions/openjdk/installer.ts new file mode 100644 index 00000000..7c225172 --- /dev/null +++ b/src/distributions/openjdk/installer.ts @@ -0,0 +1,153 @@ +import * as core from '@actions/core'; +import * as tc from '@actions/tool-cache'; +import fs from 'fs'; +import path from 'path'; +import semver from 'semver'; + +import {JavaBase} from '../base-installer.js'; +import { + JavaDownloadRelease, + JavaInstallerOptions, + JavaInstallerResults +} from '../base-models.js'; +import { + extractJdkFile, + getDownloadArchiveExtension, + isVersionSatisfies, + renameWinArchive +} from '../../util.js'; + +const OPENJDK_BASE_URL = 'https://jdk.java.net'; + +export class OpenJdkDistribution extends JavaBase { + constructor(installerOptions: JavaInstallerOptions) { + super('OpenJDK', installerOptions); + } + + protected async findPackageForDownload( + range: string + ): Promise { + if (this.packageType !== 'jdk') { + throw new Error('OpenJDK provides only the `jdk` package type'); + } + + const arch = this.distributionArchitecture(); + if (!['x64', 'aarch64'].includes(arch)) { + throw new Error(`Unsupported architecture: ${this.architecture}`); + } + + const platform = this.getPlatform(); + const releases = await this.getAvailableVersions(platform, arch); + const matchingReleases = releases + .filter(release => isVersionSatisfies(range, release.version)) + .sort((left, right) => -semver.compareBuild(left.version, right.version)); + + if (!matchingReleases.length) { + throw this.createVersionNotFoundError( + range, + releases.map(release => release.version), + `Platform: ${platform}` + ); + } + + return matchingReleases[0]; + } + + protected async downloadTool( + javaRelease: JavaDownloadRelease + ): Promise { + core.info( + `Downloading Java ${javaRelease.version} (${this.distribution}) from ${javaRelease.url} ...` + ); + let javaArchivePath = await tc.downloadTool(javaRelease.url); + + core.info(`Extracting Java archive...`); + const extension = getDownloadArchiveExtension(); + if (process.platform === 'win32') { + javaArchivePath = renameWinArchive(javaArchivePath); + } + const extractedJavaPath = await extractJdkFile(javaArchivePath, extension); + + const archiveName = fs.readdirSync(extractedJavaPath)[0]; + const archivePath = path.join(extractedJavaPath, archiveName); + const javaPath = await tc.cacheDir( + archivePath, + this.toolcacheFolderName, + this.getToolcacheVersionName(javaRelease.version), + this.architecture + ); + + return {version: javaRelease.version, path: javaPath}; + } + + private async getAvailableVersions( + platform: string, + arch: string + ): Promise { + const homePage = await this.fetchPage(`${OPENJDK_BASE_URL}/`); + const releasePageUrls = Array.from( + homePage.matchAll(/href="\/(\d+)\/">JDK\s+\d+/g), + match => `${OPENJDK_BASE_URL}/${match[1]}/` + ); + + const pages = await Promise.all( + releasePageUrls.map(url => this.fetchPage(url)) + ); + if (this.stable) { + pages.push(await this.fetchPage(`${OPENJDK_BASE_URL}/archive/`)); + } + + const releases = pages.flatMap(page => + this.parseReleases(page, platform, arch) + ); + + return releases.filter( + release => release.url.includes('/early_access/') !== this.stable + ); + } + + private async fetchPage(url: string): Promise { + const response = await this.http.get(url); + return response.readBody(); + } + + private parseReleases( + html: string, + platform: string, + arch: string + ): JavaDownloadRelease[] { + const extension = platform === 'windows' ? 'zip' : 'tar.gz'; + const pattern = new RegExp( + `href="(https://download\\.java\\.net/[^"]+/openjdk-([^"_]+)_${platform}-${arch}_bin\\.${extension.replaceAll('.', '\\.')})"`, + 'g' + ); + + return Array.from(html.matchAll(pattern), match => ({ + version: this.toSemver(match[2]), + url: match[1] + })); + } + + private toSemver(version: string): string { + const [javaVersion, build] = version.replace('-ea', '').split('+'); + const normalizedVersion = javaVersion.includes('.') + ? javaVersion + : `${javaVersion}.0.0`; + return build ? `${normalizedVersion}+${build}` : normalizedVersion; + } + + private getPlatform(platform: NodeJS.Platform = process.platform): string { + switch (platform) { + case 'darwin': + return 'macos'; + case 'linux': + return 'linux'; + case 'win32': + return 'windows'; + default: + throw new Error( + `Platform '${platform}' is not supported. Supported platforms: 'linux', 'macos', 'windows'` + ); + } + } +}