Skip to content

Commit bc6a9b1

Browse files
brunoborgesCopilot
andcommitted
Cache Maven wrapper distribution separately from the local repository
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>
1 parent 174d4b5 commit bc6a9b1

6 files changed

Lines changed: 469 additions & 30 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ The workflow output `cache-primary-key` exposes the primary cache key computed b
162162

163163
The cache input is optional, and caching is turned off by default.
164164

165-
**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.
165+
**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.
166166

167167
#### Caching gradle dependencies
168168
```yaml

‎__tests__/cache.test.ts‎

Lines changed: 90 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -166,10 +166,7 @@ describe('dependency cache', () => {
166166

167167
await restore('maven', '');
168168
expect(spyCacheRestore).toHaveBeenCalledWith(
169-
[
170-
join(os.homedir(), '.m2', 'repository'),
171-
join(os.homedir(), '.m2', 'wrapper', 'dists')
172-
],
169+
[join(os.homedir(), '.m2', 'repository')],
173170
expect.any(String)
174171
);
175172
expect(spyGlobHashFiles).toHaveBeenCalledWith(
@@ -196,10 +193,7 @@ describe('dependency cache', () => {
196193

197194
await restore('maven', '');
198195
expect(spyCacheRestore).toHaveBeenCalledWith(
199-
[
200-
join(os.homedir(), '.m2', 'repository'),
201-
join(os.homedir(), '.m2', 'wrapper', 'dists')
202-
],
196+
[join(os.homedir(), '.m2', 'repository')],
203197
expect.any(String)
204198
);
205199
expect(spyGlobHashFiles).toHaveBeenCalledWith(
@@ -214,10 +208,7 @@ describe('dependency cache', () => {
214208

215209
await restore('maven', '');
216210
expect(spyCacheRestore).toHaveBeenCalledWith(
217-
[
218-
join(os.homedir(), '.m2', 'repository'),
219-
join(os.homedir(), '.m2', 'wrapper', 'dists')
220-
],
211+
[join(os.homedir(), '.m2', 'repository')],
221212
expect.any(String)
222213
);
223214
expect(spyGlobHashFiles).toHaveBeenCalledWith(
@@ -226,6 +217,49 @@ describe('dependency cache', () => {
226217
expect(spyWarning).not.toHaveBeenCalled();
227218
expect(spyInfo).toHaveBeenCalledWith('maven cache is not found');
228219
});
220+
it('restores the maven wrapper distribution cache independently of the main cache', async () => {
221+
createDirectory(join(workspace, '.mvn'));
222+
createDirectory(join(workspace, '.mvn', 'wrapper'));
223+
createFile(
224+
join(workspace, '.mvn', 'wrapper', 'maven-wrapper.properties')
225+
);
226+
227+
await restore('maven', '');
228+
// Main dependency cache no longer carries the wrapper dists path.
229+
expect(spyCacheRestore).toHaveBeenCalledWith(
230+
[join(os.homedir(), '.m2', 'repository')],
231+
expect.any(String)
232+
);
233+
// Wrapper distribution is restored on its own, keyed only on the
234+
// wrapper properties file.
235+
expect(spyCacheRestore).toHaveBeenCalledWith(
236+
[join(os.homedir(), '.m2', 'wrapper', 'dists')],
237+
expect.stringContaining('setup-java-')
238+
);
239+
expect(spyGlobHashFiles).toHaveBeenCalledWith(
240+
'**/.mvn/wrapper/maven-wrapper.properties'
241+
);
242+
});
243+
it('skips the maven wrapper cache when no wrapper properties exist', async () => {
244+
createFile(join(workspace, 'pom.xml'));
245+
spyGlobHashFiles.mockImplementation((pattern: string) =>
246+
Promise.resolve(
247+
pattern === '**/.mvn/wrapper/maven-wrapper.properties'
248+
? ''
249+
: 'hash-stub'
250+
)
251+
);
252+
253+
await restore('maven', '');
254+
// Only the main dependency cache is restored; the wrapper cache path is
255+
// never touched because the project does not use mvnw.
256+
expect(spyCacheRestore).toHaveBeenCalledTimes(1);
257+
expect(spyCacheRestore).toHaveBeenCalledWith(
258+
[join(os.homedir(), '.m2', 'repository')],
259+
expect.any(String)
260+
);
261+
expect(spyWarning).not.toHaveBeenCalled();
262+
});
229263
});
230264
describe('for gradle', () => {
231265
it('throws error if no build.gradle found', async () => {
@@ -457,6 +491,50 @@ describe('dependency cache', () => {
457491
expect.stringMatching(/^Cache saved with the key:.*/)
458492
);
459493
});
494+
it('saves the maven wrapper distribution cache under its own key', async () => {
495+
createFile(join(workspace, 'pom.xml'));
496+
(core.getState as jest.Mock<any>).mockImplementation((name: any) => {
497+
switch (name) {
498+
case 'cache-primary-key':
499+
return 'setup-java-cache-primary-key';
500+
case 'cache-matched-key':
501+
return 'setup-java-cache-matched-key';
502+
case 'cache-primary-key-maven-wrapper':
503+
return 'setup-java-maven-wrapper-key';
504+
default:
505+
return '';
506+
}
507+
});
508+
509+
await save('maven');
510+
expect(spyCacheSave).toHaveBeenCalledWith(
511+
[join(os.homedir(), '.m2', 'wrapper', 'dists')],
512+
'setup-java-maven-wrapper-key'
513+
);
514+
expect(spyWarning).not.toHaveBeenCalled();
515+
});
516+
it('does not save the maven wrapper cache on an exact wrapper hit', async () => {
517+
createFile(join(workspace, 'pom.xml'));
518+
(core.getState as jest.Mock<any>).mockImplementation((name: any) => {
519+
switch (name) {
520+
case 'cache-primary-key':
521+
return 'setup-java-cache-primary-key';
522+
case 'cache-matched-key':
523+
return 'setup-java-cache-matched-key';
524+
case 'cache-primary-key-maven-wrapper':
525+
case 'cache-matched-key-maven-wrapper':
526+
return 'setup-java-maven-wrapper-key';
527+
default:
528+
return '';
529+
}
530+
});
531+
532+
await save('maven');
533+
expect(spyCacheSave).not.toHaveBeenCalledWith(
534+
[join(os.homedir(), '.m2', 'wrapper', 'dists')],
535+
expect.any(String)
536+
);
537+
});
460538
});
461539
describe('for gradle', () => {
462540
it('uploads cache even if no build.gradle found', async () => {

‎dist/cleanup/index.js‎

Lines changed: 104 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -99666,15 +99666,23 @@ const CACHE_KEY_PREFIX = 'setup-java';
9966699666
const supportedPackageManager = [
9966799667
{
9966899668
id: 'maven',
99669-
path: [
99670-
(0,external_path_.join)(external_os_default().homedir(), '.m2', 'repository'),
99671-
(0,external_path_.join)(external_os_default().homedir(), '.m2', 'wrapper', 'dists')
99672-
],
99669+
path: [(0,external_path_.join)(external_os_default().homedir(), '.m2', 'repository')],
9967399670
// https://github.com/actions/cache/blob/0638051e9af2c23d10bb70fa9beffcad6cff9ce3/examples.md#java---maven
9967499671
pattern: [
9967599672
'**/pom.xml',
9967699673
'**/.mvn/wrapper/maven-wrapper.properties',
9967799674
'**/.mvn/extensions.xml'
99675+
],
99676+
// The Maven wrapper distribution only depends on the wrapper properties,
99677+
// which change very rarely, so it is cached separately from the local
99678+
// repository. This keeps it available across the frequent pom.xml changes
99679+
// that rotate the main cache key. See issue #1095.
99680+
additionalCaches: [
99681+
{
99682+
name: 'maven-wrapper',
99683+
path: [(0,external_path_.join)(external_os_default().homedir(), '.m2', 'wrapper', 'dists')],
99684+
pattern: ['**/.mvn/wrapper/maven-wrapper.properties']
99685+
}
9967899686
]
9967999687
},
9968099688
{
@@ -99726,6 +99734,19 @@ function findPackageManager(id) {
9972699734
}
9972799735
return packageManager;
9972899736
}
99737+
/**
99738+
* State keys used to carry an additional cache's restore-time information over
99739+
* to the post (save) action, scoped by the additional cache name.
99740+
*/
99741+
function additionalCachePrimaryKeyState(name) {
99742+
return `${STATE_CACHE_PRIMARY_KEY}-${name}`;
99743+
}
99744+
function additionalCacheMatchedKeyState(name) {
99745+
return `${CACHE_MATCHED_KEY}-${name}`;
99746+
}
99747+
function buildCacheKey(id, fileHash) {
99748+
return `${CACHE_KEY_PREFIX}-${process.env['RUNNER_OS']}-${process.arch}-${id}-${fileHash}`;
99749+
}
9972999750
/**
9973099751
* A function that generates a cache key to use.
9973199752
* Format of the generated key will be "${{ platform }}-${{ id }}-${{ fileHash }}"".
@@ -99739,7 +99760,19 @@ async function computeCacheKey(packageManager, cacheDependencyPath) {
9973999760
if (!fileHash) {
9974099761
throw new Error(`No file in ${process.cwd()} matched to [${pattern}], make sure you have checked out the target repository`);
9974199762
}
99742-
return `${CACHE_KEY_PREFIX}-${process.env['RUNNER_OS']}-${process.arch}-${packageManager.id}-${fileHash}`;
99763+
return buildCacheKey(packageManager.id, fileHash);
99764+
}
99765+
/**
99766+
* Computes the cache key for an additional cache. Unlike {@link computeCacheKey}
99767+
* this returns undefined (instead of throwing) when no file matches the pattern,
99768+
* because additional caches are optional features that many projects do not use.
99769+
*/
99770+
async function computeAdditionalCacheKey(additionalCache) {
99771+
const fileHash = await glob.hashFiles(additionalCache.pattern.join('\n'));
99772+
if (!fileHash) {
99773+
return undefined;
99774+
}
99775+
return buildCacheKey(additionalCache.name, fileHash);
9974399776
}
9974499777
/**
9974599778
* Restore the dependency cache
@@ -99763,6 +99796,32 @@ async function restore(id, cacheDependencyPath) {
9976399796
core.setOutput('cache-hit', false);
9976499797
core.info(`${packageManager.id} cache is not found`);
9976599798
}
99799+
for (const additionalCache of packageManager.additionalCaches ?? []) {
99800+
await restoreAdditionalCache(additionalCache);
99801+
}
99802+
}
99803+
/**
99804+
* Restore an additional cache (e.g. a build-tool wrapper distribution) that is
99805+
* keyed independently of the main dependency cache so that it survives changes
99806+
* to volatile dependency files. Skips silently when the project does not use
99807+
* the corresponding feature.
99808+
*/
99809+
async function restoreAdditionalCache(additionalCache) {
99810+
const primaryKey = await computeAdditionalCacheKey(additionalCache);
99811+
if (!primaryKey) {
99812+
core.debug(`No file matched [${additionalCache.pattern}] for the ${additionalCache.name} cache, skipping.`);
99813+
return;
99814+
}
99815+
core.debug(`${additionalCache.name} primary key is ${primaryKey}`);
99816+
core.saveState(additionalCachePrimaryKeyState(additionalCache.name), primaryKey);
99817+
const matchedKey = await cache.restoreCache(additionalCache.path, primaryKey);
99818+
if (matchedKey) {
99819+
core.saveState(additionalCacheMatchedKeyState(additionalCache.name), matchedKey);
99820+
core.info(`Cache restored from key: ${matchedKey}`);
99821+
}
99822+
else {
99823+
core.info(`${additionalCache.name} cache is not found`);
99824+
}
9976699825
}
9976799826
/**
9976899827
* Save the dependency cache
@@ -99773,6 +99832,9 @@ async function save(id) {
9977399832
const matchedKey = getState(CACHE_MATCHED_KEY);
9977499833
// Inputs are re-evaluated before the post action, so we want the original key used for restore
9977599834
const primaryKey = getState(STATE_CACHE_PRIMARY_KEY);
99835+
for (const additionalCache of packageManager.additionalCaches ?? []) {
99836+
await saveAdditionalCache(packageManager, additionalCache);
99837+
}
9977699838
if (!primaryKey) {
9977799839
warning('Error retrieving key from state.');
9977899840
return;
@@ -99807,6 +99869,43 @@ async function save(id) {
9980799869
}
9980899870
}
9980999871
}
99872+
/**
99873+
* Save an additional cache under its own key. Skips when no key was recorded at
99874+
* restore time (feature unused) or when the exact key was already restored.
99875+
*/
99876+
async function saveAdditionalCache(packageManager, additionalCache) {
99877+
const primaryKey = getState(additionalCachePrimaryKeyState(additionalCache.name));
99878+
const matchedKey = getState(additionalCacheMatchedKeyState(additionalCache.name));
99879+
if (!primaryKey) {
99880+
// The feature is not used by this project, nothing to save.
99881+
core_debug(`No primary key for the ${additionalCache.name} cache, not saving cache.`);
99882+
return;
99883+
}
99884+
else if (matchedKey === primaryKey) {
99885+
info(`Cache hit occurred on the ${additionalCache.name} primary key ${primaryKey}, not saving cache.`);
99886+
return;
99887+
}
99888+
try {
99889+
const cacheId = await cache_saveCache(additionalCache.path, primaryKey);
99890+
if (cacheId === -1) {
99891+
core_debug(`Cache was not saved for the key: ${primaryKey}`);
99892+
return;
99893+
}
99894+
info(`Cache saved with the key: ${primaryKey}`);
99895+
}
99896+
catch (error) {
99897+
const err = error;
99898+
if (err.name === ReserveCacheError.name) {
99899+
info(err.message);
99900+
}
99901+
else {
99902+
if (isProbablyGradleDaemonProblem(packageManager, err)) {
99903+
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.');
99904+
}
99905+
throw error;
99906+
}
99907+
}
99908+
}
9981099909
/**
9981199910
* @param packageManager the specified package manager by user
9981299911
* @param error the error thrown by the saveCache

0 commit comments

Comments
 (0)