import path from 'path' import fs from 'fs' import os from 'os' import * as core from '@actions/core' import * as glob from '@actions/glob' import * as exec from '@actions/exec' import { AbstractCache, getCacheKeyPrefix, hashFileNames, tryDelete } from './cache-utils' const META_FILE_DIR = '.gradle-build-action' const INCLUDE_PATHS_PARAMETER = 'gradle-home-cache-includes' const EXCLUDE_PATHS_PARAMETER = 'gradle-home-cache-excludes' const ARTIFACT_BUNDLES_PARAMETER = 'gradle-home-cache-artifact-bundles' export class GradleUserHomeCache extends AbstractCache { private gradleUserHome: string constructor(rootDir: string) { super('gradle', 'Gradle User Home') this.gradleUserHome = this.determineGradleUserHome(rootDir) } async afterRestore(): Promise { await this.reportGradleUserHomeSize('as restored from cache') await this.restoreArtifactBundles() await this.reportGradleUserHomeSize('after restoring common artifacts') } private async restoreArtifactBundles(): Promise { const processes: Promise[] = [] for (const [bundle, pattern] of this.getArtifactBundles()) { const p = this.restoreArtifactBundle(bundle, pattern) // Run sequentially when debugging enabled if (this.cacheDebuggingEnabled) { await p } processes.push(p) } await Promise.all(processes) } private async restoreArtifactBundle( bundle: string, artifactPath: string ): Promise { const bundleMetaFile = this.getBundleMetaFile(bundle) if (fs.existsSync(bundleMetaFile)) { const cacheKey = fs.readFileSync(bundleMetaFile, 'utf-8').trim() const restoreKey = await this.restoreCache([artifactPath], cacheKey) if (restoreKey) { core.info( `Restored ${bundle} with key ${cacheKey} to ${artifactPath}` ) } else { this.debug( `Did not restore ${bundle} with key ${cacheKey} to ${artifactPath}` ) } } else { this.debug( `No metafile found to restore ${bundle}: ${bundleMetaFile}` ) } } private getBundleMetaFile(name: string): string { return path.resolve(this.gradleUserHome, META_FILE_DIR, `${name}.cache`) } async beforeSave(): Promise { await this.reportGradleUserHomeSize('before saving common artifacts') this.removeExcludedPaths() await this.saveArtifactBundles() await this.reportGradleUserHomeSize( "after saving common artifacts (only 'caches' and 'notifications' will be stored)" ) } private removeExcludedPaths(): void { const rawPaths: string[] = JSON.parse( core.getInput(EXCLUDE_PATHS_PARAMETER) ) const resolvedPaths = rawPaths.map(x => path.resolve(this.gradleUserHome, x) ) for (const p of resolvedPaths) { this.debug(`Deleting excluded path: ${p}`) tryDelete(p) } } private async saveArtifactBundles(): Promise { const processes: Promise[] = [] for (const [bundle, pattern] of this.getArtifactBundles()) { const p = this.saveArtifactBundle(bundle, pattern) // Run sequentially when debugging enabled if (this.cacheDebuggingEnabled) { await p } processes.push(p) } await Promise.all(processes) } private async saveArtifactBundle( bundle: string, artifactPath: string ): Promise { const bundleMetaFile = this.getBundleMetaFile(bundle) const globber = await glob.create(artifactPath, { implicitDescendants: false, followSymbolicLinks: false }) const bundleFiles = await globber.glob() // Handle no matching files if (bundleFiles.length === 0) { this.debug(`No files found to cache for ${bundle}`) if (fs.existsSync(bundleMetaFile)) { tryDelete(bundleMetaFile) } return } const previouslyRestoredKey = fs.existsSync(bundleMetaFile) ? fs.readFileSync(bundleMetaFile, 'utf-8').trim() : '' const cacheKey = this.createCacheKey(bundle, bundleFiles) if (previouslyRestoredKey === cacheKey) { this.debug( `No change to previously restored ${bundle}. Not caching.` ) } else { core.info(`Caching ${bundle} with cache key: ${cacheKey}`) await this.saveCache([artifactPath], cacheKey) this.writeBundleMetaFile(bundleMetaFile, cacheKey) } for (const file of bundleFiles) { tryDelete(file) } } protected createCacheKey(bundle: string, files: string[]): string { const cacheKeyPrefix = getCacheKeyPrefix() const relativeFiles = files.map(x => path.relative(this.gradleUserHome, x) ) const key = hashFileNames(relativeFiles) this.debug( `Generating cache key for ${bundle} from files: ${relativeFiles}` ) return `${cacheKeyPrefix}${bundle}-${key}` } private writeBundleMetaFile(metaFile: string, cacheKey: string): void { this.debug(`Writing bundle metafile: ${metaFile}`) const dirName = path.dirname(metaFile) if (!fs.existsSync(dirName)) { fs.mkdirSync(dirName) } fs.writeFileSync(metaFile, cacheKey) } protected determineGradleUserHome(rootDir: string): string { const customGradleUserHome = process.env['GRADLE_USER_HOME'] if (customGradleUserHome) { return path.resolve(rootDir, customGradleUserHome) } return path.resolve(os.homedir(), '.gradle') } protected cacheOutputExists(): boolean { // Need to check for 'caches' directory to avoid incorrect detection on MacOS agents const dir = path.resolve(this.gradleUserHome, 'caches') return fs.existsSync(dir) } protected getCachePath(): string[] { const rawPaths: string[] = JSON.parse( core.getInput(INCLUDE_PATHS_PARAMETER) ) rawPaths.push(META_FILE_DIR) const resolvedPaths = rawPaths.map(x => this.resolveCachePath(x)) this.debug(`Using cache paths: ${resolvedPaths}`) return resolvedPaths } private resolveCachePath(rawPath: string): string { if (rawPath.startsWith('!')) { const resolved = this.resolveCachePath(rawPath.substring(1)) return `!${resolved}` } return path.resolve(this.gradleUserHome, rawPath) } private getArtifactBundles(): Map { const artifactBundleDefinition = core.getInput( ARTIFACT_BUNDLES_PARAMETER ) this.debug( `Using artifact bundle definition: ${artifactBundleDefinition}` ) const artifactBundles = JSON.parse(artifactBundleDefinition) return new Map( Array.from(artifactBundles, ([key, value]) => [ key, path.resolve(this.gradleUserHome, value) ]) ) } private async reportGradleUserHomeSize(label: string): Promise { if (!this.cacheDebuggingEnabled) { return } if (!fs.existsSync(this.gradleUserHome)) { return } const result = await exec.getExecOutput( 'du', ['-h', '-c', '-t', '5M'], { cwd: this.gradleUserHome, silent: true, ignoreReturnCode: true } ) core.info(`Gradle User Home (directories >5M): ${label}`) core.info( result.stdout .trimEnd() .replace(/\t/g, ' ') .split('\n') .map(it => { return ` ${it}` }) .join('\n') ) core.info('-----------------------') } }