From bc6a9b1b01bec434f1616ead1c1a26a36ebcdd4e Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Thu, 9 Jul 2026 20:27:55 -0400 Subject: [PATCH] Cache Maven wrapper distribution separately from the local repository MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Maven wrapper distribution (~/.m2/wrapper/dists) was cached in the same entry as the local Maven repository (~/.m2/repository), keyed on a hash of **/pom.xml (plus wrapper properties and extensions). Because pom.xml changes frequently and no restoreKeys are used (by design, #269), almost every change produces a full cache miss and the wrapper distribution is re-downloaded via mvnw — which intermittently fails due to upstream rate limiting. The wrapper distribution only depends on maven-wrapper.properties, which changes very rarely. Give it its own cache entry keyed solely on **/.mvn/wrapper/maven-wrapper.properties so it survives the frequent pom.xml changes that rotate the main dependency cache key. - Add a generic additionalCaches concept to PackageManager, restored and saved independently with name-scoped state keys. - Move ~/.m2/wrapper/dists out of the main maven path into a maven-wrapper additional cache; skip silently when the project does not use mvnw. - Keep cache-hit / cache-primary-key outputs driven by the main cache. - Update tests, docs, and rebuild dist bundles. Fixes #1095 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 2 +- __tests__/cache.test.ts | 102 +++++++++++++++++++++--- dist/cleanup/index.js | 109 ++++++++++++++++++++++++-- dist/setup/index.js | 109 ++++++++++++++++++++++++-- docs/advanced-usage.md | 11 ++- src/cache.ts | 166 ++++++++++++++++++++++++++++++++++++++-- 6 files changed, 469 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index be8c8c59..a0c1bee8 100644 --- a/README.md +++ b/README.md @@ -162,7 +162,7 @@ The workflow output `cache-primary-key` exposes the primary cache key computed b The cache input is optional, and caching is turned off by default. -**Maven Wrapper:** when `cache: 'maven'` is enabled, the action also caches and restores the Maven Wrapper distribution downloaded to `~/.m2/wrapper/dists` (in addition to the local repository), so wrapper-based (`./mvnw`) builds don't re-download the wrapper on every run. This is keyed on `**/.mvn/wrapper/maven-wrapper.properties` as shown above. +**Maven Wrapper:** when `cache: 'maven'` is enabled, the action also caches and restores the Maven Wrapper distribution downloaded to `~/.m2/wrapper/dists` (in addition to the local repository), so wrapper-based (`./mvnw`) builds don't re-download the Maven distribution. The wrapper distribution is stored in a **separate** cache entry keyed only on `**/.mvn/wrapper/maven-wrapper.properties`, so it stays cached across the frequent `pom.xml` changes that rotate the main dependency cache key. #### Caching gradle dependencies ```yaml diff --git a/__tests__/cache.test.ts b/__tests__/cache.test.ts index 0b5a1903..332af9f6 100644 --- a/__tests__/cache.test.ts +++ b/__tests__/cache.test.ts @@ -166,10 +166,7 @@ describe('dependency cache', () => { await restore('maven', ''); expect(spyCacheRestore).toHaveBeenCalledWith( - [ - join(os.homedir(), '.m2', 'repository'), - join(os.homedir(), '.m2', 'wrapper', 'dists') - ], + [join(os.homedir(), '.m2', 'repository')], expect.any(String) ); expect(spyGlobHashFiles).toHaveBeenCalledWith( @@ -196,10 +193,7 @@ describe('dependency cache', () => { await restore('maven', ''); expect(spyCacheRestore).toHaveBeenCalledWith( - [ - join(os.homedir(), '.m2', 'repository'), - join(os.homedir(), '.m2', 'wrapper', 'dists') - ], + [join(os.homedir(), '.m2', 'repository')], expect.any(String) ); expect(spyGlobHashFiles).toHaveBeenCalledWith( @@ -214,10 +208,7 @@ describe('dependency cache', () => { await restore('maven', ''); expect(spyCacheRestore).toHaveBeenCalledWith( - [ - join(os.homedir(), '.m2', 'repository'), - join(os.homedir(), '.m2', 'wrapper', 'dists') - ], + [join(os.homedir(), '.m2', 'repository')], expect.any(String) ); expect(spyGlobHashFiles).toHaveBeenCalledWith( @@ -226,6 +217,49 @@ describe('dependency cache', () => { expect(spyWarning).not.toHaveBeenCalled(); expect(spyInfo).toHaveBeenCalledWith('maven cache is not found'); }); + it('restores the maven wrapper distribution cache independently of the main cache', async () => { + createDirectory(join(workspace, '.mvn')); + createDirectory(join(workspace, '.mvn', 'wrapper')); + createFile( + join(workspace, '.mvn', 'wrapper', 'maven-wrapper.properties') + ); + + await restore('maven', ''); + // Main dependency cache no longer carries the wrapper dists path. + expect(spyCacheRestore).toHaveBeenCalledWith( + [join(os.homedir(), '.m2', 'repository')], + expect.any(String) + ); + // Wrapper distribution is restored on its own, keyed only on the + // wrapper properties file. + expect(spyCacheRestore).toHaveBeenCalledWith( + [join(os.homedir(), '.m2', 'wrapper', 'dists')], + expect.stringContaining('setup-java-') + ); + expect(spyGlobHashFiles).toHaveBeenCalledWith( + '**/.mvn/wrapper/maven-wrapper.properties' + ); + }); + it('skips the maven wrapper cache when no wrapper properties exist', async () => { + createFile(join(workspace, 'pom.xml')); + spyGlobHashFiles.mockImplementation((pattern: string) => + Promise.resolve( + pattern === '**/.mvn/wrapper/maven-wrapper.properties' + ? '' + : 'hash-stub' + ) + ); + + await restore('maven', ''); + // Only the main dependency cache is restored; the wrapper cache path is + // never touched because the project does not use mvnw. + expect(spyCacheRestore).toHaveBeenCalledTimes(1); + expect(spyCacheRestore).toHaveBeenCalledWith( + [join(os.homedir(), '.m2', 'repository')], + expect.any(String) + ); + expect(spyWarning).not.toHaveBeenCalled(); + }); }); describe('for gradle', () => { it('throws error if no build.gradle found', async () => { @@ -457,6 +491,50 @@ describe('dependency cache', () => { expect.stringMatching(/^Cache saved with the key:.*/) ); }); + it('saves the maven wrapper distribution cache under its own key', async () => { + createFile(join(workspace, 'pom.xml')); + (core.getState as jest.Mock).mockImplementation((name: any) => { + switch (name) { + case 'cache-primary-key': + return 'setup-java-cache-primary-key'; + case 'cache-matched-key': + return 'setup-java-cache-matched-key'; + case 'cache-primary-key-maven-wrapper': + return 'setup-java-maven-wrapper-key'; + default: + return ''; + } + }); + + await save('maven'); + expect(spyCacheSave).toHaveBeenCalledWith( + [join(os.homedir(), '.m2', 'wrapper', 'dists')], + 'setup-java-maven-wrapper-key' + ); + expect(spyWarning).not.toHaveBeenCalled(); + }); + it('does not save the maven wrapper cache on an exact wrapper hit', async () => { + createFile(join(workspace, 'pom.xml')); + (core.getState as jest.Mock).mockImplementation((name: any) => { + switch (name) { + case 'cache-primary-key': + return 'setup-java-cache-primary-key'; + case 'cache-matched-key': + return 'setup-java-cache-matched-key'; + case 'cache-primary-key-maven-wrapper': + case 'cache-matched-key-maven-wrapper': + return 'setup-java-maven-wrapper-key'; + default: + return ''; + } + }); + + await save('maven'); + expect(spyCacheSave).not.toHaveBeenCalledWith( + [join(os.homedir(), '.m2', 'wrapper', 'dists')], + expect.any(String) + ); + }); }); describe('for gradle', () => { it('uploads cache even if no build.gradle found', async () => { diff --git a/dist/cleanup/index.js b/dist/cleanup/index.js index 3e52658a..8bcea519 100644 --- a/dist/cleanup/index.js +++ b/dist/cleanup/index.js @@ -99666,15 +99666,23 @@ const CACHE_KEY_PREFIX = 'setup-java'; const supportedPackageManager = [ { id: 'maven', - path: [ - (0,external_path_.join)(external_os_default().homedir(), '.m2', 'repository'), - (0,external_path_.join)(external_os_default().homedir(), '.m2', 'wrapper', 'dists') - ], + path: [(0,external_path_.join)(external_os_default().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: [(0,external_path_.join)(external_os_default().homedir(), '.m2', 'wrapper', 'dists')], + pattern: ['**/.mvn/wrapper/maven-wrapper.properties'] + } ] }, { @@ -99726,6 +99734,19 @@ function findPackageManager(id) { } return packageManager; } +/** + * 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) { + return `${STATE_CACHE_PRIMARY_KEY}-${name}`; +} +function additionalCacheMatchedKeyState(name) { + return `${CACHE_MATCHED_KEY}-${name}`; +} +function buildCacheKey(id, fileHash) { + 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 }}"". @@ -99739,7 +99760,19 @@ async function computeCacheKey(packageManager, cacheDependencyPath) { if (!fileHash) { throw new Error(`No file in ${process.cwd()} matched to [${pattern}], make sure you have checked out the target repository`); } - return `${CACHE_KEY_PREFIX}-${process.env['RUNNER_OS']}-${process.arch}-${packageManager.id}-${fileHash}`; + 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) { + const fileHash = await glob.hashFiles(additionalCache.pattern.join('\n')); + if (!fileHash) { + return undefined; + } + return buildCacheKey(additionalCache.name, fileHash); } /** * Restore the dependency cache @@ -99763,6 +99796,32 @@ async function restore(id, cacheDependencyPath) { core.setOutput('cache-hit', false); core.info(`${packageManager.id} cache is not found`); } + for (const additionalCache of packageManager.additionalCaches ?? []) { + await restoreAdditionalCache(additionalCache); + } +} +/** + * Restore an additional cache (e.g. a build-tool wrapper distribution) that is + * keyed independently of the main dependency cache so that it survives changes + * to volatile dependency files. Skips silently when the project does not use + * the corresponding feature. + */ +async function restoreAdditionalCache(additionalCache) { + const primaryKey = await computeAdditionalCacheKey(additionalCache); + if (!primaryKey) { + core.debug(`No file matched [${additionalCache.pattern}] for the ${additionalCache.name} cache, skipping.`); + return; + } + core.debug(`${additionalCache.name} primary key is ${primaryKey}`); + core.saveState(additionalCachePrimaryKeyState(additionalCache.name), primaryKey); + const matchedKey = await cache.restoreCache(additionalCache.path, primaryKey); + if (matchedKey) { + core.saveState(additionalCacheMatchedKeyState(additionalCache.name), matchedKey); + core.info(`Cache restored from key: ${matchedKey}`); + } + else { + core.info(`${additionalCache.name} cache is not found`); + } } /** * Save the dependency cache @@ -99773,6 +99832,9 @@ async function save(id) { const matchedKey = getState(CACHE_MATCHED_KEY); // Inputs are re-evaluated before the post action, so we want the original key used for restore const primaryKey = getState(STATE_CACHE_PRIMARY_KEY); + for (const additionalCache of packageManager.additionalCaches ?? []) { + await saveAdditionalCache(packageManager, additionalCache); + } if (!primaryKey) { warning('Error retrieving key from state.'); return; @@ -99807,6 +99869,43 @@ async function save(id) { } } } +/** + * 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, additionalCache) { + const primaryKey = getState(additionalCachePrimaryKeyState(additionalCache.name)); + const matchedKey = 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) { + info(`Cache hit occurred on the ${additionalCache.name} primary key ${primaryKey}, not saving cache.`); + return; + } + try { + const cacheId = await cache_saveCache(additionalCache.path, primaryKey); + if (cacheId === -1) { + core_debug(`Cache was not saved for the key: ${primaryKey}`); + return; + } + info(`Cache saved with the key: ${primaryKey}`); + } + catch (error) { + const err = error; + if (err.name === ReserveCacheError.name) { + info(err.message); + } + else { + if (isProbablyGradleDaemonProblem(packageManager, err)) { + 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; + } + } +} /** * @param packageManager the specified package manager by user * @param error the error thrown by the saveCache diff --git a/dist/setup/index.js b/dist/setup/index.js index b3e2cb67..cc8e888e 100644 --- a/dist/setup/index.js +++ b/dist/setup/index.js @@ -130888,15 +130888,23 @@ const CACHE_KEY_PREFIX = 'setup-java'; const supportedPackageManager = [ { id: 'maven', - path: [ - (0,external_path_.join)(external_os_default().homedir(), '.m2', 'repository'), - (0,external_path_.join)(external_os_default().homedir(), '.m2', 'wrapper', 'dists') - ], + path: [(0,external_path_.join)(external_os_default().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: [(0,external_path_.join)(external_os_default().homedir(), '.m2', 'wrapper', 'dists')], + pattern: ['**/.mvn/wrapper/maven-wrapper.properties'] + } ] }, { @@ -130948,6 +130956,19 @@ function findPackageManager(id) { } return packageManager; } +/** + * 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) { + return `${STATE_CACHE_PRIMARY_KEY}-${name}`; +} +function additionalCacheMatchedKeyState(name) { + return `${CACHE_MATCHED_KEY}-${name}`; +} +function buildCacheKey(id, fileHash) { + 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 }}"". @@ -130961,7 +130982,19 @@ async function computeCacheKey(packageManager, cacheDependencyPath) { if (!fileHash) { throw new Error(`No file in ${process.cwd()} matched to [${pattern}], make sure you have checked out the target repository`); } - return `${CACHE_KEY_PREFIX}-${process.env['RUNNER_OS']}-${process.arch}-${packageManager.id}-${fileHash}`; + 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) { + const fileHash = await lib_glob_hashFiles(additionalCache.pattern.join('\n')); + if (!fileHash) { + return undefined; + } + return buildCacheKey(additionalCache.name, fileHash); } /** * Restore the dependency cache @@ -130985,6 +131018,32 @@ async function restore(id, cacheDependencyPath) { setOutput('cache-hit', false); info(`${packageManager.id} cache is not found`); } + for (const additionalCache of packageManager.additionalCaches ?? []) { + await restoreAdditionalCache(additionalCache); + } +} +/** + * Restore an additional cache (e.g. a build-tool wrapper distribution) that is + * keyed independently of the main dependency cache so that it survives changes + * to volatile dependency files. Skips silently when the project does not use + * the corresponding feature. + */ +async function restoreAdditionalCache(additionalCache) { + const primaryKey = await computeAdditionalCacheKey(additionalCache); + if (!primaryKey) { + core_debug(`No file matched [${additionalCache.pattern}] for the ${additionalCache.name} cache, skipping.`); + return; + } + core_debug(`${additionalCache.name} primary key is ${primaryKey}`); + saveState(additionalCachePrimaryKeyState(additionalCache.name), primaryKey); + const matchedKey = await restoreCache(additionalCache.path, primaryKey); + if (matchedKey) { + saveState(additionalCacheMatchedKeyState(additionalCache.name), matchedKey); + info(`Cache restored from key: ${matchedKey}`); + } + else { + info(`${additionalCache.name} cache is not found`); + } } /** * Save the dependency cache @@ -130995,6 +131054,9 @@ async function save(id) { 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 ?? []) { + await saveAdditionalCache(packageManager, additionalCache); + } if (!primaryKey) { core.warning('Error retrieving key from state.'); return; @@ -131029,6 +131091,43 @@ async function save(id) { } } } +/** + * 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, 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; + } + try { + const cacheId = await cache.saveCache(additionalCache.path, primaryKey); + if (cacheId === -1) { + core.debug(`Cache was not saved for the key: ${primaryKey}`); + return; + } + core.info(`Cache saved with the key: ${primaryKey}`); + } + catch (error) { + const err = 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; + } + } +} /** * @param packageManager the specified package manager by user * @param error the error thrown by the saveCache diff --git a/docs/advanced-usage.md b/docs/advanced-usage.md index 323ee1e6..8133a009 100644 --- a/docs/advanced-usage.md +++ b/docs/advanced-usage.md @@ -289,8 +289,7 @@ To run the JavaFX application in CI: ## Ensuring the Maven cache is complete (plugin dependencies) When you enable `cache: maven`, the action caches your local Maven repository -(`~/.m2/repository`) and downloaded Maven Wrapper distributions -(`~/.m2/wrapper/dists`). The cache key is a hash of your Maven inputs — every +(`~/.m2/repository`). The cache key is a hash of your Maven inputs — every `**/pom.xml`, plus `**/.mvn/wrapper/maven-wrapper.properties` and `**/.mvn/extensions.xml` — so changing any of those files (for example bumping the wrapper version or editing core extensions) produces a new key and @@ -298,6 +297,14 @@ invalidates the cache. At the end of the job the action saves whatever was downloaded during that run. It does **not** re-save the cache when the key already matches (a cache *hit*). +Downloaded Maven Wrapper distributions (`~/.m2/wrapper/dists`) are cached in a +**separate** cache entry keyed only on `**/.mvn/wrapper/maven-wrapper.properties`. +Because the wrapper distribution changes far less often than your `pom.xml` +files, this keeps it available across the frequent dependency changes that +rotate the main cache key, so wrapper-based (`./mvnw`) builds don't re-download +the Maven distribution on every dependency change. See +[issue #1095](https://github.com/actions/setup-java/issues/1095). + Maven resolves **plugin** dependencies lazily: it only downloads the plugins and plugin dependencies required by the goals that actually execute. As a result, the run that first creates the cache determines what is stored. If that run executed a diff --git a/src/cache.ts b/src/cache.ts index 263cc0d8..c9e67dcc 100644 --- a/src/cache.ts +++ b/src/cache.ts @@ -12,6 +12,30 @@ const STATE_CACHE_PRIMARY_KEY = 'cache-primary-key'; 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 PackageManager { id: 'maven' | 'gradle' | 'sbt'; /** @@ -19,19 +43,31 @@ interface PackageManager { */ 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'), - join(os.homedir(), '.m2', 'wrapper', 'dists') - ], + 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'] + } ] }, { @@ -87,6 +123,21 @@ function findPackageManager(id: string): PackageManager { return packageManager; } +/** + * 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 }}"". @@ -105,7 +156,22 @@ async function computeCacheKey( `No file in ${process.cwd()} matched to [${pattern}], make sure you have checked out the target repository` ); } - return `${CACHE_KEY_PREFIX}-${process.env['RUNNER_OS']}-${process.arch}-${packageManager.id}-${fileHash}`; + 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); } /** @@ -130,6 +196,42 @@ export async function restore(id: string, cacheDependencyPath: string) { core.setOutput('cache-hit', false); core.info(`${packageManager.id} cache is not found`); } + + for (const additionalCache of packageManager.additionalCaches ?? []) { + await restoreAdditionalCache(additionalCache); + } +} + +/** + * Restore an additional cache (e.g. a build-tool wrapper distribution) that is + * keyed independently of the main dependency cache so that it survives changes + * to volatile dependency files. Skips silently when the project does not use + * the corresponding feature. + */ +async function restoreAdditionalCache(additionalCache: AdditionalCache) { + const primaryKey = await computeAdditionalCacheKey(additionalCache); + if (!primaryKey) { + core.debug( + `No file matched [${additionalCache.pattern}] for the ${additionalCache.name} cache, skipping.` + ); + return; + } + core.debug(`${additionalCache.name} primary key is ${primaryKey}`); + core.saveState( + additionalCachePrimaryKeyState(additionalCache.name), + primaryKey + ); + + const matchedKey = await cache.restoreCache(additionalCache.path, primaryKey); + if (matchedKey) { + core.saveState( + additionalCacheMatchedKeyState(additionalCache.name), + matchedKey + ); + core.info(`Cache restored from key: ${matchedKey}`); + } else { + core.info(`${additionalCache.name} cache is not found`); + } } /** @@ -143,6 +245,10 @@ export async function save(id: string) { // 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 ?? []) { + await saveAdditionalCache(packageManager, additionalCache); + } + if (!primaryKey) { core.warning('Error retrieving key from state.'); return; @@ -180,6 +286,56 @@ export async function save(id: string) { } } +/** + * 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; + } + try { + const cacheId = await cache.saveCache(additionalCache.path, primaryKey); + if (cacheId === -1) { + 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; + } + } +} + /** * @param packageManager the specified package manager by user * @param error the error thrown by the saveCache