diff --git a/sources/legacy-caching/package-lock.json b/sources/legacy-caching/package-lock.json index 9e880095..8616846a 100644 --- a/sources/legacy-caching/package-lock.json +++ b/sources/legacy-caching/package-lock.json @@ -14,7 +14,10 @@ "@actions/exec": "3.0.0", "@actions/github": "9.0.0", "@actions/glob": "0.6.1", - "semver": "7.7.4" + "@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", @@ -68,6 +71,16 @@ "minimatch": "^3.0.4" } }, + "node_modules/@actions/cache/node_modules/@actions/http-client": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-2.2.3.tgz", + "integrity": "sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA==", + "license": "MIT", + "dependencies": { + "tunnel": "^0.0.6", + "undici": "^5.25.4" + } + }, "node_modules/@actions/cache/node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -77,6 +90,18 @@ "semver": "bin/semver.js" } }, + "node_modules/@actions/cache/node_modules/undici": { + "version": "5.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", + "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==", + "license": "MIT", + "dependencies": { + "@fastify/busboy": "^2.0.0" + }, + "engines": { + "node": ">=14.0" + } + }, "node_modules/@actions/core": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@actions/core/-/core-3.0.0.tgz", @@ -87,16 +112,6 @@ "@actions/http-client": "^4.0.0" } }, - "node_modules/@actions/core/node_modules/@actions/http-client": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-4.0.0.tgz", - "integrity": "sha512-QuwPsgVMsD6qaPD57GLZi9sqzAZCtiJT8kVBCDpLtxhL5MydQ4gS+DrejtZZPdIYyB1e95uCK9Luyds7ybHI3g==", - "license": "MIT", - "dependencies": { - "tunnel": "^0.0.6", - "undici": "^6.23.0" - } - }, "node_modules/@actions/exec": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@actions/exec/-/exec-3.0.0.tgz", @@ -148,25 +163,13 @@ } }, "node_modules/@actions/http-client": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-2.2.3.tgz", - "integrity": "sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-4.0.0.tgz", + "integrity": "sha512-QuwPsgVMsD6qaPD57GLZi9sqzAZCtiJT8kVBCDpLtxhL5MydQ4gS+DrejtZZPdIYyB1e95uCK9Luyds7ybHI3g==", "license": "MIT", "dependencies": { "tunnel": "^0.0.6", - "undici": "^5.25.4" - } - }, - "node_modules/@actions/http-client/node_modules/undici": { - "version": "5.29.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", - "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==", - "license": "MIT", - "dependencies": { - "@fastify/busboy": "^2.0.0" - }, - "engines": { - "node": ">=14.0" + "undici": "^6.23.0" } }, "node_modules/@actions/io": { @@ -175,6 +178,25 @@ "integrity": "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q==", "license": "MIT" }, + "node_modules/@actions/tool-cache": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@actions/tool-cache/-/tool-cache-4.0.0.tgz", + "integrity": "sha512-L8P9HbXvpvqjZDveb/fdsa55IVC0trfPgQ4ZwGo6r5af6YDVdM9vMGPZ7rgY2fAT9gGj4PSYd6bYlg3p3jD78A==", + "license": "MIT", + "dependencies": { + "@actions/core": "^3.0.0", + "@actions/exec": "^3.0.0", + "@actions/http-client": "^4.0.0", + "@actions/io": "^3.0.0", + "semver": "^7.7.3" + } + }, + "node_modules/@actions/tool-cache/node_modules/@actions/io": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@actions/io/-/io-3.0.2.tgz", + "integrity": "sha512-nRBchcMM+QK1pdjO7/idu86rbJI5YHUKCvKs0KxnSYbVe3F51UfGxuZX4Qy/fWlp6l7gWFwIkrOzN+oUK03kfw==", + "license": "MIT" + }, "node_modules/@azure/abort-controller": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-1.1.0.tgz", @@ -1333,6 +1355,29 @@ "node": ">= 8" } }, + "node_modules/cross-spawn/node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/cross-spawn/node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1851,11 +1896,13 @@ "license": "MIT" }, "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, - "license": "ISC" + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", + "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=20" + } }, "node_modules/json-stable-stringify": { "version": "1.3.0", @@ -2363,19 +2410,18 @@ } }, "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", + "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", "license": "ISC", "dependencies": { - "isexe": "^2.0.0" + "isexe": "^4.0.0" }, "bin": { - "node-which": "bin/node-which" + "node-which": "bin/which.js" }, "engines": { - "node": ">= 8" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/wrappy": { diff --git a/sources/legacy-caching/package.json b/sources/legacy-caching/package.json index f86973b7..0cfc309d 100644 --- a/sources/legacy-caching/package.json +++ b/sources/legacy-caching/package.json @@ -14,7 +14,10 @@ "@actions/exec": "3.0.0", "@actions/github": "9.0.0", "@actions/glob": "0.6.1", - "semver": "7.7.4" + "@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", diff --git a/sources/legacy-caching/src/cache-cleaner.ts b/sources/legacy-caching/src/cache-cleaner.ts index aa30ec95..241c949a 100644 --- a/sources/legacy-caching/src/cache-cleaner.ts +++ b/sources/legacy-caching/src/cache-cleaner.ts @@ -4,7 +4,10 @@ import * as exec from '@actions/exec' import fs from 'fs' import path from 'path' import {BuildResults} from './build-results-adapter' -import {findGradleExecutableForCleanup} from './gradle-utils' +import {versionIsAtLeast, provisionGradleWithVersionAtLeast} from './gradle-utils' + +const MINIMUM_CLEANUP_GRADLE_VERSION = '8.11' +const DEFAULT_CLEANUP_GRADLE_VERSION = '9.4.1' export class CacheCleaner { private readonly gradleUserHome: string @@ -23,15 +26,32 @@ export class CacheCleaner { } async forceCleanup(buildResults: BuildResults): Promise { - const executable = findGradleExecutableForCleanup(buildResults) - if (!executable) { - core.warning('Cache cleanup skipped: no suitable Gradle >= 8.11 found in build results.') - return - } + 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 { + const preferredVersion = buildResults.highestGradleVersion() + if (preferredVersion && versionIsAtLeast(preferredVersion, MINIMUM_CLEANUP_GRADLE_VERSION)) { + try { + return await provisionGradleWithVersionAtLeast(preferredVersion) + } catch (e) { + core.info( + `Failed to provision Gradle ${preferredVersion} for cache cleanup. Falling back to default version.` + ) + } + } + + // Fallback to the default version for cache-cleanup + return await provisionGradleWithVersionAtLeast(DEFAULT_CLEANUP_GRADLE_VERSION) + } + // Visible for testing async forceCleanupFilesOlderThan(cleanTimestamp: string, executable: string): Promise { // Run a dummy Gradle build to trigger cache cleanup diff --git a/sources/legacy-caching/src/gradle-utils.ts b/sources/legacy-caching/src/gradle-utils.ts index a153ca86..87538c52 100644 --- a/sources/legacy-caching/src/gradle-utils.ts +++ b/sources/legacy-caching/src/gradle-utils.ts @@ -1,10 +1,15 @@ 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 {BuildResults} from './build-results-adapter' +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})?))?/ @@ -63,33 +68,96 @@ export function versionIsAtLeast(actualVersion: string, requiredVersion: string) return true } -function wrapperScriptFilename(): string { - return IS_WINDOWS ? 'gradlew.bat' : 'gradlew' +function installScriptFilename(): string { + return IS_WINDOWS ? 'gradle.bat' : 'gradle' +} + +async function findGradleExecutableOnPath(): Promise { + return await which('gradle', {nothrow: true}) +} + +async function determineGradleVersion(gradleExecutable: string): Promise { + 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 } /** - * Attempts to find a Gradle wrapper script from build results that has Gradle >= 8.11. - * Returns the full path to the wrapper script, or null if none found. + * 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 function findGradleExecutableForCleanup(buildResults: BuildResults): string | null { - const preferredVersion = buildResults.highestGradleVersion() - if (!preferredVersion || !versionIsAtLeast(preferredVersion, '8.11')) { - core.info( - `No Gradle version >= 8.11 found in build results (highest: ${preferredVersion ?? 'none'}). Cache cleanup will be skipped.` - ) - return null - } +export async function provisionGradleWithVersionAtLeast( + minimumVersion: string, + candidates: string[] = [] +): Promise { + const gradleOnPath = await findGradleExecutableOnPath() + const allCandidates = gradleOnPath ? [gradleOnPath, ...candidates] : candidates - // Find a build result with the highest version that has a wrapper script - for (const result of buildResults.results) { - if (versionIsAtLeast(result.gradleVersion, '8.11')) { - const wrapperScript = path.resolve(result.rootProjectDir, wrapperScriptFilename()) - if (fs.existsSync(wrapperScript)) { - return wrapperScript + 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 { + 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 { + 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) } - core.info('Could not locate a Gradle >= 8.11 executable for cache cleanup.') - return null + 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 { + const httpClient = new httpm.HttpClient('gradle/actions') + const response = await httpClient.get(url) + return response.readBody() }