mirror of
https://github.com/gradle/actions.git
synced 2025-12-08 17:15:46 +08:00
Moved cache-related code into subpackage
This commit is contained in:
69
sources/src/caching/cache-cleaner.ts
Normal file
69
sources/src/caching/cache-cleaner.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import * as core from '@actions/core'
|
||||
import * as exec from '@actions/exec'
|
||||
import * as glob from '@actions/glob'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
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<void> {
|
||||
// Reset the file-access journal so that files appear not to have been used recently
|
||||
fs.rmSync(path.resolve(this.gradleUserHome, 'caches/journal-1'), {recursive: true, force: true})
|
||||
fs.mkdirSync(path.resolve(this.gradleUserHome, 'caches/journal-1'), {recursive: true})
|
||||
fs.writeFileSync(
|
||||
path.resolve(this.gradleUserHome, 'caches/journal-1/file-access.properties'),
|
||||
'inceptionTimestamp=0'
|
||||
)
|
||||
|
||||
// Set the modification time of all files to the past: this timestamp is used when there is no matching entry in the journal
|
||||
await this.ageAllFiles()
|
||||
|
||||
// Touch all 'gc' files so that cache cleanup won't run immediately.
|
||||
await this.touchAllFiles('gc.properties')
|
||||
}
|
||||
|
||||
async forceCleanup(): Promise<void> {
|
||||
// Age all 'gc' files so that cache cleanup will run immediately.
|
||||
await this.ageAllFiles('gc.properties')
|
||||
|
||||
// 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, 'build.gradle'), 'task("noop") {}')
|
||||
|
||||
const gradleCommand = `gradle -g ${this.gradleUserHome} --no-daemon --build-cache --no-scan --quiet -DGITHUB_DEPENDENCY_GRAPH_ENABLED=false noop`
|
||||
await exec.exec(gradleCommand, [], {
|
||||
cwd: cleanupProjectDir
|
||||
})
|
||||
}
|
||||
|
||||
private async ageAllFiles(fileName = '*'): Promise<void> {
|
||||
core.debug(`Aging all files in Gradle User Home with name ${fileName}`)
|
||||
await this.setUtimes(`${this.gradleUserHome}/**/${fileName}`, new Date(0))
|
||||
}
|
||||
|
||||
private async touchAllFiles(fileName = '*'): Promise<void> {
|
||||
core.debug(`Touching all files in Gradle User Home with name ${fileName}`)
|
||||
await this.setUtimes(`${this.gradleUserHome}/**/${fileName}`, new Date())
|
||||
}
|
||||
|
||||
private async setUtimes(pattern: string, timestamp: Date): Promise<void> {
|
||||
const globber = await glob.create(pattern, {
|
||||
implicitDescendants: false
|
||||
})
|
||||
for await (const file of globber.globGenerator()) {
|
||||
fs.utimesSync(file, timestamp, timestamp)
|
||||
}
|
||||
}
|
||||
}
|
||||
97
sources/src/caching/cache-key.ts
Normal file
97
sources/src/caching/cache-key.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import * as github from '@actions/github'
|
||||
|
||||
import {CacheConfig, getJobMatrix} from '../input-params'
|
||||
import {hashStrings} from './cache-utils'
|
||||
|
||||
const CACHE_PROTOCOL_VERSION = 'v9-'
|
||||
|
||||
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 protocol version
|
||||
* - The name of the cache
|
||||
* - 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 cacheKeyBase = `${getCacheKeyPrefix()}${CACHE_PROTOCOL_VERSION}${cacheName}`
|
||||
|
||||
// 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 getCacheKeyPrefix(): string {
|
||||
// Prefix can be used to force change all cache keys (defaults to cache protocol version)
|
||||
return process.env[CACHE_KEY_PREFIX_VAR] || ''
|
||||
}
|
||||
|
||||
function getCacheKeyEnvironment(): string {
|
||||
const runnerOs = process.env['RUNNER_OS'] || ''
|
||||
return process.env[CACHE_KEY_OS_VAR] || runnerOs
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
220
sources/src/caching/cache-reporting.ts
Normal file
220
sources/src/caching/cache-reporting.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
import * as cache from '@actions/cache'
|
||||
|
||||
/**
|
||||
* 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
|
||||
cacheDisabledReason = 'disabled'
|
||||
|
||||
get fullyRestored(): boolean {
|
||||
return this.cacheEntries.every(x => !x.wasRequestedButNotRestored())
|
||||
}
|
||||
|
||||
get cacheStatus(): string {
|
||||
if (!cache.isFeatureAvailable()) return 'not available'
|
||||
if (this.cacheDisabled) return this.cacheDisabledReason
|
||||
if (this.cacheWriteOnly) return 'write-only'
|
||||
if (this.cacheReadOnly) return 'read-only'
|
||||
return 'enabled'
|
||||
}
|
||||
|
||||
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
|
||||
notRestored: string | undefined
|
||||
|
||||
savedKey: string | undefined
|
||||
savedSize: 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): CacheEntryListener {
|
||||
this.restoredKey = key
|
||||
this.restoredSize = size
|
||||
return this
|
||||
}
|
||||
|
||||
markNotRestored(message: string): CacheEntryListener {
|
||||
this.notRestored = message
|
||||
return this
|
||||
}
|
||||
|
||||
markSaved(key: string, size: number | undefined): CacheEntryListener {
|
||||
this.savedKey = key
|
||||
this.savedSize = size
|
||||
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>
|
||||
${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></tr>
|
||||
<tr><td>Entries Restored</td>
|
||||
<td>${getCount(entries, e => e.restoredSize)}</td>
|
||||
<td>${getSize(entries, e => e.restoredSize)}</td>
|
||||
</tr>
|
||||
<tr><td>Entries Saved</td>
|
||||
<td>${getCount(entries, e => e.savedSize)}</td>
|
||||
<td>${getSize(entries, e => e.savedSize)}</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)}
|
||||
${getRestoredMessage(entry, listener.cacheWriteOnly)}
|
||||
Saved Key : ${entry.savedKey ?? ''}
|
||||
Size: ${formatSize(entry.savedSize)}
|
||||
${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 formatSize(bytes: number | undefined): string {
|
||||
if (bytes === undefined || bytes === 0) {
|
||||
return ''
|
||||
}
|
||||
return `${Math.round(bytes / (1024 * 1024))} MB (${bytes} B)`
|
||||
}
|
||||
134
sources/src/caching/cache-utils.ts
Normal file
134
sources/src/caching/cache-utils.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
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 {
|
||||
// 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) {
|
||||
listener.markRestored(restoredEntry.key, restoredEntry.size)
|
||||
}
|
||||
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 savedEntry = await cache.saveCache(cachePath, cacheKey)
|
||||
listener.markSaved(savedEntry.key, savedEntry.size)
|
||||
} 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
|
||||
}
|
||||
104
sources/src/caching/caches.ts
Normal file
104
sources/src/caching/caches.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import * as core from '@actions/core'
|
||||
import {CacheListener} from './cache-reporting'
|
||||
import {GradleUserHomeCache} from './gradle-user-home-cache'
|
||||
import {CacheCleaner} from './cache-cleaner'
|
||||
import {DaemonController} from '../daemon-controller'
|
||||
import {CacheConfig} from '../input-params'
|
||||
|
||||
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.cacheDisabled = true
|
||||
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.cacheDisabled = true
|
||||
cacheListener.cacheDisabledReason = 'disabled due to pre-existing Gradle User 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.isCacheWriteOnly()) {
|
||||
core.info('Cache is write-only: will not restore from cache.')
|
||||
cacheListener.cacheWriteOnly = true
|
||||
return
|
||||
}
|
||||
|
||||
await core.group('Restore Gradle state from cache', async () => {
|
||||
await gradleStateCache.restore(cacheListener)
|
||||
})
|
||||
|
||||
if (cacheConfig.isCacheCleanupEnabled()) {
|
||||
core.info('Preparing cache for cleanup.')
|
||||
const cacheCleaner = new CacheCleaner(gradleUserHome, process.env['RUNNER_TEMP']!)
|
||||
await cacheCleaner.prepare()
|
||||
}
|
||||
}
|
||||
|
||||
export async function save(
|
||||
userHome: string,
|
||||
gradleUserHome: string,
|
||||
cacheListener: CacheListener,
|
||||
daemonController: DaemonController,
|
||||
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.cacheReadOnly = true
|
||||
return
|
||||
}
|
||||
|
||||
await daemonController.stopAllDaemons()
|
||||
|
||||
if (cacheConfig.isCacheCleanupEnabled()) {
|
||||
core.info('Forcing cache cleanup.')
|
||||
const cacheCleaner = new CacheCleaner(gradleUserHome, process.env['RUNNER_TEMP']!)
|
||||
try {
|
||||
await cacheCleaner.forceCleanup()
|
||||
} catch (e) {
|
||||
core.warning(`Cache cleanup failed. Will continue. ${String(e)}`)
|
||||
}
|
||||
}
|
||||
|
||||
await core.group('Caching Gradle state', async () => {
|
||||
return new GradleUserHomeCache(userHome, gradleUserHome, cacheConfig).save(cacheListener)
|
||||
})
|
||||
}
|
||||
470
sources/src/caching/gradle-home-extry-extractor.ts
Normal file
470
sources/src/caching/gradle-home-extry-extractor.ts
Normal file
@@ -0,0 +1,470 @@
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import * as core from '@actions/core'
|
||||
import * as glob from '@actions/glob'
|
||||
import * as semver from 'semver'
|
||||
|
||||
import {META_FILE_DIR} from './gradle-user-home-cache'
|
||||
import {CacheEntryListener, CacheListener} from './cache-reporting'
|
||||
import {cacheDebug, hashFileNames, isCacheDebuggingEnabled, restoreCache, saveCache, tryDelete} from './cache-utils'
|
||||
|
||||
import {BuildResult, loadBuildResults} from '../build-results'
|
||||
import {CacheConfig} from '../input-params'
|
||||
import {getCacheKeyPrefix} from './cache-key'
|
||||
|
||||
const SKIP_RESTORE_VAR = 'GRADLE_BUILD_ACTION_SKIP_RESTORE'
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
/**
|
||||
* Specify that the cache entry, should not be saved for some reason, even though the contents exist.
|
||||
* This is used to prevent configuration-cache entries being cached when they were generated by Gradle < 8.6,
|
||||
*/
|
||||
notCacheableBecause(reason: string): ExtractedCacheEntryDefinition {
|
||||
this.notCacheableReason = reason
|
||||
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], cacheKey, [], listener)
|
||||
if (restoredEntry) {
|
||||
core.info(`Restored ${artifactType} with key ${cacheKey} to ${pattern}`)
|
||||
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 {
|
||||
core.info(`Caching ${artifactType} with path '${pattern}' and cache key: ${cacheKey}`)
|
||||
await saveCache([pattern], cacheKey, entryListener)
|
||||
}
|
||||
|
||||
for (const file of matchingFiles) {
|
||||
tryDelete(file)
|
||||
}
|
||||
|
||||
return new ExtractedCacheEntry(artifactType, pattern, cacheKey)
|
||||
}
|
||||
|
||||
protected createCacheKeyFromFileNames(artifactType: string, files: string[]): string {
|
||||
const cacheKeyPrefix = getCacheKeyPrefix()
|
||||
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 `${cacheKeyPrefix}${artifactType}-${key}`
|
||||
}
|
||||
|
||||
protected async createCacheKeyFromFileContents(artifactType: string, pattern: string): Promise<string> {
|
||||
const cacheKeyPrefix = getCacheKeyPrefix()
|
||||
const key = await glob.hashFiles(pattern)
|
||||
|
||||
cacheDebug(`Generating cache key for ${artifactType} from files matching: ${pattern}`)
|
||||
|
||||
return `${cacheKeyPrefix}${artifactType}-${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, META_FILE_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/*/*'], true)
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
export class ConfigurationCacheEntryExtractor extends AbstractEntryExtractor {
|
||||
constructor(gradleUserHome: string, cacheConfig: CacheConfig) {
|
||||
super(gradleUserHome, 'configuration-cache', cacheConfig)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the case where Gradle User Home has not been fully restored, so that the configuration-cache
|
||||
* entry is not reusable.
|
||||
*/
|
||||
async restore(listener: CacheListener): Promise<void> {
|
||||
if (!listener.fullyRestored) {
|
||||
this.markNotRestored(listener, 'Gradle User Home was not fully restored')
|
||||
return
|
||||
}
|
||||
|
||||
if (!this.cacheConfig.getCacheEncryptionKey()) {
|
||||
this.markNotRestored(listener, 'Encryption Key was not provided')
|
||||
return
|
||||
}
|
||||
|
||||
return await super.restore(listener)
|
||||
}
|
||||
|
||||
private markNotRestored(listener: CacheListener, reason: string): void {
|
||||
const cacheEntries = this.loadExtractedCacheEntries()
|
||||
if (cacheEntries.length > 0) {
|
||||
core.info(`Not restoring configuration-cache state, as ${reason}`)
|
||||
for (const cacheEntry of cacheEntries) {
|
||||
listener.entry(cacheEntry.pattern).markNotRestored(reason)
|
||||
}
|
||||
|
||||
// Update the results file based on no entries restored
|
||||
this.saveMetadataForCacheResults([])
|
||||
}
|
||||
}
|
||||
|
||||
async extract(listener: CacheListener): Promise<void> {
|
||||
if (!this.cacheConfig.getCacheEncryptionKey()) {
|
||||
const cacheEntryDefinitions = this.getExtractedCacheEntryDefinitions()
|
||||
if (cacheEntryDefinitions.length > 0) {
|
||||
core.info('Not saving configuration-cache state, as no encryption key was provided')
|
||||
for (const cacheEntry of cacheEntryDefinitions) {
|
||||
listener.entry(cacheEntry.pattern).markNotSaved('No encryption key provided')
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
await super.extract(listener)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract cache entries for the configuration cache in each project.
|
||||
*/
|
||||
protected getExtractedCacheEntryDefinitions(): ExtractedCacheEntryDefinition[] {
|
||||
// Group BuildResult by existing configCacheDir
|
||||
const groupedResults = this.getConfigCacheDirectoriesWithAssociatedBuildResults()
|
||||
|
||||
return Object.entries(groupedResults).map(([configCachePath, pathResults]) => {
|
||||
// Create a entry definition for each unique configuration cache directory
|
||||
const definition = new ExtractedCacheEntryDefinition(
|
||||
'configuration-cache',
|
||||
configCachePath,
|
||||
true
|
||||
).withNonUniqueFileNames()
|
||||
|
||||
// If any associated build result used Gradle < 8.6, then mark it as not cacheable
|
||||
if (
|
||||
pathResults.find(result => {
|
||||
const gradleVersion = semver.coerce(result.gradleVersion)
|
||||
return gradleVersion && semver.lt(gradleVersion, '8.6.0')
|
||||
})
|
||||
) {
|
||||
core.info(
|
||||
`Not saving config-cache data for ${configCachePath}. Configuration cache data is only saved for Gradle 8.6+`
|
||||
)
|
||||
definition.notCacheableBecause('Configuration cache data only saved for Gradle 8.6+')
|
||||
}
|
||||
return definition
|
||||
})
|
||||
}
|
||||
|
||||
private getConfigCacheDirectoriesWithAssociatedBuildResults(): Record<string, BuildResult[]> {
|
||||
return loadBuildResults().reduce(
|
||||
(acc, buildResult) => {
|
||||
// For each build result, find the config-cache dir
|
||||
const configCachePath = path.resolve(buildResult.rootProjectDir, '.gradle/configuration-cache')
|
||||
// Ignore case where config-cache dir doesn't exist
|
||||
if (!fs.existsSync(configCachePath)) {
|
||||
return acc
|
||||
}
|
||||
|
||||
// Group by unique config cache directories and collect associated build results
|
||||
if (!acc[configCachePath]) {
|
||||
acc[configCachePath] = []
|
||||
}
|
||||
acc[configCachePath].push(buildResult)
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, BuildResult[]>
|
||||
)
|
||||
}
|
||||
}
|
||||
299
sources/src/caching/gradle-user-home-cache.ts
Normal file
299
sources/src/caching/gradle-user-home-cache.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
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 {GradleHomeEntryExtractor, ConfigurationCacheEntryExtractor} from './gradle-home-extry-extractor'
|
||||
import {CacheConfig} from '../input-params'
|
||||
|
||||
const RESTORED_CACHE_KEY_KEY = 'restored-cache-key'
|
||||
|
||||
export const META_FILE_DIR = '.setup-gradle'
|
||||
|
||||
export class GradleUserHomeCache {
|
||||
private readonly cacheName = 'gradle'
|
||||
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 cacheResult = await restoreCache(this.getCachePath(), 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)
|
||||
|
||||
core.info(`Restored ${this.cacheDescription} from cache 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 new ConfigurationCacheEntryExtractor(this.gradleUserHome, this.cacheConfig).restore(listener)
|
||||
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
|
||||
}
|
||||
|
||||
core.info(`Caching ${this.cacheDescription} with cache key: ${cacheKey}`)
|
||||
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 Promise.all([
|
||||
new GradleHomeEntryExtractor(this.gradleUserHome, this.cacheConfig).extract(listener),
|
||||
new ConfigurationCacheEntryExtractor(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(META_FILE_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)
|
||||
}
|
||||
|
||||
private initializeGradleUserHome(): void {
|
||||
// Create a directory for storing action metadata
|
||||
const actionCacheDir = path.resolve(this.gradleUserHome, META_FILE_DIR)
|
||||
fs.mkdirSync(actionCacheDir, {recursive: true})
|
||||
|
||||
this.copyInitScripts()
|
||||
|
||||
// Copy the default toolchain definitions to `~/.m2/toolchains.xml`
|
||||
this.registerToolchains()
|
||||
|
||||
if (core.isDebug()) {
|
||||
this.configureInfoLogLevel()
|
||||
}
|
||||
}
|
||||
|
||||
private copyInitScripts(): void {
|
||||
// Copy init scripts from src/resources to Gradle UserHome
|
||||
const initScriptsDir = path.resolve(this.gradleUserHome, 'init.d')
|
||||
fs.mkdirSync(initScriptsDir, {recursive: true})
|
||||
const initScriptFilenames = [
|
||||
'gradle-actions.build-result-capture.init.gradle',
|
||||
'gradle-actions.build-result-capture-service.plugin.groovy',
|
||||
'gradle-actions.github-dependency-graph.init.gradle',
|
||||
'gradle-actions.github-dependency-graph-gradle-plugin-apply.groovy',
|
||||
'gradle-actions.inject-develocity.init.gradle'
|
||||
]
|
||||
for (const initScriptFilename of initScriptFilenames) {
|
||||
const initScriptContent = this.readResourceFileAsString('init-scripts', initScriptFilename)
|
||||
const initScriptPath = path.resolve(initScriptsDir, initScriptFilename)
|
||||
fs.writeFileSync(initScriptPath, initScriptContent)
|
||||
}
|
||||
}
|
||||
|
||||
private registerToolchains(): void {
|
||||
const preInstalledToolchains = this.readResourceFileAsString('toolchains.xml')
|
||||
const m2dir = path.resolve(this.userHome, '.m2')
|
||||
const toolchainXmlTarget = path.resolve(m2dir, 'toolchains.xml')
|
||||
if (!fs.existsSync(toolchainXmlTarget)) {
|
||||
// Write a new toolchains.xml file if it doesn't exist
|
||||
fs.mkdirSync(m2dir, {recursive: true})
|
||||
fs.writeFileSync(toolchainXmlTarget, preInstalledToolchains)
|
||||
|
||||
core.info(`Wrote default JDK locations to ${toolchainXmlTarget}`)
|
||||
} else {
|
||||
// Merge into an existing toolchains.xml file
|
||||
const existingToolchainContent = fs.readFileSync(toolchainXmlTarget, 'utf8')
|
||||
const appendedContent = preInstalledToolchains.split('<toolchains>').pop()!
|
||||
const mergedContent = existingToolchainContent.replace('</toolchains>', appendedContent)
|
||||
|
||||
fs.writeFileSync(toolchainXmlTarget, mergedContent)
|
||||
core.info(`Merged default JDK locations into ${toolchainXmlTarget}`)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO:DAZ Move this to a utility class
|
||||
private readResourceFileAsString(...paths: string[]): string {
|
||||
// Resolving relative to __dirname will allow node to find the resource at runtime
|
||||
const absolutePath = path.resolve(__dirname, '..', '..', '..', 'sources', 'src', 'resources', ...paths)
|
||||
return fs.readFileSync(absolutePath, 'utf8')
|
||||
}
|
||||
|
||||
/**
|
||||
* When the GitHub environment ACTIONS_RUNNER_DEBUG is true, run Gradle with --info and --stacktrace.
|
||||
* see https://docs.github.com/en/actions/monitoring-and-troubleshooting-workflows/enabling-debug-logging
|
||||
*
|
||||
* @VisibleForTesting
|
||||
*/
|
||||
configureInfoLogLevel(): void {
|
||||
const infoProperties = `org.gradle.logging.level=info\norg.gradle.logging.stacktrace=all\n`
|
||||
const propertiesFile = path.resolve(this.gradleUserHome, 'gradle.properties')
|
||||
if (fs.existsSync(propertiesFile)) {
|
||||
core.info(`Merged --info and --stacktrace into existing ${propertiesFile} file`)
|
||||
const existingProperties = fs.readFileSync(propertiesFile, 'utf-8')
|
||||
fs.writeFileSync(propertiesFile, `${infoProperties}\n${existingProperties}`)
|
||||
} else {
|
||||
core.info(`Created a new ${propertiesFile} with --info and --stacktrace`)
|
||||
fs.writeFileSync(propertiesFile, infoProperties)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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('-----------------------')
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user