diff --git a/__tests__/distributors/base-installer.test.ts b/__tests__/distributors/base-installer.test.ts index aef22936..3e2f0a2e 100644 --- a/__tests__/distributors/base-installer.test.ts +++ b/__tests__/distributors/base-installer.test.ts @@ -518,6 +518,106 @@ describe('setupJava', () => { ); }); + describe('floating tool-cache reuse', () => { + let toolCacheRoot: string; + + const installVersion = (toolcacheVersion: string): string => { + const architecturePath = path.join( + toolCacheRoot, + 'Java_Floating_jdk', + toolcacheVersion, + 'x64' + ); + fs.mkdirSync(architecturePath, {recursive: true}); + fs.writeFileSync(`${architecturePath}.complete`, ''); + return architecturePath; + }; + + beforeEach(() => { + toolCacheRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'setup-java-tc-')); + process.env['RUNNER_TOOL_CACHE'] = toolCacheRoot; + FloatingJavaBase.actualVersion = '21.0.8+9'; + FloatingJavaBase.checksum = 'artifact-one'; + (jdkCache.restoreJdk as jest.Mock).mockResolvedValue(false); + spyTcFindAllVersions.mockReturnValue([]); + }); + + afterEach(() => { + fs.rmSync(toolCacheRoot, {recursive: true, force: true}); + delete process.env['RUNNER_TOOL_CACHE']; + }); + + const createDistribution = (forceDownload = false) => + new FloatingJavaBase({ + version: '21', + architecture: 'x64', + packageType: 'jdk', + checkLatest: false, + cacheJdk: true, + forceDownload + }); + + it('reuses a tool-cache installation once the resolution cache identifies the floating version', async () => { + const installedPath = installVersion('21.0.8-9'); + (jdkResolutionCache.restoreJdkResolution as jest.Mock).mockResolvedValue({ + release: {version: '21.0.8+9'} + }); + const distribution = createDistribution(); + const downloadTool = jest.spyOn(distribution as any, 'downloadTool'); + + await expect(distribution.setupJava()).resolves.toEqual({ + version: '21.0.8+9', + path: installedPath + }); + + // The artifact behind the mutable URL is already installed, so neither a + // download nor a cache round-trip is needed. + expect(downloadTool).not.toHaveBeenCalled(); + expect(jdkCache.restoreJdk).not.toHaveBeenCalled(); + }); + + it('ignores a tool-cache installation of a version the resolution cache did not vouch for', async () => { + installVersion('21.0.7-6'); + (jdkResolutionCache.restoreJdkResolution as jest.Mock).mockResolvedValue({ + release: {version: '21.0.8+9'} + }); + const distribution = createDistribution(); + const downloadTool = jest.spyOn(distribution as any, 'downloadTool'); + + await distribution.setupJava(); + + expect(downloadTool).toHaveBeenCalled(); + }); + + it('never reuses the tool-cache for a floating artifact the resolution cache cannot identify', async () => { + installVersion('21.0.8-9'); + spyTcFindAllVersions.mockReturnValue(['21.0.8-9']); + (jdkResolutionCache.restoreJdkResolution as jest.Mock).mockResolvedValue( + undefined + ); + const distribution = createDistribution(); + const downloadTool = jest.spyOn(distribution as any, 'downloadTool'); + + await distribution.setupJava(); + + // Nothing ties the installed bytes to what the URL serves right now. + expect(downloadTool).toHaveBeenCalled(); + }); + + it('still downloads a resolution-cache-identified version when force-download is set', async () => { + installVersion('21.0.8-9'); + (jdkResolutionCache.restoreJdkResolution as jest.Mock).mockResolvedValue({ + release: {version: '21.0.8+9'} + }); + const distribution = createDistribution(true); + const downloadTool = jest.spyOn(distribution as any, 'downloadTool'); + + await distribution.setupJava(); + + expect(downloadTool).toHaveBeenCalled(); + }); + }); + it('uses the concrete versions of two different floating artifacts under the same major', async () => { spyTcFindAllVersions.mockReturnValue(['21.0.8-9']); spyGetToolcachePath.mockImplementation( diff --git a/dist/setup/242.index.js b/dist/setup/242.index.js index f191e2ce..2be41731 100644 --- a/dist/setup/242.index.js +++ b/dist/setup/242.index.js @@ -218,6 +218,12 @@ class JavaBase { checkLatest; forceDownload; cacheJdk; + /** + * Whether the concrete version of a floating release has been established + * from the checksum-bound resolution cache. Until then the release version is + * only the requested major and says nothing about the bytes behind the URL. + */ + floatingVersionVerified = false; setDefault; verifySignature; verifySignaturePublicKey; @@ -328,10 +334,16 @@ class JavaBase { let javaRelease = await this.resolveJavaRelease(); core/* info */.pq(`Resolved latest version as ${javaRelease.version}`); if (javaRelease.floating) { - // A tool-cache entry has no source identity. Even when its concrete - // version matches, only the checksum-bound JDK cache can prove that - // it contains the bytes currently served by the mutable URL. - foundJava = null; + // A tool-cache entry has no source identity, and until the + // checksum-bound resolution cache maps the current artifact to a + // concrete version the release version is still just the requested + // major — so nothing already on the runner can be trusted. Once that + // mapping is known, an installation of exactly that version is the + // artifact we would otherwise download. + foundJava = + this.floatingVersionVerified && !this.forceDownload + ? this.findConcreteVersionInToolcache(javaRelease.version) + : null; } if (!this.forceDownload && foundJava?.version === javaRelease.version) { core/* info */.pq(`Resolved Java ${foundJava.version} from tool-cache`); @@ -489,6 +501,7 @@ class JavaBase { return javaRelease; } core/* info */.pq(`Resolved ${this.distribution} ${restored.release.version} for the current floating artifact`); + this.floatingVersionVerified = true; return { ...javaRelease, version: restored.release.version }; } async registerFloatingResolution(javaRelease) { @@ -609,6 +622,19 @@ class JavaBase { ? architecturePath : null; } + /** + * Locates an installation of an exact version in the tool cache, unlike + * `findInToolcache()` which returns the newest entry satisfying the requested + * range. Used to reuse a JDK the runner already holds instead of downloading + * the identical artifact again. + */ + findConcreteVersionInToolcache(version) { + if (!semver_default().valid(version)) { + return null; + } + const installedPath = this.getRestoredJdkPath(version); + return installedPath ? { version, path: installedPath } : null; + } getJdkReleaseIdentity(javaRelease) { if (javaRelease.checksum) { return `${javaRelease.checksum.algorithm}:${javaRelease.checksum.value}`; diff --git a/src/distributions/base-installer.ts b/src/distributions/base-installer.ts index a4576813..18fd5318 100644 --- a/src/distributions/base-installer.ts +++ b/src/distributions/base-installer.ts @@ -33,6 +33,12 @@ export abstract class JavaBase { protected checkLatest: boolean; protected forceDownload: boolean; protected cacheJdk: boolean; + /** + * Whether the concrete version of a floating release has been established + * from the checksum-bound resolution cache. Until then the release version is + * only the requested major and says nothing about the bytes behind the URL. + */ + private floatingVersionVerified = false; protected setDefault: boolean; protected verifySignature: boolean; protected verifySignaturePublicKey: string | undefined; @@ -186,10 +192,16 @@ export abstract class JavaBase { let javaRelease = await this.resolveJavaRelease(); core.info(`Resolved latest version as ${javaRelease.version}`); if (javaRelease.floating) { - // A tool-cache entry has no source identity. Even when its concrete - // version matches, only the checksum-bound JDK cache can prove that - // it contains the bytes currently served by the mutable URL. - foundJava = null; + // A tool-cache entry has no source identity, and until the + // checksum-bound resolution cache maps the current artifact to a + // concrete version the release version is still just the requested + // major — so nothing already on the runner can be trusted. Once that + // mapping is known, an installation of exactly that version is the + // artifact we would otherwise download. + foundJava = + this.floatingVersionVerified && !this.forceDownload + ? this.findConcreteVersionInToolcache(javaRelease.version) + : null; } if (!this.forceDownload && foundJava?.version === javaRelease.version) { core.info(`Resolved Java ${foundJava.version} from tool-cache`); @@ -390,6 +402,7 @@ export abstract class JavaBase { core.info( `Resolved ${this.distribution} ${restored.release.version} for the current floating artifact` ); + this.floatingVersionVerified = true; return {...javaRelease, version: restored.release.version}; } @@ -531,6 +544,22 @@ export abstract class JavaBase { : null; } + /** + * Locates an installation of an exact version in the tool cache, unlike + * `findInToolcache()` which returns the newest entry satisfying the requested + * range. Used to reuse a JDK the runner already holds instead of downloading + * the identical artifact again. + */ + private findConcreteVersionInToolcache( + version: string + ): JavaInstallerResults | null { + if (!semver.valid(version)) { + return null; + } + const installedPath = this.getRestoredJdkPath(version); + return installedPath ? {version, path: installedPath} : null; + } + private getJdkReleaseIdentity(javaRelease: JavaDownloadRelease): string { if (javaRelease.checksum) { return `${javaRelease.checksum.algorithm}:${javaRelease.checksum.value}`;