diff --git a/README.md b/README.md index dba72b63..ded2fa82 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,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) +| `oracle-openjdk` | [Oracle OpenJDK](https://jdk.java.net/) | [`oracle-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) @@ -159,6 +160,7 @@ Currently, the following distributions are supported: > [!NOTE] > - The different distributors can provide discrepant list of available versions / supported configurations. Please refer to the official documentation to see the list of supported versions. > - AdoptOpenJDK got moved to Eclipse Temurin and won't be updated anymore. It is highly recommended to migrate workflows from `adopt` and `adopt-openj9`, to `temurin` and `semeru` respectively, to keep receiving software and security updates. See more details in the [Good-bye AdoptOpenJDK post](https://blog.adoptopenjdk.net/2021/08/goodbye-adoptopenjdk-hello-adoptium/). +> - Oracle OpenJDK builds are created and hosted by Oracle. After a limited number of releases, Oracle archives these builds and no longer provides security updates. To continue receiving security patches, users must move to Oracle JDK or choose a different vendor. > - For Azul Zulu OpenJDK, architecture `arm64` is mapped to `aarch64` when querying the Azul Metadata API. > - To comply with the GraalVM Free Terms and Conditions (GFTC) license, it is recommended to use GraalVM JDK 17 version 17.0.12, as this is the only version of GraalVM JDK 17 available under the GFTC license. Additionally, it is encouraged to consider upgrading to GraalVM JDK 21, which offers the latest features and improvements. > - GraalVM Community is available as `distribution: 'graalvm-community'` for stable JDK 17 and later releases published on GitHub. diff --git a/__tests__/distributors/openjdk-installer.test.ts b/__tests__/distributors/openjdk-installer.test.ts new file mode 100644 index 00000000..48720a41 --- /dev/null +++ b/__tests__/distributors/openjdk-installer.test.ts @@ -0,0 +1,216 @@ +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 + tar.gz + 9.0.4 (build 9.0.4+11) + tar.gz +`; + +function createDistribution( + version = '26', + architecture = 'x64', + packageType = 'jdk', + useFixturePlatform = true +) { + const distribution = new OpenJdkDistribution({ + version, + architecture, + packageType, + checkLatest: false + }); + if (useFixturePlatform) { + distribution['getPlatform'] = jest.fn(() => 'linux'); + } + return distribution; +} + +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+10', + 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+8'); + expect(result.url).toContain('/openjdk-26.0.1_linux-x64_bin.tar.gz'); + }); + + it('resolves an exact GA build', async () => { + const result = + await createDistribution('26.0.2+10')['findPackageForDownload']( + '26.0.2+10' + ); + + expect(result.version).toBe('26.0.2+10'); + }); + + it('resolves an exact build from a legacy archive heading', async () => { + const result = + await createDistribution('9.0.4+11')['findPackageForDownload']( + '9.0.4+11' + ); + + expect(result.version).toBe('9.0.4+11'); + expect(result.url).toContain('/binaries/openjdk-9.0.4_linux-x64_bin'); + }); + + it('resolves a four-field Java version', async () => { + const result = + await createDistribution('18.0.1.1')['findPackageForDownload']( + '18.0.1+1' + ); + + expect(result.version).toBe('18.0.1+1'); + expect(result.url).toContain('/openjdk-18.0.1.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: Oracle OpenJDK" + ); + }); + + it.each([ + ['jre', 'Oracle OpenJDK provides only the `jdk` package type'], + ['jdk+fx', 'Oracle 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('26', 'x64', 'jdk', false); + + 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('parses legacy platform names and archive formats', () => { + const distribution = createDistribution(); + const macRelease = distribution['parseReleases']( + 'tar.gz', + 'macos', + 'x64' + ); + const windowsRelease = distribution['parseReleases']( + 'tar.gz', + 'windows', + 'x64' + ); + + expect(macRelease[0].version).toBe('16.0.0+7'); + expect(windowsRelease[0].version).toBe('10.0.2+13'); + expect(windowsRelease[0].url.endsWith('.tar.gz')).toBe(true); + }); + + it('is registered in the distribution factory', () => { + const distribution = getJavaDistribution('oracle-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 10be0455..d311f7fe 100644 --- a/dist/setup/index.js +++ b/dist/setup/index.js @@ -131882,6 +131882,109 @@ 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('Oracle OpenJDK', installerOptions); + } + async findPackageForDownload(range) { + if (this.packageType !== 'jdk') { + throw new Error('Oracle 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 = javaRelease.url.endsWith('.zip') ? 'zip' : 'tar.gz'; + if (extension === 'zip') { + 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 platformPattern = platform === 'macos' ? '(?:macos|osx)' : platform; + const extensionPattern = platform === 'windows' ? '(?:zip|tar\\.gz)' : 'tar\\.gz'; + const pattern = new RegExp(`href="(https://download\\.java\\.net/[^"]+/openjdk-([^"_]+)_${platformPattern}-${arch}_bin\\.${extensionPattern})"`, 'g'); + return Array.from(html.matchAll(pattern), match => { + const url = match[1]; + const build = url.match(/\/(\d+)\/(?:GPL\/)?openjdk-/)?.[1] ?? + this.findBuildInArchiveHeading(html, match.index, match[2]); + return { + version: this.toSemver(match[2], build), + url + }; + }); + } + findBuildInArchiveHeading(html, assetIndex, version) { + const headings = Array.from(html.slice(0, assetIndex).matchAll(/\(build\s+([^)]+)\)/g)); + const headingVersion = headings.at(-1)?.[1]; + if (!headingVersion) { + return undefined; + } + const [javaVersion, build] = headingVersion.split('+'); + return javaVersion === version ? build : undefined; + } + toSemver(version, urlBuild) { + const [javaVersion, filenameBuild] = version.replace('-ea', '').split('+'); + const versionParts = javaVersion.split('.'); + const normalizedVersion = convertVersionToSemver(versionParts.length === 1 ? `${javaVersion}.0.0` : javaVersion); + const build = filenameBuild ?? (versionParts.length <= 3 ? urlBuild : undefined); + 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 @@ -131898,6 +132001,7 @@ class KonaDistribution extends JavaBase { + var JavaDistribution; (function (JavaDistribution) { JavaDistribution["Adopt"] = "adopt"; @@ -131918,6 +132022,7 @@ var JavaDistribution; JavaDistribution["GraalVMCommunity"] = "graalvm-community"; JavaDistribution["JetBrains"] = "jetbrains"; JavaDistribution["Kona"] = "kona"; + JavaDistribution["OracleOpenJdk"] = "oracle-openjdk"; })(JavaDistribution || (JavaDistribution = {})); function getJavaDistribution(distributionName, installerOptions, jdkFile) { switch (distributionName) { @@ -131956,6 +132061,8 @@ function getJavaDistribution(distributionName, installerOptions, jdkFile) { return new JetBrainsDistribution(installerOptions); case JavaDistribution.Kona: return new KonaDistribution(installerOptions); + case JavaDistribution.OracleOpenJdk: + return new OpenJdkDistribution(installerOptions); default: return null; } diff --git a/docs/advanced-usage.md b/docs/advanced-usage.md index 36549c66..6ef042a0 100644 --- a/docs/advanced-usage.md +++ b/docs/advanced-usage.md @@ -154,6 +154,21 @@ steps: - run: java --version ``` +### Oracle OpenJDK +Oracle OpenJDK builds are created and hosted by Oracle under GPLv2+CE. To install the latest early-access build for a feature release, append `-ea` to the Java version: + +```yaml +steps: + - uses: actions/checkout@v7 + - uses: actions/setup-java@v6 + with: + distribution: 'oracle-openjdk' + java-version: '27-ea' + - run: java --version +``` + +Using `27` without the `-ea` suffix selects a stable (GA) release. Oracle archives OpenJDK builds after a limited number of releases and no longer provides security updates for them. To continue receiving security patches, move to Oracle JDK or choose a different vendor. + ### Alibaba Dragonwell **NOTE:** Alibaba Dragonwell only provides jdk. @@ -469,7 +484,7 @@ In this example, `JAVA_HOME` and `java` on `PATH` point to Java 17, while Java 2 If your use-case requires a custom distribution or a version that is not provided by setup-java, you can download it manually and setup-java will take care of the installation and caching on the VM: > [!NOTE] -> This approach also lets you use builds that setup-java does not provide directly, such as **Early Access (EA)** or other unreleased JDK builds (for example, an upcoming feature release or a Loom/Valhalla preview build). Download the desired archive in a prior step and point `jdk-file` at it; setup-java will extract, install, and cache it just like a supported distribution. When targeting multiple architectures, select the correct binary per architecture in your workflow (for example, with a build matrix). +> This approach also lets you use builds that setup-java does not provide directly, such as unreleased Loom/Valhalla preview builds or early-access builds not exposed by a supported distribution. Download the desired archive in a prior step and point `jdk-file` at it; setup-java will extract, install, and cache it just like a supported distribution. When targeting multiple architectures, select the correct binary per architecture in your workflow (for example, with a build matrix). ```yaml steps: @@ -486,23 +501,6 @@ steps: - run: java --version ``` -For example, to use an **Early Access** build from [jdk.java.net](https://jdk.java.net/), download the archive for your runner OS/architecture and install it via `distribution: 'jdkfile'` (example below assumes Linux x64): - -```yaml -steps: - - run: | - download_url="https://download.java.net/java/early_access/jdk25/36/GPL/openjdk-25-ea+36_linux-x64_bin.tar.gz" - wget -O $RUNNER_TEMP/java_package.tar.gz $download_url - - uses: actions/setup-java@v6 - with: - distribution: 'jdkfile' - jdk-file: ${{ runner.temp }}/java_package.tar.gz - java-version: '25.0.0-ea.36' - architecture: x64 - - - run: java --version -``` - If your use-case requires a custom distribution (in the example, alpine-linux is used) or a version that is not provided by setup-java and you want to always install the latest version during runtime, then you can use the following code to auto-download the latest JDK, determine the semver needed for setup-java, and setup-java will take care of the installation and caching on the VM: ```yaml diff --git a/src/distributions/distribution-factory.ts b/src/distributions/distribution-factory.ts index 7ad5db03..d27844ee 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', + OracleOpenJdk = 'oracle-openjdk' } export function getJavaDistribution( @@ -93,6 +95,8 @@ export function getJavaDistribution( return new JetBrainsDistribution(installerOptions); case JavaDistribution.Kona: return new KonaDistribution(installerOptions); + case JavaDistribution.OracleOpenJdk: + 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..8c8abea6 --- /dev/null +++ b/src/distributions/openjdk/installer.ts @@ -0,0 +1,181 @@ +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 { + convertVersionToSemver, + extractJdkFile, + isVersionSatisfies, + renameWinArchive +} from '../../util.js'; + +const OPENJDK_BASE_URL = 'https://jdk.java.net'; + +export class OpenJdkDistribution extends JavaBase { + constructor(installerOptions: JavaInstallerOptions) { + super('Oracle OpenJDK', installerOptions); + } + + protected async findPackageForDownload( + range: string + ): Promise { + if (this.packageType !== 'jdk') { + throw new Error('Oracle 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 = javaRelease.url.endsWith('.zip') ? 'zip' : 'tar.gz'; + if (extension === 'zip') { + 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 platformPattern = platform === 'macos' ? '(?:macos|osx)' : platform; + const extensionPattern = + platform === 'windows' ? '(?:zip|tar\\.gz)' : 'tar\\.gz'; + const pattern = new RegExp( + `href="(https://download\\.java\\.net/[^"]+/openjdk-([^"_]+)_${platformPattern}-${arch}_bin\\.${extensionPattern})"`, + 'g' + ); + + return Array.from(html.matchAll(pattern), match => { + const url = match[1]; + const build = + url.match(/\/(\d+)\/(?:GPL\/)?openjdk-/)?.[1] ?? + this.findBuildInArchiveHeading(html, match.index, match[2]); + return { + version: this.toSemver(match[2], build), + url + }; + }); + } + + private findBuildInArchiveHeading( + html: string, + assetIndex: number, + version: string + ): string | undefined { + const headings = Array.from( + html.slice(0, assetIndex).matchAll(/\(build\s+([^)]+)\)/g) + ); + const headingVersion = headings.at(-1)?.[1]; + if (!headingVersion) { + return undefined; + } + + const [javaVersion, build] = headingVersion.split('+'); + return javaVersion === version ? build : undefined; + } + + private toSemver(version: string, urlBuild?: string): string { + const [javaVersion, filenameBuild] = version.replace('-ea', '').split('+'); + const versionParts = javaVersion.split('.'); + const normalizedVersion = convertVersionToSemver( + versionParts.length === 1 ? `${javaVersion}.0.0` : javaVersion + ); + const build = + filenameBuild ?? (versionParts.length <= 3 ? urlBuild : undefined); + 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'` + ); + } + } +}