diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d653140..6069599 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -30,28 +30,90 @@ jobs: needs: [build] strategy: matrix: - os: [ubuntu-latest, macos-latest, windows-latest, windows-11-arm] + os: [ubuntu-latest, macos-latest] runs-on: ${{ matrix.os }} steps: - uses: actions/download-artifact@v4 with: name: dist - - uses: ./ + - name: Test default + uses: ./ + id: test-default - run: | firefox --version - - uses: ./ + ${{ steps.test-default.outputs.firefox-path }} --version + - name: Test latest-esr + uses: ./ with: firefox-version: latest-esr + id: test-latest-esr - run: | firefox --version - - uses: ./ + ${{ steps.test-latest-esr.outputs.firefox-path }} --version + - name: Test 132.0 + uses: ./ with: firefox-version: "132.0" + id: test-132-0 - run: | firefox --version - - uses: ./ + ${{ steps.test-132-0.outputs.firefox-path }} --version + - name: Test devedition-132.0b1 + uses: ./ with: firefox-version: "devedition-132.0b1" + id: test-devedition-132-0b1 - run: | firefox --version + ${{ steps.test-devedition-132-0b1.outputs.firefox-path }} --version + + test-windows: + needs: [build] + strategy: + matrix: + os: [windows-latest, windows-11-arm] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/download-artifact@v4 + with: + name: dist + + - name: Test default + uses: ./ + id: test-default + - name: Test default version + shell: pwsh + run: | + firefox --version + & "${{ steps.test-default.outputs.firefox-path }}" --version + - name: Test latest-esr + uses: ./ + with: + firefox-version: latest-esr + id: test-latest-esr + - name: Test latest-esr version + shell: pwsh + run: | + firefox --version + & "${{ steps.test-latest-esr.outputs.firefox-path }}" --version + - name: Test 132.0 + uses: ./ + with: + firefox-version: "132.0" + id: test-132-0 + - name: Test 132.0 version + shell: pwsh + run: | + firefox --version + & "${{ steps.test-132-0.outputs.firefox-path }}" --version + - name: Test devedition-132.0b1 + uses: ./ + with: + firefox-version: "devedition-132.0b1" + id: test-devedition-132-0b1 + - name: Test devedition-132.0b1 version + shell: pwsh + run: | + firefox --version + & "${{ steps.test-devedition-132-0b1.outputs.firefox-path }}" --version diff --git a/__test__/InstallerFactory.test.ts b/__test__/installers.test.ts similarity index 68% rename from __test__/InstallerFactory.test.ts rename to __test__/installers.test.ts index 2664f61..b4bc2cd 100644 --- a/__test__/InstallerFactory.test.ts +++ b/__test__/installers.test.ts @@ -1,10 +1,8 @@ import { describe, expect, test } from "vitest"; -import { - LinuxInstaller, - MacOSInstaller, - WindowsInstaller, -} from "../src/Installer"; -import InstallerFactory from "../src/InstallerFactory"; +import { LinuxInstaller } from "../src/LinuxInstaller"; +import { MacOSInstaller } from "../src/MacOSInstaller"; +import { WindowsInstaller } from "../src/WindowsInstaller"; +import { InstallerFactory } from "../src/installers"; import { Arch, OS } from "../src/platform"; describe("InstallerFactory", () => { diff --git a/src/Installer.ts b/src/Installer.ts deleted file mode 100644 index 79f901b..0000000 --- a/src/Installer.ts +++ /dev/null @@ -1,190 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; -import * as core from "@actions/core"; -import * as exec from "@actions/exec"; -import * as io from "@actions/io"; -import * as tc from "@actions/tool-cache"; -import { DownloadURLFactory } from "./DownloadURLFactory"; -import type { Platform } from "./platform"; -import { LatestVersion } from "./versions"; - -export type InstallSpec = { - version: string; - platform: Platform; - language: string; -}; - -export default interface Installer { - install(spec: InstallSpec): Promise; - - testVersion(bin: string): Promise; -} - -const commonTestVersion = async (bin: string): Promise => { - const output = await exec.getExecOutput(`"${bin}"`, ["--version"]); - if (output.exitCode !== 0) { - throw new Error( - `firefox exits with status ${output.exitCode}: ${output.stderr}`, - ); - } - if (!output.stdout.startsWith("Mozilla Firefox ")) { - throw new Error(`firefox outputs unexpected results: ${output.stdout}`); - } - return output.stdout.trimEnd().replace("Mozilla Firefox ", ""); -}; - -export class LinuxInstaller implements Installer { - async install(spec: InstallSpec): Promise { - return this.download(spec); - } - - private async download({ - version, - platform, - language, - }: InstallSpec): Promise { - const toolPath = tc.find("firefox", version); - if (toolPath) { - core.info(`Found in cache @ ${toolPath}`); - return toolPath; - } - core.info(`Attempting to download firefox ${version}...`); - - const url = new DownloadURLFactory(version, platform, language) - .create() - .getURL(); - core.info(`Acquiring ${version} from ${url}`); - - const archivePath = await tc.downloadTool(url); - core.info("Extracting Firefox..."); - const handle = await fs.promises.open(archivePath, "r"); - const firstBytes = new Int8Array(3); - if (handle !== null) { - await handle.read(firstBytes, 0, 3, null); - core.debug( - `Extracted ${firstBytes[0]}, ${firstBytes[1]} and ${firstBytes[2]}`, - ); - } - const options = - firstBytes[0] === 66 && firstBytes[1] === 90 && firstBytes[2] === 104 - ? "xj" - : "xJ"; - const extPath = await tc.extractTar(archivePath, "", [ - options, - "--strip-components=1", - ]); - core.info(`Successfully extracted firefox ${version} to ${extPath}`); - - core.info("Adding to the cache ..."); - const cachedDir = await tc.cacheDir(extPath, "firefox", version); - core.info(`Successfully cached firefox ${version} to ${cachedDir}`); - return cachedDir; - } - - testVersion = commonTestVersion; -} - -export class MacOSInstaller implements Installer { - async install(spec: InstallSpec): Promise { - const installPath = await this.download(spec); - return path.join(installPath, "Contents", "MacOS"); - } - - async download({ - version, - platform, - language, - }: InstallSpec): Promise { - const toolPath = tc.find("firefox", version); - if (toolPath) { - core.info(`Found in cache @ ${toolPath}`); - return toolPath; - } - core.info(`Attempting to download firefox ${version}...`); - - const url = new DownloadURLFactory(version, platform, language) - .create() - .getURL(); - core.info(`Acquiring ${version} from ${url}`); - - const archivePath = await tc.downloadTool(url); - core.info("Extracting Firefox..."); - - const mountpoint = path.join("/Volumes", path.basename(archivePath)); - const appPath = (() => { - if (version === LatestVersion.LATEST_NIGHTLY) { - return path.join(mountpoint, "Firefox Nightly.app"); - } else if (version.includes("devedition")) { - return path.join(mountpoint, "Firefox Developer Edition.app"); - } else { - return path.join(mountpoint, "Firefox.app"); - } - })(); - - await exec.exec("hdiutil", [ - "attach", - "-quiet", - "-noautofsck", - "-noautoopen", - "-mountpoint", - mountpoint, - archivePath, - ]); - core.info(`Successfully extracted firefox ${version} to ${appPath}`); - - core.info("Adding to the cache ..."); - const cachedDir = await tc.cacheDir(appPath, "firefox", version); - core.info(`Successfully cached firefox ${version} to ${cachedDir}`); - return cachedDir; - } - - testVersion = commonTestVersion; -} - -export class WindowsInstaller implements Installer { - install(spec: InstallSpec): Promise { - return this.download(spec); - } - - async download({ - version, - platform, - language, - }: InstallSpec): Promise { - const installPath = `C:\\Program Files\\Firefox_${version}`; - if (await this.checkInstall(installPath)) { - core.info(`Already installed @ ${installPath}`); - return installPath; - } - - core.info(`Attempting to download firefox ${version}...`); - - const url = new DownloadURLFactory(version, platform, language) - .create() - .getURL(); - core.info(`Acquiring ${version} from ${url}`); - - const installerPath = await tc.downloadTool(url); - await io.mv(installerPath, `${installerPath}.exe`); - core.info("Extracting Firefox..."); - - await exec.exec(installerPath, [ - "/S", - `/InstallDirectoryName=${path.basename(installPath)}`, - ]); - core.info(`Successfully installed firefox ${version} to ${installPath}`); - - return installPath; - } - - private async checkInstall(dir: string): Promise { - try { - await fs.promises.access(dir, fs.constants.F_OK); - } catch (err) { - return false; - } - return true; - } - - testVersion = commonTestVersion; -} diff --git a/src/InstallerFactory.ts b/src/InstallerFactory.ts deleted file mode 100644 index 64a1c4f..0000000 --- a/src/InstallerFactory.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type Installer from "./Installer"; -import { LinuxInstaller, MacOSInstaller, WindowsInstaller } from "./Installer"; -import { OS, type Platform } from "./platform"; - -export default class InstallerFactory { - create(platform: Platform): Installer { - switch (platform.os) { - case OS.LINUX: - return new LinuxInstaller(); - case OS.MACOS: - return new MacOSInstaller(); - case OS.WINDOWS: - return new WindowsInstaller(); - } - } -} diff --git a/src/LinuxInstaller.ts b/src/LinuxInstaller.ts new file mode 100644 index 0000000..86530e2 --- /dev/null +++ b/src/LinuxInstaller.ts @@ -0,0 +1,82 @@ +import fs from "node:fs"; +import path from "node:path"; +import * as core from "@actions/core"; +import * as tc from "@actions/tool-cache"; +import { DownloadURLFactory } from "./DownloadURLFactory"; +import { testBinaryVersion } from "./firefoxUtils"; +import type { InstallResult, InstallSpec, Installer } from "./installers"; + +export class LinuxInstaller implements Installer { + async install({ + version, + platform, + language, + }: InstallSpec): Promise { + const toolPath = tc.find("firefox", version); + if (toolPath) { + core.info(`Found in cache @ ${toolPath}`); + return { + installedDir: toolPath, + installedBinPath: path.join(toolPath, "firefox"), + }; + } + core.info(`Attempting to download firefox ${version}...`); + const archivePath = await this.downloadArchive({ + version, + platform, + language, + }); + + core.info("Extracting Firefox..."); + const extPath = await this.extractArchive(archivePath); + core.info(`Successfully extracted firefox ${version} to ${extPath}`); + + core.info("Adding to the cache ..."); + const cachedDir = await tc.cacheDir(extPath, "firefox", version); + core.info(`Successfully cached firefox ${version} to ${cachedDir}`); + + return { + installedDir: extPath, + installedBinPath: path.join(cachedDir, "firefox"), + }; + } + + private async downloadArchive({ + version, + platform, + language, + }: InstallSpec): Promise { + const url = new DownloadURLFactory(version, platform, language) + .create() + .getURL(); + core.info(`Acquiring ${version} from ${url}`); + + const archivePath = await tc.downloadTool(url); + return archivePath; + } + + private async extractArchive(archivePath: string): Promise { + const file = await fs.promises.open(archivePath, "r"); + const firstBytes = new Int8Array(3); + if (file !== null) { + await file.read(firstBytes, 0, 3, null); + core.debug( + `Extracted ${firstBytes[0]}, ${firstBytes[1]} and ${firstBytes[2]}`, + ); + } + const options = + firstBytes[0] === 66 && firstBytes[1] === 90 && firstBytes[2] === 104 + ? "xj" + : "xJ"; + const extPath = await tc.extractTar(archivePath, "", [ + options, + "--strip-components=1", + ]); + + return extPath; + } + + async testVersion(bin: string): Promise { + return testBinaryVersion(bin); + } +} diff --git a/src/MacOSInstaller.ts b/src/MacOSInstaller.ts new file mode 100644 index 0000000..8903ab4 --- /dev/null +++ b/src/MacOSInstaller.ts @@ -0,0 +1,87 @@ +import path from "node:path"; +import * as core from "@actions/core"; +import * as exec from "@actions/exec"; +import * as tc from "@actions/tool-cache"; +import { DownloadURLFactory } from "./DownloadURLFactory"; +import { testBinaryVersion } from "./firefoxUtils"; +import type { InstallResult, InstallSpec, Installer } from "./installers"; +import { LatestVersion } from "./versions"; + +export class MacOSInstaller implements Installer { + async install({ + version, + platform, + language, + }: InstallSpec): Promise { + const toolPath = tc.find("firefox", version); + if (toolPath) { + core.info(`Found in cache @ ${toolPath}`); + return { + installedDir: toolPath, + installedBinPath: path.join(toolPath, "Contents", "MacOS", "firefox"), + }; + } + core.info(`Attempting to download firefox ${version}...`); + const archivePath = await this.downloadArchive({ + version, + platform, + language, + }); + + core.info("Extracting Firefox..."); + const appPath = await this.extractArchive(archivePath, version); + core.info(`Successfully extracted firefox ${version} to ${appPath}`); + + core.info("Adding to the cache ..."); + const cachedDir = await tc.cacheDir(appPath, "firefox", version); + core.info(`Successfully cached firefox ${version} to ${cachedDir}`); + + return { + installedDir: cachedDir, + installedBinPath: path.join(cachedDir, "Contents", "MacOS", "firefox"), + }; + } + + private async downloadArchive({ + version, + platform, + language, + }: InstallSpec): Promise { + const url = new DownloadURLFactory(version, platform, language) + .create() + .getURL(); + core.info(`Acquiring ${version} from ${url}`); + + const archivePath = await tc.downloadTool(url); + return archivePath; + } + + private async extractArchive( + archivePath: string, + version: string, + ): Promise { + const mountpoint = path.join("/Volumes", path.basename(archivePath)); + + await exec.exec("hdiutil", [ + "attach", + "-quiet", + "-noautofsck", + "-noautoopen", + "-mountpoint", + mountpoint, + archivePath, + ]); + + if (version === LatestVersion.LATEST_NIGHTLY) { + return path.join(mountpoint, "Firefox Nightly.app"); + } else if (version.includes("devedition")) { + return path.join(mountpoint, "Firefox Developer Edition.app"); + } else { + return path.join(mountpoint, "Firefox.app"); + } + } + + async testVersion(bin: string): Promise { + return testBinaryVersion(bin); + } +} diff --git a/src/WindowsInstaller.ts b/src/WindowsInstaller.ts new file mode 100644 index 0000000..144c19c --- /dev/null +++ b/src/WindowsInstaller.ts @@ -0,0 +1,81 @@ +import fs from "node:fs"; +import path from "node:path"; +import * as core from "@actions/core"; +import * as exec from "@actions/exec"; +import * as io from "@actions/io"; +import * as tc from "@actions/tool-cache"; +import { DownloadURLFactory } from "./DownloadURLFactory"; +import { testBinaryVersion } from "./firefoxUtils"; +import type { InstallResult, InstallSpec, Installer } from "./installers"; + +export class WindowsInstaller implements Installer { + async install({ + version, + platform, + language, + }: InstallSpec): Promise { + const installPath = `C:\\Program Files\\Firefox_${version}`; + if (await this.checkInstall(installPath)) { + core.info(`Already installed @ ${installPath}`); + return { + installedDir: installPath, + installedBinPath: path.join(installPath, "firefox.exe"), + }; + } + + core.info(`Attempting to download firefox ${version}...`); + const installerPath = await this.downloadArchive({ + version, + platform, + language, + }); + + core.info("Extracting Firefox..."); + await this.extractArchive(installerPath, installPath); + core.info(`Successfully installed firefox ${version} to ${installPath}`); + + return { + installedDir: installPath, + installedBinPath: path.join(installPath, "firefox.exe"), + }; + } + + private async downloadArchive({ + version, + platform, + language, + }: InstallSpec): Promise { + const url = new DownloadURLFactory(version, platform, language) + .create() + .getURL(); + core.info(`Acquiring ${version} from ${url}`); + + const installerPath = await tc.downloadTool(url); + await io.mv(installerPath, `${installerPath}.exe`); + + return installerPath; + } + + private async checkInstall(dir: string): Promise { + try { + await fs.promises.access(dir, fs.constants.F_OK); + } catch (err) { + return false; + } + return true; + } + + private async extractArchive( + installerPath: string, + installPath: string, + ): Promise { + await exec.exec(installerPath, [ + "/S", + `/InstallDirectoryName=${path.basename(installPath)}`, + ]); + } + + async testVersion(bin: string): Promise { + return testBinaryVersion(bin); + } +} diff --git a/src/firefoxUtils.ts b/src/firefoxUtils.ts new file mode 100644 index 0000000..3a95b1b --- /dev/null +++ b/src/firefoxUtils.ts @@ -0,0 +1,14 @@ +import * as exec from "@actions/exec"; + +export const testBinaryVersion = async (bin: string): Promise => { + const output = await exec.getExecOutput(`"${bin}"`, ["--version"]); + if (output.exitCode !== 0) { + throw new Error( + `firefox exits with status ${output.exitCode}: ${output.stderr}`, + ); + } + if (!output.stdout.startsWith("Mozilla Firefox ")) { + throw new Error(`firefox outputs unexpected results: ${output.stdout}`); + } + return output.stdout.trimEnd().replace("Mozilla Firefox ", ""); +}; diff --git a/src/index.ts b/src/index.ts index 1dac907..dda0013 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,5 @@ -import * as path from "node:path"; import * as core from "@actions/core"; -import InstallerFactory from "./InstallerFactory"; +import { InstallerFactory } from "./installers"; import { getPlatform } from "./platform"; import { LatestVersion } from "./versions"; @@ -17,11 +16,14 @@ const run = async (): Promise => { core.info(`Setup firefox ${version} (${language})`); const installer = new InstallerFactory().create(platform); - const installDir = await installer.install({ version, platform, language }); + const { installedDir, installedBinPath } = await installer.install({ + version, + platform, + language, + }); - core.addPath(installDir); + core.addPath(installedDir); - const installedBinPath = path.join(installDir, "firefox"); const installedVersion = await installer.testVersion(installedBinPath); core.info(`Successfully setup firefox version ${installedVersion}`); core.setOutput("firefox-version", installedVersion); diff --git a/src/installers.ts b/src/installers.ts new file mode 100644 index 0000000..aa7eb27 --- /dev/null +++ b/src/installers.ts @@ -0,0 +1,34 @@ +import { LinuxInstaller } from "./LinuxInstaller"; +import { MacOSInstaller } from "./MacOSInstaller"; +import { WindowsInstaller } from "./WindowsInstaller"; +import { OS, type Platform } from "./platform"; + +export type InstallSpec = { + version: string; + platform: Platform; + language: string; +}; + +export type InstallResult = { + installedDir: string; + installedBinPath: string; +}; + +export interface Installer { + install(spec: InstallSpec): Promise; + + testVersion(bin: string): Promise; +} + +export class InstallerFactory { + create(platform: Platform): Installer { + switch (platform.os) { + case OS.LINUX: + return new LinuxInstaller(); + case OS.MACOS: + return new MacOSInstaller(); + case OS.WINDOWS: + return new WindowsInstaller(); + } + } +}