1414 * limitations under the License.
1515 */
1616
17+ import crypto from 'crypto' ;
1718import fs from 'fs' ;
1819import os from 'os' ;
1920import path from 'path' ;
2021import * as core from '@actions/core' ;
2122import * as httpm from '@actions/http-client' ;
2223import * as tc from '@actions/tool-cache' ;
24+ import * as cache from '@actions/cache' ;
2325import * as semver from 'semver' ;
2426import * as util from 'util' ;
2527
@@ -28,6 +30,7 @@ import {Context} from '../context';
2830import { Exec } from '../exec' ;
2931import { Docker } from '../docker/docker' ;
3032import { Git } from '../git' ;
33+ import { Util } from '../util' ;
3134
3235import { 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 - 9 a - f A - 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 - 9 a - f A - 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