diff --git a/__tests__/buildx/buildx.test.ts b/__tests__/buildx/buildx.test.ts index a5273df..cf07892 100644 --- a/__tests__/buildx/buildx.test.ts +++ b/__tests__/buildx/buildx.test.ts @@ -45,34 +45,6 @@ afterEach(() => { rimraf.sync(tmpDir); }); -describe('getRelease', () => { - it('returns latest buildx GitHub release', async () => { - const release = await Buildx.getRelease('latest'); - expect(release).not.toBeNull(); - expect(release?.tag_name).not.toEqual(''); - }); - - it('returns v0.10.1 buildx GitHub release', async () => { - const release = await Buildx.getRelease('v0.10.1'); - expect(release).not.toBeNull(); - expect(release?.id).toEqual(90346950); - expect(release?.tag_name).toEqual('v0.10.1'); - expect(release?.html_url).toEqual('https://github.com/docker/buildx/releases/tag/v0.10.1'); - }); - - it('returns v0.2.2 buildx GitHub release', async () => { - const release = await Buildx.getRelease('v0.2.2'); - expect(release).not.toBeNull(); - expect(release?.id).toEqual(17671545); - expect(release?.tag_name).toEqual('v0.2.2'); - expect(release?.html_url).toEqual('https://github.com/docker/buildx/releases/tag/v0.2.2'); - }); - - it('unknown release', async () => { - await expect(Buildx.getRelease('foo')).rejects.toThrowError(new Error('Cannot find Buildx release foo in https://raw.githubusercontent.com/docker/buildx/master/.github/releases.json')); - }); -}); - describe('isAvailable', () => { it('docker cli', async () => { const execSpy = jest.spyOn(exec, 'getExecOutput'); diff --git a/__tests__/buildx/install.test.ts b/__tests__/buildx/install.test.ts new file mode 100644 index 0000000..1e996b4 --- /dev/null +++ b/__tests__/buildx/install.test.ts @@ -0,0 +1,96 @@ +/** + * Copyright 2023 actions-toolkit authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {describe, expect, it, jest, test, beforeEach} from '@jest/globals'; +import * as fs from 'fs'; +import os from 'os'; +import * as path from 'path'; +import osm = require('os'); + +import {Install} from '../../src/buildx/install'; + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('install', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'actions-toolkit-')); + + // prettier-ignore + test.each([ + ['v0.4.1', false], + ['latest', false], + ['v0.4.1', true], + ['latest', true] + ])( + 'acquires %p of buildx (standalone: %p)', async (version, standalone) => { + const install = new Install({standalone: standalone}); + const buildxBin = await install.install(version, tmpDir); + expect(fs.existsSync(buildxBin)).toBe(true); + }, + 100000 + ); + + // TODO: add tests for arm + // prettier-ignore + test.each([ + ['win32', 'x64'], + ['win32', 'arm64'], + ['darwin', 'x64'], + ['darwin', 'arm64'], + ['linux', 'x64'], + ['linux', 'arm64'], + ['linux', 'ppc64'], + ['linux', 's390x'], + ])( + 'acquires buildx for %s/%s', async (os, arch) => { + jest.spyOn(osm, 'platform').mockImplementation(() => os); + jest.spyOn(osm, 'arch').mockImplementation(() => arch); + const install = new Install(); + const buildxBin = await install.install('latest', tmpDir); + expect(fs.existsSync(buildxBin)).toBe(true); + }, + 100000 + ); +}); + +describe('getRelease', () => { + it('returns latest buildx GitHub release', async () => { + const release = await Install.getRelease('latest'); + expect(release).not.toBeNull(); + expect(release?.tag_name).not.toEqual(''); + }); + + it('returns v0.10.1 buildx GitHub release', async () => { + const release = await Install.getRelease('v0.10.1'); + expect(release).not.toBeNull(); + expect(release?.id).toEqual(90346950); + expect(release?.tag_name).toEqual('v0.10.1'); + expect(release?.html_url).toEqual('https://github.com/docker/buildx/releases/tag/v0.10.1'); + }); + + it('returns v0.2.2 buildx GitHub release', async () => { + const release = await Install.getRelease('v0.2.2'); + expect(release).not.toBeNull(); + expect(release?.id).toEqual(17671545); + expect(release?.tag_name).toEqual('v0.2.2'); + expect(release?.html_url).toEqual('https://github.com/docker/buildx/releases/tag/v0.2.2'); + }); + + it('unknown release', async () => { + await expect(Install.getRelease('foo')).rejects.toThrowError(new Error('Cannot find Buildx release foo in https://raw.githubusercontent.com/docker/buildx/master/.github/releases.json')); + }); +}); diff --git a/package.json b/package.json index bd2a2cf..c42b8db 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "@actions/exec": "^1.1.1", "@actions/github": "^5.1.1", "@actions/http-client": "^2.0.1", + "@actions/tool-cache": "^2.0.1", "csv-parse": "^5.3.4", "jwt-decode": "^3.1.2", "semver": "^7.3.8", diff --git a/src/buildx/buildx.ts b/src/buildx/buildx.ts index 6025ebd..ed3fce2 100644 --- a/src/buildx/buildx.ts +++ b/src/buildx/buildx.ts @@ -15,14 +15,12 @@ */ import * as exec from '@actions/exec'; -import * as httpm from '@actions/http-client'; import * as semver from 'semver'; import {Docker} from '../docker'; import {Context} from '../context'; import {Inputs} from './inputs'; - -import {GitHubRelease} from '../types/github'; +import {Install} from './install'; export interface BuildxOpts { context: Context; @@ -34,31 +32,16 @@ export class Buildx { private _version: string | undefined; public readonly inputs: Inputs; + public readonly install: Install; public readonly standalone: boolean; constructor(opts: BuildxOpts) { this.context = opts.context; this.inputs = new Inputs(this.context); + this.install = new Install({standalone: opts.standalone}); this.standalone = opts?.standalone ?? !Docker.isAvailable(); } - public static async getRelease(version: string): Promise { - // FIXME: Use https://raw.githubusercontent.com/docker/actions-toolkit/main/.github/buildx-releases.json when repo public - const url = `https://raw.githubusercontent.com/docker/buildx/master/.github/releases.json`; - const http: httpm.HttpClient = new httpm.HttpClient('docker-actions-toolkit'); - const resp: httpm.HttpClientResponse = await http.get(url); - const body = await resp.readBody(); - const statusCode = resp.message.statusCode || 500; - if (statusCode >= 400) { - throw new Error(`Failed to get Buildx release ${version} from ${url} with status code ${statusCode}: ${body}`); - } - const releases = >JSON.parse(body); - if (!releases[version]) { - throw new Error(`Cannot find Buildx release ${version} in ${url}`); - } - return releases[version]; - } - public getCommand(args: Array) { return { command: this.standalone ? 'buildx' : 'docker', diff --git a/src/buildx/install.ts b/src/buildx/install.ts new file mode 100644 index 0000000..f95ad26 --- /dev/null +++ b/src/buildx/install.ts @@ -0,0 +1,142 @@ +/** + * Copyright 2023 actions-toolkit authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import * as core from '@actions/core'; +import * as httpm from '@actions/http-client'; +import * as tc from '@actions/tool-cache'; +import * as semver from 'semver'; +import * as util from 'util'; + +import {GitHubRelease} from '../types/github'; + +export interface InstallOpts { + standalone?: boolean; +} + +export class Install { + private readonly opts: InstallOpts; + + constructor(opts?: InstallOpts) { + this.opts = opts || {}; + } + + public async install(version: string, dest: string): Promise { + const release: GitHubRelease = await Install.getRelease(version); + const fversion = release.tag_name.replace(/^v+|v+$/g, ''); + let toolPath: string; + toolPath = tc.find('buildx', fversion, this.platform()); + if (!toolPath) { + const c = semver.clean(fversion) || ''; + if (!semver.valid(c)) { + throw new Error(`Invalid Buildx version "${fversion}".`); + } + toolPath = await this.download(fversion); + } + if (this.opts.standalone) { + return this.setStandalone(toolPath, dest); + } + return this.setPlugin(toolPath, dest); + } + + public async setStandalone(toolPath: string, dest: string): Promise { + const toolBinPath = path.join(toolPath, os.platform() == 'win32' ? 'docker-buildx.exe' : 'docker-buildx'); + const binDir = path.join(dest, 'bin'); + if (!fs.existsSync(binDir)) { + fs.mkdirSync(binDir, {recursive: true}); + } + const filename: string = os.platform() == 'win32' ? 'buildx.exe' : 'buildx'; + const buildxPath: string = path.join(binDir, filename); + fs.copyFileSync(toolBinPath, buildxPath); + fs.chmodSync(buildxPath, '0755'); + core.addPath(binDir); + return buildxPath; + } + + public async setPlugin(toolPath: string, dest: string): Promise { + const toolBinPath = path.join(toolPath, os.platform() == 'win32' ? 'docker-buildx.exe' : 'docker-buildx'); + const pluginsDir: string = path.join(dest, 'cli-plugins'); + if (!fs.existsSync(pluginsDir)) { + fs.mkdirSync(pluginsDir, {recursive: true}); + } + const filename: string = os.platform() == 'win32' ? 'docker-buildx.exe' : 'docker-buildx'; + const pluginPath: string = path.join(pluginsDir, filename); + fs.copyFileSync(toolBinPath, pluginPath); + fs.chmodSync(pluginPath, '0755'); + return pluginPath; + } + + private async download(version: string): Promise { + const targetFile: string = os.platform() == 'win32' ? 'docker-buildx.exe' : 'docker-buildx'; + const downloadUrl = util.format('https://github.com/docker/buildx/releases/download/v%s/%s', version, this.filename(version)); + const downloadPath = await tc.downloadTool(downloadUrl); + core.debug(`downloadUrl=${downloadUrl}`); + core.debug(`downloadPath=${downloadPath}`); + return await tc.cacheFile(downloadPath, targetFile, 'buildx', version); + } + + private platform(): string { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const arm_version = (process.config.variables as any).arm_version; + return `${os.platform()}-${os.arch()}${arm_version ? 'v' + arm_version : ''}`; + } + + private filename(version: string): string { + let arch: string; + switch (os.arch()) { + case 'x64': { + arch = 'amd64'; + break; + } + case 'ppc64': { + arch = 'ppc64le'; + break; + } + case 'arm': { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const arm_version = (process.config.variables as any).arm_version; + arch = arm_version ? 'arm-v' + arm_version : 'arm'; + break; + } + default: { + arch = os.arch(); + break; + } + } + const platform: string = os.platform() == 'win32' ? 'windows' : os.platform(); + const ext: string = os.platform() == 'win32' ? '.exe' : ''; + return util.format('buildx-v%s.%s-%s%s', version, platform, arch, ext); + } + + public static async getRelease(version: string): Promise { + // FIXME: Use https://raw.githubusercontent.com/docker/actions-toolkit/main/.github/buildx-releases.json when repo public + const url = `https://raw.githubusercontent.com/docker/buildx/master/.github/releases.json`; + const http: httpm.HttpClient = new httpm.HttpClient('docker-actions-toolkit'); + const resp: httpm.HttpClientResponse = await http.get(url); + const body = await resp.readBody(); + const statusCode = resp.message.statusCode || 500; + if (statusCode >= 400) { + throw new Error(`Failed to get Buildx release ${version} from ${url} with status code ${statusCode}: ${body}`); + } + const releases = >JSON.parse(body); + if (!releases[version]) { + throw new Error(`Cannot find Buildx release ${version} in ${url}`); + } + return releases[version]; + } +} diff --git a/yarn.lock b/yarn.lock index c1aa28b..e6d7c99 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5,7 +5,7 @@ __metadata: version: 6 cacheKey: 8 -"@actions/core@npm:^1.10.0": +"@actions/core@npm:^1.10.0, @actions/core@npm:^1.2.6": version: 1.10.0 resolution: "@actions/core@npm:1.10.0" dependencies: @@ -15,7 +15,7 @@ __metadata: languageName: node linkType: hard -"@actions/exec@npm:^1.1.1": +"@actions/exec@npm:^1.0.0, @actions/exec@npm:^1.1.1": version: 1.1.1 resolution: "@actions/exec@npm:1.1.1" dependencies: @@ -52,6 +52,27 @@ __metadata: languageName: node linkType: hard +"@actions/io@npm:^1.1.1": + version: 1.1.2 + resolution: "@actions/io@npm:1.1.2" + checksum: 3c6583c4557abf6c95e9cfc9b6377045e65ba2c5dd4863f4feedd6be9daf4f6b60e588ab0151d5626b5f8320a37f05b8d44ab5c329b8c19f65be31b0616e1464 + languageName: node + linkType: hard + +"@actions/tool-cache@npm:^2.0.1": + version: 2.0.1 + resolution: "@actions/tool-cache@npm:2.0.1" + dependencies: + "@actions/core": ^1.2.6 + "@actions/exec": ^1.0.0 + "@actions/http-client": ^2.0.1 + "@actions/io": ^1.1.1 + semver: ^6.1.0 + uuid: ^3.3.2 + checksum: 33f6393b9b163e4af2b9759e8d37cda4f018f10ddda3643355bb8a9f92d732e5bdff089cf8036b46d181e1ef2b3210b895b2f746fdf54487afe88f1d340aa9e1 + languageName: node + linkType: hard + "@ampproject/remapping@npm:^2.1.0": version: 2.1.2 resolution: "@ampproject/remapping@npm:2.1.2" @@ -745,6 +766,7 @@ __metadata: "@actions/exec": ^1.1.1 "@actions/github": ^5.1.1 "@actions/http-client": ^2.0.1 + "@actions/tool-cache": ^2.0.1 "@types/csv-parse": ^1.2.2 "@types/node": ^16.18.11 "@types/semver": ^7.3.13 @@ -5986,7 +6008,7 @@ __metadata: languageName: node linkType: hard -"semver@npm:^6.0.0, semver@npm:^6.3.0": +"semver@npm:^6.0.0, semver@npm:^6.1.0, semver@npm:^6.3.0": version: 6.3.0 resolution: "semver@npm:6.3.0" bin: @@ -6731,6 +6753,15 @@ __metadata: languageName: node linkType: hard +"uuid@npm:^3.3.2": + version: 3.4.0 + resolution: "uuid@npm:3.4.0" + bin: + uuid: ./bin/uuid + checksum: 58de2feed61c59060b40f8203c0e4ed7fd6f99d42534a499f1741218a1dd0c129f4aa1de797bcf822c8ea5da7e4137aa3673431a96dae729047f7aca7b27866f + languageName: node + linkType: hard + "uuid@npm:^8.3.2": version: 8.3.2 resolution: "uuid@npm:8.3.2"