Backport #1097/#1098: cache Maven and Gradle wrapper distributions separately (#1122)

Backports the wrapper caching fix to the v5 release line.

The Maven wrapper distribution (~/.m2/wrapper/dists) and Gradle wrapper
distribution (~/.gradle/wrapper) were cached in the same entry as the
dependency cache, keyed on the volatile dependency-file hashes (**/pom.xml,
**/*.gradle*). With no restoreKeys fallback (#269), almost every dependency
change was a full cache miss, forcing ./mvnw and ./gradlew to re-download the
build tool distribution and hitting intermittent rate-limit failures.

Each wrapper distribution now lives in its own additional cache entry keyed
only on the rarely-changing wrapper properties file
(**/.mvn/wrapper/maven-wrapper.properties, **/gradle-wrapper.properties), so
it survives dependency changes. Optional-cache saves swallow ValidationError
and ReserveCacheError so a project without a wrapper never fails the post step.

Fixes: #1095


Copilot-Session: ea015caa-980a-4e0a-af20-3fc9f39c77fd

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Bruno Borges
2026-07-14 17:14:30 -04:00
committed by GitHub
parent d229d2e858
commit 03ad4de099
5 changed files with 581 additions and 40 deletions
+3 -1
View File
@@ -149,7 +149,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
@@ -167,6 +167,8 @@ steps:
```
Using the `cache: gradle` provides a simple and effective way to cache Gradle dependencies with minimal configuration.
**Gradle Wrapper:** when `cache: 'gradle'` is enabled, the action also caches and restores the Gradle Wrapper distribution downloaded to `~/.gradle/wrapper` (in addition to the Gradle caches), so wrapper-based (`./gradlew`) builds don't re-download the Gradle distribution. The wrapper distribution is stored in a **separate** cache entry keyed only on `**/gradle-wrapper.properties`, so it stays cached across the frequent `*.gradle*` changes that rotate the main dependency cache key.
For projects that require more advanced `Gradle` caching features, such as caching build outputs, support for Gradle configuration cache, encrypted cache storage, fine-grained cache control (including options to enable or disable the cache, set it to read-only or write-only, perform automated cleanup, and define custom cache rules), or optimized performance for complex CI workflows, consider using [`gradle/actions/setup-gradle`](https://github.com/gradle/actions/tree/main/setup-gradle).
For setup details and a comprehensive overview of all available features, visit the [setup-gradle documentation](https://github.com/gradle/actions/blob/main/docs/setup-gradle.md).
+137 -12
View File
@@ -113,10 +113,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(
@@ -143,10 +140,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(
@@ -161,10 +155,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(
@@ -173,6 +164,39 @@ 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 () => {
createFile(join(workspace, 'pom.xml'));
createDirectory(join(workspace, '.mvn'));
createDirectory(join(workspace, '.mvn', 'wrapper'));
createFile(
join(workspace, '.mvn', 'wrapper', 'maven-wrapper.properties')
);
await restore('maven', '');
// Main cache no longer includes the wrapper distribution.
expect(spyCacheRestore).toHaveBeenCalledWith(
[join(os.homedir(), '.m2', 'repository')],
expect.any(String)
);
// Wrapper distribution is restored on its own, keyed only on the
// maven-wrapper.properties hash.
expect(spyCacheRestore).toHaveBeenCalledWith(
[join(os.homedir(), '.m2', 'wrapper', 'dists')],
expect.any(String)
);
expect(spyGlobHashFiles).toHaveBeenCalledWith(
'**/.mvn/wrapper/maven-wrapper.properties'
);
});
it('skips the maven wrapper cache when no maven-wrapper.properties exists', async () => {
createFile(join(workspace, 'pom.xml'));
await restore('maven', '');
expect(spyCacheRestore).not.toHaveBeenCalledWith(
[join(os.homedir(), '.m2', 'wrapper', 'dists')],
expect.any(String)
);
});
});
describe('for gradle', () => {
it('throws error if no build.gradle found', async () => {
@@ -228,6 +252,30 @@ describe('dependency cache', () => {
expect(spyWarning).not.toHaveBeenCalled();
expect(spyInfo).toHaveBeenCalledWith('gradle cache is not found');
});
it('restores the gradle wrapper distribution cache independently of the main cache', async () => {
createFile(join(workspace, 'build.gradle'));
createDirectory(join(workspace, 'gradle'));
createDirectory(join(workspace, 'gradle', 'wrapper'));
createFile(
join(workspace, 'gradle', 'wrapper', 'gradle-wrapper.properties')
);
await restore('gradle', '');
// Main cache no longer includes the wrapper distribution.
expect(spyCacheRestore).toHaveBeenCalledWith(
[join(os.homedir(), '.gradle', 'caches')],
expect.any(String)
);
// Wrapper distribution is restored on its own, keyed only on the
// gradle-wrapper.properties hash.
expect(spyCacheRestore).toHaveBeenCalledWith(
[join(os.homedir(), '.gradle', 'wrapper')],
expect.any(String)
);
expect(spyGlobHashFiles).toHaveBeenCalledWith(
'**/gradle-wrapper.properties'
);
});
});
describe('for sbt', () => {
it('throws error if no build.sbt found', async () => {
@@ -399,6 +447,40 @@ 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'));
createStateForWrapperRestore('maven-wrapper', false);
await save('maven');
expect(spyCacheSave).toHaveBeenCalledWith(
[join(os.homedir(), '.m2', 'wrapper', 'dists')],
'setup-java-maven-wrapper-primary-key'
);
});
it('does not save the maven wrapper cache on an exact wrapper hit', async () => {
createFile(join(workspace, 'pom.xml'));
createStateForWrapperRestore('maven-wrapper', true);
await save('maven');
expect(spyCacheSave).not.toHaveBeenCalledWith(
[join(os.homedir(), '.m2', 'wrapper', 'dists')],
expect.any(String)
);
});
it('does not fail the post step when the wrapper distribution path is missing', async () => {
createFile(join(workspace, 'pom.xml'));
createStateForWrapperRestore('maven-wrapper', false);
spyCacheSave.mockImplementation((paths: string[], key: string) => {
if (paths.includes(join(os.homedir(), '.m2', 'wrapper', 'dists'))) {
return Promise.reject(
new cache.ValidationError('Path Validation Error')
);
}
return Promise.resolve(0);
});
await expect(save('maven')).resolves.not.toThrow();
});
});
describe('for gradle', () => {
it('uploads cache even if no build.gradle found', async () => {
@@ -451,6 +533,26 @@ describe('dependency cache', () => {
expect.stringMatching(/^Cache saved with the key:.*/)
);
});
it('saves the gradle wrapper distribution cache under its own key', async () => {
createFile(join(workspace, 'build.gradle'));
createStateForWrapperRestore('gradle-wrapper', false);
await save('gradle');
expect(spyCacheSave).toHaveBeenCalledWith(
[join(os.homedir(), '.gradle', 'wrapper')],
'setup-java-gradle-wrapper-primary-key'
);
});
it('does not save the gradle wrapper cache on an exact wrapper hit', async () => {
createFile(join(workspace, 'build.gradle'));
createStateForWrapperRestore('gradle-wrapper', true);
await save('gradle');
expect(spyCacheSave).not.toHaveBeenCalledWith(
[join(os.homedir(), '.gradle', 'wrapper')],
expect.any(String)
);
});
});
describe('for sbt', () => {
it('uploads cache even if no build.sbt found', async () => {
@@ -528,6 +630,29 @@ function createStateForSuccessfulRestore() {
});
}
/**
* Create states to emulate a restore process where an additional (wrapper)
* cache was restored. When `hit` is true the matched key equals the primary
* key, emulating an exact wrapper cache hit.
*/
function createStateForWrapperRestore(wrapperName: string, hit: boolean) {
const primaryKey = `setup-java-${wrapperName}-primary-key`;
jest.spyOn(core, 'getState').mockImplementation(name => {
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-${wrapperName}`:
return primaryKey;
case `cache-matched-key-${wrapperName}`:
return hit ? primaryKey : '';
default:
return '';
}
});
}
function createFile(path: string) {
core.info(`created a file at ${path}`);
fs.writeFileSync(path, '');
+128 -9
View File
@@ -51968,23 +51968,28 @@ const CACHE_KEY_PREFIX = 'setup-java';
const supportedPackageManager = [
{
id: 'maven',
path: [
(0, path_1.join)(os_1.default.homedir(), '.m2', 'repository'),
(0, path_1.join)(os_1.default.homedir(), '.m2', 'wrapper', 'dists')
],
path: [(0, path_1.join)(os_1.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, path_1.join)(os_1.default.homedir(), '.m2', 'wrapper', 'dists')],
pattern: ['**/.mvn/wrapper/maven-wrapper.properties']
}
]
},
{
id: 'gradle',
path: [
(0, path_1.join)(os_1.default.homedir(), '.gradle', 'caches'),
(0, path_1.join)(os_1.default.homedir(), '.gradle', 'wrapper')
],
path: [(0, path_1.join)(os_1.default.homedir(), '.gradle', 'caches')],
// https://github.com/actions/cache/blob/0638051e9af2c23d10bb70fa9beffcad6cff9ce3/examples.md#java---gradle
pattern: [
'**/*.gradle*',
@@ -51993,6 +51998,17 @@ const supportedPackageManager = [
'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: [(0, path_1.join)(os_1.default.homedir(), '.gradle', 'wrapper')],
pattern: ['**/gradle-wrapper.properties']
}
]
},
{
@@ -52028,6 +52044,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 }}"".
@@ -52042,7 +52071,21 @@ 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.
*/
function computeAdditionalCacheKey(additionalCache) {
return __awaiter(this, void 0, void 0, function* () {
const fileHash = yield glob.hashFiles(additionalCache.pattern.join('\n'));
if (!fileHash) {
return undefined;
}
return buildCacheKey(additionalCache.name, fileHash);
});
}
/**
@@ -52051,6 +52094,7 @@ function computeCacheKey(packageManager, cacheDependencyPath) {
* @param cacheDependencyPath The path to a dependency file
*/
function restore(id, cacheDependencyPath) {
var _a;
return __awaiter(this, void 0, void 0, function* () {
const packageManager = findPackageManager(id);
const primaryKey = yield computeCacheKey(packageManager, cacheDependencyPath);
@@ -52068,19 +52112,48 @@ function restore(id, cacheDependencyPath) {
core.setOutput('cache-hit', false);
core.info(`${packageManager.id} cache is not found`);
}
for (const additionalCache of (_a = packageManager.additionalCaches) !== null && _a !== void 0 ? _a : []) {
yield restoreAdditionalCache(additionalCache);
}
});
}
exports.restore = restore;
/**
* 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.
*/
function restoreAdditionalCache(additionalCache) {
return __awaiter(this, void 0, void 0, function* () {
const primaryKey = yield 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 = yield cache.restoreCache(additionalCache.path, primaryKey);
if (matchedKey) {
core.saveState(additionalCacheMatchedKeyState(additionalCache.name), matchedKey);
core.info(`${additionalCache.name} cache restored from key: ${matchedKey}`);
}
});
}
/**
* Save the dependency cache
* @param id ID of the package manager, should be "maven" or "gradle"
*/
function save(id) {
var _a;
return __awaiter(this, void 0, void 0, function* () {
const packageManager = findPackageManager(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 (_a = packageManager.additionalCaches) !== null && _a !== void 0 ? _a : []) {
yield saveAdditionalCache(packageManager, additionalCache);
}
if (!primaryKey) {
core.warning('Error retrieving key from state.');
return;
@@ -52117,6 +52190,52 @@ function save(id) {
});
}
exports.save = save;
/**
* 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.
*/
function saveAdditionalCache(packageManager, additionalCache) {
return __awaiter(this, void 0, void 0, function* () {
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 = yield cache.saveCache(additionalCache.path, 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;
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 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
+128 -9
View File
@@ -77833,23 +77833,28 @@ const CACHE_KEY_PREFIX = 'setup-java';
const supportedPackageManager = [
{
id: 'maven',
path: [
(0, path_1.join)(os_1.default.homedir(), '.m2', 'repository'),
(0, path_1.join)(os_1.default.homedir(), '.m2', 'wrapper', 'dists')
],
path: [(0, path_1.join)(os_1.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, path_1.join)(os_1.default.homedir(), '.m2', 'wrapper', 'dists')],
pattern: ['**/.mvn/wrapper/maven-wrapper.properties']
}
]
},
{
id: 'gradle',
path: [
(0, path_1.join)(os_1.default.homedir(), '.gradle', 'caches'),
(0, path_1.join)(os_1.default.homedir(), '.gradle', 'wrapper')
],
path: [(0, path_1.join)(os_1.default.homedir(), '.gradle', 'caches')],
// https://github.com/actions/cache/blob/0638051e9af2c23d10bb70fa9beffcad6cff9ce3/examples.md#java---gradle
pattern: [
'**/*.gradle*',
@@ -77858,6 +77863,17 @@ const supportedPackageManager = [
'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: [(0, path_1.join)(os_1.default.homedir(), '.gradle', 'wrapper')],
pattern: ['**/gradle-wrapper.properties']
}
]
},
{
@@ -77893,6 +77909,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 }}"".
@@ -77907,7 +77936,21 @@ 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.
*/
function computeAdditionalCacheKey(additionalCache) {
return __awaiter(this, void 0, void 0, function* () {
const fileHash = yield glob.hashFiles(additionalCache.pattern.join('\n'));
if (!fileHash) {
return undefined;
}
return buildCacheKey(additionalCache.name, fileHash);
});
}
/**
@@ -77916,6 +77959,7 @@ function computeCacheKey(packageManager, cacheDependencyPath) {
* @param cacheDependencyPath The path to a dependency file
*/
function restore(id, cacheDependencyPath) {
var _a;
return __awaiter(this, void 0, void 0, function* () {
const packageManager = findPackageManager(id);
const primaryKey = yield computeCacheKey(packageManager, cacheDependencyPath);
@@ -77933,19 +77977,48 @@ function restore(id, cacheDependencyPath) {
core.setOutput('cache-hit', false);
core.info(`${packageManager.id} cache is not found`);
}
for (const additionalCache of (_a = packageManager.additionalCaches) !== null && _a !== void 0 ? _a : []) {
yield restoreAdditionalCache(additionalCache);
}
});
}
exports.restore = restore;
/**
* 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.
*/
function restoreAdditionalCache(additionalCache) {
return __awaiter(this, void 0, void 0, function* () {
const primaryKey = yield 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 = yield cache.restoreCache(additionalCache.path, primaryKey);
if (matchedKey) {
core.saveState(additionalCacheMatchedKeyState(additionalCache.name), matchedKey);
core.info(`${additionalCache.name} cache restored from key: ${matchedKey}`);
}
});
}
/**
* Save the dependency cache
* @param id ID of the package manager, should be "maven" or "gradle"
*/
function save(id) {
var _a;
return __awaiter(this, void 0, void 0, function* () {
const packageManager = findPackageManager(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 (_a = packageManager.additionalCaches) !== null && _a !== void 0 ? _a : []) {
yield saveAdditionalCache(packageManager, additionalCache);
}
if (!primaryKey) {
core.warning('Error retrieving key from state.');
return;
@@ -77982,6 +78055,52 @@ function save(id) {
});
}
exports.save = save;
/**
* 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.
*/
function saveAdditionalCache(packageManager, additionalCache) {
return __awaiter(this, void 0, void 0, function* () {
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 = yield cache.saveCache(additionalCache.path, 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;
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 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
+185 -9
View File
@@ -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,27 +43,36 @@ 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']
}
]
},
{
id: 'gradle',
path: [
join(os.homedir(), '.gradle', 'caches'),
join(os.homedir(), '.gradle', 'wrapper')
],
path: [join(os.homedir(), '.gradle', 'caches')],
// https://github.com/actions/cache/blob/0638051e9af2c23d10bb70fa9beffcad6cff9ce3/examples.md#java---gradle
pattern: [
'**/*.gradle*',
@@ -48,6 +81,17 @@ const supportedPackageManager: PackageManager[] = [
'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']
}
]
},
{
@@ -87,6 +131,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 +164,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<string | undefined> {
const fileHash = await glob.hashFiles(additionalCache.pattern.join('\n'));
if (!fileHash) {
return undefined;
}
return buildCacheKey(additionalCache.name, fileHash);
}
/**
@@ -130,6 +204,40 @@ 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(`${additionalCache.name} cache restored from key: ${matchedKey}`);
}
}
/**
@@ -143,6 +251,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 +292,70 @@ 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(
`${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 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