From e88eaf738be41864c31ee42ad17d06d10b166676 Mon Sep 17 00:00:00 2001 From: Shin'ya Ueoka Date: Fri, 3 May 2024 18:04:37 +0900 Subject: [PATCH] fix: tool-cache does not work (#533) The tool-cache package does not work for the Chromium version. It works only for the server format. The chrome has a four-part version like `120.0.6099.109`, and the tool-cache does not retrieve the cached version. This pull request replaces the standard tool-cache package with the cache that supports the chrome version format, four-part, release channel name, snapshot number, and `latest`. Close #504 --- __test__/cache.test.ts | 146 ++++++++++++++ __test__/version.test.ts | 191 ++++++++----------- __test__/version_installer.test.ts | 20 +- src/cache.ts | 132 +++++++++++++ src/channel.ts | 9 - src/channel_linux.ts | 11 +- src/channel_macos.ts | 13 +- src/channel_windows.ts | 12 +- src/installer.ts | 19 +- src/snapshot.ts | 7 +- src/version.ts | 297 +++++++++++++++++------------ src/version_installer.ts | 42 ++-- 12 files changed, 596 insertions(+), 303 deletions(-) create mode 100644 __test__/cache.test.ts create mode 100644 src/cache.ts delete mode 100644 src/channel.ts diff --git a/__test__/cache.test.ts b/__test__/cache.test.ts new file mode 100644 index 0000000..dbb2902 --- /dev/null +++ b/__test__/cache.test.ts @@ -0,0 +1,146 @@ +import { cacheDir, find } from "../src/cache"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; + +const mkdir = (dir: string) => fs.promises.mkdir(dir, { recursive: true }); +const touch = (file: string) => fs.promises.writeFile(file, ""); +const expectDir = async (dir: string) => + expect((await fs.promises.stat(dir)).isDirectory()).toBeTruthy(); +const expectFile = async (file: string) => + expect((await fs.promises.stat(file)).isFile()).toBeTruthy(); + +describe("find", () => { + let tempToolCacheDir: string; + beforeEach(async () => { + tempToolCacheDir = await fs.promises.mkdtemp( + path.join(os.tmpdir(), "setup-chrome-"), + ); + process.env.RUNNER_TOOL_CACHE = tempToolCacheDir; + }); + + afterEach(async () => { + await fs.promises.rm(tempToolCacheDir, { recursive: true }); + }); + + describe("when several versions are cached", () => { + beforeEach(async () => { + const caches = [ + ["100.0.1.0", "x64"], + ["100.1.0.0", "x64"], + ["100.1.1.0", "x64"], + ["100.2.0.0", "x64"], + ["latest", "x64"], + ["canary", "x64"], + ["123456", "x64"], + ["200000", "x64"], + ["300000", "arm64"], + ]; + for (const [version, arch] of caches) { + const dir = path.join( + tempToolCacheDir, + "setup-chrome", + "chrome", + version, + arch, + ); + await mkdir(dir); + await touch(`${dir}.complete`); + } + }); + + test.each` + version | arch | subdir + ${"100.0.1.0"} | ${"x64"} | ${"100.0.1.0/x64"} + ${"100.1"} | ${"x64"} | ${"100.1.1.0/x64"} + ${"100"} | ${"x64"} | ${"100.2.0.0/x64"} + ${"latest"} | ${"x64"} | ${"latest/x64"} + ${"canary"} | ${"x64"} | ${"canary/x64"} + ${"123456"} | ${"x64"} | ${"123456/x64"} + ${"300000"} | ${"arm64"} | ${"300000/arm64"} + ${"200"} | ${"x64"} | ${undefined} + ${"stable"} | ${"x64"} | ${undefined} + `("finds a tool in the cache", async ({ version, arch, subdir }) => { + expect(await find("chrome", version, arch)).toBe( + subdir && path.join(tempToolCacheDir, "setup-chrome", "chrome", subdir), + ); + }); + }); + + describe("when cache is empty", () => { + test("cache is not found", async () => { + expect(await find("chrome", "100", "x64")).toBeUndefined(); + }); + }); + + describe("when cache includes corrupted cache", () => { + beforeEach(async () => { + const dir = path.join(tempToolCacheDir, "setup-chrome", "chrome"); + await mkdir(path.join(dir, "100.0.0.0", "x64")); + await mkdir(path.join(dir, "100.0.0.0", "x64") + ".complete"); + await mkdir(path.join(dir, "100.1.0.0", "x64")); + await mkdir(path.join(dir, "100.1.0.0", "x64") + ".complete"); + await mkdir(path.join(dir, "100.2.0.0", "x64")); + }); + + test("cache is not found", async () => { + expect(await find("chrome", "100.2.0.0", "x64")).toBeUndefined(); + }); + + test("corrupted cache is ignored", async () => { + expect(await find("chrome", "100", "x64")).toBe( + path.join( + tempToolCacheDir, + "setup-chrome", + "chrome", + "100.1.0.0", + "x64", + ), + ); + }); + }); +}); + +describe("cacheDir", () => { + let tempToolCacheDir: string; + let workspaceDir: string; + beforeEach(async () => { + tempToolCacheDir = await fs.promises.mkdtemp( + path.join(os.tmpdir(), "setup-chrome-"), + ); + workspaceDir = await fs.promises.mkdtemp( + path.join(os.tmpdir(), "setup-chrome-"), + ); + process.env.RUNNER_TOOL_CACHE = tempToolCacheDir; + }); + + afterEach(async () => { + await fs.promises.rm(workspaceDir, { recursive: true }); + await fs.promises.rm(tempToolCacheDir, { recursive: true }); + }); + + test("saves a tool in the cache", async () => { + const caches = [ + ["100.0.0.0", "x64"], + ["100.1.0.0", "arm64"], + ["latest", "x64"], + ]; + for (const [version, arch] of caches) { + const src = path.join(workspaceDir, version); + await mkdir(src); + await touch(path.join(src, "file")); + + await cacheDir(src, "chrome", version, arch); + } + + const prefix = path.join(tempToolCacheDir, "setup-chrome", "chrome"); + await expectDir(path.join(prefix, "100.0.0.0", "x64")); + await expectFile(path.join(prefix, "100.0.0.0", "x64", "file")); + await expectFile(path.join(prefix, "100.0.0.0", "x64") + ".complete"); + await expectDir(path.join(prefix, "100.1.0.0", "arm64")); + await expectFile(path.join(prefix, "100.1.0.0", "arm64", "file")); + await expectFile(path.join(prefix, "100.1.0.0", "arm64") + ".complete"); + await expectDir(path.join(prefix, "latest", "x64")); + await expectFile(path.join(prefix, "latest", "x64", "file")); + }); +}); diff --git a/__test__/version.test.ts b/__test__/version.test.ts index 4eec79a..3bdba6a 100644 --- a/__test__/version.test.ts +++ b/__test__/version.test.ts @@ -1,141 +1,106 @@ -import { StaticVersion, VersionSpec } from "../src/version"; +import { parse, isReleaseChannelName } from "../src/version"; -describe("StaticVersion", () => { - describe("constructor", () => { - test("new instance", () => { - const version = new StaticVersion({ - major: 119, - minor: 0, - build: 6045, - patch: 123, - }); - - expect(version.major).toBe(119); - expect(version.minor).toBe(0); - expect(version.build).toBe(6045); - expect(version.patch).toBe(123); - }); - - test("parse", () => { - const version = new StaticVersion("119.0.6045.123"); - - expect([ - version.major, - version.minor, - version.build, - version.patch, - ]).toEqual([119, 0, 6045, 123]); - }); - - test.each([ - ["119.0.6045.123.456"], - ["119.0.6045.-123"], - ["119.0.6045.beta"], - ["119.0.6045"], - ])("throw an error for %s", (version) => { - expect(() => new StaticVersion(version)).toThrow( - `Invalid version: ${version}`, - ); - }); +describe("isReleaseChannelName", () => { + test("return true if the version is a release channel name", () => { + expect(isReleaseChannelName("stable")).toBe(true); + expect(isReleaseChannelName("beta")).toBe(true); + expect(isReleaseChannelName("dev")).toBe(true); + expect(isReleaseChannelName("canary")).toBe(true); + expect(isReleaseChannelName("latest")).toBe(false); + expect(isReleaseChannelName("unknown")).toBe(false); }); +}); - describe("compare", () => { - test.each` - a | b | equals | greaterThan | lessThan | greaterThanOrEqual | lessThanOrEqual - ${"119.0.6045.123"} | ${"119.0.6045.123"} | ${true} | ${false} | ${false} | ${true} | ${true} - ${"119.0.6045.123"} | ${"119.0.6045.100"} | ${false} | ${true} | ${false} | ${false} | ${true} - ${"119.0.6045.123"} | ${"119.0.6045.200"} | ${false} | ${false} | ${true} | ${false} | ${true} - ${"119.0.6045.123"} | ${"119.0.7000.100"} | ${false} | ${false} | ${true} | ${false} | ${true} - ${"119.0.6045.123"} | ${"119.0.5000.100"} | ${false} | ${true} | ${false} | ${false} | ${true} - ${"119.0.6045.123"} | ${"119.1.6045.100"} | ${false} | ${false} | ${true} | ${false} | ${true} - ${"119.0.6045.123"} | ${"120.0.6045.100"} | ${false} | ${false} | ${true} | ${false} | ${true} - ${"119.0.6045.123"} | ${"118.0.6045.100"} | ${false} | ${true} | ${false} | ${false} | ${true} - ${"119.0.6045.123"} | ${"119.0.6045.122"} | ${false} | ${true} | ${false} | ${false} | ${true} - `('compare "$a" and "$b"', ({ a, b, equals, greaterThan, lessThan }) => { - const v1 = new StaticVersion(a); - const v2 = new StaticVersion(b); - expect(v1.equals(v2)).toBe(equals); - expect(v1.greaterThan(v2)).toBe(greaterThan); - expect(v1.lessThan(v2)).toBe(lessThan); - expect(v1.greaterThanOrEqual(v2)).toBe(greaterThan || equals); - expect(v1.lessThanOrEqual(v2)).toBe(lessThan || equals); - }); +describe("parse", () => { + test.each([ + [ + "119.0.6045.123", + { type: "four-parts", major: 119, minor: 0, build: 6045, patch: 123 }, + ], + ["119.0.6045", { type: "four-parts", major: 119, minor: 0, build: 6045 }], + ["119.0", { type: "four-parts", major: 119, minor: 0 }], + ["119", { type: "four-parts", major: 119 }], + ["119.0.6045.x", { type: "four-parts", major: 119, minor: 0, build: 6045 }], + ["119.0.x", { type: "four-parts", major: 119, minor: 0 }], + ["119.x", { type: "four-parts", major: 119 }], + ["latest", { type: "latest" }], + ["beta", { type: "channel", channel: "beta" }], + ["stable", { type: "channel", channel: "stable" }], + ["canary", { type: "channel", channel: "canary" }], + ["123456", { type: "snapshot", snapshot: 123456 }], + ])("parse %s", (version, expected) => { + const v = parse(version); + expect(v.value).toEqual(expected); }); - describe("toString", () => { - test("return stringified version", () => { - const v = new StaticVersion("119.0.6045.123"); - expect(v.toString()).toBe("119.0.6045.123"); - }); + test.each([ + ["119.0.6045.beta"], + ["119.0.x.123"], + ["x"], + ["119.0.6045.123.456"], + ["119.0.6045.-123"], + [""], + ["invalid"], + ])("throw an error for %s", (version) => { + expect(() => parse(version)).toThrow(`Invalid version: ${version}`); }); }); describe("VersionSpec", () => { - describe("constructor", () => { - test("new instance", () => { - const version = new VersionSpec({ - major: 119, - minor: 0, - build: 6045, - patch: 123, - }); - - expect(version.major).toBe(119); - expect(version.minor).toBe(0); - expect(version.build).toBe(6045); - expect(version.patch).toBe(123); - }); - - test.each([ - ["119.0.6045.123", [119, 0, 6045, 123]], - ["119.0.6045", [119, 0, 6045]], - ["119.0", [119, 0]], - ["119", [119]], - ["119.0.6045.x", [119, 0, 6045]], - ["119.0.x", [119, 0]], - ["119.x", [119]], - ])("parse %s", (version, expected) => { - const v = new VersionSpec(version); - expect([v.major, v.minor, v.build, v.patch]).toEqual(expected); - }); - - test.each([ - ["119.0.6045.beta"], - ["119.0.x.123"], - ["x"], - ["119.0.6045.123.456"], - ["119.0.6045.-123"], - [""], - ])("throw an error for %s", (version) => { - expect(() => new VersionSpec(version)).toThrow( - `Invalid version: ${version}`, - ); - }); - }); - describe("toString", () => { test.each([ ["119.0.6045.123", "119.0.6045.123"], ["119", "119"], - ])("return %s for %s", (expected, version) => { - const v = new VersionSpec(version); + ["latest", "latest"], + ["123456", "123456"], + ])("return %s for %s", (spec, expected) => { + const v = parse(spec); expect(v.toString()).toBe(expected); }); }); describe("satisfies", () => { test.each` - spec | version | satisfies + spec | target | satisfies ${"119.0.6045.123"} | ${"119.0.6045.123"} | ${true} ${"119.0.6045"} | ${"119.0.6045.123"} | ${true} ${"119"} | ${"119.0.6045.123"} | ${true} ${"119.0.6045.123"} | ${"119.0.6045.100"} | ${false} ${"119.0.6000"} | ${"119.0.6045.100"} | ${false} ${"120"} | ${"119.0.6045.100"} | ${false} - `("return if $spec satisfies $version", ({ spec, version, satisfies }) => { - const s = new VersionSpec(spec); - const v = new StaticVersion(version); - expect(s.satisfies(v)).toBe(satisfies); + ${"latest"} | ${"119.0.6045.100"} | ${false} + ${"latest"} | ${"latest"} | ${true} + ${"123456"} | ${"123456"} | ${true} + ${"123456"} | ${"123457"} | ${false} + `("return if $spec satisfies $target", ({ spec, target, satisfies }) => { + const v = parse(spec); + expect(v.satisfies(target)).toBe(satisfies); + }); + }); + + describe("compare", () => { + test.each` + a | b | gt | lt + ${"119.0.6045.123"} | ${"119.0.6045.123"} | ${false} | ${false} + ${"119.0.6045.123"} | ${"119.0.6045.100"} | ${true} | ${false} + ${"119.0.6045.123"} | ${"119.0.6045.200"} | ${false} | ${true} + ${"119.0.6045.123"} | ${"119.0.7000.100"} | ${false} | ${true} + ${"119.0.6045.123"} | ${"119.0.5000.100"} | ${true} | ${false} + ${"119.0.6045.123"} | ${"119.1.6045.100"} | ${false} | ${true} + ${"119.0.6045.123"} | ${"120.0.6045.100"} | ${false} | ${true} + ${"119.0.6045.123"} | ${"118.0.6045.100"} | ${true} | ${false} + ${"119.0.6045.123"} | ${"119.0.6045.122"} | ${true} | ${false} + ${"119"} | ${"119.0.6045.123"} | ${false} | ${false} + ${"123456"} | ${"123456"} | ${false} | ${false} + ${"123456"} | ${"123457"} | ${false} | ${true} + ${"latest"} | ${"latest"} | ${false} | ${false} + ${"latest"} | ${"stable"} | ${false} | ${false} + ${"119.0.6045.123"} | ${"latest"} | ${false} | ${false} + `('compare "$a" and "$b"', ({ a, b, gt, lt }) => { + const v1 = parse(a); + + expect(v1.gt(b)).toBe(gt); + expect(v1.lt(b)).toBe(lt); }); }); }); diff --git a/__test__/version_installer.test.ts b/__test__/version_installer.test.ts index 9953cb8..85cbbcb 100644 --- a/__test__/version_installer.test.ts +++ b/__test__/version_installer.test.ts @@ -2,9 +2,9 @@ import { KnownGoodVersionResolver, KnownGoodVersionInstaller, } from "../src/version_installer"; -import { VersionSpec } from "../src/version"; import * as httpm from "@actions/http-client"; import * as tc from "@actions/tool-cache"; +import * as cache from "../src/cache"; import fs from "fs"; import path from "path"; @@ -33,13 +33,13 @@ describe("VersionResolver", () => { ${"1234.0.6099.x"} | ${undefined} `("should resolve known good versions", async ({ spec, resolved }) => { const resolver = new KnownGoodVersionResolver("linux64"); - const version = await resolver.resolve(new VersionSpec(spec)); + const version = await resolver.resolve(spec); expect(version?.toString()).toEqual(resolved); }); test("should resolve an url for a known good version", async () => { const resolver = new KnownGoodVersionResolver("linux64"); - const url = await resolver.resolveUrl(new VersionSpec("120.0.6099.x")); + const url = await resolver.resolveUrl("120.0.6099.x"); expect(url).toEqual( "https://edgedl.me.gvt1.com/edgedl/chrome/chrome-for-testing/120.0.6099.56/linux64/chrome-linux64.zip", ); @@ -47,19 +47,21 @@ describe("VersionResolver", () => { test("should cache known good versions", async () => { const resolver = new KnownGoodVersionResolver("linux64"); - await resolver.resolve(new VersionSpec("120.0.6099.5")); - await resolver.resolve(new VersionSpec("120.0.6099.18")); + await resolver.resolve("120.0.6099.5"); + await resolver.resolve("120.0.6099.18"); expect(getJsonSpy).toHaveBeenCalledTimes(1); }); }); describe("KnownGoodVersionInstaller", () => { - const tcFindSpy = jest.spyOn(tc, "find"); + const tcFindSpy = jest.spyOn(cache, "find"); const tcDownloadToolSpy = jest.spyOn(tc, "downloadTool"); - test("should return true if installed", async () => { + test("should return installed path if installed", async () => { tcFindSpy.mockImplementation((name: string, version: string) => { - return `/opt/hostedtoolcache/${name}/${version}`; + return Promise.resolve( + `/opt/hostedtoolcache/setup-chrome/${name}/${version}/x64`, + ); }); const installer = new KnownGoodVersionInstaller({ @@ -68,7 +70,7 @@ describe("KnownGoodVersionInstaller", () => { }); const installed = await installer.checkInstalled("120.0.6099.x"); expect(installed?.root).toEqual( - "/opt/hostedtoolcache/chromium/120.0.6099.56", + "/opt/hostedtoolcache/setup-chrome/chromium/120.0.6099.56/x64", ); expect(tcFindSpy).toHaveBeenCalledWith("chromium", "120.0.6099.56"); }); diff --git a/src/cache.ts b/src/cache.ts new file mode 100644 index 0000000..b18879f --- /dev/null +++ b/src/cache.ts @@ -0,0 +1,132 @@ +/** + * This module is an another implementation of the cache module in GitHub Actions. + * The original tool-cache can cache with only semver format. This module can cache + * with chrome version format, e.g., "120.0.6099.5", "123456", "latest". + * + * The cacheDir function copies the contents of a source directory to the cache + * directory. The find function looks-up a cached directory by the tool name and + * version spec. The cache directory is located in the sub-directory under + * RUNNER_TOOL_CACHE. + * + * /opt/hostedtoolcache/setup-chrome/${toolName}/${version}/${arch}/... + */ + +import * as core from "@actions/core"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { ok } from "assert"; +import { parse } from "./version"; + +export async function cacheDir( + sourceDir: string, + tool: string, + version: string, + arch: string = os.arch(), +): Promise { + core.debug(`Caching tool ${tool} ${version} ${arch}`); + core.debug(`source dir: ${sourceDir}`); + if (!(await fs.promises.stat(sourceDir)).isDirectory()) { + throw new Error(`cacheDir: sourceDir is not a directory`); + } + + const destPath: string = await _createToolPath(tool, version, arch); + for (const itemName of await fs.promises.readdir(sourceDir)) { + const s = path.join(sourceDir, itemName); + const d = path.join(destPath, itemName); + await fs.promises.cp(s, d, { recursive: true }); + core.debug(`cacheDir: copied ${s} to ${d}`); + } + + _completeToolPath(tool, version, arch); + + return destPath; +} + +export const find = async ( + toolName: string, + versionSpec: string, + arch: string = os.arch(), +): Promise => { + if (!toolName) { + throw new Error("toolName parameter is required"); + } + + if (!versionSpec) { + throw new Error("versionSpec parameter is required"); + } + + // attempt to resolve an explicit version + const spec = parse(versionSpec); + const toolPath = path.join(_getCacheDirectory(), toolName); + if (!fs.existsSync(toolPath)) { + core.debug(`Cache directory not found ${toolPath}`); + return undefined; + } + + const versions = await fs.promises.readdir(toolPath); + let cachePath: string | undefined; + for (const v of versions) { + if (!spec.satisfies(v) || spec.lt(v)) { + continue; + } + + const p = path.join(toolPath, v, arch); + const markerPath = `${p}.complete`; + if (!fs.existsSync(p) || !fs.existsSync(markerPath)) { + continue; + } + cachePath = p; + } + + if (cachePath) { + core.debug(`Found tool in cache ${cachePath}`); + } else { + core.debug( + `Unable to find tool in cache ${toolName} ${versionSpec} ${arch}`, + ); + } + return cachePath; +}; + +async function _createToolPath( + tool: string, + version: string, + arch: string, +): Promise { + const folderPath = path.join( + _getCacheDirectory(), + tool, + version.toString(), + arch, + ); + const markerPath = `${folderPath}.complete`; + await fs.promises.rm(folderPath, { recursive: true, force: true }); + core.debug(`_createToolPath: removed ${folderPath}`); + await fs.promises.rm(markerPath, { force: true }); + core.debug(`_createToolPath: removed ${markerPath}`); + await fs.promises.mkdir(folderPath, { recursive: true }); + core.debug(`_createToolPath: created ${folderPath}`); + return folderPath; +} + +const _completeToolPath = async ( + tool: string, + version: string, + arch?: string, +): Promise => { + const folderPath = path.join(_getCacheDirectory(), tool, version, arch || ""); + const markerPath = `${folderPath}.complete`; + await fs.promises.writeFile(markerPath, ""); + core.debug(`_completeToolPath: created ${markerPath}`); +}; + +/** + * Gets cache directory prefix. The directory is located in the sub-directory + * under RUNNER_TOOL_CACHE to avoid conflicts with tool-cache action. + */ +function _getCacheDirectory(): string { + const cacheDirectory = process.env["RUNNER_TOOL_CACHE"] || ""; + ok(cacheDirectory, "Expected RUNNER_TOOL_CACHE to be defined"); + return path.join(cacheDirectory, "setup-chrome"); +} diff --git a/src/channel.ts b/src/channel.ts deleted file mode 100644 index b736295..0000000 --- a/src/channel.ts +++ /dev/null @@ -1,9 +0,0 @@ -export type ChannelName = "stable" | "beta" | "dev" | "canary"; -export const isChannelName = (version: string): version is ChannelName => { - return ( - version === "stable" || - version === "beta" || - version === "dev" || - version === "canary" - ); -}; diff --git a/src/channel_linux.ts b/src/channel_linux.ts index 1ae3060..cc9f9c4 100644 --- a/src/channel_linux.ts +++ b/src/channel_linux.ts @@ -1,6 +1,7 @@ import { Platform } from "./platform"; import { Installer, DownloadResult, InstallResult } from "./installer"; -import { isChannelName } from "./channel"; +import { isReleaseChannelName } from "./version"; +import * as cache from "./cache"; import * as tc from "@actions/tool-cache"; import * as exec from "@actions/exec"; import * as core from "@actions/core"; @@ -12,14 +13,14 @@ export class LinuxChannelInstaller implements Installer { constructor(private readonly platform: Platform) {} async checkInstalled(version: string): Promise { - const root = tc.find("chromium", version); + const root = await cache.find("chromium", version); if (root) { return { root, bin: "chrome" }; } } async download(version: string): Promise { - if (!isChannelName(version)) { + if (!isReleaseChannelName(version)) { throw new Error(`Unexpected version: ${version}`); } if (version === "canary") { @@ -45,7 +46,7 @@ export class LinuxChannelInstaller implements Installer { } async install(version: string, archive: string): Promise { - if (!isChannelName(version)) { + if (!isReleaseChannelName(version)) { throw new Error(`Unexpected version: ${version}`); } if (version === "canary") { @@ -68,7 +69,7 @@ export class LinuxChannelInstaller implements Installer { // remove broken symlink await fs.promises.unlink(path.join(extdir, "google-chrome")); - const root = await tc.cacheDir(extdir, "chromium", version); + const root = await cache.cacheDir(extdir, "chromium", version); core.info(`Successfully Installed chromium to ${root}`); return { root: extdir, bin: "chrome" }; diff --git a/src/channel_macos.ts b/src/channel_macos.ts index e70d029..179c45b 100644 --- a/src/channel_macos.ts +++ b/src/channel_macos.ts @@ -1,6 +1,7 @@ import { Platform } from "./platform"; import { Installer, DownloadResult, InstallResult } from "./installer"; -import { isChannelName } from "./channel"; +import { isReleaseChannelName } from "./version"; +import * as cache from "./cache"; import * as tc from "@actions/tool-cache"; import * as exec from "@actions/exec"; import * as core from "@actions/core"; @@ -11,17 +12,17 @@ export class MacOSChannelInstaller implements Installer { constructor(private readonly platform: Platform) {} async checkInstalled(version: string): Promise { - if (!isChannelName(version)) { + if (!isReleaseChannelName(version)) { throw new Error(`Unexpected version: ${version}`); } - const root = tc.find("chromium", version); + const root = await cache.find("chromium", version); if (root) { return { root, bin: "Contents/MacOS/chrome" }; } } async download(version: string): Promise { - if (!isChannelName(version)) { + if (!isReleaseChannelName(version)) { throw new Error(`Unexpected version: ${version}`); } @@ -40,7 +41,7 @@ export class MacOSChannelInstaller implements Installer { } async install(version: string, archive: string): Promise { - if (!isChannelName(version)) { + if (!isReleaseChannelName(version)) { throw new Error(`Unexpected version: ${version}`); } const mountpoint = path.join("/Volumes", path.basename(archive)); @@ -80,7 +81,7 @@ export class MacOSChannelInstaller implements Installer { })(); const bin2 = path.join(path.dirname(bin), "chrome"); - root = await tc.cacheDir(root, "chromium", version); + root = await cache.cacheDir(root, "chromium", version); await fs.promises.symlink(path.basename(bin), path.join(root, bin2)); core.info(`Successfully Installed chromium to ${root}`); diff --git a/src/channel_windows.ts b/src/channel_windows.ts index a9ef66e..813e5e4 100644 --- a/src/channel_windows.ts +++ b/src/channel_windows.ts @@ -1,9 +1,9 @@ import { Platform, Arch } from "./platform"; import { Installer, DownloadResult, InstallResult } from "./installer"; -import { ChannelName, isChannelName } from "./channel"; -import * as tc from "@actions/tool-cache"; +import { isReleaseChannelName, type ReleaseChannelName } from "./version"; import * as exec from "@actions/exec"; import * as core from "@actions/core"; +import * as tc from "@actions/tool-cache"; import fs from "fs"; const isENOENT = (e: unknown): boolean => { @@ -16,7 +16,7 @@ export class WindowsChannelInstaller implements Installer { constructor(private readonly platform: Platform) {} async checkInstalled(version: string): Promise { - if (!isChannelName(version)) { + if (!isReleaseChannelName(version)) { throw new Error(`Unexpected version: ${version}`); } @@ -34,7 +34,7 @@ export class WindowsChannelInstaller implements Installer { } async download(version: string): Promise { - if (!isChannelName(version)) { + if (!isReleaseChannelName(version)) { throw new Error(`Unexpected version: ${version}`); } if (version === "canary" || this.platform.arch === Arch.ARM64) { @@ -102,7 +102,7 @@ export class WindowsChannelInstaller implements Installer { } async install(version: string, archive: string): Promise { - if (!isChannelName(version)) { + if (!isReleaseChannelName(version)) { throw new Error(`Unexpected version: ${version}`); } await exec.exec(archive, ["/silent", "/install"]); @@ -110,7 +110,7 @@ export class WindowsChannelInstaller implements Installer { return { root: this.rootDir(version), bin: "chrome.exe" }; } - private rootDir(version: ChannelName) { + private rootDir(version: ReleaseChannelName) { switch (version) { case "stable": return "C:\\Program Files\\Google\\Chrome\\Application"; diff --git a/src/installer.ts b/src/installer.ts index a79722c..8923463 100644 --- a/src/installer.ts +++ b/src/installer.ts @@ -1,6 +1,7 @@ import { Platform, OS } from "./platform"; import * as core from "@actions/core"; import path from "path"; +import { parse } from "./version"; import { SnapshotInstaller, LatestInstaller } from "./snapshot"; import { LinuxChannelInstaller } from "./channel_linux"; import { MacOSChannelInstaller } from "./channel_macos"; @@ -29,13 +30,11 @@ export const install = async ( version: string, ): Promise => { const installer = (() => { - switch (version) { + const spec = parse(version); + switch (spec.value.type) { case "latest": return new LatestInstaller(platform); - case "stable": - case "beta": - case "dev": - case "canary": + case "channel": switch (platform.os) { case OS.LINUX: return new LinuxChannelInstaller(platform); @@ -44,12 +43,12 @@ export const install = async ( case OS.WINDOWS: return new WindowsChannelInstaller(platform); } + break; + case "snapshot": + return new SnapshotInstaller(platform); + case "four-parts": + return new KnownGoodVersionInstaller(platform); } - // To distinguish between commit number and known-good version, assume commit number is greater than 10,000 - if (Number(version) > 10000) { - return new SnapshotInstaller(platform); - } - return new KnownGoodVersionInstaller(platform); })(); const cache = await installer.checkInstalled(version); diff --git a/src/snapshot.ts b/src/snapshot.ts index 8e3ed79..7e3fba1 100644 --- a/src/snapshot.ts +++ b/src/snapshot.ts @@ -1,5 +1,6 @@ import { Platform, OS, Arch } from "./platform"; import { Installer, DownloadResult, InstallResult } from "./installer"; +import * as cache from "./cache"; import * as tc from "@actions/tool-cache"; import * as httpm from "@actions/http-client"; import * as core from "@actions/core"; @@ -9,7 +10,7 @@ export class SnapshotInstaller implements Installer { constructor(private readonly platform: Platform) {} async checkInstalled(version: string): Promise { - const root = tc.find("chromium", version); + const root = await cache.find("chromium", version); if (root) { return { root, bin: "chrome" }; } @@ -48,7 +49,7 @@ export class SnapshotInstaller implements Installer { } })(); - root = await tc.cacheDir(root, "chromium", version); + root = await cache.cacheDir(root, "chromium", version); core.info(`Successfully Installed chromium to ${root}`); return { root, bin }; @@ -63,7 +64,7 @@ export class LatestInstaller implements Installer { constructor(private readonly platform: Platform) {} async checkInstalled(version: string): Promise { - const root = tc.find("chromium", version); + const root = await cache.find("chromium", version); if (root) { return { root, bin: "chrome" }; } diff --git a/src/version.ts b/src/version.ts index fbe590e..df8e773 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,149 +1,202 @@ -export class StaticVersion { - public readonly major: number; - public readonly minor: number; - public readonly build: number; - public readonly patch: number; - - constructor( - arg: - | string - | { major: number; minor: number; build: number; patch: number }, - ) { - if (typeof arg === "string") { - if (arg === "") { - throw new Error(`Invalid version: ${arg}`); - } - const digits: Array = arg.split(".").map((part) => { - const num = Number(part); - if (isNaN(num) || num < 0) { - throw new Error(`Invalid version: ${arg}`); - } - return num; - }); - if (digits.length !== 4) { - throw new Error(`Invalid version: ${arg}`); - } - this.major = digits[0]; - this.minor = digits[1]; - this.build = digits[2]; - this.patch = digits[3]; - } else { - this.major = arg.major; - this.minor = arg.minor; - this.build = arg.build; - this.patch = arg.patch; - } +export type ReleaseChannelName = "stable" | "beta" | "dev" | "canary"; + +export const isReleaseChannelName = ( + version: string, +): version is ReleaseChannelName => { + return ( + version === "stable" || + version === "beta" || + version === "dev" || + version === "canary" + ); +}; + +type FourPartsVersion = { + type: "four-parts"; + major: number; + minor: number | undefined; + build: number | undefined; + patch: number | undefined; +}; + +type SnapshotVersion = { + type: "snapshot"; + snapshot: number; +}; + +type ChannelVersion = { + type: "channel"; + channel: ReleaseChannelName; +}; + +type LatestVersion = { + type: "latest"; +}; + +type VersionValue = + | FourPartsVersion + | SnapshotVersion + | ChannelVersion + | LatestVersion; + +class VersionSpec { + public readonly value: VersionValue; + + constructor(value: VersionValue) { + this.value = value; } - private compare(o: StaticVersion): number { - if (this.major !== o.major) { - return this.major - o.major; + static parse(version: string): VersionSpec { + if (version === "") { + throw new Error(`Invalid version: ${version}`); } - if (this.minor !== o.minor) { - return this.minor - o.minor; - } - if (this.build !== o.build) { - return this.build - o.build; + + if (version === "latest") { + return new VersionSpec({ type: "latest" }); } - if (this.patch !== o.patch) { - return this.patch - o.patch; + + if (isReleaseChannelName(version)) { + return new VersionSpec({ type: "channel", channel: version }); } - return 0; - } - public greaterThan(o: StaticVersion): boolean { - return this.compare(o) > 0; - } + // Assume that the snapshot version is greater than 10000 to distinguish it from the four-parts version. + if (Number(version) > 10000) { + return new VersionSpec({ + type: "snapshot", + snapshot: Number(version), + }); + } - public greaterThanOrEqual(o: StaticVersion): boolean { - return this.compare(o) >= 0; - } + const digits: Array = version.split(".").map((part) => { + if (part === "x") { + return undefined; + } + const num = Number(part); + if (isNaN(num) || num < 0) { + throw new Error(`Invalid version: ${version}`); + } + return num; + }); + if (digits.length > 4) { + throw new Error(`Invalid version: ${version}`); + } + if (digits.length === 1 && digits[0] === undefined) { + throw new Error(`Invalid version: ${version}`); + } + for (let i = 0; i < digits.length - 1; i++) { + const [d1, d2] = [digits[i], digits[i + 1]]; + if (d1 === undefined && d2 !== undefined) { + throw new Error(`Invalid version: ${version}`); + } + } - public lessThan(o: StaticVersion): boolean { - return this.compare(o) < 0; + return new VersionSpec({ + type: "four-parts", + major: digits[0] as number, + minor: digits[1], + build: digits[2], + patch: digits[3], + }); } - public lessThanOrEqual(o: StaticVersion): boolean { - return this.compare(o) <= 0; + public satisfies(version: string): boolean { + const spec = VersionSpec.parse(version); + const [v1, v2] = [this.value, spec.value]; + if (v1.type === "latest" && v2.type === "latest") { + return true; + } + if (v1.type === "channel" && v2.type === "channel") { + return v1.channel === v2.channel; + } + if (v1.type === "snapshot" && v2.type === "snapshot") { + return v1.snapshot === v2.snapshot; + } + if (v1.type === "four-parts" && v2.type === "four-parts") { + if (v1.major !== v2.major) { + return false; + } + if (v1.minor !== undefined && v1.minor !== v2.minor) { + return false; + } + if (v1.build !== undefined && v1.build !== v2.build) { + return false; + } + if (v1.patch !== undefined && v1.patch !== v2.patch) { + return false; + } + return true; + } + return false; } - public equals(o: StaticVersion): boolean { - return this.compare(o) === 0; + public gt(version: string): boolean { + return this.compare(version) > 0; } - public toString(): string { - return `${this.major}.${this.minor}.${this.build}.${this.patch}`; + public lt(version: string): boolean { + return this.compare(version) < 0; } -} -export class VersionSpec { - public readonly major: number; - public readonly minor: number | undefined; - public readonly build: number | undefined; - public readonly patch: number | undefined; - - constructor( - arg: - | string - | { major: number; minor?: number; build?: number; patch?: number }, - ) { - if (typeof arg === "string") { - if (arg === "") { - throw new Error(`Invalid version: ${arg}`); + private compare(version: string): number { + const spec = VersionSpec.parse(version); + const [v1, v2] = [this.value, spec.value]; + if (v1.type === "latest" && v2.type === "latest") { + return 0; + } + if (v1.type === "channel" && v2.type === "channel") { + return v1.channel === v2.channel ? 0 : NaN; + } + if (v1.type === "snapshot" && v2.type === "snapshot") { + return v1.snapshot - v2.snapshot; + } + if (v1.type === "four-parts" && v2.type === "four-parts") { + if (v1.major !== v2.major) { + return v1.major - v2.major; } - const digits: Array = arg.split(".").map((part) => { - if (part === "x") { - return undefined; - } - const num = Number(part); - if (isNaN(num) || num < 0) { - throw new Error(`Invalid version: ${arg}`); - } - return num; - }); - if (digits.length > 4) { - throw new Error(`Invalid version: ${arg}`); + if (v1.minor !== v2.minor) { + return v1.minor === undefined || v2.minor === undefined + ? NaN + : v1.minor - v2.minor; } - if (digits.length === 1 && digits[0] === undefined) { - throw new Error(`Invalid version: ${arg}`); + if (v1.build !== v2.build) { + return v1.build === undefined || v2.build === undefined + ? NaN + : v1.build - v2.build; } - for (let i = 0; i < digits.length - 1; i++) { - const [d1, d2] = [digits[i], digits[i + 1]]; - if (d1 === undefined && d2 !== undefined) { - throw new Error(`Invalid version: ${arg}`); - } + if (v1.patch !== v2.patch) { + return v1.patch === undefined || v2.patch === undefined + ? NaN + : v1.patch - v2.patch; } - this.major = digits[0] as number; - this.minor = digits[1]; - this.build = digits[2]; - this.patch = digits[3]; - } else { - this.major = arg.major; - this.minor = arg.minor; - this.build = arg.build; - this.patch = arg.patch; + return 0; } - } - public satisfies(version: StaticVersion): boolean { - if (this.major !== version.major) { - return false; - } - if (this.minor !== undefined && this.minor !== version.minor) { - return false; - } - if (this.build !== undefined && this.build !== version.build) { - return false; - } - if (this.patch !== undefined && this.patch !== version.patch) { - return false; - } - return true; + return NaN; } public toString(): string { - return [this.major, this.minor, this.build, this.patch] - .filter((d) => d !== undefined) - .join("."); + switch (this.value.type) { + case "latest": + return "latest"; + case "channel": + return this.value.channel; + case "snapshot": + return String(this.value.snapshot); + case "four-parts": + return [ + this.value.major, + this.value.minor, + this.value.build, + this.value.patch, + ] + .filter((d) => d !== undefined) + .join("."); + } } } + +export type { VersionSpec }; + +export const parse = (version: string): VersionSpec => { + return VersionSpec.parse(version); +}; diff --git a/src/version_installer.ts b/src/version_installer.ts index 35f7f70..9d106ee 100644 --- a/src/version_installer.ts +++ b/src/version_installer.ts @@ -3,8 +3,9 @@ import * as httpm from "@actions/http-client"; import * as core from "@actions/core"; import path from "path"; import { Arch, OS, Platform } from "./platform"; -import { StaticVersion, VersionSpec } from "./version"; +import { parse } from "./version"; import { Installer, DownloadResult, InstallResult } from "./installer"; +import * as cache from "./cache"; const KNOWN_GOOD_VERSIONS_URL = "https://googlechromelabs.github.io/chrome-for-testing/known-good-versions-with-downloads.json"; @@ -39,43 +40,43 @@ export class KnownGoodVersionResolver { private knownGoodVersionsCache?: KnownGoodVersion[]; - private readonly resolvedVersions = new Map(); + private readonly resolvedVersions = new Map(); constructor(platform: KnownGoodVersionPlatform) { this.platform = platform; } - async resolve(spec: VersionSpec): Promise { + async resolve(version: string): Promise { + const spec = parse(version); if (this.resolvedVersions.has(spec.toString())) { return this.resolvedVersions.get(spec.toString())!; } const knownGoodVersions = await this.getKnownGoodVersions(); for (const version of knownGoodVersions) { - const v = new StaticVersion(version.version); - if (!spec.satisfies(v)) { + if (!spec.satisfies(version.version)) { continue; } const found = version.downloads.chrome.find( ({ platform }) => platform === this.platform, ); if (found) { - this.resolvedVersions.set(spec.toString(), v); - return v; + this.resolvedVersions.set(spec.toString(), version.version); + return version.version; } } return undefined; } - async resolveUrl(spec: VersionSpec): Promise { - const version = await this.resolve(spec); - if (!version) { + async resolveUrl(version: string): Promise { + const resolved = await this.resolve(version); + if (!resolved) { return undefined; } const knownGoodVersions = await this.getKnownGoodVersions(); const knownGoodVersion = knownGoodVersions.find( - (v) => v.version === version.toString(), + (v) => v.version === resolved.toString(), ); if (!knownGoodVersion) { return undefined; @@ -138,26 +139,24 @@ export class KnownGoodVersionInstaller implements Installer { } async checkInstalled(version: string): Promise { - const spec = new VersionSpec(version); - const resolved = await this.versionResolver.resolve(spec); + const resolved = await this.versionResolver.resolve(version); if (!resolved) { return undefined; } - const root = tc.find("chromium", resolved.toString()); + const root = await cache.find("chromium", resolved.toString()); if (root) { return { root, bin: "chrome" }; } } async download(version: string): Promise { - const spec = new VersionSpec(version); - const resolved = await this.versionResolver.resolve(spec); + const resolved = await this.versionResolver.resolve(version); if (!resolved) { throw new Error(`Version ${version} not found in known good versions`); } - const url = await this.versionResolver.resolveUrl(spec); + const url = await this.versionResolver.resolveUrl(version); if (!url) { throw new Error(`Version ${version} not found in known good versions`); } @@ -167,8 +166,7 @@ export class KnownGoodVersionInstaller implements Installer { } async install(version: string, archive: string): Promise { - const spec = new VersionSpec(version); - const resolved = await this.versionResolver.resolve(spec); + const resolved = await this.versionResolver.resolve(version); if (!resolved) { throw new Error(`Version ${version} not found in known good versions`); } @@ -178,7 +176,11 @@ export class KnownGoodVersionInstaller implements Installer { `chrome-${this.knownGoodVersionPlatform}`, ); - const root = await tc.cacheDir(extAppRoot, "chromium", resolved.toString()); + const root = await cache.cacheDir( + extAppRoot, + "chromium", + resolved.toString(), + ); core.info(`Successfully Installed chromium to ${root}`); const bin = (() => { switch (this.platform.os) {