/** * @fileoverview this file provides methods handling dependency cache */ import {join} from 'path'; import os from 'os'; import * as cache from '@actions/cache'; import * as core from '@actions/core'; import * as glob from '@actions/glob'; const STATE_CACHE_PRIMARY_KEY = 'cache-primary-key'; const STATE_CACHE_PATHS = 'cache-paths'; const CACHE_MATCHED_KEY = 'cache-matched-key'; const CACHE_KEY_PREFIX = 'setup-java'; /** * An additional cache entry that is restored and saved independently of the * main dependency cache. Used for build-tool wrapper distributions that rarely * change (e.g. the Maven wrapper distribution) so that they are not evicted * every time a volatile dependency file such as pom.xml changes. See * https://github.com/actions/setup-java/issues/1095. */ interface AdditionalCache { /** * Short identifier for the cache, used to build its cache key and to scope * the state keys that carry information from restore to save. */ name: string; /** * Paths that make up this cache entry. */ path: string[]; /** * Glob patterns whose hash forms the cache key. If no file matches, the * cache is skipped silently (the project simply does not use this feature). */ pattern: string[]; } interface PreparedAdditionalCache { cache: AdditionalCache; primaryKey: string; } interface PackageManager { id: 'maven' | 'gradle' | 'sbt'; /** * Paths of the file that specify the files to cache. */ path: string[]; pattern: string[]; /** * Additional caches keyed independently of the main dependency cache. */ additionalCaches?: AdditionalCache[]; } const supportedPackageManager: PackageManager[] = [ { id: 'maven', path: [join(os.homedir(), '.m2', 'repository')], // https://github.com/actions/cache/blob/0638051e9af2c23d10bb70fa9beffcad6cff9ce3/examples.md#java---maven pattern: [ '**/pom.xml', '**/.mvn/wrapper/maven-wrapper.properties', '**/.mvn/extensions.xml' ], // The Maven wrapper distribution only depends on the wrapper properties, // which change very rarely, so it is cached separately from the local // repository. This keeps it available across the frequent pom.xml changes // that rotate the main cache key. See issue #1095. additionalCaches: [ { name: 'maven-wrapper', path: [join(os.homedir(), '.m2', 'wrapper', 'dists')], pattern: ['**/.mvn/wrapper/maven-wrapper.properties'] } ] }, { id: 'gradle', path: [join(os.homedir(), '.gradle', 'caches')], // https://github.com/actions/cache/blob/0638051e9af2c23d10bb70fa9beffcad6cff9ce3/examples.md#java---gradle pattern: [ '**/*.gradle*', '**/gradle.properties', '**/gradle-wrapper.properties', 'buildSrc/**/Versions.kt', 'buildSrc/**/Dependencies.kt', 'gradle/*.versions.toml', '**/versions.properties' ], // The Gradle wrapper distribution only depends on the wrapper properties, // which change very rarely, so it is cached separately from the Gradle // caches. This keeps it available across the frequent *.gradle* changes // that rotate the main cache key. See issue #269. additionalCaches: [ { name: 'gradle-wrapper', path: [join(os.homedir(), '.gradle', 'wrapper')], pattern: ['**/gradle-wrapper.properties'] } ] }, { id: 'sbt', path: [ join(os.homedir(), '.ivy2', 'cache'), join(os.homedir(), '.sbt'), getCoursierCachePath(), // Some files should not be cached to avoid resolution problems. // In particular the resolution of snapshots (ideological gap between maven/ivy). '!' + join(os.homedir(), '.sbt', '*.lock'), '!' + join(os.homedir(), '**', 'ivydata-*.properties') ], pattern: [ '**/*.sbt', '**/project/build.properties', '**/project/**.scala', '**/project/**.sbt' ] } ]; function getCoursierCachePath(): string { if (os.type() === 'Linux') return join(os.homedir(), '.cache', 'coursier'); if (os.type() === 'Darwin') return join(os.homedir(), 'Library', 'Caches', 'Coursier'); return join(os.homedir(), 'AppData', 'Local', 'Coursier', 'Cache'); } function findPackageManager(id: string): PackageManager { const packageManager = supportedPackageManager.find( packageManager => packageManager.id === id ); if (packageManager === undefined) { throw new Error(`unknown package manager specified: ${id}`); } return packageManager; } function resolveCachePaths( packageManager: PackageManager, cachePaths: string[] ): string[] { return cachePaths.length > 0 ? cachePaths : packageManager.path; } function getCachePathsFromState(packageManager: PackageManager): string[] { const cachePathsState = core.getState(STATE_CACHE_PATHS); if (!cachePathsState) { return packageManager.path; } const cachePaths: unknown = JSON.parse(cachePathsState); if ( !Array.isArray(cachePaths) || !cachePaths.every(cachePath => typeof cachePath === 'string') ) { throw new Error('Invalid cache paths retrieved from state.'); } return cachePaths; } /** * State keys used to carry an additional cache's restore-time information over * to the post (save) action, scoped by the additional cache name. */ function additionalCachePrimaryKeyState(name: string): string { return `${STATE_CACHE_PRIMARY_KEY}-${name}`; } function additionalCacheMatchedKeyState(name: string): string { return `${CACHE_MATCHED_KEY}-${name}`; } function buildCacheKey(id: string, fileHash: string): string { return `${CACHE_KEY_PREFIX}-${process.env['RUNNER_OS']}-${process.arch}-${id}-${fileHash}`; } /** * A function that generates a cache key to use. * Format of the generated key will be "${{ platform }}-${{ id }}-${{ fileHash }}"". * @see {@link https://docs.github.com/en/actions/guides/caching-dependencies-to-speed-up-workflows#matching-a-cache-key|spec of cache key} */ async function computeCacheKey( packageManager: PackageManager, cacheDependencyPath: string ) { const pattern = cacheDependencyPath ? cacheDependencyPath.trim().split('\n') : packageManager.pattern; const fileHash = await glob.hashFiles(pattern.join('\n')); if (!fileHash) { throw new Error( `No file in ${process.cwd()} matched to [${pattern}], make sure you have checked out the target repository` ); } return buildCacheKey(packageManager.id, fileHash); } /** * Computes the cache key for an additional cache. Unlike {@link computeCacheKey} * this returns undefined (instead of throwing) when no file matches the pattern, * because additional caches are optional features that many projects do not use. */ async function computeAdditionalCacheKey( additionalCache: AdditionalCache ): Promise { const fileHash = await glob.hashFiles(additionalCache.pattern.join('\n')); if (!fileHash) { return undefined; } return buildCacheKey(additionalCache.name, fileHash); } /** * Restore the dependency cache * @param id ID of the package manager, should be "maven", "gradle", or "sbt" * @param cacheDependencyPath The path to a dependency file * @param cachePaths Paths to cache instead of the package manager defaults */ export async function restore( id: string, cacheDependencyPath: string, cachePaths: string[] = [] ) { const packageManager = findPackageManager(id); const resolvedCachePaths = resolveCachePaths(packageManager, cachePaths); const [primaryKey, preparedAdditionalCaches] = await Promise.all([ computeCacheKey(packageManager, cacheDependencyPath), prepareAdditionalCaches(packageManager.additionalCaches ?? []) ]); core.debug(`primary key is ${primaryKey}`); core.saveState(STATE_CACHE_PRIMARY_KEY, primaryKey); core.saveState(STATE_CACHE_PATHS, JSON.stringify(resolvedCachePaths)); core.setOutput(STATE_CACHE_PRIMARY_KEY, primaryKey); for (const preparedCache of preparedAdditionalCaches) { core.debug( `${preparedCache.cache.name} primary key is ${preparedCache.primaryKey}` ); core.saveState( additionalCachePrimaryKeyState(preparedCache.cache.name), preparedCache.primaryKey ); } await Promise.all([ restorePrimaryCache(packageManager, resolvedCachePaths, primaryKey), ...preparedAdditionalCaches.map(preparedCache => restoreAdditionalCache(preparedCache) ) ]); } async function restorePrimaryCache( packageManager: PackageManager, cachePaths: string[], primaryKey: string ) { // No "restoreKeys" is set, to start with a clear cache after dependency update (see https://github.com/actions/setup-java/issues/269) const matchedKey = await cache.restoreCache(cachePaths, primaryKey); if (matchedKey) { core.saveState(CACHE_MATCHED_KEY, matchedKey); core.setOutput('cache-hit', matchedKey === primaryKey); core.info(`Cache restored from key: ${matchedKey}`); } else { core.setOutput('cache-hit', false); core.info(`${packageManager.id} cache is not found`); } } /** * Compute keys for additional caches (e.g. build-tool wrapper distributions). * Additional caches without a matching configuration file are omitted. */ async function prepareAdditionalCaches( additionalCaches: AdditionalCache[] ): Promise { const preparedCaches = await Promise.all( additionalCaches.map(async additionalCache => { const primaryKey = await computeAdditionalCacheKey(additionalCache); if (!primaryKey) { core.debug( `No file matched [${additionalCache.pattern}] for the ${additionalCache.name} cache, skipping.` ); return undefined; } return {cache: additionalCache, primaryKey}; }) ); return preparedCaches.filter( (preparedCache): preparedCache is PreparedAdditionalCache => preparedCache !== undefined ); } /** * Restore an additional cache keyed independently of the main dependency cache. */ async function restoreAdditionalCache(preparedCache: PreparedAdditionalCache) { const {cache: additionalCache, primaryKey} = preparedCache; const matchedKey = await cache.restoreCache(additionalCache.path, primaryKey); if (matchedKey) { core.saveState( additionalCacheMatchedKeyState(additionalCache.name), matchedKey ); core.info(`${additionalCache.name} cache restored from key: ${matchedKey}`); } else { core.info(`${additionalCache.name} cache is not found`); } } /** * Save the dependency cache * @param id ID of the package manager, should be "maven" or "gradle" */ export async function save(id: string) { const packageManager = findPackageManager(id); const cachePaths = getCachePathsFromState(packageManager); const matchedKey = core.getState(CACHE_MATCHED_KEY); // Inputs are re-evaluated before the post action, so we want the original key used for restore const primaryKey = core.getState(STATE_CACHE_PRIMARY_KEY); for (const additionalCache of packageManager.additionalCaches ?? []) { try { await saveAdditionalCache(packageManager, additionalCache); } catch (error) { const err = error as Error; core.warning( `Failed to save ${additionalCache.name} cache: ${err.message}. Continuing with primary cache save.` ); } } if (!primaryKey) { core.warning('Error retrieving key from state.'); return; } else if (matchedKey === primaryKey) { // no change in target directories core.info( `Cache hit occurred on the primary key ${primaryKey}, not saving cache.` ); return; } try { const cacheId = await cache.saveCache(cachePaths, primaryKey); if (cacheId === -1) { // saveCache returns -1 without throwing when the cache was not saved, // e.g. a reserve collision or a read-only token (fork PR). @actions/cache // has already logged the reason at the appropriate severity, so just // trace it instead of misreporting that the cache was saved. core.debug(`Cache was not saved for the key: ${primaryKey}`); return; } core.info(`Cache saved with the key: ${primaryKey}`); } catch (error) { const err = error as Error; if (err.name === cache.ReserveCacheError.name) { core.info(err.message); } else { if (isProbablyGradleDaemonProblem(packageManager, err)) { core.warning( 'Failed to save Gradle cache on Windows. If tar.exe reported "Permission denied", try to run Gradle with `--no-daemon` option. Refer to https://github.com/actions/cache/issues/454 for details.' ); } throw error; } } } /** * Save an additional cache under its own key. Skips when no key was recorded at * restore time (feature unused) or when the exact key was already restored. */ async function saveAdditionalCache( packageManager: PackageManager, additionalCache: AdditionalCache ) { const primaryKey = core.getState( additionalCachePrimaryKeyState(additionalCache.name) ); const matchedKey = core.getState( additionalCacheMatchedKeyState(additionalCache.name) ); if (!primaryKey) { // The feature is not used by this project, nothing to save. core.debug( `No primary key for the ${additionalCache.name} cache, not saving cache.` ); return; } else if (matchedKey === primaryKey) { core.info( `Cache hit occurred on the ${additionalCache.name} primary key ${primaryKey}, not saving cache.` ); return; } const globber = await glob.create(additionalCache.path.join('\n'), { implicitDescendants: false }); const cachePaths = await globber.glob(); if (cachePaths.length === 0) { core.debug( `${additionalCache.name} cache paths do not exist, not saving cache.` ); return; } try { const cacheId = await cache.saveCache(cachePaths, primaryKey); if (cacheId === -1) { core.debug( `${additionalCache.name} cache was not saved for the key: ${primaryKey}` ); return; } core.info( `${additionalCache.name} cache saved with the key: ${primaryKey}` ); } catch (error) { const err = error as Error; if (err.name === cache.ValidationError.name) { // The cache paths did not resolve, e.g. the wrapper distribution was // never downloaded because a system build tool was used or the download // failed. Optional wrapper caches must not fail the post step, so skip. core.debug( `${additionalCache.name} cache paths do not exist, not saving cache: ${err.message}` ); return; } if (err.name === cache.ReserveCacheError.name) { core.info(err.message); } else { if (isProbablyGradleDaemonProblem(packageManager, err)) { core.warning( `Failed to save ${additionalCache.name} cache on Windows. If tar.exe reported "Permission denied", try to run Gradle with \`--no-daemon\` option. Refer to https://github.com/actions/cache/issues/454 for details.` ); } throw error; } } } /** * @param packageManager the specified package manager by user * @param error the error thrown by the saveCache * @returns true if the given error seems related to the {@link https://github.com/actions/cache/issues/454|running Gradle Daemon issue}. * @see {@link https://github.com/actions/cache/issues/454#issuecomment-840493935|why --no-daemon is necessary} */ function isProbablyGradleDaemonProblem( packageManager: PackageManager, error: Error ) { if ( packageManager.id !== 'gradle' || process.env['RUNNER_OS'] !== 'Windows' ) { return false; } const message = error.message || ''; return message.startsWith('Tar failed with error: '); }