Compare commits

...

7 Commits

Author SHA1 Message Date
Daz DeBoer 804a48c557 Fix legacy test: override env var after init-integ-test
GITHUB_ENV from init-integ-test overwrites the workflow-level env.
Re-set GRADLE_ACTIONS_CACHE_USE_VENDORED=false after init runs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 15:10:10 -06:00
Daz DeBoer c5a7f0e838 Add GRADLE_ACTIONS_CACHE_USE_VENDORED env var for test configuration
Add env var that forces the vendored caching module when set to
'true', decoupling module selection from the Develocity access key.
Set it centrally in init-integ-test so all tests use the vendored
module by default.

Add a dedicated legacy caching integration test that overrides the
env var to 'false' to verify the legacy module works correctly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 15:03:35 -06:00
Daz DeBoer ad455dcf5d Update cache module log and report messages
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 14:47:31 -06:00
Daz DeBoer 8236123cab Simplify gradleExecutableForCleanup version selection
Use the higher of highestGradleVersion() or MINIMUM_CLEANUP_GRADLE_VERSION.
Remove the separate DEFAULT_CLEANUP_GRADLE_VERSION constant.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 14:30:14 -06:00
Daz DeBoer 09a8521010 Add Gradle provisioning for cache cleanup (self-contained)
Restore full Gradle provisioning in the legacy module with all
functions copied locally. No cross-package imports from sources/src/.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 14:20:34 -06:00
Daz DeBoer 0ea104b0b8 Fix legacy caching module distribution for CI
The legacy caching bundle at sources/legacy-caching/dist/ was
gitignored by sources/.gitignore, so CI never included it. Fix by:
- Copying the bundle into sources/dist/legacy-caching/ during build
- Updating the loader path to resolve from dist/legacy-caching/
- Adding legacy module build steps to build-dist action and
  ci-update-dist workflow

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 13:28:18 -06:00
Daz DeBoer e48bd1161b Add legacy open-source caching module as default
Recover the pre-extraction caching code (from 7a4e0968~1) into a
standalone module at sources/legacy-caching/. This module bundles
@actions/cache@4.0.5 with the existing patch and adapts the old
caching API to the current CacheService interface.

