Skip to content

Commit 8d02bad

Browse files
authored
Merge pull request #129 from crazy-max/buildx-bin-cache
buildx: cache binary to hosted tool cache and GHA cache backend
2 parents 0e5fc36 + c1edd0b commit 8d02bad

File tree

6 files changed

+598
-76
lines changed

6 files changed

+598
-76
lines changed

__tests__/buildx/install.test.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,8 @@ afterEach(function () {
3636
describe('download', () => {
3737
// prettier-ignore
3838
test.each([
39-
['v0.9.1', false],
40-
['latest', false],
41-
['v0.9.1', true],
39+
['v0.9.0', false],
40+
['v0.10.5', true],
4241
['latest', true]
4342
])(
4443
'acquires %p of buildx (standalone: %p)', async (version, standalone) => {
@@ -56,6 +55,18 @@ describe('download', () => {
5655
100000
5756
);
5857

58+
// prettier-ignore
59+
test.each([
60+
// following versions are already cached to htc from previous test cases
61+
['v0.9.0'],
62+
['v0.10.5'],
63+
])(
64+
'acquires %p of buildx with cache', async (version) => {
65+
const install = new Install({standalone: false});
66+
const toolPath = await install.download(version);
67+
expect(fs.existsSync(toolPath)).toBe(true);
68+
});
69+
5970
// TODO: add tests for arm
6071
// prettier-ignore
6172
test.each([

__tests__/util.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,41 @@ describe('isValidRef', () => {
232232
});
233233
});
234234

235+
describe('trimPrefix', () => {
236+
test.each([
237+
['', 'abc', ''],
238+
['abc', 'a', 'bc'],
239+
['abc', 'ab', 'c'],
240+
['abc', '', 'abc'],
241+
['abc', '', 'abc'],
242+
['abc', 'd', 'abc'],
243+
['abc', 'abc', ''],
244+
['abc', 'abcd', 'abc'],
245+
['abcdabc', 'abc', 'dabc'],
246+
['abcabc', 'abc', 'abc'],
247+
['abcdabc', 'd', 'abcdabc']
248+
])('given %p', async (str, prefix, expected) => {
249+
expect(Util.trimPrefix(str, prefix)).toEqual(expected);
250+
});
251+
});
252+
253+
describe('trimSuffix', () => {
254+
test.each([
255+
['', 'abc', ''],
256+
['abc', 'c', 'ab'],
257+
['abc', '', 'abc'],
258+
['abc', 'bc', 'a'],
259+
['abc', 'abc', ''],
260+
['abc', 'abcd', 'abc'],
261+
['abc', 'aabc', 'abc'],
262+
['abcdabc', 'abc', 'abcd'],
263+
['abcabc', 'abc', 'abc'],
264+
['abcdabc', 'd', 'abcdabc']
265+
])('given %p', async (str, suffix, expected) => {
266+
expect(Util.trimSuffix(str, suffix)).toEqual(expected);
267+
});
268+
});
269+
235270
// See: https://github.com/actions/toolkit/blob/a1b068ec31a042ff1e10a522d8fdf0b8869d53ca/packages/core/src/core.ts#L89
236271
function getInputName(name: string): string {
237272
return `INPUT_${name.replace(/ /g, '_').toUpperCase()}`;

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
"registry": "https://registry.npmjs.org/"
4646
},
4747
"dependencies": {
48+
"@actions/cache": "^3.2.1",
4849
"@actions/core": "^1.10.0",
4950
"@actions/exec": "^1.1.1",
5051
"@actions/github": "^5.1.1",

src/buildx/install.ts

Lines changed: 177 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,14 @@
1414
* limitations under the License.
1515
*/
1616

17+
import crypto from 'crypto';
1718
import fs from 'fs';
1819
import os from 'os';
1920
import path from 'path';
2021
import * as core from '@actions/core';
2122
import * as httpm from '@actions/http-client';
2223
import * as tc from '@actions/tool-cache';
24+
import * as cache from '@actions/cache';
2325
import * as semver from 'semver';
2426
import * as util from 'util';
2527

@@ -28,6 +30,7 @@ import {Context} from '../context';
2830
import {Exec} from '../exec';
2931
import {Docker} from '../docker/docker';
3032
import {Git} from '../git';
33+
import {Util} from '../util';
3134

3235
import {GitHubRelease} from '../types/github';
3336

@@ -42,70 +45,87 @@ export class Install {
4245
this._standalone = opts?.standalone;
4346
}
4447

48+
/*
49+
* Download buildx binary from GitHub release
50+
* @param version semver version or latest
51+
* @returns path to the buildx binary
52+
*/
4553
public async download(version: string): Promise<string> {
4654
const release: GitHubRelease = await Install.getRelease(version);
47-
const fversion = release.tag_name.replace(/^v+|v+$/g, '');
48-
core.debug(`Install.download version: ${fversion}`);
49-
50-
let toolPath: string;
51-
toolPath = tc.find('buildx', fversion, this.platform());
52-
if (!toolPath) {
53-
const c = semver.clean(fversion) || '';
54-
if (!semver.valid(c)) {
55-
throw new Error(`Invalid Buildx version "${fversion}".`);
56-
}
57-
toolPath = await this.fetchBinary(fversion);
55+
core.debug(`Install.download release tag name: ${release.tag_name}`);
56+
57+
const vspec = await this.vspec(release.tag_name);
58+
core.debug(`Install.download vspec: ${vspec}`);
59+
60+
const c = semver.clean(vspec) || '';
61+
if (!semver.valid(c)) {
62+
throw new Error(`Invalid Buildx version "${vspec}".`);
5863
}
59-
core.debug(`Install.download toolPath: ${toolPath}`);
6064

61-
return toolPath;
65+
const installCache = new InstallCache('buildx-dl-bin', vspec);
66+
67+
const cacheFoundPath = await installCache.find();
68+
if (cacheFoundPath) {
69+
core.info(`Buildx binary found in ${cacheFoundPath}`);
70+
return cacheFoundPath;
71+
}
72+
73+
const downloadURL = util.format('https://github.com/docker/buildx/releases/download/v%s/%s', vspec, this.filename(vspec));
74+
core.info(`Downloading ${downloadURL}`);
75+
76+
const htcDownloadPath = await tc.downloadTool(downloadURL);
77+
core.debug(`Install.download htcDownloadPath: ${htcDownloadPath}`);
78+
79+
const cacheSavePath = await installCache.save(htcDownloadPath);
80+
core.info(`Cached to ${cacheSavePath}`);
81+
return cacheSavePath;
6282
}
6383

84+
/*
85+
* Build buildx binary from source
86+
* @param gitContext git repo context
87+
* @returns path to the buildx binary
88+
*/
6489
public async build(gitContext: string): Promise<string> {
65-
// eslint-disable-next-line prefer-const
66-
let [repo, ref] = gitContext.split('#');
67-
if (ref.length == 0) {
68-
ref = 'master';
90+
const vspec = await this.vspec(gitContext);
91+
core.debug(`Install.build vspec: ${vspec}`);
92+
93+
const installCache = new InstallCache('buildx-build-bin', vspec);
94+
95+
const cacheFoundPath = await installCache.find();
96+
if (cacheFoundPath) {
97+
core.info(`Buildx binary found in ${cacheFoundPath}`);
98+
return cacheFoundPath;
6999
}
70100

71-
let vspec: string;
72-
// TODO: include full ref as fingerprint. Use commit sha as best-effort in the meantime.
73-
if (ref.match(/^[0-9a-fA-F]{40}$/)) {
74-
vspec = ref;
75-
} else {
76-
vspec = await Git.remoteSha(repo, ref, process.env.GIT_AUTH_TOKEN);
77-
}
78-
core.debug(`Install.build: tool version spec ${vspec}`);
79-
80-
let toolPath: string;
81-
toolPath = tc.find('buildx', vspec);
82-
if (!toolPath) {
83-
const outputDir = path.join(Context.tmpDir(), 'build-cache');
84-
const buildCmd = await this.buildCommand(gitContext, outputDir);
85-
toolPath = await Exec.getExecOutput(buildCmd.command, buildCmd.args, {
86-
ignoreReturnCode: true
87-
}).then(res => {
88-
if (res.stderr.length > 0 && res.exitCode != 0) {
89-
throw new Error(`build failed with: ${res.stderr.match(/(.*)\s*$/)?.[0]?.trim() ?? 'unknown error'}`);
90-
}
91-
return tc.cacheFile(`${outputDir}/buildx`, os.platform() == 'win32' ? 'docker-buildx.exe' : 'docker-buildx', 'buildx', vspec, this.platform());
92-
});
93-
}
94-
95-
return toolPath;
101+
const outputDir = path.join(Context.tmpDir(), 'buildx-build-cache');
102+
const buildCmd = await this.buildCommand(gitContext, outputDir);
103+
104+
const buildBinPath = await Exec.getExecOutput(buildCmd.command, buildCmd.args, {
105+
ignoreReturnCode: true
106+
}).then(res => {
107+
if (res.stderr.length > 0 && res.exitCode != 0) {
108+
throw new Error(`build failed with: ${res.stderr.match(/(.*)\s*$/)?.[0]?.trim() ?? 'unknown error'}`);
109+
}
110+
return `${outputDir}/buildx`;
111+
});
112+
113+
const cacheSavePath = await installCache.save(buildBinPath);
114+
core.info(`Cached to ${cacheSavePath}`);
115+
return cacheSavePath;
96116
}
97117

98-
public async installStandalone(toolPath: string, dest?: string): Promise<string> {
118+
public async installStandalone(binPath: string, dest?: string): Promise<string> {
99119
core.info('Standalone mode');
100120
dest = dest || Context.tmpDir();
101-
const toolBinPath = path.join(toolPath, os.platform() == 'win32' ? 'docker-buildx.exe' : 'docker-buildx');
102-
const binDir = path.join(dest, 'bin');
121+
122+
const binDir = path.join(dest, 'buildx-bin-standalone');
103123
if (!fs.existsSync(binDir)) {
104124
fs.mkdirSync(binDir, {recursive: true});
105125
}
106-
const filename: string = os.platform() == 'win32' ? 'buildx.exe' : 'buildx';
107-
const buildxPath: string = path.join(binDir, filename);
108-
fs.copyFileSync(toolBinPath, buildxPath);
126+
const binName: string = os.platform() == 'win32' ? 'buildx.exe' : 'buildx';
127+
const buildxPath: string = path.join(binDir, binName);
128+
fs.copyFileSync(binPath, buildxPath);
109129

110130
core.info('Fixing perms');
111131
fs.chmodSync(buildxPath, '0755');
@@ -117,17 +137,17 @@ export class Install {
117137
return buildxPath;
118138
}
119139

120-
public async installPlugin(toolPath: string, dest?: string): Promise<string> {
140+
public async installPlugin(binPath: string, dest?: string): Promise<string> {
121141
core.info('Docker plugin mode');
122142
dest = dest || Docker.configDir;
123-
const toolBinPath = path.join(toolPath, os.platform() == 'win32' ? 'docker-buildx.exe' : 'docker-buildx');
143+
124144
const pluginsDir: string = path.join(dest, 'cli-plugins');
125145
if (!fs.existsSync(pluginsDir)) {
126146
fs.mkdirSync(pluginsDir, {recursive: true});
127147
}
128-
const filename: string = os.platform() == 'win32' ? 'docker-buildx.exe' : 'docker-buildx';
129-
const pluginPath: string = path.join(pluginsDir, filename);
130-
fs.copyFileSync(toolBinPath, pluginPath);
148+
const binName: string = os.platform() == 'win32' ? 'docker-buildx.exe' : 'docker-buildx';
149+
const pluginPath: string = path.join(pluginsDir, binName);
150+
fs.copyFileSync(binPath, pluginPath);
131151

132152
core.info('Fixing perms');
133153
fs.chmodSync(pluginPath, '0755');
@@ -173,21 +193,6 @@ export class Install {
173193
return standalone;
174194
}
175195

176-
private async fetchBinary(version: string): Promise<string> {
177-
const targetFile: string = os.platform() == 'win32' ? 'docker-buildx.exe' : 'docker-buildx';
178-
const downloadURL = util.format('https://github.com/docker/buildx/releases/download/v%s/%s', version, this.filename(version));
179-
core.info(`Downloading ${downloadURL}`);
180-
const downloadPath = await tc.downloadTool(downloadURL);
181-
core.debug(`Install.fetchBinary downloadPath: ${downloadPath}`);
182-
return await tc.cacheFile(downloadPath, targetFile, 'buildx', version, this.platform());
183-
}
184-
185-
private platform(): string {
186-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
187-
const arm_version = (process.config.variables as any).arm_version;
188-
return `${os.platform()}-${os.arch()}${arm_version ? 'v' + arm_version : ''}`;
189-
}
190-
191196
private filename(version: string): string {
192197
let arch: string;
193198
switch (os.arch()) {
@@ -215,6 +220,39 @@ export class Install {
215220
return util.format('buildx-v%s.%s-%s%s', version, platform, arch, ext);
216221
}
217222

223+
/*
224+
* Get version spec (fingerprint) for cache key. If versionOrRef is a valid
225+
* Git context, then return the SHA of the ref along the repo and owner and
226+
* create a hash of it. Otherwise, return the versionOrRef (semver) as is
227+
* without the 'v' prefix.
228+
*/
229+
private async vspec(versionOrRef: string): Promise<string> {
230+
if (!Util.isValidRef(versionOrRef)) {
231+
const v = versionOrRef.replace(/^v+|v+$/g, '');
232+
core.info(`Use ${v} version spec cache key for ${versionOrRef}`);
233+
return v;
234+
}
235+
236+
// eslint-disable-next-line prefer-const
237+
let [baseURL, ref] = versionOrRef.split('#');
238+
if (ref.length == 0) {
239+
ref = 'master';
240+
}
241+
242+
let sha: string;
243+
if (ref.match(/^[0-9a-fA-F]{40}$/)) {
244+
sha = ref;
245+
} else {
246+
sha = await Git.remoteSha(baseURL, ref, process.env.GIT_AUTH_TOKEN);
247+
}
248+
249+
const [owner, repo] = baseURL.substring('https://github.com/'.length).split('/');
250+
const key = `${owner}/${Util.trimSuffix(repo, '.git')}/${sha}`;
251+
const hash = crypto.createHash('sha256').update(key).digest('hex');
252+
core.info(`Use ${hash} version spec cache key for ${key}`);
253+
return hash;
254+
}
255+
218256
public static async getRelease(version: string): Promise<GitHubRelease> {
219257
const url = `https://raw.githubusercontent.com/docker/actions-toolkit/main/.github/buildx-releases.json`;
220258
const http: httpm.HttpClient = new httpm.HttpClient('docker-actions-toolkit');
@@ -231,3 +269,74 @@ export class Install {
231269
return releases[version];
232270
}
233271
}
272+
273+
class InstallCache {
274+
private readonly htcName: string;
275+
private readonly htcVersion: string;
276+
private readonly ghaCacheKey: string;
277+
private readonly cacheDir: string;
278+
private readonly cacheFile: string;
279+
private readonly cachePath: string;
280+
281+
constructor(htcName: string, htcVersion: string) {
282+
this.htcName = htcName;
283+
this.htcVersion = htcVersion;
284+
this.ghaCacheKey = util.format('%s-%s-%s', this.htcName, this.htcVersion, this.platform());
285+
this.cacheDir = path.join(Buildx.configDir, '.bin', htcVersion, this.platform());
286+
this.cacheFile = os.platform() == 'win32' ? 'docker-buildx.exe' : 'docker-buildx';
287+
this.cachePath = path.join(this.cacheDir, this.cacheFile);
288+
if (!fs.existsSync(this.cacheDir)) {
289+
fs.mkdirSync(this.cacheDir, {recursive: true});
290+
}
291+
}
292+
293+
public async save(file: string): Promise<string> {
294+
core.debug(`InstallCache.save ${file}`);
295+
const cachePath = this.copyToCache(file);
296+
297+
const htcPath = await tc.cacheDir(this.cacheDir, this.htcName, this.htcVersion, this.platform());
298+
core.debug(`InstallCache.save cached to hosted tool cache ${htcPath}`);
299+
300+
if (cache.isFeatureAvailable()) {
301+
core.debug(`InstallCache.save caching ${this.ghaCacheKey} to GitHub Actions cache`);
302+
await cache.saveCache([this.cacheDir], this.ghaCacheKey);
303+
}
304+
305+
return cachePath;
306+
}
307+
308+
public async find(): Promise<string> {
309+
let htcPath = tc.find(this.htcName, this.htcVersion, this.platform());
310+
if (htcPath) {
311+
core.info(`Restored from hosted tool cache ${htcPath}`);
312+
return this.copyToCache(`${htcPath}/${this.cacheFile}`);
313+
}
314+
315+
if (cache.isFeatureAvailable()) {
316+
core.debug(`GitHub Actions cache feature available`);
317+
if (await cache.restoreCache([this.cacheDir], this.ghaCacheKey)) {
318+
core.info(`Restored ${this.ghaCacheKey} from GitHub Actions cache`);
319+
htcPath = await tc.cacheDir(this.cacheDir, this.htcName, this.htcVersion, this.platform());
320+
core.info(`Restored to hosted tool cache ${htcPath}`);
321+
return this.copyToCache(`${htcPath}/${this.cacheFile}`);
322+
}
323+
} else {
324+
core.info(`GitHub Actions cache feature not available`);
325+
}
326+
327+
return '';
328+
}
329+
330+
private copyToCache(file: string): string {
331+
core.debug(`Copying ${file} to ${this.cachePath}`);
332+
fs.copyFileSync(file, this.cachePath);
333+
fs.chmodSync(this.cachePath, '0755');
334+
return this.cachePath;
335+
}
336+
337+
private platform(): string {
338+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
339+
const arm_version = (process.config.variables as any).arm_version;
340+
return `${os.platform()}-${os.arch()}${arm_version ? 'v' + arm_version : ''}`;
341+
}
342+
}

0 commit comments

Comments
 (0)