mirror of
https://hub.gitmirror.com/https://github.com/gradle/actions.git
synced 2025-10-28 08:30:02 +08:00
Instead of always installing and using the latest Gradle version for cache cleanup, we now require at least Gradle 8.9. This avoids downloading and installing Gradle if the version on PATH is sufficient to perform cache cleanup.
470 lines
19 KiB
TypeScript
470 lines
19 KiB
TypeScript
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 {BuildResult, loadBuildResults} from '../build-results'
|
|
import {CacheConfig, ACTION_METADATA_DIR} from '../configuration'
|
|
import {getCacheKeyBase} from './cache-key'
|
|
import {versionIsAtLeast} from '../execution/gradle'
|
|
|
|
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
|
|
}
|
|
|
|
/**
|
|
* 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 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)
|
|
]
|
|
}
|
|
}
|
|
|
|
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 => {
|
|
return !versionIsAtLeast(result.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().results.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[]>
|
|
)
|
|
}
|
|
}
|