The cache-service-loader now uses the legacy module by default and
only loads the proprietary vendored module when the license is
accepted. Both paths are wrapped in a LoggingCacheService that
identifies which module is active in logs and the Job Summary.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 13:10:20 -06:00
23 changed files with 4626 additions and 42 deletions
+9
View File
@@ -8,6 +8,13 @@ runs:
node-version: 24
cache: npm
cache-dependency-path: sources/package-lock.json
- name: Build legacy caching module
shell: bash
run: |
npm install
npm run build
working-directory: sources/legacy-caching
- name: Build distribution
shell: bash
run: |
@@ -20,6 +27,8 @@ runs:
- name: Copy the generated sources/dist directory to the top-level dist
shell: bash
run: |
mkdir -p sources/dist/legacy-caching
cp sources/legacy-caching/dist/index.js sources/dist/legacy-caching/
cp -r sources/dist .
- name: Upload distribution
@@ -19,6 +19,7 @@ runs:
shell: bash
run: |
echo "ALLOWED_GRADLE_WRAPPER_CHECKSUMS=e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" >> "$GITHUB_ENV"
echo "GRADLE_ACTIONS_CACHE_USE_VENDORED=true" >> "$GITHUB_ENV"
# Downloads a 'dist' directory artifact that was uploaded in an earlier 'build-dist' step
- name: Download dist
+8
View File
@@ -38,6 +38,12 @@ jobs:
run: |
npm exec -y -- pacote extract @gradle-tech/develocity-agent@3.0.1 ~/.node_libraries/@gradle-tech/develocity-agent
- name: Build legacy caching module
run: |
npm clean-install
npm run build
working-directory: sources/legacy-caching
- name: Install npm dependencies
run: |
npm clean-install
@@ -58,6 +64,8 @@ jobs:
- name: Copy the generated sources/dist directory to the top-level dist
run: |
mkdir -p sources/dist/legacy-caching
cp sources/legacy-caching/dist/index.js sources/dist/legacy-caching/
cp -r sources/dist .
- name: Import GPG key to sign commits
@@ -0,0 +1,72 @@
name: Test legacy caching module
on:
workflow_call:
inputs:
cache-key-prefix:
type: string
default: '0'
runner-os:
type: string
default: '["ubuntu-latest"]'
skip-dist:
type: boolean
default: false
env:
SKIP_DIST: ${{ inputs.skip-dist }}
GRADLE_BUILD_ACTION_CACHE_KEY_PREFIX: legacy-caching-${{ inputs.cache-key-prefix }}
GRADLE_ACTIONS_CACHE_USE_VENDORED: false
permissions:
contents: read
jobs:
legacy-caching-seed-build:
strategy:
max-parallel: 1
fail-fast: false
matrix:
os: ${{fromJSON(inputs.runner-os)}}
runs-on: ${{ matrix.os }}
steps:
- name: Checkout sources
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Initialize integ-test
uses: ./.github/actions/init-integ-test
- name: Use legacy caching module
shell: bash
run: echo "GRADLE_ACTIONS_CACHE_USE_VENDORED=false" >> "$GITHUB_ENV"
- name: Setup Gradle
uses: ./setup-gradle
with:
cache-read-only: false # For testing, allow writing cache entries on non-default branches
- name: Build kotlin-dsl project
working-directory: .github/workflow-samples/kotlin-dsl
run: ./gradlew build
legacy-caching-verify-build:
needs: legacy-caching-seed-build
strategy:
max-parallel: 1
fail-fast: false
matrix:
os: ${{fromJSON(inputs.runner-os)}}
runs-on: ${{ matrix.os }}
steps:
- name: Checkout sources
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Initialize integ-test
uses: ./.github/actions/init-integ-test
- name: Use legacy caching module
shell: bash
run: echo "GRADLE_ACTIONS_CACHE_USE_VENDORED=false" >> "$GITHUB_ENV"
- name: Setup Gradle
uses: ./setup-gradle
with:
cache-read-only: true
- name: Build kotlin-dsl project
working-directory: .github/workflow-samples/kotlin-dsl
run: ./gradlew build --offline
@@ -45,3 +45,8 @@ jobs:
uses: ./.github/workflows/integ-test-restore-java-toolchain.yml
with:
skip-dist: ${{ inputs.skip-dist }}
legacy-caching:
uses: ./.github/workflows/integ-test-legacy-caching.yml
with:
skip-dist: ${{ inputs.skip-dist }}
+26
View File
@@ -1,13 +1,30 @@
#!/bin/bash
build_legacy_caching() {
echo "Building legacy caching module..."
cd sources/legacy-caching
npm clean-install
npm run build
cd ../..
# Copy legacy caching bundle into sources/dist so the CI bot picks it up
mkdir -p sources/dist/legacy-caching
cp sources/legacy-caching/dist/index.js sources/dist/legacy-caching/
}
cd sources
case "$1" in
all)
cd ..
build_legacy_caching
cd sources
npm run all
;;
act)
# Build and copy outputs to the dist directory
cd ..
build_legacy_caching
cd sources
npm run build
cd ..
cp -r sources/dist .
@@ -17,6 +34,9 @@ case "$1" in
git checkout -- dist
;;
dist)
cd ..
build_legacy_caching
cd sources
npm clean-install
npm run build
cd ..
@@ -27,6 +47,9 @@ case "$1" in
./gradlew check
;;
install)
cd ..
build_legacy_caching
cd sources
npm clean-install
npm run build
;;
@@ -35,6 +58,9 @@ case "$1" in
npm test -- $@
;;
*)
cd ..
build_legacy_caching
cd sources
npm run build
;;
esac
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
{
"name": "gradle-actions-legacy-caching",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
"scripts": {
"postinstall": "patch-package",
"build": "esbuild src/index.ts --bundle --platform=node --target=node24 --format=esm --banner:js=\"import {createRequire} from 'module';const require=createRequire(import.meta.url);\" --outfile=dist/index.js --sourcemap --minify"
},
"dependencies": {
"@actions/cache": "4.0.5",
"@actions/core": "3.0.0",
"@actions/exec": "3.0.0",
"@actions/github": "9.0.0",
"@actions/glob": "0.6.1",
"@actions/http-client": "4.0.0",
"@actions/tool-cache": "4.0.0",
"semver": "7.7.4",
"which": "6.0.1"
},
"devDependencies": {
"esbuild": "0.27.4",
"patch-package": "8.0.0"
}
}
@@ -0,0 +1,248 @@
diff --git a/node_modules/@actions/cache/lib/cache.d.ts b/node_modules/@actions/cache/lib/cache.d.ts
index ef0928b..d06e675 100644
--- a/node_modules/@actions/cache/lib/cache.d.ts
+++ b/node_modules/@actions/cache/lib/cache.d.ts
@@ -21,7 +21,8 @@ export declare function isFeatureAvailable(): boolean;
* @param enableCrossOsArchive an optional boolean enabled to restore on windows any cache created on any platform
* @returns string returns the key for the cache hit, otherwise returns undefined
*/
-export declare function restoreCache(paths: string[], primaryKey: string, restoreKeys?: string[], options?: DownloadOptions, enableCrossOsArchive?: boolean): Promise<string | undefined>;
+export declare function restoreCache(paths: string[], primaryKey: string, restoreKeys?: string[], options?: DownloadOptions, enableCrossOsArchive?: boolean): Promise<CacheEntry | undefined>;
+
/**
* Saves a list of files with the specified key
*
@@ -31,4 +32,12 @@ export declare function restoreCache(paths: string[], primaryKey: string, restor
* @param options cache upload options
* @returns number returns cacheId if the cache was saved successfully and throws an error if save fails
*/
-export declare function saveCache(paths: string[], key: string, options?: UploadOptions, enableCrossOsArchive?: boolean): Promise<number>;
+export declare function saveCache(paths: string[], key: string, options?: UploadOptions, enableCrossOsArchive?: boolean): Promise<CacheEntry>;
+
+// PATCHED: Add `CacheEntry` as return type for save/restore functions
+// This allows us to track and report on cache entry sizes.
+export declare class CacheEntry {
+ key: string;
+ size?: number;
+ constructor(key: string, size?: number);
+}
diff --git a/node_modules/@actions/cache/lib/cache.js b/node_modules/@actions/cache/lib/cache.js
index 41f2a37..2fe1600 100644
--- a/node_modules/@actions/cache/lib/cache.js
+++ b/node_modules/@actions/cache/lib/cache.js
@@ -165,26 +165,29 @@ function restoreCacheV1(paths, primaryKey, restoreKeys, options, enableCrossOsAr
core.info(`Cache Size: ~${Math.round(archiveFileSize / (1024 * 1024))} MB (${archiveFileSize} B)`);
yield (0, tar_1.extractTar)(archivePath, compressionMethod);
core.info('Cache restored successfully');
- return cacheEntry.cacheKey;
- }
- catch (error) {
- const typedError = error;
- if (typedError.name === ValidationError.name) {
- throw error;
- }
- else {
- // warn on cache restore failure and continue build
- // Log server errors (5xx) as errors, all other errors as warnings
- if (typedError instanceof http_client_1.HttpClientError &&
- typeof typedError.statusCode === 'number' &&
- typedError.statusCode >= 500) {
- core.error(`Failed to restore: ${error.message}`);
- }
- else {
- core.warning(`Failed to restore: ${error.message}`);
- }
- }
+
+ // PATCHED - Include size of restored entry
+ return new CacheEntry(cacheEntry.cacheKey, archiveFileSize);
}
+ // PATCHED - propagate errors
+ // catch (error) {
+ // const typedError = error;
+ // if (typedError.name === ValidationError.name) {
+ // throw error;
+ // }
+ // else {
+ // // warn on cache restore failure and continue build
+ // // Log server errors (5xx) as errors, all other errors as warnings
+ // if (typedError instanceof http_client_1.HttpClientError &&
+ // typeof typedError.statusCode === 'number' &&
+ // typedError.statusCode >= 500) {
+ // core.error(`Failed to restore: ${error.message}`);
+ // }
+ // else {
+ // core.warning(`Failed to restore: ${error.message}`);
+ // }
+ // }
+ //}
finally {
// Try to delete the archive to save space
try {
@@ -257,26 +260,29 @@ function restoreCacheV2(paths, primaryKey, restoreKeys, options, enableCrossOsAr
}
yield (0, tar_1.extractTar)(archivePath, compressionMethod);
core.info('Cache restored successfully');
- return response.matchedKey;
- }
- catch (error) {
- const typedError = error;
- if (typedError.name === ValidationError.name) {
- throw error;
- }
- else {
- // Supress all non-validation cache related errors because caching should be optional
- // Log server errors (5xx) as errors, all other errors as warnings
- if (typedError instanceof http_client_1.HttpClientError &&
- typeof typedError.statusCode === 'number' &&
- typedError.statusCode >= 500) {
- core.error(`Failed to restore: ${error.message}`);
- }
- else {
- core.warning(`Failed to restore: ${error.message}`);
- }
- }
+
+ // PATCHED - Include size of restored entry
+ return new CacheEntry(response.matchedKey, archiveFileSize);
}
+ // PATCHED - propagate errors
+ // catch (error) {
+ // const typedError = error;
+ // if (typedError.name === ValidationError.name) {
+ // throw error;
+ // }
+ // else {
+ // // Supress all non-validation cache related errors because caching should be optional
+ // // Log server errors (5xx) as errors, all other errors as warnings
+ // if (typedError instanceof http_client_1.HttpClientError &&
+ // typeof typedError.statusCode === 'number' &&
+ // typedError.statusCode >= 500) {
+ // core.error(`Failed to restore: ${error.message}`);
+ // }
+ // else {
+ // core.warning(`Failed to restore: ${error.message}`);
+ // }
+ // }
+ //}
finally {
try {
if (archivePath) {
@@ -367,27 +373,31 @@ function saveCacheV1(paths, key, options, enableCrossOsArchive = false) {
}
core.debug(`Saving Cache (ID: ${cacheId})`);
yield cacheHttpClient.saveCache(cacheId, archivePath, '', options);
+
+ // PATCHED - Include size of saved entry
+ return new CacheEntry(key, archiveFileSize);
}
- catch (error) {
- const typedError = error;
- if (typedError.name === ValidationError.name) {
- throw error;
- }
- else if (typedError.name === ReserveCacheError.name) {
- core.info(`Failed to save: ${typedError.message}`);
- }
- else {
- // Log server errors (5xx) as errors, all other errors as warnings
- if (typedError instanceof http_client_1.HttpClientError &&
- typeof typedError.statusCode === 'number' &&
- typedError.statusCode >= 500) {
- core.error(`Failed to save: ${typedError.message}`);
- }
- else {
- core.warning(`Failed to save: ${typedError.message}`);
- }
- }
- }
+ // PATCHED - propagate errors
+ //catch (error) {
+ // const typedError = error;
+ // if (typedError.name === ValidationError.name) {
+ // throw error;
+ // }
+ // else if (typedError.name === ReserveCacheError.name) {
+ // core.info(`Failed to save: ${typedError.message}`);
+ // }
+ // else {
+ // // Log server errors (5xx) as errors, all other errors as warnings
+ // if (typedError instanceof http_client_1.HttpClientError &&
+ // typeof typedError.statusCode === 'number' &&
+ // typedError.statusCode >= 500) {
+ // core.error(`Failed to save: ${typedError.message}`);
+ // }
+ // else {
+ // core.warning(`Failed to save: ${typedError.message}`);
+ // }
+ // }
+ //}
finally {
// Try to delete the archive to save space
try {
@@ -471,27 +481,31 @@ function saveCacheV2(paths, key, options, enableCrossOsArchive = false) {
throw new Error(`Unable to finalize cache with key ${key}, another job may be finalizing this cache.`);
}
cacheId = parseInt(finalizeResponse.entryId);
+
+ // PATCHED - Include size of saved entry
+ return new CacheEntry(key, archiveFileSize);
}
- catch (error) {
- const typedError = error;
- if (typedError.name === ValidationError.name) {
- throw error;
- }
- else if (typedError.name === ReserveCacheError.name) {
- core.info(`Failed to save: ${typedError.message}`);
- }
- else {
- // Log server errors (5xx) as errors, all other errors as warnings
- if (typedError instanceof http_client_1.HttpClientError &&
- typeof typedError.statusCode === 'number' &&
- typedError.statusCode >= 500) {
- core.error(`Failed to save: ${typedError.message}`);
- }
- else {
- core.warning(`Failed to save: ${typedError.message}`);
- }
- }
- }
+ // PATCHED - propagate errors
+ //catch (error) {
+ // const typedError = error;
+ // if (typedError.name === ValidationError.name) {
+ // throw error;
+ // }
+ // else if (typedError.name === ReserveCacheError.name) {
+ // core.info(`Failed to save: ${typedError.message}`);
+ // }
+ // else {
+ // // Log server errors (5xx) as errors, all other errors as warnings
+ // if (typedError instanceof http_client_1.HttpClientError &&
+ // typeof typedError.statusCode === 'number' &&
+ // typedError.statusCode >= 500) {
+ // core.error(`Failed to save: ${typedError.message}`);
+ // }
+ // else {
+ // core.warning(`Failed to save: ${typedError.message}`);
+ // }
+ // }
+ //}
finally {
// Try to delete the archive to save space
try {
@@ -504,4 +518,12 @@ function saveCacheV2(paths, key, options, enableCrossOsArchive = false) {
return cacheId;
});
}
+// PATCHED - CacheEntry class
+class CacheEntry {
+ constructor(key, size) {
+ this.key = key;
+ this.size = size;
+ }
+}
+
//# sourceMappingURL=cache.js.map
\ No newline at end of file
@@ -0,0 +1,52 @@
import {versionIsAtLeast} from './gradle-utils'
/**
* Mirrors the BuildResult interface from the main package.
*/
export interface BuildResult {
get rootProjectName(): string
get rootProjectDir(): string
get requestedTasks(): string
get gradleVersion(): string
get gradleHomeDir(): string
get buildFailed(): boolean
get configCacheHit(): boolean
get buildScanUri(): string
get buildScanFailed(): boolean
}
/**
* Wraps BuildResult[] to provide the helper methods expected by the old caching code.
*/
export class BuildResults {
results: BuildResult[]
constructor(results: BuildResult[]) {
this.results = results
}
anyFailed(): boolean {
return this.results.some(result => result.buildFailed)
}
anyConfigCacheHit(): boolean {
return this.results.some(result => result.configCacheHit)
}
uniqueGradleHomes(): string[] {
const allHomes = this.results.map(buildResult => buildResult.gradleHomeDir)
return Array.from(new Set(allHomes))
}
highestGradleVersion(): string | null {
if (this.results.length === 0) {
return null
}
return this.results
.map(result => result.gradleVersion)
.reduce((maxVersion: string, currentVersion: string) => {
if (!maxVersion) return currentVersion
return versionIsAtLeast(currentVersion, maxVersion) ? currentVersion : maxVersion
})
}
}
+101
View File
@@ -0,0 +1,101 @@
import * as core from '@actions/core'
import * as exec from '@actions/exec'
import fs from 'fs'
import path from 'path'
import {BuildResults} from './build-results-adapter'
import {versionIsAtLeast, provisionGradleWithVersionAtLeast} from './gradle-utils'
const MINIMUM_CLEANUP_GRADLE_VERSION = '8.11'
export class CacheCleaner {
private readonly gradleUserHome: string
private readonly tmpDir: string
constructor(gradleUserHome: string, tmpDir: string) {
this.gradleUserHome = gradleUserHome
this.tmpDir = tmpDir
}
async prepare(): Promise<string> {
// Save the current timestamp
const timestamp = Date.now().toString()
core.saveState('clean-timestamp', timestamp)
return timestamp
}
async forceCleanup(buildResults: BuildResults): Promise<void> {
const executable = await this.gradleExecutableForCleanup(buildResults)
const cleanTimestamp = core.getState('clean-timestamp')
await this.forceCleanupFilesOlderThan(cleanTimestamp, executable)
}
/**
* Attempt to use the newest Gradle version that was used to run a build, at least 8.11.
*
* This will avoid the need to provision a Gradle version for the cleanup when not necessary.
*/
private async gradleExecutableForCleanup(buildResults: BuildResults): Promise<string> {
const highestVersion = buildResults.highestGradleVersion()
const version =
highestVersion && versionIsAtLeast(highestVersion, MINIMUM_CLEANUP_GRADLE_VERSION)
? highestVersion
: MINIMUM_CLEANUP_GRADLE_VERSION
return await provisionGradleWithVersionAtLeast(version)
}
// Visible for testing
async forceCleanupFilesOlderThan(cleanTimestamp: string, executable: string): Promise<void> {
// Run a dummy Gradle build to trigger cache cleanup
const cleanupProjectDir = path.resolve(this.tmpDir, 'dummy-cleanup-project')
fs.mkdirSync(cleanupProjectDir, {recursive: true})
fs.writeFileSync(
path.resolve(cleanupProjectDir, 'settings.gradle'),
'rootProject.name = "dummy-cleanup-project"'
)
fs.writeFileSync(
path.resolve(cleanupProjectDir, 'init.gradle'),
`
beforeSettings { settings ->
def cleanupTime = ${cleanTimestamp}
settings.caches {
cleanup = Cleanup.ALWAYS
releasedWrappers.setRemoveUnusedEntriesOlderThan(cleanupTime)
snapshotWrappers.setRemoveUnusedEntriesOlderThan(cleanupTime)
downloadedResources.setRemoveUnusedEntriesOlderThan(cleanupTime)
createdResources.setRemoveUnusedEntriesOlderThan(cleanupTime)
buildCache.setRemoveUnusedEntriesOlderThan(cleanupTime)
}
}
`
)
fs.writeFileSync(path.resolve(cleanupProjectDir, 'build.gradle'), 'task("noop") {}')
await core.group('Executing Gradle to clean up caches', async () => {
core.info(`Cleaning up caches last used before ${cleanTimestamp}`)
await this.executeCleanupBuild(executable, cleanupProjectDir)
})
}
private async executeCleanupBuild(executable: string, cleanupProjectDir: string): Promise<void> {
const args = [
'-g',
this.gradleUserHome,
'-I',
'init.gradle',
'--info',
'--no-daemon',
'--no-scan',
'--build-cache',
'-DGITHUB_DEPENDENCY_GRAPH_ENABLED=false',
'-DGRADLE_ACTIONS_SKIP_BUILD_RESULT_CAPTURE=true',
'noop'
]
await exec.exec(executable, args, {
cwd: cleanupProjectDir
})
}
}
@@ -0,0 +1,86 @@
import * as core from '@actions/core'
export const ACTION_METADATA_DIR = '.setup-gradle'
/**
* Represents the cache options passed from the main action.
* This mirrors the CacheOptions interface from the main package.
*/
export interface CacheOptions {
disabled: boolean
readOnly: boolean
writeOnly: boolean
overwriteExisting: boolean
strictMatch: boolean
cleanup: string
encryptionKey?: string
includes: string[]
excludes: string[]
}
/**
* Adapts the CacheOptions interface to the old CacheConfig method-based API
* expected by the recovered caching code.
*/
export class CacheConfig {
private readonly options: CacheOptions
constructor(options: CacheOptions) {
this.options = options
}
isCacheDisabled(): boolean {
return this.options.disabled
}
isCacheReadOnly(): boolean {
return this.options.readOnly
}
isCacheWriteOnly(): boolean {
return this.options.writeOnly
}
isCacheOverwriteExisting(): boolean {
return this.options.overwriteExisting
}
isCacheStrictMatch(): boolean {
return this.options.strictMatch
}
isCacheCleanupEnabled(): boolean {
return this.options.cleanup !== 'never'
}
shouldPerformCacheCleanup(hasFailure: boolean): boolean {
const cleanup = this.options.cleanup
if (cleanup === 'always') {
return true
}
if (cleanup === 'on-success') {
return !hasFailure
}
return false
}
getCacheCleanupOption(): string {
return this.options.cleanup
}
getCacheEncryptionKey(): string | undefined {
return this.options.encryptionKey
}
getCacheIncludes(): string[] {
return [...this.options.includes]
}
getCacheExcludes(): string[] {
return [...this.options.excludes]
}
}
export function getJobMatrix(): string {
return core.getInput('workflow-job-context')
}
+100
View File
@@ -0,0 +1,100 @@
import * as github from '@actions/github'
import {CacheConfig, getJobMatrix} from './cache-config-adapter'
import {hashStrings} from './cache-utils'
const CACHE_PROTOCOL_VERSION = 'v1'
const CACHE_KEY_PREFIX_VAR = 'GRADLE_BUILD_ACTION_CACHE_KEY_PREFIX'
const CACHE_KEY_OS_VAR = 'GRADLE_BUILD_ACTION_CACHE_KEY_ENVIRONMENT'
const CACHE_KEY_JOB_VAR = 'GRADLE_BUILD_ACTION_CACHE_KEY_JOB'
const CACHE_KEY_JOB_INSTANCE_VAR = 'GRADLE_BUILD_ACTION_CACHE_KEY_JOB_INSTANCE'
const CACHE_KEY_JOB_EXECUTION_VAR = 'GRADLE_BUILD_ACTION_CACHE_KEY_JOB_EXECUTION'
/**
* Represents a key used to restore a cache entry.
* The Github Actions cache will first try for an exact match on the key.
* If that fails, it will try for a prefix match on any of the restoreKeys.
*/
export class CacheKey {
key: string
restoreKeys: string[]
constructor(key: string, restoreKeys: string[]) {
this.key = key
this.restoreKeys = restoreKeys
}
}
/**
* Generates a cache key specific to the current job execution.
* The key is constructed from the following inputs (with some user overrides):
* - The cache key prefix: defaults to 'gradle-' but can be overridden by the user
* - The cache protocol version
* - The runner operating system
* - The name of the workflow and Job being executed
* - The matrix values for the Job being executed (job context)
* - The SHA of the commit being executed
*
* Caches are restored by trying to match the these key prefixes in order:
* - The full key with SHA
* - A previous key for this Job + matrix
* - Any previous key for this Job (any matrix)
* - Any previous key for this cache on the current OS
*/
export function generateCacheKey(cacheName: string, config: CacheConfig): CacheKey {
const prefix = process.env[CACHE_KEY_PREFIX_VAR] || ''
const cacheKeyBase = `${prefix}${getCacheKeyBase(cacheName, CACHE_PROTOCOL_VERSION)}`
// At the most general level, share caches for all executions on the same OS
const cacheKeyForEnvironment = `${cacheKeyBase}|${getCacheKeyEnvironment()}`
// Then prefer caches that run job with the same ID
const cacheKeyForJob = `${cacheKeyForEnvironment}|${getCacheKeyJob()}`
// Prefer (even more) jobs that run this job in the same workflow with the same context (matrix)
const cacheKeyForJobContext = `${cacheKeyForJob}[${getCacheKeyJobInstance()}]`
// Exact match on Git SHA
const cacheKey = `${cacheKeyForJobContext}-${getCacheKeyJobExecution()}`
if (config.isCacheStrictMatch()) {
return new CacheKey(cacheKey, [cacheKeyForJobContext])
}
return new CacheKey(cacheKey, [cacheKeyForJobContext, cacheKeyForJob, cacheKeyForEnvironment])
}
export function getCacheKeyBase(cacheName: string, cacheProtocolVersion: string): string {
// Prefix can be used to force change all cache keys (defaults to cache protocol version)
return `gradle-${cacheName}-${cacheProtocolVersion}`
}
function getCacheKeyEnvironment(): string {
const runnerOs = process.env['RUNNER_OS'] || ''
const runnerArch = process.env['RUNNER_ARCH'] || ''
return process.env[CACHE_KEY_OS_VAR] || `${runnerOs}-${runnerArch}`
}
function getCacheKeyJob(): string {
return process.env[CACHE_KEY_JOB_VAR] || github.context.job
}
function getCacheKeyJobInstance(): string {
const override = process.env[CACHE_KEY_JOB_INSTANCE_VAR]
if (override) {
return override
}
// By default, we hash the workflow name and the full `matrix` data for the run, to uniquely identify this job invocation
// The only way we can obtain the `matrix` data is via the `workflow-job-context` parameter in action.yml.
const workflowName = github.context.workflow
const workflowJobContext = getJobMatrix()
return hashStrings([workflowName, workflowJobContext])
}
function getCacheKeyJobExecution(): string {
// Used to associate a cache key with a particular execution (default is bound to the git commit sha)
return process.env[CACHE_KEY_JOB_EXECUTION_VAR] || github.context.sha
}
@@ -0,0 +1,294 @@
import * as cache from '@actions/cache'
export const DEFAULT_CACHE_ENABLED_REASON = `[Cache was enabled](https://github.com/gradle/actions/blob/main/docs/setup-gradle.md#caching-build-state-between-jobs). Action attempted to both restore and save the Gradle User Home.`
export const DEFAULT_READONLY_REASON = `[Cache was read-only](https://github.com/gradle/actions/blob/main/docs/setup-gradle.md#using-the-cache-read-only). By default, the action will only write to the cache for Jobs running on the default branch.`
export const DEFAULT_DISABLED_REASON = `[Cache was disabled](https://github.com/gradle/actions/blob/main/docs/setup-gradle.md#disabling-caching). Gradle User Home was not restored from or saved to the cache.`
export const DEFAULT_WRITEONLY_REASON = `[Cache was set to write-only](https://github.com/gradle/actions/blob/main/docs/setup-gradle.md#using-the-cache-write-only). Gradle User Home was not restored from cache.`
export const EXISTING_GRADLE_HOME = `[Cache was disabled to avoid overwriting a pre-existing Gradle User Home](https://github.com/gradle/actions/blob/main/docs/setup-gradle.md#overwriting-an-existing-gradle-user-home). Gradle User Home was not restored from or saved to the cache.`
export const CLEANUP_DISABLED_READONLY = `[Cache cleanup](https://github.com/gradle/actions/blob/main/docs/setup-gradle.md#configuring-cache-cleanup) is always disabled when cache is read-only or disabled.`
export const DEFAULT_CLEANUP_ENABLED_REASON = `[Cache cleanup](https://github.com/gradle/actions/blob/main/docs/setup-gradle.md#configuring-cache-cleanup) was enabled. Stale files in Gradle User Home were purged before saving to the cache.`
export const DEFAULT_CLEANUP_DISABLED_REASON = `[Cache cleanup](https://github.com/gradle/actions/blob/main/docs/setup-gradle.md#configuring-cache-cleanup) was disabled via action parameter. No cleanup of Gradle User Home was performed.`
export const CLEANUP_DISABLED_DUE_TO_FAILURE =
'[Cache cleanup was disabled due to build failure](https://github.com/gradle/actions/blob/main/docs/setup-gradle.md#configuring-cache-cleanup). Use `cache-cleanup: always` to override this behavior.'
export const CLEANUP_DISABLED_DUE_TO_CONFIG_CACHE_HIT =
'[Cache cleanup was disabled due to configuration-cache reuse](https://github.com/gradle/actions/blob/main/docs/setup-gradle.md#configuring-cache-cleanup). This is expected.'
/**
* Collects information on what entries were saved and restored during the action.
* This information is used to generate a summary of the cache usage.
*/
export class CacheListener {
cacheEntries: CacheEntryListener[] = []
cacheReadOnly = false
cacheWriteOnly = false
cacheDisabled = false
cacheStatusReason: string = DEFAULT_CACHE_ENABLED_REASON
cacheCleanupMessage: string = DEFAULT_CLEANUP_DISABLED_REASON
get fullyRestored(): boolean {
return this.cacheEntries.every(x => !x.wasRequestedButNotRestored())
}
get cacheStatus(): string {
if (!cache.isFeatureAvailable()) return 'not available'
if (this.cacheDisabled) return 'disabled'
if (this.cacheWriteOnly) return 'write-only'
if (this.cacheReadOnly) return 'read-only'
return 'enabled'
}
setReadOnly(reason: string = DEFAULT_READONLY_REASON): void {
this.cacheReadOnly = true
this.cacheStatusReason = reason
this.cacheCleanupMessage = CLEANUP_DISABLED_READONLY
}
setDisabled(reason: string = DEFAULT_DISABLED_REASON): void {
this.cacheDisabled = true
this.cacheStatusReason = reason
this.cacheCleanupMessage = CLEANUP_DISABLED_READONLY
}
setWriteOnly(reason: string = DEFAULT_WRITEONLY_REASON): void {
this.cacheWriteOnly = true
this.cacheStatusReason = reason
}
setCacheCleanupEnabled(): void {
this.cacheCleanupMessage = DEFAULT_CLEANUP_ENABLED_REASON
}
setCacheCleanupDisabled(reason: string = DEFAULT_CLEANUP_DISABLED_REASON): void {
this.cacheCleanupMessage = reason
}
entry(name: string): CacheEntryListener {
for (const entry of this.cacheEntries) {
if (entry.entryName === name) {
return entry
}
}
const newEntry = new CacheEntryListener(name)
this.cacheEntries.push(newEntry)
return newEntry
}
stringify(): string {
return JSON.stringify(this)
}
static rehydrate(stringRep: string): CacheListener {
if (stringRep === '') {
return new CacheListener()
}
const rehydrated: CacheListener = Object.assign(new CacheListener(), JSON.parse(stringRep))
const entries = rehydrated.cacheEntries
for (let index = 0; index < entries.length; index++) {
const rawEntry = entries[index]
entries[index] = Object.assign(new CacheEntryListener(rawEntry.entryName), rawEntry)
}
return rehydrated
}
}
/**
* Collects information on the state of a single cache entry.
*/
export class CacheEntryListener {
entryName: string
requestedKey: string | undefined
requestedRestoreKeys: string[] | undefined
restoredKey: string | undefined
restoredSize: number | undefined
restoredTime: number | undefined
notRestored: string | undefined
savedKey: string | undefined
savedSize: number | undefined
savedTime: number | undefined
notSaved: string | undefined
constructor(entryName: string) {
this.entryName = entryName
}
wasRequestedButNotRestored(): boolean {
return this.requestedKey !== undefined && this.restoredKey === undefined
}
markRequested(key: string, restoreKeys: string[] = []): CacheEntryListener {
this.requestedKey = key
this.requestedRestoreKeys = restoreKeys
return this
}
markRestored(key: string, size: number | undefined, time: number): CacheEntryListener {
this.restoredKey = key
this.restoredSize = size
this.restoredTime = time
return this
}
markNotRestored(message: string): CacheEntryListener {
this.notRestored = message
return this
}
markSaved(key: string, size: number | undefined, time: number): CacheEntryListener {
this.savedKey = key
this.savedSize = size
this.savedTime = time
return this
}
markAlreadyExists(key: string): CacheEntryListener {
this.savedKey = key
this.savedSize = 0
return this
}
markNotSaved(message: string): CacheEntryListener {
this.notSaved = message
return this
}
}
export function generateCachingReport(listener: CacheListener): string {
const entries = listener.cacheEntries
return `
<details>
<summary><h4>Caching for Gradle actions was ${listener.cacheStatus} - expand for details</h4></summary>
- ${listener.cacheStatusReason}
- ${listener.cacheCleanupMessage}
${renderEntryTable(entries)}
<h5>Cache Entry Details</h5>
<pre>
${renderEntryDetails(listener)}
</pre>
</details>
`
}
function renderEntryTable(entries: CacheEntryListener[]): string {
return `
<table>
<tr><td></td><th>Count</th><th>Total Size (Mb)</th><th>Total Time (ms)</tr>
<tr><td>Entries Restored</td>
<td>${getCount(entries, e => e.restoredSize)}</td>
<td>${getSize(entries, e => e.restoredSize)}</td>
<td>${getTime(entries, e => e.restoredTime)}</td>
</tr>
<tr><td>Entries Saved</td>
<td>${getCount(entries, e => e.savedSize)}</td>
<td>${getSize(entries, e => e.savedSize)}</td>
<td>${getTime(entries, e => e.savedTime)}</td>
</tr>
</table>
`
}
function renderEntryDetails(listener: CacheListener): string {
return listener.cacheEntries
.map(
entry => `Entry: ${entry.entryName}
Requested Key : ${entry.requestedKey ?? ''}
Restored Key : ${entry.restoredKey ?? ''}
Size: ${formatSize(entry.restoredSize)}
Time: ${formatTime(entry.restoredTime)}
${getRestoredMessage(entry, listener.cacheWriteOnly)}
Saved Key : ${entry.savedKey ?? ''}
Size: ${formatSize(entry.savedSize)}
Time: ${formatTime(entry.savedTime)}
${getSavedMessage(entry, listener.cacheReadOnly)}
`
)
.join('---\n')
}
function getRestoredMessage(entry: CacheEntryListener, cacheWriteOnly: boolean): string {
if (entry.notRestored) {
return `(Entry not restored: ${entry.notRestored})`
}
if (cacheWriteOnly) {
return '(Entry not restored: cache is write-only)'
}
if (entry.requestedKey === undefined) {
return '(Entry not restored: not requested)'
}
if (entry.restoredKey === undefined) {
return '(Entry not restored: no match found)'
}
if (entry.restoredKey === entry.requestedKey) {
return '(Entry restored: exact match found)'
}
return '(Entry restored: partial match found)'
}
function getSavedMessage(entry: CacheEntryListener, cacheReadOnly: boolean): string {
if (entry.notSaved) {
return `(Entry not saved: ${entry.notSaved})`
}
if (entry.savedKey === undefined) {
if (cacheReadOnly) {
return '(Entry not saved: cache is read-only)'
}
if (entry.notRestored) {
return '(Entry not saved: not restored)'
}
return '(Entry not saved: reason unknown)'
}
if (entry.savedSize === 0) {
return '(Entry not saved: entry with key already exists)'
}
return '(Entry saved)'
}
function getCount(
cacheEntries: CacheEntryListener[],
predicate: (value: CacheEntryListener) => number | undefined
): number {
return cacheEntries.filter(e => predicate(e)).length
}
function getSize(
cacheEntries: CacheEntryListener[],
predicate: (value: CacheEntryListener) => number | undefined
): number {
const bytes = cacheEntries.map(e => predicate(e) ?? 0).reduce((p, v) => p + v, 0)
return Math.round(bytes / (1024 * 1024))
}
function getTime(
cacheEntries: CacheEntryListener[],
predicate: (value: CacheEntryListener) => number | undefined
): number {
return cacheEntries.map(e => predicate(e) ?? 0).reduce((p, v) => p + v, 0)
}
function formatSize(bytes: number | undefined): string {
if (bytes === undefined || bytes === 0) {
return ''
}
return `${Math.round(bytes / (1024 * 1024))} MB (${bytes} B)`
}
function formatTime(ms: number | undefined): string {
if (ms === undefined || ms === 0) {
return ''
}
return `${ms} ms`
}
+140
View File
@@ -0,0 +1,140 @@
import * as core from '@actions/core'
import * as cache from '@actions/cache'
import * as exec from '@actions/exec'
import * as crypto from 'crypto'
import * as path from 'path'
import * as fs from 'fs'
import {CacheEntryListener} from './cache-reporting'
const SEGMENT_DOWNLOAD_TIMEOUT_VAR = 'SEGMENT_DOWNLOAD_TIMEOUT_MINS'
const SEGMENT_DOWNLOAD_TIMEOUT_DEFAULT = 10 * 60 * 1000 // 10 minutes
export function isCacheDebuggingEnabled(): boolean {
if (core.isDebug()) {
return true
}
return process.env['GRADLE_BUILD_ACTION_CACHE_DEBUG_ENABLED'] ? true : false
}
export function hashFileNames(fileNames: string[]): string {
return hashStrings(fileNames.map(x => x.replace(new RegExp(`\\${path.sep}`, 'g'), '/')))
}
export function hashStrings(values: string[]): string {
const hash = crypto.createHash('md5')
for (const value of values) {
hash.update(value)
}
return hash.digest('hex')
}
export async function restoreCache(
cachePath: string[],
cacheKey: string,
cacheRestoreKeys: string[],
listener: CacheEntryListener
): Promise<cache.CacheEntry | undefined> {
listener.markRequested(cacheKey, cacheRestoreKeys)
try {
const startTime = Date.now()
// Only override the read timeout if the SEGMENT_DOWNLOAD_TIMEOUT_MINS env var has NOT been set
const cacheRestoreOptions = process.env[SEGMENT_DOWNLOAD_TIMEOUT_VAR]
? {}
: {segmentTimeoutInMs: SEGMENT_DOWNLOAD_TIMEOUT_DEFAULT}
const restoredEntry = await cache.restoreCache(cachePath, cacheKey, cacheRestoreKeys, cacheRestoreOptions)
if (restoredEntry !== undefined) {
const restoreTime = Date.now() - startTime
listener.markRestored(restoredEntry.key, restoredEntry.size, restoreTime)
core.info(`Restored cache entry with key ${cacheKey} to ${cachePath.join()} in ${restoreTime}ms`)
}
return restoredEntry
} catch (error) {
listener.markNotRestored((error as Error).message)
handleCacheFailure(error, `Failed to restore ${cacheKey}`)
return undefined
}
}
export async function saveCache(cachePath: string[], cacheKey: string, listener: CacheEntryListener): Promise<void> {
try {
const startTime = Date.now()
const savedEntry = await cache.saveCache(cachePath, cacheKey)
const saveTime = Date.now() - startTime
listener.markSaved(savedEntry.key, savedEntry.size, saveTime)
core.info(`Saved cache entry with key ${cacheKey} from ${cachePath.join()} in ${saveTime}ms`)
} catch (error) {
if (error instanceof cache.ReserveCacheError) {
listener.markAlreadyExists(cacheKey)
} else {
listener.markNotSaved((error as Error).message)
}
handleCacheFailure(error, `Failed to save cache entry with path '${cachePath}' and key: ${cacheKey}`)
}
}
export function cacheDebug(message: string): void {
if (isCacheDebuggingEnabled()) {
core.info(message)
} else {
core.debug(message)
}
}
export function handleCacheFailure(error: unknown, message: string): void {
if (error instanceof cache.ValidationError) {
// Fail on cache validation errors
throw error
}
if (error instanceof cache.ReserveCacheError) {
// Reserve cache errors are expected if the artifact has been previously cached
core.info(`${message}: ${error}`)
} else {
// Warn on all other errors
core.warning(`${message}: ${error}`)
if (error instanceof Error && error.stack) {
cacheDebug(error.stack)
}
}
}
/**
* Attempt to delete a file or directory, waiting to allow locks to be released
*/
export async function tryDelete(file: string): Promise<void> {
const maxAttempts = 5
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
if (!fs.existsSync(file)) {
return
}
try {
const stat = fs.lstatSync(file)
if (stat.isDirectory()) {
fs.rmSync(file, {recursive: true})
} else {
fs.unlinkSync(file)
}
return
} catch (error) {
if (attempt === maxAttempts) {
core.warning(`Failed to delete ${file}, which will impact caching.
It is likely locked by another process. Output of 'jps -ml':
${await getJavaProcesses()}`)
throw error
} else {
cacheDebug(`Attempt to delete ${file} failed. Will try again.`)
await delay(1000)
}
}
}
}
async function delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
async function getJavaProcesses(): Promise<string> {
const jpsOutput = await exec.getExecOutput('jps', ['-lm'])
return jpsOutput.stdout
}
+124
View File
@@ -0,0 +1,124 @@
import * as core from '@actions/core'
import {
CacheListener,
EXISTING_GRADLE_HOME,
CLEANUP_DISABLED_DUE_TO_FAILURE,
CLEANUP_DISABLED_DUE_TO_CONFIG_CACHE_HIT
} from './cache-reporting'
import {GradleUserHomeCache} from './gradle-user-home-cache'
import {CacheCleaner} from './cache-cleaner'
import {DaemonController} from './daemon-controller'
import {CacheConfig} from './cache-config-adapter'
import {BuildResults} from './build-results-adapter'
const CACHE_RESTORED_VAR = 'GRADLE_BUILD_ACTION_CACHE_RESTORED'
export async function restore(
userHome: string,
gradleUserHome: string,
cacheListener: CacheListener,
cacheConfig: CacheConfig
): Promise<void> {
// Bypass restore cache on all but first action step in workflow.
if (process.env[CACHE_RESTORED_VAR]) {
core.info('Cache only restored on first action step.')
return
}
core.exportVariable(CACHE_RESTORED_VAR, true)
const gradleStateCache = new GradleUserHomeCache(userHome, gradleUserHome, cacheConfig)
if (cacheConfig.isCacheDisabled()) {
core.info('Cache is disabled: will not restore state from previous builds.')
// Initialize the Gradle User Home even when caching is disabled.
gradleStateCache.init()
cacheListener.setDisabled()
return
}
if (gradleStateCache.cacheOutputExists()) {
if (!cacheConfig.isCacheOverwriteExisting()) {
core.info('Gradle User Home already exists: will not restore from cache.')
// Initialize pre-existing Gradle User Home.
gradleStateCache.init()
cacheListener.setDisabled(EXISTING_GRADLE_HOME)
return
}
core.info('Gradle User Home already exists: will overwrite with cached contents.')
}
gradleStateCache.init()
// Mark the state as restored so that post-action will perform save.
core.saveState(CACHE_RESTORED_VAR, true)
if (cacheConfig.isCacheCleanupEnabled()) {
core.info('Preparing cache for cleanup.')
const cacheCleaner = new CacheCleaner(gradleUserHome, process.env['RUNNER_TEMP']!)
await cacheCleaner.prepare()
}
if (cacheConfig.isCacheWriteOnly()) {
core.info('Cache is write-only: will not restore from cache.')
cacheListener.setWriteOnly()
return
}
await core.group('Restore Gradle state from cache', async () => {
await gradleStateCache.restore(cacheListener)
})
}
export async function save(
userHome: string,
gradleUserHome: string,
cacheListener: CacheListener,
daemonController: DaemonController,
buildResults: BuildResults,
cacheConfig: CacheConfig
): Promise<void> {
if (cacheConfig.isCacheDisabled()) {
core.info('Cache is disabled: will not save state for later builds.')
return
}
if (!core.getState(CACHE_RESTORED_VAR)) {
core.info('Cache will not be saved: not restored in main action step.')
return
}
if (cacheConfig.isCacheReadOnly()) {
core.info('Cache is read-only: will not save state for use in subsequent builds.')
cacheListener.setReadOnly()
return
}
await core.group('Stopping Gradle daemons', async () => {
await daemonController.stopAllDaemons()
})
if (cacheConfig.isCacheCleanupEnabled()) {
if (buildResults.anyConfigCacheHit()) {
core.info('Not performing cache-cleanup due to config-cache reuse')
cacheListener.setCacheCleanupDisabled(CLEANUP_DISABLED_DUE_TO_CONFIG_CACHE_HIT)
} else if (cacheConfig.shouldPerformCacheCleanup(buildResults.anyFailed())) {
cacheListener.setCacheCleanupEnabled()
await performCacheCleanup(gradleUserHome, buildResults)
} else {
core.info('Not performing cache-cleanup due to build failure')
cacheListener.setCacheCleanupDisabled(CLEANUP_DISABLED_DUE_TO_FAILURE)
}
}
await core.group('Caching Gradle state', async () => {
return new GradleUserHomeCache(userHome, gradleUserHome, cacheConfig).save(cacheListener)
})
}
async function performCacheCleanup(gradleUserHome: string, buildResults: BuildResults): Promise<void> {
const cacheCleaner = new CacheCleaner(gradleUserHome, process.env['RUNNER_TEMP']!)
try {
await cacheCleaner.forceCleanup(buildResults)
} catch (e) {
core.warning(`Cache cleanup failed. Will continue. ${String(e)}`)
}
}
@@ -0,0 +1,33 @@
import * as core from '@actions/core'
import * as exec from '@actions/exec'
import * as fs from 'fs'
import * as path from 'path'
import {BuildResults} from './build-results-adapter'
export class DaemonController {
private readonly gradleHomes: string[]
constructor(buildResults: BuildResults) {
this.gradleHomes = buildResults.uniqueGradleHomes()
}
async stopAllDaemons(): Promise<void> {
const executions: Promise<number>[] = []
const args = ['--stop']
for (const gradleHome of this.gradleHomes) {
const executable = path.resolve(gradleHome, 'bin', 'gradle')
if (!fs.existsSync(executable)) {
core.warning(`Gradle executable not found at ${executable}. Could not stop Gradle daemons.`)
continue
}
core.info(`Stopping Gradle daemons for ${gradleHome}`)
executions.push(
exec.exec(executable, args, {
ignoreReturnCode: true
})
)
}
await Promise.all(executions)
}
}
@@ -0,0 +1,353 @@
import path from 'path'
import fs from 'fs'
import * as core from '@actions/core'
import * as glob from '@actions/glob'
import {CacheEntryListener, CacheListener} from './cache-reporting'
import {cacheDebug, hashFileNames, isCacheDebuggingEnabled, restoreCache, saveCache, tryDelete} from './cache-utils'
import {CacheConfig, ACTION_METADATA_DIR} from './cache-config-adapter'
import {getCacheKeyBase} from './cache-key'
const SKIP_RESTORE_VAR = 'GRADLE_BUILD_ACTION_SKIP_RESTORE'
const CACHE_PROTOCOL_VERSION = 'v1'
/**
* Represents the result of attempting to load or store an extracted cache entry.
* An undefined cacheKey indicates that the operation did not succeed.
* The collected results are then used to populate the `cache-metadata.json` file for later use.
*/
class ExtractedCacheEntry {
artifactType: string
pattern: string
cacheKey: string | undefined
constructor(artifactType: string, pattern: string, cacheKey: string | undefined) {
this.artifactType = artifactType
this.pattern = pattern
this.cacheKey = cacheKey
}
}
/**
* Representation of all of the extracted cache entries for this Gradle User Home.
* This object is persisted to JSON file in the Gradle User Home directory for storing,
* and subsequently used to restore the Gradle User Home.
*/
class ExtractedCacheEntryMetadata {
entries: ExtractedCacheEntry[] = []
}
/**
* The specification for a type of extracted cache entry.
*/
class ExtractedCacheEntryDefinition {
artifactType: string
pattern: string
bundle: boolean
uniqueFileNames = true
notCacheableReason: string | undefined
constructor(artifactType: string, pattern: string, bundle: boolean) {
this.artifactType = artifactType
this.pattern = pattern
this.bundle = bundle
}
/**
* Indicate that the file names matching the cache entry pattern are NOT sufficient to uniquely identify the contents.
* If the file names are sufficient, then we use a hash of the file names to identify the entry.
* With non-unique-file-names, we hash the file contents to identify the cache entry.
*/
withNonUniqueFileNames(): ExtractedCacheEntryDefinition {
this.uniqueFileNames = false
return this
}
}
/**
* Caches and restores the entire Gradle User Home directory, extracting entries containing common artifacts
* for more efficient storage.
*/
abstract class AbstractEntryExtractor {
protected readonly cacheConfig: CacheConfig
protected readonly gradleUserHome: string
private extractorName: string
constructor(gradleUserHome: string, extractorName: string, cacheConfig: CacheConfig) {
this.gradleUserHome = gradleUserHome
this.extractorName = extractorName
this.cacheConfig = cacheConfig
}
/**
* Restores any artifacts that were cached separately, based on the information in the `cache-metadata.json` file.
* Each extracted cache entry is restored in parallel, except when debugging is enabled.
*/
async restore(listener: CacheListener): Promise<void> {
const previouslyExtractedCacheEntries = this.loadExtractedCacheEntries()
const processes: Promise<ExtractedCacheEntry>[] = []
for (const cacheEntry of previouslyExtractedCacheEntries) {
const artifactType = cacheEntry.artifactType
const entryListener = listener.entry(cacheEntry.pattern)
// Handle case where the extracted-cache-entry definitions have been changed
const skipRestore = process.env[SKIP_RESTORE_VAR] || ''
if (skipRestore.includes(artifactType)) {
core.info(`Not restoring extracted cache entry for ${artifactType}`)
entryListener.markRequested('SKIP_RESTORE')
} else {
processes.push(
this.awaitForDebugging(
this.restoreExtractedCacheEntry(
artifactType,
cacheEntry.cacheKey!,
cacheEntry.pattern,
entryListener
)
)
)
}
}
this.saveMetadataForCacheResults(await Promise.all(processes))
}
private async restoreExtractedCacheEntry(
artifactType: string,
cacheKey: string,
pattern: string,
listener: CacheEntryListener
): Promise<ExtractedCacheEntry> {
const restoredEntry = await restoreCache(pattern.split('\n'), cacheKey, [], listener)
if (restoredEntry) {
return new ExtractedCacheEntry(artifactType, pattern, cacheKey)
} else {
core.info(`Did not restore ${artifactType} with key ${cacheKey} to ${pattern}`)
return new ExtractedCacheEntry(artifactType, pattern, undefined)
}
}
/**
* Saves any artifacts that are configured to be cached separately, based on the extracted cache entry definitions.
* Each entry is extracted and saved in parallel, except when debugging is enabled.
*/
async extract(listener: CacheListener): Promise<void> {
// Load the cache entry definitions (from config) and the previously restored entries (from persisted metadata file)
const cacheEntryDefinitions = this.getExtractedCacheEntryDefinitions()
cacheDebug(
`Extracting cache entries for ${this.extractorName}: ${JSON.stringify(cacheEntryDefinitions, null, 2)}`
)
const previouslyRestoredEntries = this.loadExtractedCacheEntries()
const cacheActions: Promise<ExtractedCacheEntry>[] = []
// For each cache entry definition, determine if it has already been restored, and if not, extract it
for (const cacheEntryDefinition of cacheEntryDefinitions) {
const artifactType = cacheEntryDefinition.artifactType
const pattern = cacheEntryDefinition.pattern
if (cacheEntryDefinition.notCacheableReason) {
listener.entry(pattern).markNotSaved(cacheEntryDefinition.notCacheableReason)
continue
}
// Find all matching files for this cache entry definition
const globber = await glob.create(pattern, {
implicitDescendants: false
})
const matchingFiles = await globber.glob()
if (matchingFiles.length === 0) {
cacheDebug(`No files found to cache for ${artifactType}`)
continue
}
if (cacheEntryDefinition.bundle) {
// For an extracted "bundle", use the defined pattern and cache all matching files in a single entry.
cacheActions.push(
this.awaitForDebugging(
this.saveExtractedCacheEntry(
matchingFiles,
artifactType,
pattern,
cacheEntryDefinition.uniqueFileNames,
previouslyRestoredEntries,
listener.entry(pattern)
)
)
)
} else {
// Otherwise cache each matching file in a separate entry, using the complete file path as the cache pattern.
for (const cacheFile of matchingFiles) {
cacheActions.push(
this.awaitForDebugging(
this.saveExtractedCacheEntry(
[cacheFile],
artifactType,
cacheFile,
cacheEntryDefinition.uniqueFileNames,
previouslyRestoredEntries,
listener.entry(cacheFile)
)
)
)
}
}
}
this.saveMetadataForCacheResults(await Promise.all(cacheActions))
}
private async saveExtractedCacheEntry(
matchingFiles: string[],
artifactType: string,
pattern: string,
uniqueFileNames: boolean,
previouslyRestoredEntries: ExtractedCacheEntry[],
entryListener: CacheEntryListener
): Promise<ExtractedCacheEntry> {
const cacheKey = uniqueFileNames
? this.createCacheKeyFromFileNames(artifactType, matchingFiles)
: await this.createCacheKeyFromFileContents(artifactType, pattern)
const previouslyRestoredKey = previouslyRestoredEntries.find(
x => x.artifactType === artifactType && x.pattern === pattern
)?.cacheKey
if (previouslyRestoredKey === cacheKey) {
cacheDebug(`No change to previously restored ${artifactType}. Not saving.`)
entryListener.markNotSaved('contents unchanged')
} else {
await saveCache(pattern.split('\n'), cacheKey, entryListener)
}
for (const file of matchingFiles) {
tryDelete(file)
}
return new ExtractedCacheEntry(artifactType, pattern, cacheKey)
}
protected createCacheKeyFromFileNames(artifactType: string, files: string[]): string {
const relativeFiles = files.map(x => path.relative(this.gradleUserHome, x))
const key = hashFileNames(relativeFiles)
cacheDebug(`Generating cache key for ${artifactType} from file names: ${relativeFiles}`)
return `${getCacheKeyBase(artifactType, CACHE_PROTOCOL_VERSION)}-${key}`
}
protected async createCacheKeyFromFileContents(artifactType: string, pattern: string): Promise<string> {
const key = await glob.hashFiles(pattern)
cacheDebug(`Generating cache key for ${artifactType} from files matching: ${pattern}`)
return `${getCacheKeyBase(artifactType, CACHE_PROTOCOL_VERSION)}-${key}`
}
// Run actions sequentially if debugging is enabled
private async awaitForDebugging(p: Promise<ExtractedCacheEntry>): Promise<ExtractedCacheEntry> {
if (isCacheDebuggingEnabled()) {
await p
}
return p
}
/**
* Load information about the extracted cache entries previously restored/saved. This is loaded from the 'cache-metadata.json' file.
*/
protected loadExtractedCacheEntries(): ExtractedCacheEntry[] {
const cacheMetadataFile = this.getCacheMetadataFile()
if (!fs.existsSync(cacheMetadataFile)) {
return []
}
const filedata = fs.readFileSync(cacheMetadataFile, 'utf-8')
cacheDebug(`Loaded cache metadata for ${this.extractorName}: ${filedata}`)
const extractedCacheEntryMetadata = JSON.parse(filedata) as ExtractedCacheEntryMetadata
return extractedCacheEntryMetadata.entries
}
/**
* Saves information about the extracted cache entries into the 'cache-metadata.json' file.
*/
protected saveMetadataForCacheResults(results: ExtractedCacheEntry[]): void {
const extractedCacheEntryMetadata = new ExtractedCacheEntryMetadata()
extractedCacheEntryMetadata.entries = results.filter(x => x.cacheKey !== undefined)
const filedata = JSON.stringify(extractedCacheEntryMetadata)
cacheDebug(`Saving cache metadata for ${this.extractorName}: ${filedata}`)
fs.writeFileSync(this.getCacheMetadataFile(), filedata, 'utf-8')
}
private getCacheMetadataFile(): string {
const actionMetadataDirectory = path.resolve(this.gradleUserHome, ACTION_METADATA_DIR)
fs.mkdirSync(actionMetadataDirectory, {recursive: true})
return path.resolve(actionMetadataDirectory, `${this.extractorName}-entry-metadata.json`)
}
protected abstract getExtractedCacheEntryDefinitions(): ExtractedCacheEntryDefinition[]
}
export class GradleHomeEntryExtractor extends AbstractEntryExtractor {
constructor(gradleUserHome: string, cacheConfig: CacheConfig) {
super(gradleUserHome, 'gradle-home', cacheConfig)
}
async extract(listener: CacheListener): Promise<void> {
await this.deleteWrapperZips()
return super.extract(listener)
}
/**
* Delete any downloaded wrapper zip files that are not needed after extraction.
* These files are cleaned up by Gradle >= 7.5, but for older versions we remove them manually.
*/
private async deleteWrapperZips(): Promise<void> {
const wrapperZips = path.resolve(this.gradleUserHome, 'wrapper/dists/*/*/*.zip')
const globber = await glob.create(wrapperZips, {
implicitDescendants: false
})
for (const wrapperZip of await globber.glob()) {
cacheDebug(`Deleting wrapper zip: ${wrapperZip}`)
await tryDelete(wrapperZip)
}
}
/**
* Return the extracted cache entry definitions, which determine which artifacts will be cached
* separately from the rest of the Gradle User Home cache entry.
*/
protected getExtractedCacheEntryDefinitions(): ExtractedCacheEntryDefinition[] {
const entryDefinition = (
artifactType: string,
patterns: string[],
bundle: boolean
): ExtractedCacheEntryDefinition => {
const resolvedPatterns = patterns
.map(x => {
const isDir = x.endsWith('/')
const resolved = path.resolve(this.gradleUserHome, x)
return isDir ? `${resolved}/` : resolved // Restore trailing '/' removed by path.resolve()
})
.join('\n')
return new ExtractedCacheEntryDefinition(artifactType, resolvedPatterns, bundle)
}
return [
entryDefinition('generated-gradle-jars', ['caches/*/generated-gradle-jars/*.jar'], false),
entryDefinition('wrapper-zips', ['wrapper/dists/*/*/'], false), // Each wrapper directory cached separately
entryDefinition('java-toolchains', ['jdks/*/'], false), // Each extracted JDK cached separately
entryDefinition('dependencies', ['caches/modules-*/files-*/*/*/*/*'], true),
entryDefinition('instrumented-jars', ['caches/jars-*/*/'], true),
entryDefinition('kotlin-dsl', ['caches/*/kotlin-dsl/accessors/*/', 'caches/*/kotlin-dsl/scripts/*/'], true),
entryDefinition('groovy-dsl', ['caches/*/groovy-dsl/*/'], true),
entryDefinition('transforms', ['caches/transforms-4/*/', 'caches/*/transforms/*/'], true)
]
}
}
@@ -0,0 +1,222 @@
import * as core from '@actions/core'
import * as exec from '@actions/exec'
import * as glob from '@actions/glob'
import path from 'path'
import fs from 'fs'
import {generateCacheKey} from './cache-key'
import {CacheListener} from './cache-reporting'
import {saveCache, restoreCache, cacheDebug, isCacheDebuggingEnabled, tryDelete} from './cache-utils'
import {CacheConfig, ACTION_METADATA_DIR} from './cache-config-adapter'
import {GradleHomeEntryExtractor} from './gradle-home-extry-extractor'
const RESTORED_CACHE_KEY_KEY = 'restored-cache-key'
export class GradleUserHomeCache {
private readonly cacheName = 'home'
private readonly cacheDescription = 'Gradle User Home'
private readonly userHome: string
private readonly gradleUserHome: string
private readonly cacheConfig: CacheConfig
constructor(userHome: string, gradleUserHome: string, cacheConfig: CacheConfig) {
this.userHome = userHome
this.gradleUserHome = gradleUserHome
this.cacheConfig = cacheConfig
}
init(): void {
this.initializeGradleUserHome()
// Export the GRADLE_ENCRYPTION_KEY variable if provided
const encryptionKey = this.cacheConfig.getCacheEncryptionKey()
if (encryptionKey) {
core.exportVariable('GRADLE_ENCRYPTION_KEY', encryptionKey)
}
}
cacheOutputExists(): boolean {
const cachesDir = path.resolve(this.gradleUserHome, 'caches')
if (fs.existsSync(cachesDir)) {
cacheDebug(`Cache output exists at ${cachesDir}`)
return true
}
return false
}
/**
* Restores the cache entry, finding the closest match to the currently running job.
*/
async restore(listener: CacheListener): Promise<void> {
const entryListener = listener.entry(this.cacheDescription)
const cacheKey = generateCacheKey(this.cacheName, this.cacheConfig)
cacheDebug(
`Requesting ${this.cacheDescription} with
key:${cacheKey.key}
restoreKeys:[${cacheKey.restoreKeys}]`
)
const cachePath = this.getCachePath()
const cacheResult = await restoreCache(cachePath, cacheKey.key, cacheKey.restoreKeys, entryListener)
if (!cacheResult) {
core.info(`${this.cacheDescription} cache not found. Will initialize empty.`)
return
}
core.saveState(RESTORED_CACHE_KEY_KEY, cacheResult.key)
try {
await this.afterRestore(listener)
} catch (error) {
core.warning(`Restore ${this.cacheDescription} failed in 'afterRestore': ${error}`)
}
}
/**
* Restore any extracted cache entries after the main Gradle User Home entry is restored.
*/
async afterRestore(listener: CacheListener): Promise<void> {
await this.debugReportGradleUserHomeSize('as restored from cache')
await new GradleHomeEntryExtractor(this.gradleUserHome, this.cacheConfig).restore(listener)
await this.deleteExcludedPaths()
await this.debugReportGradleUserHomeSize('after restoring common artifacts')
}
/**
* Saves the cache entry based on the current cache key unless the cache was restored with the exact key,
* in which case we cannot overwrite it.
*
* If the cache entry was restored with a partial match on a restore key, then
* it is saved with the exact key.
*/
async save(listener: CacheListener): Promise<void> {
const cacheKey = generateCacheKey(this.cacheName, this.cacheConfig).key
const restoredCacheKey = core.getState(RESTORED_CACHE_KEY_KEY)
const gradleHomeEntryListener = listener.entry(this.cacheDescription)
if (restoredCacheKey && cacheKey === restoredCacheKey) {
core.info(`Cache hit occurred on the cache key ${cacheKey}, not saving cache.`)
for (const entryListener of listener.cacheEntries) {
if (entryListener === gradleHomeEntryListener) {
entryListener.markNotSaved('cache key not changed')
} else {
entryListener.markNotSaved(`referencing '${this.cacheDescription}' cache entry not saved`)
}
}
return
}
try {
await this.beforeSave(listener)
} catch (error) {
core.warning(`Save ${this.cacheDescription} failed in 'beforeSave': ${error}`)
return
}
const cachePath = this.getCachePath()
await saveCache(cachePath, cacheKey, gradleHomeEntryListener)
return
}
/**
* Extract and save any defined extracted cache entries prior to the main Gradle User Home entry being saved.
*/
async beforeSave(listener: CacheListener): Promise<void> {
await this.debugReportGradleUserHomeSize('before saving common artifacts')
await this.deleteExcludedPaths()
await new GradleHomeEntryExtractor(this.gradleUserHome, this.cacheConfig).extract(listener)
await this.debugReportGradleUserHomeSize(
"after extracting common artifacts (only 'caches' and 'notifications' will be stored)"
)
}
/**
* Delete any file paths that are excluded by the `gradle-home-cache-excludes` parameter.
*/
private async deleteExcludedPaths(): Promise<void> {
const rawPaths: string[] = this.cacheConfig.getCacheExcludes()
rawPaths.push('caches/*/cc-keystore')
const resolvedPaths = rawPaths.map(x => path.resolve(this.gradleUserHome, x))
for (const p of resolvedPaths) {
cacheDebug(`Removing excluded path: ${p}`)
const globber = await glob.create(p, {
implicitDescendants: false
})
for (const toDelete of await globber.glob()) {
cacheDebug(`Removing excluded file: ${toDelete}`)
await tryDelete(toDelete)
}
}
}
/**
* Determines the paths within Gradle User Home to cache.
* By default, this is the 'caches' and 'notifications' directories,
* but this can be overridden by the `gradle-home-cache-includes` parameter.
*/
protected getCachePath(): string[] {
const rawPaths: string[] = this.cacheConfig.getCacheIncludes()
rawPaths.push(ACTION_METADATA_DIR)
const resolvedPaths = rawPaths.map(x => this.resolveCachePath(x))
cacheDebug(`Using cache paths: ${resolvedPaths}`)
return resolvedPaths
}
private resolveCachePath(rawPath: string): string {
if (rawPath.startsWith('!')) {
const resolved = this.resolveCachePath(rawPath.substring(1))
return `!${resolved}`
}
return path.resolve(this.gradleUserHome, rawPath)
}
/**
* Initialize the Gradle User Home directory for caching.
* Note: init scripts, toolchain registration, and debug log level are handled by
* initializeGradleUserHome() in the main package before cacheService.restore() is called.
*/
private initializeGradleUserHome(): void {
// Create a directory for storing action metadata
const actionCacheDir = path.resolve(this.gradleUserHome, ACTION_METADATA_DIR)
fs.mkdirSync(actionCacheDir, {recursive: true})
}
/**
* When cache debugging is enabled (or ACTIONS_STEP_DEBUG is on),
* this method will give a detailed report of the Gradle User Home contents.
*/
private async debugReportGradleUserHomeSize(label: string): Promise<void> {
if (!isCacheDebuggingEnabled() && !core.isDebug()) {
return
}
if (!fs.existsSync(this.gradleUserHome)) {
return
}
const result = await exec.getExecOutput('du', ['-h', '-c', '-t', '5M'], {
cwd: this.gradleUserHome,
silent: true,
ignoreReturnCode: true
})
core.info(`Gradle User Home (directories >5M): ${label}`)
core.info(
result.stdout
.trimEnd()
.replace(/\t/g, ' ')
.split('\n')
.map(it => {
return ` ${it}`
})
.join('\n')
)
core.info('-----------------------')
}
}
+163
View File
@@ -0,0 +1,163 @@
import * as core from '@actions/core'
import * as path from 'path'
import * as fs from 'fs'
import * as os from 'os'
import * as semver from 'semver'
import * as httpm from '@actions/http-client'
import * as toolCache from '@actions/tool-cache'
import which from 'which'
const IS_WINDOWS = process.platform === 'win32'
const gradleVersionsBaseUrl = 'https://services.gradle.org/versions'
class GradleVersion {
static PATTERN = /((\d+)(\.\d+)+)(-([a-z]+)-(\w+))?(-(SNAPSHOT|\d{14}([-+]\d{4})?))?/
versionPart: string
stagePart: string
snapshotPart: string
constructor(readonly version: string) {
const matcher = GradleVersion.PATTERN.exec(version)
if (!matcher) {
throw new Error(`'${version}' is not a valid Gradle version string (examples: '1.0', '1.0-rc-1')`)
}
this.versionPart = matcher[1]
this.stagePart = matcher[4]
this.snapshotPart = matcher[7]
}
}
export function versionIsAtLeast(actualVersion: string, requiredVersion: string): boolean {
if (actualVersion === requiredVersion) {
return true
}
const actual = new GradleVersion(actualVersion)
const required = new GradleVersion(requiredVersion)
const actualSemver = semver.coerce(actual.versionPart)!
const comparisonSemver = semver.coerce(required.versionPart)!
if (semver.gt(actualSemver, comparisonSemver)) {
return true
}
if (semver.lt(actualSemver, comparisonSemver)) {
return false
}
if (actual.snapshotPart || required.snapshotPart) {
if (actual.snapshotPart && !required.snapshotPart && !required.stagePart) {
return false
}
if (required.snapshotPart && !actual.snapshotPart && !actual.stagePart) {
return true
}
return false
}
if (actual.stagePart) {
if (required.stagePart) {
return actual.stagePart >= required.stagePart
}
return false
}
return true
}
function installScriptFilename(): string {
return IS_WINDOWS ? 'gradle.bat' : 'gradle'
}
async function findGradleExecutableOnPath(): Promise<string | null> {
return await which('gradle', {nothrow: true})
}
async function determineGradleVersion(gradleExecutable: string): Promise<string | undefined> {
const {exec} = await import('@actions/exec')
const output = await (await import('@actions/exec')).getExecOutput(gradleExecutable, ['-v'], {silent: true})
const regex = /Gradle (\d+\.\d+(\.\d+)?(-.*)?)/
return output.stdout.match(regex)?.[1]
}
interface GradleVersionInfo {
version: string
downloadUrl: string
}
/**
* Find (or install) a Gradle executable that meets the specified version requirement.
* Checks Gradle on PATH and any candidates first, then downloads if needed.
*/
export async function provisionGradleWithVersionAtLeast(
minimumVersion: string,
candidates: string[] = []
): Promise<string> {
const gradleOnPath = await findGradleExecutableOnPath()
const allCandidates = gradleOnPath ? [gradleOnPath, ...candidates] : candidates
return core.group(`Provision Gradle >= ${minimumVersion}`, async () => {
for (const candidate of allCandidates) {
const candidateVersion = await determineGradleVersion(candidate)
if (candidateVersion && versionIsAtLeast(candidateVersion, minimumVersion)) {
core.info(
`Gradle version ${candidateVersion} is available at ${candidate} and >= ${minimumVersion}. Not installing.`
)
return candidate
}
}
return locateGradleAndDownloadIfRequired(await gradleRelease(minimumVersion))
})
}
async function gradleRelease(version: string): Promise<GradleVersionInfo> {
const allVersions: GradleVersionInfo[] = JSON.parse(
await httpGetString(`${gradleVersionsBaseUrl}/all`)
)
const versionInfo = allVersions.find(entry => entry.version === version)
if (!versionInfo) {
throw new Error(`Gradle version ${version} does not exist`)
}
return versionInfo
}
async function locateGradleAndDownloadIfRequired(versionInfo: GradleVersionInfo): Promise<string> {
const installsDir = path.join(getProvisionDir(), 'installs')
const installDir = path.join(installsDir, `gradle-${versionInfo.version}`)
if (fs.existsSync(installDir)) {
core.info(`Gradle installation already exists at ${installDir}`)
return executableFrom(installDir)
}
const downloadPath = path.join(getProvisionDir(), `downloads/gradle-${versionInfo.version}-bin.zip`)
await toolCache.downloadTool(versionInfo.downloadUrl, downloadPath)
core.info(`Downloaded ${versionInfo.downloadUrl} to ${downloadPath} (size ${fs.statSync(downloadPath).size})`)
await toolCache.extractZip(downloadPath, installsDir)
core.info(`Extracted Gradle ${versionInfo.version} to ${installDir}`)
const executable = executableFrom(installDir)
fs.chmodSync(executable, '755')
core.info(`Provisioned Gradle executable ${executable}`)
return executable
}
function getProvisionDir(): string {
const tmpDir = process.env['RUNNER_TEMP'] ?? os.tmpdir()
return path.join(tmpDir, '.gradle-actions/gradle-installations')
}
function executableFrom(installDir: string): string {
return path.join(installDir, 'bin', installScriptFilename())
}
async function httpGetString(url: string): Promise<string> {
const httpClient = new httpm.HttpClient('gradle/actions')
const response = await httpClient.get(url)
return response.readBody()
}
+41
View File
@@ -0,0 +1,41 @@
import * as core from '@actions/core'
import * as os from 'os'
import {CacheConfig, CacheOptions} from './cache-config-adapter'
import {BuildResult, BuildResults} from './build-results-adapter'
import {CacheListener, generateCachingReport} from './cache-reporting'
import {DaemonController} from './daemon-controller'
import * as caches from './caches'
const CACHE_LISTENER_STATE_KEY = 'legacy-cache-listener'
export async function restore(gradleUserHome: string, cacheOptions: CacheOptions): Promise<void> {
const userHome = os.homedir()
const cacheListener = new CacheListener()
const cacheConfig = new CacheConfig(cacheOptions)
await caches.restore(userHome, gradleUserHome, cacheListener, cacheConfig)
// Persist the listener so it can be rehydrated in save()
core.saveState(CACHE_LISTENER_STATE_KEY, cacheListener.stringify())
}
export async function save(
gradleUserHome: string,
buildResults: BuildResult[],
cacheOptions: CacheOptions
): Promise<string> {
const userHome = os.homedir()
const cacheConfig = new CacheConfig(cacheOptions)
// Rehydrate the listener from the restore phase
const listenerState = core.getState(CACHE_LISTENER_STATE_KEY)
const cacheListener = CacheListener.rehydrate(listenerState)
const buildResultsWrapper = new BuildResults(buildResults)
const daemonController = new DaemonController(buildResultsWrapper)
await caches.save(userHome, gradleUserHome, cacheListener, daemonController, buildResultsWrapper, cacheConfig)
return generateCachingReport(cacheListener)
}
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "./dist",
"rootDir": "./src",
"declaration": false,
"sourceMap": true
},
"include": ["src/**/*.ts"]
}
+34 -42
View File
@@ -1,3 +1,4 @@
import * as core from '@actions/core'
import * as fs from 'fs'
import * as path from 'path'
import {pathToFileURL} from 'url'
@@ -10,35 +11,15 @@ const NOOP_CACHING_REPORT = `
[Cache was disabled](https://github.com/gradle/actions/blob/main/docs/setup-gradle.md#disabling-caching). Gradle User Home was not restored from or saved to the cache.
`
const CACHE_LICENSE_WARNING = `
***********************************************************
LICENSING NOTICE
const LEGACY_CACHE_LOG_MESSAGE = 'Using the legacy caching module'
const VENDORED_CACHE_LOG_MESSAGE = 'Using the `gradle-actions-caching` caching module'
The caching functionality in \`gradle-actions\` has been extracted into \`gradle-actions-caching\`, a proprietary commercial component that is not covered by the MIT License.
The bundled \`gradle-actions-caching\` component is licensed and governed by a separate license, available at https://gradle.com/legal/terms-of-use/.
The \`gradle-actions-caching\` component is used only when caching is enabled and is not loaded or used when caching is disabled.
Use of the \`gradle-actions-caching\` component is subject to a separate license, available at https://gradle.com/legal/terms-of-use/.
If you do not agree to these license terms, do not use the \`gradle-actions-caching\` component.
You can suppress this message by accepting the terms in your action configuration: see https://github.com/gradle/actions/blob/main/README.md
***********************************************************
const LEGACY_CACHE_REPORT_NOTICE = `
> _Using the legacy caching module._
`
const CACHE_LICENSE_SUMMARY = `
> [!IMPORTANT]
> #### Licensing notice
>
> The caching functionality in \`gradle-actions\` has been extracted into \`gradle-actions-caching\`, a proprietary commercial component that is not covered by the MIT License.
> The bundled \`gradle-actions-caching\` component is licensed and governed by a separate license, available at https://gradle.com/legal/terms-of-use/.
>
> The \`gradle-actions-caching\` component is used only when caching is enabled and is not loaded or used when caching is disabled.
>
> Use of the \`gradle-actions-caching\` component is subject to a separate license, available at https://gradle.com/legal/terms-of-use/.
> If you do not agree to these license terms, do not use the \`gradle-actions-caching\` component.
>
>You can suppress this message by [accepting the terms in your action configuration](https://github.com/gradle/actions/blob/main/README.md).
const VENDORED_CACHE_REPORT_NOTICE = `
> _Using the 'gradle-actions-caching' caching module._
`
class NoOpCacheService implements CacheService {
@@ -51,20 +32,25 @@ class NoOpCacheService implements CacheService {
}
}
class LicenseWarningCacheService implements CacheService {
class LoggingCacheService implements CacheService {
private delegate: CacheService
private logMessage: string
private reportNotice: string
constructor(delegate: CacheService) {
constructor(delegate: CacheService, logMessage: string, reportNotice: string) {
this.delegate = delegate
this.logMessage = logMessage
this.reportNotice = reportNotice
}
async restore(gradleUserHome: string, cacheOptions: CacheOptions): Promise<void> {
core.info(this.logMessage)
await this.delegate.restore(gradleUserHome, cacheOptions)
}
async save(gradleUserHome: string, buildResults: BuildResult[], cacheOptions: CacheOptions): Promise<string> {
const cachingReport = await this.delegate.save(gradleUserHome, buildResults, cacheOptions)
return `${cachingReport}\n${CACHE_LICENSE_SUMMARY}`
return `${cachingReport}\n${this.reportNotice}`
}
}
@@ -73,32 +59,38 @@ export async function getCacheService(cacheConfig: CacheConfig): Promise<CacheSe
return new NoOpCacheService()
}
const cacheService = await loadVendoredCacheService()
if (cacheConfig.isCacheLicenseAccepted()) {
return cacheService
if (useVendoredCacheModule() || cacheConfig.isCacheLicenseAccepted()) {
const vendoredService = await loadVendoredCacheService()
return new LoggingCacheService(vendoredService, VENDORED_CACHE_LOG_MESSAGE, VENDORED_CACHE_REPORT_NOTICE)
}
await logCacheLicenseWarning()
return new LicenseWarningCacheService(cacheService)
const legacyService = await loadLegacyCacheService()
return new LoggingCacheService(legacyService, LEGACY_CACHE_LOG_MESSAGE, LEGACY_CACHE_REPORT_NOTICE)
}
function useVendoredCacheModule(): boolean {
return process.env['GRADLE_ACTIONS_CACHE_USE_VENDORED'] === 'true'
}
export async function loadVendoredCacheService(): Promise<CacheService> {
const vendoredLibraryPath = findVendoredLibraryPath()
const vendoredLibraryPath = findLibraryPath('sources/vendor/gradle-actions-caching/index.js')
const moduleUrl = pathToFileURL(vendoredLibraryPath).href
return (await import(moduleUrl)) as CacheService
}
function findVendoredLibraryPath(): string {
export async function loadLegacyCacheService(): Promise<CacheService> {
const legacyLibraryPath = findLibraryPath('dist/legacy-caching/index.js')
const moduleUrl = pathToFileURL(legacyLibraryPath).href
return (await import(moduleUrl)) as CacheService
}
function findLibraryPath(relativePath: string): string {
const moduleDir = import.meta.dirname
const absolutePath = path.resolve(moduleDir, '../../../sources/vendor/gradle-actions-caching/index.js')
const absolutePath = path.resolve(moduleDir, '../../..', relativePath)
if (fs.existsSync(absolutePath)) {
return absolutePath
}
throw new Error(`Unable to locate vendored cache library at ${absolutePath}.`)
}
export async function logCacheLicenseWarning(): Promise<void> {
console.info(CACHE_LICENSE_WARNING)
throw new Error(`Unable to locate cache library at ${absolutePath}.`)
}