diff --git a/.github/workflows/e2e-versions.yml b/.github/workflows/e2e-versions.yml index c6e10673..2d38d55d 100644 --- a/.github/workflows/e2e-versions.yml +++ b/.github/workflows/e2e-versions.yml @@ -117,7 +117,9 @@ jobs: env: JAVA_VERSION: ${{ matrix.version }} JAVA_PATH: ${{ steps.setup-java.outputs.path }} - run: bash __tests__/verify-java.sh "$JAVA_VERSION" "$JAVA_PATH" + SETUP_JAVA_VERSION: ${{ steps.setup-java.outputs.version }} + REQUIRE_CONCRETE_VERSION: ${{ (matrix.distribution == 'oracle' || matrix.distribution == 'graalvm') && !contains(matrix.version, '.') && !contains(matrix.version, '-ea') }} + run: bash __tests__/verify-java.sh "$JAVA_VERSION" "$JAVA_PATH" "$SETUP_JAVA_VERSION" "$REQUIRE_CONCRETE_VERSION" shell: bash setup-java-checksum-verification: diff --git a/__tests__/distributors/base-installer.test.ts b/__tests__/distributors/base-installer.test.ts index 7ca2e3aa..aef22936 100644 --- a/__tests__/distributors/base-installer.test.ts +++ b/__tests__/distributors/base-installer.test.ts @@ -148,6 +148,47 @@ class EmptyJavaBase extends JavaBase { } } +class FloatingJavaBase extends JavaBase { + static actualVersion = '21.0.8+9'; + static checksum: string | undefined = 'artifact-one'; + static fingerprint: string | undefined = undefined; + + constructor(installerOptions: JavaInstallerOptions) { + super('Floating', installerOptions); + } + + protected async downloadTool(): Promise { + return { + version: FloatingJavaBase.actualVersion, + path: path.join( + 'toolcache', + this.toolcacheFolderName, + FloatingJavaBase.actualVersion.replace('+', '-'), + this.architecture + ) + }; + } + + protected async findPackageForDownload(): Promise { + return { + version: '21', + url: 'https://example.com/java/21/latest/jdk-21.tar.gz', + checksum: FloatingJavaBase.checksum + ? { + algorithm: 'sha256', + value: FloatingJavaBase.checksum + } + : undefined, + floating: true, + fingerprint: FloatingJavaBase.fingerprint + }; + } + + protected requiresRemoteResolution(): boolean { + return true; + } +} + describe('findInToolcache', () => { const actualJavaVersion = '11.0.8'; const javaPath = path.join('Java_Empty_jdk', actualJavaVersion, 'x64'); @@ -397,6 +438,7 @@ describe('setupJava', () => { spyCoreError.mockImplementation(() => undefined); jest.spyOn(os, 'arch').mockReturnValue('x86' as ReturnType); + FloatingJavaBase.fingerprint = undefined; }); afterEach(() => { @@ -476,6 +518,179 @@ describe('setupJava', () => { ); }); + it('uses the concrete versions of two different floating artifacts under the same major', async () => { + spyTcFindAllVersions.mockReturnValue(['21.0.8-9']); + spyGetToolcachePath.mockImplementation( + (_toolname: string, version: string, architecture: string) => + path.join('toolcache', 'Java_Floating_jdk', version, architecture) + ); + (jdkResolutionCache.restoreJdkResolution as jest.Mock).mockResolvedValue( + undefined + ); + (jdkCache.restoreJdk as jest.Mock).mockResolvedValue(false); + + FloatingJavaBase.actualVersion = '21.0.8+9'; + FloatingJavaBase.checksum = 'artifact-one'; + const first = new FloatingJavaBase({ + version: '21', + architecture: 'x64', + packageType: 'jdk', + checkLatest: false, + cacheJdk: true + }); + await expect(first.setupJava()).resolves.toEqual({ + version: '21.0.8+9', + path: path.join('toolcache', 'Java_Floating_jdk', '21.0.8-9', 'x64') + }); + + FloatingJavaBase.actualVersion = '21.0.9+7'; + FloatingJavaBase.checksum = 'artifact-two'; + const second = new FloatingJavaBase({ + version: '21', + architecture: 'x64', + packageType: 'jdk', + checkLatest: false, + cacheJdk: true + }); + await expect(second.setupJava()).resolves.toEqual({ + version: '21.0.9+7', + path: path.join('toolcache', 'Java_Floating_jdk', '21.0.9-7', 'x64') + }); + + expect(spyCoreSetOutput).toHaveBeenNthCalledWith(3, 'version', '21.0.8+9'); + expect(spyCoreSetOutput).toHaveBeenNthCalledWith(6, 'version', '21.0.9+7'); + expect(jdkCache.registerJdk).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + version: '21.0.8+9', + source: 'sha256:artifact-one' + }) + ); + expect(jdkCache.registerJdk).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + version: '21.0.9+7', + source: 'sha256:artifact-two' + }) + ); + expect(jdkResolutionCache.registerJdkResolution).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({source: 'sha256:artifact-two'}), + expect.objectContaining({version: '21.0.9+7', floating: true}) + ); + }); + + it('does not trust a matching tool-cache version for a floating artifact', async () => { + spyTcFindAllVersions.mockReturnValue(['21.0.8-9']); + spyGetToolcachePath.mockReturnValue( + path.join('toolcache', 'Java_Floating_jdk', '21.0.8-9', 'x64') + ); + (jdkResolutionCache.restoreJdkResolution as jest.Mock).mockResolvedValue({ + release: { + version: '21.0.8+9', + url: 'https://example.com/java/21/latest/jdk-21.tar.gz', + checksum: {algorithm: 'sha256', value: 'artifact-republished'}, + floating: true + }, + fresh: true + }); + (jdkCache.restoreJdk as jest.Mock).mockResolvedValue(false); + FloatingJavaBase.actualVersion = '21.0.8+9'; + FloatingJavaBase.checksum = 'artifact-republished'; + const distribution = new FloatingJavaBase({ + version: '21', + architecture: 'x64', + packageType: 'jdk', + checkLatest: false, + cacheJdk: true + }); + const downloadTool = jest.spyOn(distribution as any, 'downloadTool'); + + await distribution.setupJava(); + + expect(jdkCache.restoreJdk).toHaveBeenCalled(); + expect(downloadTool).toHaveBeenCalled(); + }); + + it('does not cache a floating artifact with no way to identify its bytes', async () => { + spyTcFindAllVersions.mockReturnValue(['21.0.8-9']); + spyGetToolcachePath.mockReturnValue( + path.join('toolcache', 'Java_Floating_jdk', '21.0.8-9', 'x64') + ); + FloatingJavaBase.actualVersion = '21.0.8+9'; + FloatingJavaBase.checksum = undefined; + FloatingJavaBase.fingerprint = undefined; + const distribution = new FloatingJavaBase({ + version: '21', + architecture: 'x64', + packageType: 'jdk', + checkLatest: false, + cacheJdk: true + }); + + await distribution.setupJava(); + + expect(jdkResolutionCache.restoreJdkResolution).not.toHaveBeenCalled(); + expect(jdkResolutionCache.registerJdkResolution).not.toHaveBeenCalled(); + expect(jdkCache.restoreJdk).not.toHaveBeenCalled(); + expect(jdkCache.registerJdk).not.toHaveBeenCalled(); + }); + + it('caches a checksum-less floating artifact identified by its response fingerprint', async () => { + spyTcFindAllVersions.mockReturnValue([]); + spyGetToolcachePath.mockReturnValue( + path.join('toolcache', 'Java_Floating_jdk', '21.0.8-9', 'x64') + ); + FloatingJavaBase.actualVersion = '21.0.8+9'; + FloatingJavaBase.checksum = undefined; + FloatingJavaBase.fingerprint = 'etag:"artifact-one"'; + const distribution = new FloatingJavaBase({ + version: '21', + architecture: 'x64', + packageType: 'jdk', + checkLatest: false, + cacheJdk: true + }); + + await distribution.setupJava(); + + // The fingerprint changes when the vendor republishes, so it is a safe + // identity even though no checksum is available. + expect(jdkResolutionCache.registerJdkResolution).toHaveBeenCalledWith( + expect.objectContaining({source: 'etag:"artifact-one"'}), + expect.objectContaining({version: '21.0.8+9'}) + ); + expect(jdkCache.registerJdk).toHaveBeenCalledWith( + expect.objectContaining({source: 'etag:"artifact-one"'}) + ); + }); + + it('separates the cache identities of two builds served by the same floating URL', async () => { + const sources: string[] = []; + (jdkCache.registerJdk as jest.Mock).mockImplementation((entry: any) => { + sources.push(entry.source); + }); + spyTcFindAllVersions.mockReturnValue([]); + spyGetToolcachePath.mockReturnValue( + path.join('toolcache', 'Java_Floating_jdk', '21.0.8-9', 'x64') + ); + FloatingJavaBase.actualVersion = '21.0.8+9'; + FloatingJavaBase.checksum = undefined; + + for (const fingerprint of ['etag:"before"', 'etag:"after"']) { + FloatingJavaBase.fingerprint = fingerprint; + await new FloatingJavaBase({ + version: '21', + architecture: 'x64', + packageType: 'jdk', + checkLatest: false, + cacheJdk: true + }).setupJava(); + } + + expect(sources).toEqual(['etag:"before"', 'etag:"after"']); + }); + it('should download java when force-download is enabled, even if the version is cached', async () => { mockJavaBase = new EmptyJavaBase({ version: actualJavaVersion, @@ -1074,7 +1289,7 @@ describe('setupJava', () => { ); }); - it('does not record a floating release', async () => { + it('records the concrete version for a checksum-bound floating release', async () => { mockJavaBase = new EmptyJavaBase(options); jest .spyOn(mockJavaBase as any, 'findPackageForDownload') @@ -1087,7 +1302,22 @@ describe('setupJava', () => { await mockJavaBase.setupJava(); - expect(jdkResolutionCache.registerJdkResolution).not.toHaveBeenCalled(); + expect(jdkResolutionCache.registerJdkResolution).toHaveBeenCalledWith( + { + distribution: 'Empty', + packageType: 'jdk', + architecture: 'x86', + versionSpec: '11.0.9', + stable: true, + source: 'sha256:abc' + }, + { + version: '11.0.9', + url: 'https://example.com/java/11/latest/jdk-11.tar.gz', + checksum: {algorithm: 'sha256', value: 'abc'}, + floating: true + } + ); }); it.each([ diff --git a/__tests__/distributors/graalvm-installer.test.ts b/__tests__/distributors/graalvm-installer.test.ts index 54dbb592..16b2c42c 100644 --- a/__tests__/distributors/graalvm-installer.test.ts +++ b/__tests__/distributors/graalvm-installer.test.ts @@ -72,6 +72,7 @@ jest.unstable_mockModule('../../src/util.js', () => ({ ...realUtil, extractJdkFile: jest.fn(), getDownloadArchiveExtension: jest.fn(), + getJavaVersionFromReleaseFile: jest.fn(), renameWinArchive: jest.fn(), getGitHubHttpHeaders: jest.fn().mockReturnValue({Accept: 'application/json'}) })); @@ -363,6 +364,30 @@ describe('GraalVMDistribution', () => { path: '/cached/java/path' }); }); + + it('caches Oracle GraalVM floating artifacts under their installed version', async () => { + (util.getJavaVersionFromReleaseFile as jest.Mock).mockReturnValue( + '21.0.9+7' + ); + const floatingRelease = { + version: '21', + url: 'https://example.com/graalvm/latest/graalvm-jdk-21.tar.gz', + floating: true + }; + + const result = await (distribution as any).downloadTool(floatingRelease); + + expect(tc.cacheDir).toHaveBeenCalledWith( + path.join('/tmp/extracted', 'graalvm-jdk-17.0.5'), + 'Java_GraalVM_jdk', + '21.0.9+7', + 'x64' + ); + expect(result).toEqual({ + version: '21.0.9+7', + path: '/cached/java/path' + }); + }); }); describe('findPackageForDownload', () => { @@ -451,6 +476,33 @@ describe('GraalVMDistribution', () => { }); }); + it.each([ + ['21', 'etag:"graalvm-latest"'], + ['17.0.5', undefined] + ])( + 'fingerprints only the floating artifact for version %s', + async (input, expected) => { + mockHttpClient.head.mockResolvedValue({ + message: {statusCode: 200, headers: {etag: '"graalvm-latest"'}} + } as any); + + const result = await (distribution as any).findPackageForDownload( + input + ); + + // Without a fingerprint the constant `/latest/` URL would key a cache + // entry that never invalidates when Oracle republishes the artifact. + expect(result.fingerprint).toBe(expected); + } + ); + + it('always resolves Oracle GraalVM major-only requests remotely', () => { + expect((distribution as any).requiresRemoteResolution()).toBe(true); + expect((communityDistribution as any).requiresRemoteResolution()).toBe( + false + ); + }); + it('should throw error for unsupported architecture', async () => { distribution = new GraalVMDistribution({ ...defaultOptions, diff --git a/__tests__/distributors/oracle-installer.test.ts b/__tests__/distributors/oracle-installer.test.ts index cbdebbf6..429b4f2e 100644 --- a/__tests__/distributors/oracle-installer.test.ts +++ b/__tests__/distributors/oracle-installer.test.ts @@ -147,6 +147,27 @@ describe('findPackageForDownload', () => { expect(result.floating).toBe(url.includes('/latest/')); }); + it.each([ + ['21', 'etag:"oracle-latest"'], + ['21.0.1', undefined] + ])( + 'fingerprints only the floating artifact for version %s', + async (input, expected) => { + spyHttpClient = jest.spyOn(HttpClient.prototype, 'head'); + spyHttpClient.mockResolvedValue({ + message: {statusCode: 200, headers: {etag: '"oracle-latest"'}} + }); + + const result = await distribution['findPackageForDownload'](input); + + jest.restoreAllMocks(); + + // Without a fingerprint the constant `/latest/` URL would key a cache + // entry that never invalidates when Oracle republishes the artifact. + expect(result.fingerprint).toBe(expected); + } + ); + it('fetches the authoritative sha256 checksum for the resolved archive', async () => { spyHttpClient = jest.spyOn(HttpClient.prototype, 'head'); spyHttpClient.mockResolvedValue({message: {statusCode: 200}}); @@ -164,6 +185,17 @@ describe('findPackageForDownload', () => { expect(spyHttpClientGet).toHaveBeenCalledTimes(1); }); + it('always resolves major-only requests remotely', () => { + expect(distribution['requiresRemoteResolution']()).toBe(true); + const exactDistribution = new OracleDistribution({ + version: '21.0.8', + architecture: 'x64', + packageType: 'jdk', + checkLatest: false + }); + expect(exactDistribution['requiresRemoteResolution']()).toBe(false); + }); + it.each([ ['amd64', 'x64'], ['arm64', 'aarch64'] diff --git a/__tests__/jdk-resolution-cache.test.ts b/__tests__/jdk-resolution-cache.test.ts index 8b12bbc6..f3e8af9b 100644 --- a/__tests__/jdk-resolution-cache.test.ts +++ b/__tests__/jdk-resolution-cache.test.ts @@ -248,7 +248,8 @@ describe('JDK resolution cache', () => { algorithm: 'sha512', value: 'def456', source: 'https://example.com/a.sha512' - } + }, + floating: true }; restoreWith(JSON.stringify(full), 'setup-java-jdkres-v1-old'); @@ -312,6 +313,19 @@ describe('JDK resolution cache', () => { state.length ); }); + + it('uses different keys for different floating artifact identities', () => { + createRunnerTemp(); + registerJdkResolution({...request, source: 'sha256:first'}, release); + registerJdkResolution({...request, source: 'sha256:second'}, release); + + const state = JSON.parse( + jest.mocked(core.saveState).mock.calls.at(-1)![1] as string + ); + expect(new Set(state.map((item: {key: string}) => item.key)).size).toBe( + state.length + ); + }); }); describe('saveJdkResolutionCaches', () => { diff --git a/__tests__/util-install.test.ts b/__tests__/util-install.test.ts index 4d8ab2a3..b56529cd 100644 --- a/__tests__/util-install.test.ts +++ b/__tests__/util-install.test.ts @@ -44,7 +44,12 @@ jest.unstable_mockModule('@actions/http-client', () => ({ const tc = await import('@actions/tool-cache'); const exec = await import('@actions/exec'); const io = await import('@actions/io'); -const {cacheJdkDir, extractJdkFile} = await import('../src/util.js'); +const { + cacheJdkDir, + extractJdkFile, + getArtifactFingerprint, + getJavaVersionFromReleaseFile +} = await import('../src/util.js'); const originalToolCache = process.env['RUNNER_TOOL_CACHE']; const originalTemp = process.env['RUNNER_TEMP']; @@ -298,6 +303,41 @@ describe('cacheJdkDir', () => { }); }); +describe('getJavaVersionFromReleaseFile', () => { + it.each([ + ['JAVA_RUNTIME_VERSION="21.0.9+7-LTS-123"', '21.0.9+7'], + ['JAVA_RUNTIME_VERSION="17.0.12+8-jvmci-23.1-b52"', '17.0.12+8'], + ['JAVA_RUNTIME_VERSION="25+36-LTS"', '25.0.0+36'], + ['JAVA_VERSION="25.0.1"', '25.0.1'], + ['JAVA_VERSION="25"', '25.0.0'] + ])('reads a concrete version from %s', (contents, expected) => { + const javaHome = createJdkDir(); + fs.writeFileSync(path.join(javaHome, 'release'), contents); + + expect(getJavaVersionFromReleaseFile(javaHome)).toBe(expected); + }); + + it('reads the macOS Contents/Home release file', () => { + const javaHome = path.join(workDir, 'macos-jdk'); + fs.mkdirSync(path.join(javaHome, 'Contents', 'Home'), {recursive: true}); + fs.writeFileSync( + path.join(javaHome, 'Contents', 'Home', 'release'), + 'JAVA_RUNTIME_VERSION="21.0.9+7-LTS"' + ); + + expect(getJavaVersionFromReleaseFile(javaHome)).toBe('21.0.9+7'); + }); + + it('fails when the JDK release metadata has no usable version', () => { + const javaHome = createJdkDir(); + fs.writeFileSync(path.join(javaHome, 'release'), 'IMPLEMENTOR="Oracle"'); + + expect(() => getJavaVersionFromReleaseFile(javaHome)).toThrow( + /Unable to determine the installed Java version/ + ); + }); +}); + describe('extractJdkFile', () => { it('uses pigz for tarballs when it is available', async () => { (io.which as jest.Mock).mockResolvedValue('/usr/bin/pigz' as never); @@ -410,3 +450,59 @@ describe('extractJdkFile', () => { expect(exec.exec).not.toHaveBeenCalled(); }); }); + +describe('getArtifactFingerprint', () => { + it('prefers the ETag over the other validators', () => { + expect( + getArtifactFingerprint({ + etag: '"abc123"', + 'last-modified': 'Wed, 21 Oct 2026 07:28:00 GMT', + 'content-length': '195000000' + }) + ).toBe('etag:"abc123"'); + }); + + it('combines the last-modified date and the content length without an ETag', () => { + expect( + getArtifactFingerprint({ + 'last-modified': 'Wed, 21 Oct 2026 07:28:00 GMT', + 'content-length': '195000000' + }) + ).toBe('mtime:Wed, 21 Oct 2026 07:28:00 GMT;length:195000000'); + }); + + it.each([ + ['no validators', {}], + [ + 'only a last-modified date', + {'last-modified': 'Wed, 21 Oct 2026 07:28:00 GMT'} + ], + ['only a content length', {'content-length': '195000000'}], + [ + 'blank validators', + {etag: ' ', 'last-modified': '', 'content-length': ''} + ], + ['missing headers', undefined] + ])('returns undefined for %s', (_label, headers) => { + expect(getArtifactFingerprint(headers)).toBeUndefined(); + }); + + it('uses the first value of a repeated header', () => { + expect(getArtifactFingerprint({etag: ['"first"', '"second"'] as any})).toBe( + 'etag:"first"' + ); + }); + + it('distinguishes a republished artifact from the previous one', () => { + const before = getArtifactFingerprint({ + 'last-modified': 'Wed, 21 Oct 2026 07:28:00 GMT', + 'content-length': '195000000' + }); + const after = getArtifactFingerprint({ + 'last-modified': 'Thu, 22 Oct 2026 09:03:00 GMT', + 'content-length': '195400000' + }); + + expect(before).not.toBe(after); + }); +}); diff --git a/__tests__/verify-java.sh b/__tests__/verify-java.sh index 069d9008..1453d0ba 100755 --- a/__tests__/verify-java.sh +++ b/__tests__/verify-java.sh @@ -12,6 +12,8 @@ fi EXPECTED_JAVA_VERSION=$1 EXPECTED_PATH=$2 +SETUP_JAVA_VERSION=$3 +REQUIRE_CONCRETE_VERSION=$4 EXPECTED_JAVA_VERSION=$(echo $EXPECTED_JAVA_VERSION | cut -d'+' -f1) if [[ $EXPECTED_JAVA_VERSION == 8 ]] || [[ $EXPECTED_JAVA_VERSION == 8.* ]]; then @@ -31,6 +33,21 @@ if [ -z "$GREP_RESULT" ]; then exit 1 fi +if [ -n "$SETUP_JAVA_VERSION" ]; then + OUTPUT_JAVA_VERSION=$(echo "$SETUP_JAVA_VERSION" | cut -d'+' -f1) + OUTPUT_GREP_RESULT=$(echo "$ACTUAL_JAVA_VERSION" | grep -E "^(openjdk|java) version \"$OUTPUT_JAVA_VERSION") + if [ -z "$OUTPUT_GREP_RESULT" ]; then + echo "::error::The version output does not match the installed Java version" + echo "Version output: $SETUP_JAVA_VERSION" + exit 1 + fi + if [ "$REQUIRE_CONCRETE_VERSION" = "true" ] && [ "$OUTPUT_JAVA_VERSION" = "$EXPECTED_JAVA_VERSION" ]; then + echo "::error::Expected a concrete version output for a floating JDK" + echo "Version output: $SETUP_JAVA_VERSION" + exit 1 + fi +fi + if [ "$EXPECTED_PATH" != "$JAVA_HOME" ]; then echo "::error::Unexpected path" echo "Actual path: $JAVA_HOME" diff --git a/dist/cleanup/348.index.js b/dist/cleanup/348.index.js index f02c412a..67801456 100644 --- a/dist/cleanup/348.index.js +++ b/dist/cleanup/348.index.js @@ -148,7 +148,8 @@ function getResolutionIdentity(request) { packageType: request.packageType.toLowerCase(), architecture: request.architecture.toLowerCase(), versionSpec: request.versionSpec, - stable: request.stable + stable: request.stable, + source: request.source }); return createHash('sha256').update(identity).digest('hex'); } @@ -190,6 +191,7 @@ function parseResolvedRelease(contents) { const version = candidate['version']; const url = candidate['url']; const signatureUrl = candidate['signatureUrl']; + const floating = candidate['floating']; if (typeof version !== 'string' || !version) { throw new Error('The cached resolution has no version.'); } @@ -197,6 +199,9 @@ function parseResolvedRelease(contents) { if (signatureUrl !== undefined) { assertHttpsUrl(signatureUrl, 'signatureUrl'); } + if (floating !== undefined && typeof floating !== 'boolean') { + throw new Error('The cached resolution has an invalid floating flag.'); + } const release = { version, url: url @@ -204,6 +209,9 @@ function parseResolvedRelease(contents) { if (signatureUrl !== undefined) { release.signatureUrl = signatureUrl; } + if (floating !== undefined) { + release.floating = floating; + } const checksum = candidate['checksum']; if (checksum !== undefined) { release.checksum = parseChecksum(checksum); diff --git a/dist/cleanup/index.js b/dist/cleanup/index.js index 8c7ebb34..734dc227 100644 --- a/dist/cleanup/index.js +++ b/dist/cleanup/index.js @@ -30840,7 +30840,7 @@ const DISTRIBUTIONS_ONLY_MAJOR_VERSION = (/* unused pure expression or super */ /* harmony export */ Vt: () => (/* binding */ getBooleanInput), /* harmony export */ lN: () => (/* binding */ isJdkCacheEnabled) /* harmony export */ }); -/* unused harmony exports getVersionFromToolcachePath, extractJdkFile, cacheJdkDir, getDownloadArchiveExtension, isVersionSatisfies, getToolcachePath, isGhes, getVersionFromFileContent, convertVersionToSemver, getGitHubHttpHeaders, MAX_PAGINATION_PAGES, getNextPageUrlFromLinkHeader, validatePaginationUrl, renameWinArchive, getLatestMajorVersion */ +/* unused harmony exports getVersionFromToolcachePath, extractJdkFile, cacheJdkDir, getJavaVersionFromReleaseFile, getDownloadArchiveExtension, isVersionSatisfies, getToolcachePath, isGhes, getVersionFromFileContent, convertVersionToSemver, getArtifactFingerprint, getGitHubHttpHeaders, MAX_PAGINATION_PAGES, getNextPageUrlFromLinkHeader, validatePaginationUrl, renameWinArchive, getLatestMajorVersion */ /* harmony import */ var os__WEBPACK_IMPORTED_MODULE_0__ = __nccwpck_require__(857); /* harmony import */ var os__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__nccwpck_require__.n(os__WEBPACK_IMPORTED_MODULE_0__); /* harmony import */ var path__WEBPACK_IMPORTED_MODULE_1__ = __nccwpck_require__(6928); @@ -31005,6 +31005,46 @@ async function cacheJdkDir(sourceDir, toolName, version, architecture) { } return await tc.cacheDir(sourceDir, toolName, version, architecture); } +function getJavaVersionFromReleaseFile(javaHome) { + const releasePaths = [ + path.join(javaHome, 'release'), + path.join(javaHome, 'Contents', 'Home', 'release') + ]; + const releasePath = releasePaths.find(candidate => fs.existsSync(candidate)); + if (!releasePath) { + throw new Error(`Unable to determine the installed Java version: no release file found under '${javaHome}'.`); + } + const properties = new Map(); + for (const line of fs.readFileSync(releasePath, 'utf8').split(/\r?\n/)) { + const match = line.match(/^([A-Z0-9_]+)="(.*)"$/); + if (match) { + properties.set(match[1], match[2]); + } + } + const runtimeVersion = properties.get('JAVA_RUNTIME_VERSION'); + const runtimeMatch = runtimeVersion?.match(/^(\d+(?:\.\d+)*(?:\+\d+(?:\.\d+)*)?)/); + if (runtimeMatch) { + return normalizeJavaReleaseVersion(runtimeMatch[1]); + } + const javaVersion = properties.get('JAVA_VERSION'); + if (javaVersion && /^\d+(?:\.\d+)*$/.test(javaVersion)) { + return normalizeJavaReleaseVersion(javaVersion); + } + throw new Error(`Unable to determine the installed Java version from '${releasePath}'.`); +} +function normalizeJavaReleaseVersion(version) { + const [numericVersion, buildVersion] = version.split('+', 2); + const components = numericVersion.split('.'); + while (components.length < 3) { + components.push('0'); + } + const mainVersion = components.slice(0, 3).join('.'); + const build = [ + ...components.slice(3), + ...(buildVersion ? [buildVersion] : []) + ]; + return build.length > 0 ? `${mainVersion}+${build.join('.')}` : mainVersion; +} function getToolcacheDestination(toolName, version, architecture) { const toolcacheRoot = process.env['RUNNER_TOOL_CACHE']; if (!toolcacheRoot) { @@ -31195,6 +31235,38 @@ function convertVersionToSemver(version) { } return mainVersion; } +/** + * Builds a validator for the bytes currently served by a URL from the response + * headers of a HEAD request. A vendor's `/latest/` URL never changes, so this + * is what lets a republished artifact be told apart from the previous one when + * no checksum is published alongside it. + * + * Returns `undefined` when the response carries no usable validator, in which + * case the caller must not treat the URL as a stable identity. + */ +function getArtifactFingerprint(headers) { + const readHeader = (name) => { + const value = headers?.[name]; + const resolved = Array.isArray(value) ? value[0] : value; + return typeof resolved === 'string' && resolved.trim() + ? resolved.trim() + : undefined; + }; + // A strong or weak ETag already identifies a specific representation. + const etag = readHeader('etag'); + if (etag) { + return `etag:${etag}`; + } + // Otherwise combine the two validators a static file server reliably sends. + // Neither alone is sufficient: `last-modified` has one-second granularity and + // `content-length` is unchanged by a same-size rebuild. + const lastModified = readHeader('last-modified'); + const contentLength = readHeader('content-length'); + if (lastModified && contentLength) { + return `mtime:${lastModified};length:${contentLength}`; + } + return undefined; +} function getGitHubHttpHeaders() { const resolvedToken = core.getInput('token') || process.env.GITHUB_TOKEN; const auth = !resolvedToken ? undefined : `token ${resolvedToken}`; diff --git a/dist/setup/182.index.js b/dist/setup/182.index.js index 20ff960a..cfb75b88 100644 --- a/dist/setup/182.index.js +++ b/dist/setup/182.index.js @@ -38,9 +38,15 @@ class OracleDistribution extends _base_installer_js__WEBPACK_IMPORTED_MODULE_3__ const extractedJavaPath = await (0,_util_js__WEBPACK_IMPORTED_MODULE_4__/* .extractJdkFile */ .PE)(javaArchivePath, extension); const archiveName = fs__WEBPACK_IMPORTED_MODULE_1___default().readdirSync(extractedJavaPath)[0]; const archivePath = path__WEBPACK_IMPORTED_MODULE_2___default().join(extractedJavaPath, archiveName); - const version = this.getToolcacheVersionName(javaRelease.version); + const installedVersion = javaRelease.floating + ? (0,_util_js__WEBPACK_IMPORTED_MODULE_4__/* .getJavaVersionFromReleaseFile */ .C4)(archivePath) + : javaRelease.version; + const version = this.getToolcacheVersionName(installedVersion); const javaPath = await (0,_util_js__WEBPACK_IMPORTED_MODULE_4__/* .cacheJdkDir */ .Vj)(archivePath, this.toolcacheFolderName, version, this.architecture); - return { version: javaRelease.version, path: javaPath }; + return { version: installedVersion, path: javaPath }; + } + requiresRemoteResolution() { + return this.stable && !this.version.includes('.'); } async findPackageForDownload(range) { const arch = this.distributionArchitecture(); @@ -83,11 +89,15 @@ class OracleDistribution extends _base_installer_js__WEBPACK_IMPORTED_MODULE_3__ for (const url of possibleUrls) { const response = await this.http.head(url); if (response.message.statusCode === _actions_http_client__WEBPACK_IMPORTED_MODULE_5__/* .HttpCodes */ .Hv.OK) { + const floating = url === floatingUrl; return { url, version: range, checksum: await this.fetchChecksum(`${url}.sha256`, 'sha256'), - floating: url === floatingUrl + floating, + fingerprint: floating + ? (0,_util_js__WEBPACK_IMPORTED_MODULE_4__/* .getArtifactFingerprint */ .VX)(response.message.headers) + : undefined }; } if (response.message.statusCode !== _actions_http_client__WEBPACK_IMPORTED_MODULE_5__/* .HttpCodes */ .Hv.NotFound) { diff --git a/dist/setup/242.index.js b/dist/setup/242.index.js index d33ca434..f191e2ce 100644 --- a/dist/setup/242.index.js +++ b/dist/setup/242.index.js @@ -316,31 +316,33 @@ class JavaBase { throw new Error(`Input 'verify-signature' is not supported for distribution '${this.distribution}'.`); } let foundJava = this.forceDownload ? null : this.findInToolcache(); - if (foundJava && !this.checkLatest && !this.latest) { + if (foundJava && + !this.checkLatest && + !this.latest && + !this.requiresRemoteResolution()) { core/* info */.pq(`Resolved Java ${foundJava.version} from tool-cache`); } else { core/* info */.pq('Trying to resolve the latest version from remote'); try { - const javaRelease = await this.resolveJavaRelease(); + 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; + } if (!this.forceDownload && foundJava?.version === javaRelease.version) { core/* info */.pq(`Resolved Java ${foundJava.version} from tool-cache`); } else { - let jdkCache; - if (this.cacheJdk) { - const { getJdkVerificationIdentity } = await Promise.all(/* import() */[__webpack_require__.e(824), __webpack_require__.e(971), __webpack_require__.e(779)]).then(__webpack_require__.bind(__webpack_require__, 5779)); - jdkCache = { - distribution: this.distribution, - packageType: this.packageType, - architecture: this.architecture, - version: javaRelease.version, - source: this.getJdkReleaseIdentity(javaRelease), - verification: getJdkVerificationIdentity(this.verifySignature, this.verifySignaturePublicKey), - path: this.getJdkCachePath(javaRelease.version) - }; - } + let jdkCache = this.cacheJdk && + (!javaRelease.floating || + (this.hasStableReleaseIdentity(javaRelease) && + semver_default().valid(javaRelease.version))) + ? await this.createJdkCache(javaRelease) + : undefined; if (!this.forceDownload && jdkCache) { const { restoreJdk } = await Promise.all(/* import() */[__webpack_require__.e(824), __webpack_require__.e(971), __webpack_require__.e(779)]).then(__webpack_require__.bind(__webpack_require__, 5779)); const restored = await restoreJdk(jdkCache); @@ -358,6 +360,18 @@ class JavaBase { core/* info */.pq('Trying to download...'); foundJava = await this.downloadTool(javaRelease); core/* info */.pq(`Java ${foundJava.version} was downloaded`); + if (javaRelease.floating) { + if (!semver_default().valid(foundJava.version) || + !(0,util/* isVersionSatisfies */.y)(this.version, foundJava.version)) { + throw new Error(`The downloaded ${this.distribution} artifact reported Java ${foundJava.version}, which does not satisfy '${this.version}'.`); + } + javaRelease = { ...javaRelease, version: foundJava.version }; + await this.registerFloatingResolution(javaRelease); + jdkCache = + this.cacheJdk && this.hasStableReleaseIdentity(javaRelease) + ? await this.createJdkCache(javaRelease) + : undefined; + } if (jdkCache) { // Register after the installation exists so its identity is // captured; the post-job save refuses to upload a path whose @@ -406,8 +420,10 @@ class JavaBase { if (!this.cacheJdk || this.checkLatest || this.latest || - this.forceDownload) { - return this.findPackageForDownload(this.version); + this.forceDownload || + this.requiresRemoteResolution()) { + const release = await this.findPackageForDownload(this.version); + return this.restoreFloatingResolution(release); } const { restoreJdkResolution, registerJdkResolution } = await Promise.all(/* import() */[__webpack_require__.e(824), __webpack_require__.e(971), __webpack_require__.e(348)]).then(__webpack_require__.bind(__webpack_require__, 967)); const request = { @@ -427,7 +443,7 @@ class JavaBase { if (!javaRelease.floating) { registerJdkResolution(request, javaRelease); } - return javaRelease; + return this.restoreFloatingResolution(javaRelease); } catch (error) { if (!restored) { @@ -440,6 +456,60 @@ class JavaBase { return restored.release; } } + requiresRemoteResolution() { + return false; + } + async createJdkCache(javaRelease) { + const { getJdkVerificationIdentity } = await Promise.all(/* import() */[__webpack_require__.e(824), __webpack_require__.e(971), __webpack_require__.e(779)]).then(__webpack_require__.bind(__webpack_require__, 5779)); + return { + distribution: this.distribution, + packageType: this.packageType, + architecture: this.architecture, + version: javaRelease.version, + source: this.getJdkReleaseIdentity(javaRelease), + verification: getJdkVerificationIdentity(this.verifySignature, this.verifySignaturePublicKey), + path: this.getJdkCachePath(javaRelease.version) + }; + } + async restoreFloatingResolution(javaRelease) { + if (!javaRelease.floating || + !this.hasStableReleaseIdentity(javaRelease) || + !this.cacheJdk || + this.forceDownload) { + return javaRelease; + } + const { restoreJdkResolution } = await Promise.all(/* import() */[__webpack_require__.e(824), __webpack_require__.e(971), __webpack_require__.e(348)]).then(__webpack_require__.bind(__webpack_require__, 967)); + const restored = await restoreJdkResolution(this.getFloatingResolutionRequest(javaRelease)); + if (!restored) { + return javaRelease; + } + if (!semver_default().valid(restored.release.version) || + !(0,util/* isVersionSatisfies */.y)(this.version, restored.release.version)) { + core/* debug */.Yz(`Ignoring the cached concrete version '${restored.release.version}' for ${this.distribution} ${this.version}.`); + return javaRelease; + } + core/* info */.pq(`Resolved ${this.distribution} ${restored.release.version} for the current floating artifact`); + return { ...javaRelease, version: restored.release.version }; + } + async registerFloatingResolution(javaRelease) { + if (!this.hasStableReleaseIdentity(javaRelease) || + !this.cacheJdk || + this.forceDownload) { + return; + } + const { registerJdkResolution } = await Promise.all(/* import() */[__webpack_require__.e(824), __webpack_require__.e(971), __webpack_require__.e(348)]).then(__webpack_require__.bind(__webpack_require__, 967)); + registerJdkResolution(this.getFloatingResolutionRequest(javaRelease), javaRelease); + } + getFloatingResolutionRequest(javaRelease) { + return { + distribution: this.distribution, + packageType: this.packageType, + architecture: this.architecture, + versionSpec: this.version, + stable: this.stable, + source: this.getJdkReleaseIdentity(javaRelease) + }; + } logSetupError(error) { const httpStatusCode = error instanceof tool_cache/* HTTPError */.Hl ? error.httpStatusCode @@ -543,6 +613,9 @@ class JavaBase { if (javaRelease.checksum) { return `${javaRelease.checksum.algorithm}:${javaRelease.checksum.value}`; } + if (javaRelease.fingerprint) { + return javaRelease.fingerprint; + } try { const url = new URL(javaRelease.url); return `${url.origin}${url.pathname}`; @@ -551,6 +624,15 @@ class JavaBase { return javaRelease.url; } } + /** + * Whether the release identity pins the exact bytes behind `url`. A floating + * URL is a constant string, so it only becomes a safe cache identity once a + * checksum or a response validator distinguishes one published build from the + * next. + */ + hasStableReleaseIdentity(javaRelease) { + return Boolean(javaRelease.checksum ?? javaRelease.fingerprint); + } findInToolcache() { // we can't use tc.find directly because firstly, we need to filter versions by stability flag // if *-ea is provided, take only ea versions from toolcache, otherwise - only stable versions diff --git a/dist/setup/348.index.js b/dist/setup/348.index.js index f9427538..c92c4de7 100644 --- a/dist/setup/348.index.js +++ b/dist/setup/348.index.js @@ -149,7 +149,8 @@ function getResolutionIdentity(request) { packageType: request.packageType.toLowerCase(), architecture: request.architecture.toLowerCase(), versionSpec: request.versionSpec, - stable: request.stable + stable: request.stable, + source: request.source }); return (0,crypto__WEBPACK_IMPORTED_MODULE_0__.createHash)('sha256').update(identity).digest('hex'); } @@ -191,6 +192,7 @@ function parseResolvedRelease(contents) { const version = candidate['version']; const url = candidate['url']; const signatureUrl = candidate['signatureUrl']; + const floating = candidate['floating']; if (typeof version !== 'string' || !version) { throw new Error('The cached resolution has no version.'); } @@ -198,6 +200,9 @@ function parseResolvedRelease(contents) { if (signatureUrl !== undefined) { assertHttpsUrl(signatureUrl, 'signatureUrl'); } + if (floating !== undefined && typeof floating !== 'boolean') { + throw new Error('The cached resolution has an invalid floating flag.'); + } const release = { version, url: url @@ -205,6 +210,9 @@ function parseResolvedRelease(contents) { if (signatureUrl !== undefined) { release.signatureUrl = signatureUrl; } + if (floating !== undefined) { + release.floating = floating; + } const checksum = candidate['checksum']; if (checksum !== undefined) { release.checksum = parseChecksum(checksum); diff --git a/dist/setup/968.index.js b/dist/setup/968.index.js index 0cc0ea94..b4f7f49a 100644 --- a/dist/setup/968.index.js +++ b/dist/setup/968.index.js @@ -60,15 +60,23 @@ class GraalVMDistribution extends _base_installer_js__WEBPACK_IMPORTED_MODULE_4_ throw new Error('Extraction failed: no files found in extracted directory'); } const archivePath = path__WEBPACK_IMPORTED_MODULE_2___default().join(extractedJavaPath, dirContents[0]); - const version = this.getToolcacheVersionName(javaRelease.version); + const installedVersion = javaRelease.floating + ? (0,_util_js__WEBPACK_IMPORTED_MODULE_6__/* .getJavaVersionFromReleaseFile */ .C4)(archivePath) + : javaRelease.version; + const version = this.getToolcacheVersionName(installedVersion); const javaPath = await (0,_util_js__WEBPACK_IMPORTED_MODULE_6__/* .cacheJdkDir */ .Vj)(archivePath, this.toolcacheFolderName, version, this.architecture); - return { version: javaRelease.version, path: javaPath }; + return { version: installedVersion, path: javaPath }; } catch (error) { _actions_core__WEBPACK_IMPORTED_MODULE_0__/* .error */ .z3(`Failed to download and extract GraalVM: ${error}`); throw error; } } + requiresRemoteResolution() { + return (this.distribution === 'GraalVM' && + this.stable && + !this.version.includes('.')); + } setJavaDefault(version, toolPath) { super.setJavaDefault(version, toolPath); _actions_core__WEBPACK_IMPORTED_MODULE_0__/* .exportVariable */ .dN('GRAALVM_HOME', toolPath); @@ -89,13 +97,17 @@ class GraalVMDistribution extends _base_installer_js__WEBPACK_IMPORTED_MODULE_4_ const fileUrl = this.constructFileUrl(range, major, platform, arch, extension); const response = await this.http.head(fileUrl); this.handleHttpResponse(response, range); + // A major-only range resolves to the vendor's `/latest/` path, whose + // contents change when a new build is published. + const floating = !range.includes('.'); return { url: fileUrl, version: range, checksum: await this.fetchChecksum(`${fileUrl}.sha256`, 'sha256'), - // A major-only range resolves to the vendor's `/latest/` path, whose - // contents change when a new build is published. - floating: !range.includes('.') + floating, + fingerprint: floating + ? (0,_util_js__WEBPACK_IMPORTED_MODULE_6__/* .getArtifactFingerprint */ .VX)(response.message.headers) + : undefined }; } validateVersionRange(range) { diff --git a/dist/setup/index.js b/dist/setup/index.js index 17d8e1d9..4b7ec051 100644 --- a/dist/setup/index.js +++ b/dist/setup/index.js @@ -31235,12 +31235,14 @@ function validateToolchainIds(versions, versionFile, toolchainIds) { /***/ ((__unused_webpack_module, __webpack_exports__, __nccwpck_require__) => { /* harmony export */ __nccwpck_require__.d(__webpack_exports__, { +/* harmony export */ C4: () => (/* binding */ getJavaVersionFromReleaseFile), /* harmony export */ G4: () => (/* binding */ getTempDir), /* harmony export */ OS: () => (/* binding */ getVersionFromFileContent), /* harmony export */ PE: () => (/* binding */ extractJdkFile), /* harmony export */ SA: () => (/* binding */ validatePaginationUrl), /* harmony export */ Tp: () => (/* binding */ MAX_PAGINATION_PAGES), /* harmony export */ U_: () => (/* binding */ getGitHubHttpHeaders), +/* harmony export */ VX: () => (/* binding */ getArtifactFingerprint), /* harmony export */ Vj: () => (/* binding */ cacheJdkDir), /* harmony export */ Vt: () => (/* binding */ getBooleanInput), /* harmony export */ ZY: () => (/* binding */ convertVersionToSemver), @@ -31418,6 +31420,46 @@ async function cacheJdkDir(sourceDir, toolName, version, architecture) { } return await _actions_tool_cache__WEBPACK_IMPORTED_MODULE_5__/* .cacheDir */ .e8(sourceDir, toolName, version, architecture); } +function getJavaVersionFromReleaseFile(javaHome) { + const releasePaths = [ + path__WEBPACK_IMPORTED_MODULE_1___default().join(javaHome, 'release'), + path__WEBPACK_IMPORTED_MODULE_1___default().join(javaHome, 'Contents', 'Home', 'release') + ]; + const releasePath = releasePaths.find(candidate => fs__WEBPACK_IMPORTED_MODULE_2__.existsSync(candidate)); + if (!releasePath) { + throw new Error(`Unable to determine the installed Java version: no release file found under '${javaHome}'.`); + } + const properties = new Map(); + for (const line of fs__WEBPACK_IMPORTED_MODULE_2__.readFileSync(releasePath, 'utf8').split(/\r?\n/)) { + const match = line.match(/^([A-Z0-9_]+)="(.*)"$/); + if (match) { + properties.set(match[1], match[2]); + } + } + const runtimeVersion = properties.get('JAVA_RUNTIME_VERSION'); + const runtimeMatch = runtimeVersion?.match(/^(\d+(?:\.\d+)*(?:\+\d+(?:\.\d+)*)?)/); + if (runtimeMatch) { + return normalizeJavaReleaseVersion(runtimeMatch[1]); + } + const javaVersion = properties.get('JAVA_VERSION'); + if (javaVersion && /^\d+(?:\.\d+)*$/.test(javaVersion)) { + return normalizeJavaReleaseVersion(javaVersion); + } + throw new Error(`Unable to determine the installed Java version from '${releasePath}'.`); +} +function normalizeJavaReleaseVersion(version) { + const [numericVersion, buildVersion] = version.split('+', 2); + const components = numericVersion.split('.'); + while (components.length < 3) { + components.push('0'); + } + const mainVersion = components.slice(0, 3).join('.'); + const build = [ + ...components.slice(3), + ...(buildVersion ? [buildVersion] : []) + ]; + return build.length > 0 ? `${mainVersion}+${build.join('.')}` : mainVersion; +} function getToolcacheDestination(toolName, version, architecture) { const toolcacheRoot = process.env['RUNNER_TOOL_CACHE']; if (!toolcacheRoot) { @@ -31608,6 +31650,38 @@ function convertVersionToSemver(version) { } return mainVersion; } +/** + * Builds a validator for the bytes currently served by a URL from the response + * headers of a HEAD request. A vendor's `/latest/` URL never changes, so this + * is what lets a republished artifact be told apart from the previous one when + * no checksum is published alongside it. + * + * Returns `undefined` when the response carries no usable validator, in which + * case the caller must not treat the URL as a stable identity. + */ +function getArtifactFingerprint(headers) { + const readHeader = (name) => { + const value = headers?.[name]; + const resolved = Array.isArray(value) ? value[0] : value; + return typeof resolved === 'string' && resolved.trim() + ? resolved.trim() + : undefined; + }; + // A strong or weak ETag already identifies a specific representation. + const etag = readHeader('etag'); + if (etag) { + return `etag:${etag}`; + } + // Otherwise combine the two validators a static file server reliably sends. + // Neither alone is sufficient: `last-modified` has one-second granularity and + // `content-length` is unchanged by a same-size rebuild. + const lastModified = readHeader('last-modified'); + const contentLength = readHeader('content-length'); + if (lastModified && contentLength) { + return `mtime:${lastModified};length:${contentLength}`; + } + return undefined; +} function getGitHubHttpHeaders() { const resolvedToken = _actions_core__WEBPACK_IMPORTED_MODULE_4__/* .getInput */ .V4('token') || process.env.GITHUB_TOKEN; const auth = !resolvedToken ? undefined : `token ${resolvedToken}`; diff --git a/src/distributions/base-installer.ts b/src/distributions/base-installer.ts index 519bd199..a4576813 100644 --- a/src/distributions/base-installer.ts +++ b/src/distributions/base-installer.ts @@ -173,33 +173,34 @@ export abstract class JavaBase { } let foundJava = this.forceDownload ? null : this.findInToolcache(); - if (foundJava && !this.checkLatest && !this.latest) { + if ( + foundJava && + !this.checkLatest && + !this.latest && + !this.requiresRemoteResolution() + ) { core.info(`Resolved Java ${foundJava.version} from tool-cache`); } else { core.info('Trying to resolve the latest version from remote'); try { - const javaRelease = await this.resolveJavaRelease(); + 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; + } if (!this.forceDownload && foundJava?.version === javaRelease.version) { core.info(`Resolved Java ${foundJava.version} from tool-cache`); } else { - let jdkCache: JdkCache | undefined; - if (this.cacheJdk) { - const {getJdkVerificationIdentity} = - await import('../jdk-cache.js'); - jdkCache = { - distribution: this.distribution, - packageType: this.packageType, - architecture: this.architecture, - version: javaRelease.version, - source: this.getJdkReleaseIdentity(javaRelease), - verification: getJdkVerificationIdentity( - this.verifySignature, - this.verifySignaturePublicKey - ), - path: this.getJdkCachePath(javaRelease.version) - }; - } + let jdkCache = + this.cacheJdk && + (!javaRelease.floating || + (this.hasStableReleaseIdentity(javaRelease) && + semver.valid(javaRelease.version))) + ? await this.createJdkCache(javaRelease) + : undefined; if (!this.forceDownload && jdkCache) { const {restoreJdk} = await import('../jdk-cache.js'); const restored = await restoreJdk(jdkCache); @@ -217,6 +218,22 @@ export abstract class JavaBase { core.info('Trying to download...'); foundJava = await this.downloadTool(javaRelease); core.info(`Java ${foundJava.version} was downloaded`); + if (javaRelease.floating) { + if ( + !semver.valid(foundJava.version) || + !isVersionSatisfies(this.version, foundJava.version) + ) { + throw new Error( + `The downloaded ${this.distribution} artifact reported Java ${foundJava.version}, which does not satisfy '${this.version}'.` + ); + } + javaRelease = {...javaRelease, version: foundJava.version}; + await this.registerFloatingResolution(javaRelease); + jdkCache = + this.cacheJdk && this.hasStableReleaseIdentity(javaRelease) + ? await this.createJdkCache(javaRelease) + : undefined; + } if (jdkCache) { // Register after the installation exists so its identity is // captured; the post-job save refuses to upload a path whose @@ -272,9 +289,11 @@ export abstract class JavaBase { !this.cacheJdk || this.checkLatest || this.latest || - this.forceDownload + this.forceDownload || + this.requiresRemoteResolution() ) { - return this.findPackageForDownload(this.version); + const release = await this.findPackageForDownload(this.version); + return this.restoreFloatingResolution(release); } const {restoreJdkResolution, registerJdkResolution} = @@ -300,7 +319,7 @@ export abstract class JavaBase { if (!javaRelease.floating) { registerJdkResolution(request, javaRelease); } - return javaRelease; + return this.restoreFloatingResolution(javaRelease); } catch (error) { if (!restored) { throw error; @@ -317,6 +336,92 @@ export abstract class JavaBase { } } + protected requiresRemoteResolution(): boolean { + return false; + } + + private async createJdkCache( + javaRelease: JavaDownloadRelease + ): Promise { + const {getJdkVerificationIdentity} = await import('../jdk-cache.js'); + return { + distribution: this.distribution, + packageType: this.packageType, + architecture: this.architecture, + version: javaRelease.version, + source: this.getJdkReleaseIdentity(javaRelease), + verification: getJdkVerificationIdentity( + this.verifySignature, + this.verifySignaturePublicKey + ), + path: this.getJdkCachePath(javaRelease.version) + }; + } + + private async restoreFloatingResolution( + javaRelease: JavaDownloadRelease + ): Promise { + if ( + !javaRelease.floating || + !this.hasStableReleaseIdentity(javaRelease) || + !this.cacheJdk || + this.forceDownload + ) { + return javaRelease; + } + + const {restoreJdkResolution} = await import('../jdk-resolution-cache.js'); + const restored = await restoreJdkResolution( + this.getFloatingResolutionRequest(javaRelease) + ); + if (!restored) { + return javaRelease; + } + if ( + !semver.valid(restored.release.version) || + !isVersionSatisfies(this.version, restored.release.version) + ) { + core.debug( + `Ignoring the cached concrete version '${restored.release.version}' for ${this.distribution} ${this.version}.` + ); + return javaRelease; + } + + core.info( + `Resolved ${this.distribution} ${restored.release.version} for the current floating artifact` + ); + return {...javaRelease, version: restored.release.version}; + } + + private async registerFloatingResolution( + javaRelease: JavaDownloadRelease + ): Promise { + if ( + !this.hasStableReleaseIdentity(javaRelease) || + !this.cacheJdk || + this.forceDownload + ) { + return; + } + + const {registerJdkResolution} = await import('../jdk-resolution-cache.js'); + registerJdkResolution( + this.getFloatingResolutionRequest(javaRelease), + javaRelease + ); + } + + private getFloatingResolutionRequest(javaRelease: JavaDownloadRelease) { + return { + distribution: this.distribution, + packageType: this.packageType, + architecture: this.architecture, + versionSpec: this.version, + stable: this.stable, + source: this.getJdkReleaseIdentity(javaRelease) + }; + } + private logSetupError(error: any): void { const httpStatusCode = error instanceof tc.HTTPError @@ -430,6 +535,9 @@ export abstract class JavaBase { if (javaRelease.checksum) { return `${javaRelease.checksum.algorithm}:${javaRelease.checksum.value}`; } + if (javaRelease.fingerprint) { + return javaRelease.fingerprint; + } try { const url = new URL(javaRelease.url); return `${url.origin}${url.pathname}`; @@ -438,6 +546,16 @@ export abstract class JavaBase { } } + /** + * Whether the release identity pins the exact bytes behind `url`. A floating + * URL is a constant string, so it only becomes a safe cache identity once a + * checksum or a response validator distinguishes one published build from the + * next. + */ + private hasStableReleaseIdentity(javaRelease: JavaDownloadRelease): boolean { + return Boolean(javaRelease.checksum ?? javaRelease.fingerprint); + } + protected findInToolcache(): JavaInstallerResults | null { // we can't use tc.find directly because firstly, we need to filter versions by stability flag // if *-ea is provided, take only ea versions from toolcache, otherwise - only stable versions diff --git a/src/distributions/base-models.ts b/src/distributions/base-models.ts index ac4ddbd3..c3559f10 100644 --- a/src/distributions/base-models.ts +++ b/src/distributions/base-models.ts @@ -35,4 +35,12 @@ export interface JavaDownloadRelease { * be reused by a later job. */ floating?: boolean; + /** + * Validator identifying the exact bytes a mutable `url` currently serves, + * derived from the response headers of the HEAD request that resolved it. + * Used as the cache identity for a floating release when the vendor + * publishes no checksum, so that a republished artifact produces a different + * identity instead of being masked by the constant URL. + */ + fingerprint?: string; } diff --git a/src/distributions/graalvm/installer.ts b/src/distributions/graalvm/installer.ts index 4666ae20..fb0aa3ad 100644 --- a/src/distributions/graalvm/installer.ts +++ b/src/distributions/graalvm/installer.ts @@ -14,8 +14,10 @@ import { cacheJdkDir, convertVersionToSemver, extractJdkFile, + getArtifactFingerprint, getDownloadArchiveExtension, getGitHubHttpHeaders, + getJavaVersionFromReleaseFile, getLatestMajorVersion, getNextPageUrlFromLinkHeader, isVersionSatisfies, @@ -95,7 +97,10 @@ export class GraalVMDistribution extends JavaBase { } const archivePath = path.join(extractedJavaPath, dirContents[0]); - const version = this.getToolcacheVersionName(javaRelease.version); + const installedVersion = javaRelease.floating + ? getJavaVersionFromReleaseFile(archivePath) + : javaRelease.version; + const version = this.getToolcacheVersionName(installedVersion); const javaPath = await cacheJdkDir( archivePath, @@ -104,13 +109,21 @@ export class GraalVMDistribution extends JavaBase { this.architecture ); - return {version: javaRelease.version, path: javaPath}; + return {version: installedVersion, path: javaPath}; } catch (error) { core.error(`Failed to download and extract GraalVM: ${error}`); throw error; } } + protected requiresRemoteResolution(): boolean { + return ( + this.distribution === 'GraalVM' && + this.stable && + !this.version.includes('.') + ); + } + protected setJavaDefault(version: string, toolPath: string): void { super.setJavaDefault(version, toolPath); core.exportVariable('GRAALVM_HOME', toolPath); @@ -146,13 +159,18 @@ export class GraalVMDistribution extends JavaBase { const response = await this.http.head(fileUrl); this.handleHttpResponse(response, range); + // A major-only range resolves to the vendor's `/latest/` path, whose + // contents change when a new build is published. + const floating = !range.includes('.'); + return { url: fileUrl, version: range, checksum: await this.fetchChecksum(`${fileUrl}.sha256`, 'sha256'), - // A major-only range resolves to the vendor's `/latest/` path, whose - // contents change when a new build is published. - floating: !range.includes('.') + floating, + fingerprint: floating + ? getArtifactFingerprint(response.message.headers) + : undefined }; } diff --git a/src/distributions/oracle/installer.ts b/src/distributions/oracle/installer.ts index 7610de02..ca4b2902 100644 --- a/src/distributions/oracle/installer.ts +++ b/src/distributions/oracle/installer.ts @@ -12,7 +12,9 @@ import { import { cacheJdkDir, extractJdkFile, + getArtifactFingerprint, getDownloadArchiveExtension, + getJavaVersionFromReleaseFile, getLatestMajorVersion, renameWinArchive } from '../../util.js'; @@ -43,7 +45,10 @@ export class OracleDistribution extends JavaBase { const archiveName = fs.readdirSync(extractedJavaPath)[0]; const archivePath = path.join(extractedJavaPath, archiveName); - const version = this.getToolcacheVersionName(javaRelease.version); + const installedVersion = javaRelease.floating + ? getJavaVersionFromReleaseFile(archivePath) + : javaRelease.version; + const version = this.getToolcacheVersionName(installedVersion); const javaPath = await cacheJdkDir( archivePath, @@ -52,7 +57,11 @@ export class OracleDistribution extends JavaBase { this.architecture ); - return {version: javaRelease.version, path: javaPath}; + return {version: installedVersion, path: javaPath}; + } + + protected requiresRemoteResolution(): boolean { + return this.stable && !this.version.includes('.'); } protected async findPackageForDownload( @@ -113,11 +122,15 @@ export class OracleDistribution extends JavaBase { const response = await this.http.head(url); if (response.message.statusCode === HttpCodes.OK) { + const floating = url === floatingUrl; return { url, version: range, checksum: await this.fetchChecksum(`${url}.sha256`, 'sha256'), - floating: url === floatingUrl + floating, + fingerprint: floating + ? getArtifactFingerprint(response.message.headers) + : undefined }; } diff --git a/src/jdk-resolution-cache.ts b/src/jdk-resolution-cache.ts index 6c513db3..b23b0122 100644 --- a/src/jdk-resolution-cache.ts +++ b/src/jdk-resolution-cache.ts @@ -25,6 +25,12 @@ export interface JdkResolutionRequest { architecture: string; versionSpec: string; stable: boolean; + /** + * Immutable identity of a remotely resolved artifact. When present, even an + * older cache bucket is safe to reuse because changed bytes produce a + * different request identity. + */ + source?: string; } export interface RestoredJdkResolution { @@ -210,7 +216,8 @@ function getResolutionIdentity(request: JdkResolutionRequest): string { packageType: request.packageType.toLowerCase(), architecture: request.architecture.toLowerCase(), versionSpec: request.versionSpec, - stable: request.stable + stable: request.stable, + source: request.source }); return createHash('sha256').update(identity).digest('hex'); } @@ -257,6 +264,7 @@ function parseResolvedRelease(contents: string): JavaDownloadRelease { const version = candidate['version']; const url = candidate['url']; const signatureUrl = candidate['signatureUrl']; + const floating = candidate['floating']; if (typeof version !== 'string' || !version) { throw new Error('The cached resolution has no version.'); @@ -265,6 +273,9 @@ function parseResolvedRelease(contents: string): JavaDownloadRelease { if (signatureUrl !== undefined) { assertHttpsUrl(signatureUrl, 'signatureUrl'); } + if (floating !== undefined && typeof floating !== 'boolean') { + throw new Error('The cached resolution has an invalid floating flag.'); + } const release: JavaDownloadRelease = { version, @@ -273,6 +284,9 @@ function parseResolvedRelease(contents: string): JavaDownloadRelease { if (signatureUrl !== undefined) { release.signatureUrl = signatureUrl as string; } + if (floating !== undefined) { + release.floating = floating as boolean; + } const checksum = candidate['checksum']; if (checksum !== undefined) { diff --git a/src/util.ts b/src/util.ts index b2f1ca14..8c6f0b2f 100644 --- a/src/util.ts +++ b/src/util.ts @@ -14,7 +14,7 @@ import { DISTRIBUTIONS_ONLY_MAJOR_VERSION, INPUT_CACHE_JDK } from './constants.js'; -import {OutgoingHttpHeaders} from 'http'; +import {IncomingHttpHeaders, OutgoingHttpHeaders} from 'http'; export function getTempDir() { const tempDirectory = process.env['RUNNER_TEMP'] || os.tmpdir(); @@ -192,6 +192,59 @@ export async function cacheJdkDir( return await tc.cacheDir(sourceDir, toolName, version, architecture); } +export function getJavaVersionFromReleaseFile(javaHome: string): string { + const releasePaths = [ + path.join(javaHome, 'release'), + path.join(javaHome, 'Contents', 'Home', 'release') + ]; + const releasePath = releasePaths.find(candidate => fs.existsSync(candidate)); + if (!releasePath) { + throw new Error( + `Unable to determine the installed Java version: no release file found under '${javaHome}'.` + ); + } + + const properties = new Map(); + for (const line of fs.readFileSync(releasePath, 'utf8').split(/\r?\n/)) { + const match = line.match(/^([A-Z0-9_]+)="(.*)"$/); + if (match) { + properties.set(match[1], match[2]); + } + } + + const runtimeVersion = properties.get('JAVA_RUNTIME_VERSION'); + const runtimeMatch = runtimeVersion?.match( + /^(\d+(?:\.\d+)*(?:\+\d+(?:\.\d+)*)?)/ + ); + if (runtimeMatch) { + return normalizeJavaReleaseVersion(runtimeMatch[1]); + } + + const javaVersion = properties.get('JAVA_VERSION'); + if (javaVersion && /^\d+(?:\.\d+)*$/.test(javaVersion)) { + return normalizeJavaReleaseVersion(javaVersion); + } + + throw new Error( + `Unable to determine the installed Java version from '${releasePath}'.` + ); +} + +function normalizeJavaReleaseVersion(version: string): string { + const [numericVersion, buildVersion] = version.split('+', 2); + const components = numericVersion.split('.'); + while (components.length < 3) { + components.push('0'); + } + + const mainVersion = components.slice(0, 3).join('.'); + const build = [ + ...components.slice(3), + ...(buildVersion ? [buildVersion] : []) + ]; + return build.length > 0 ? `${mainVersion}+${build.join('.')}` : mainVersion; +} + function getToolcacheDestination( toolName: string, version: string, @@ -453,6 +506,44 @@ export function convertVersionToSemver(version: number[] | string) { return mainVersion; } +/** + * Builds a validator for the bytes currently served by a URL from the response + * headers of a HEAD request. A vendor's `/latest/` URL never changes, so this + * is what lets a republished artifact be told apart from the previous one when + * no checksum is published alongside it. + * + * Returns `undefined` when the response carries no usable validator, in which + * case the caller must not treat the URL as a stable identity. + */ +export function getArtifactFingerprint( + headers: IncomingHttpHeaders | undefined +): string | undefined { + const readHeader = (name: string): string | undefined => { + const value = headers?.[name]; + const resolved = Array.isArray(value) ? value[0] : value; + return typeof resolved === 'string' && resolved.trim() + ? resolved.trim() + : undefined; + }; + + // A strong or weak ETag already identifies a specific representation. + const etag = readHeader('etag'); + if (etag) { + return `etag:${etag}`; + } + + // Otherwise combine the two validators a static file server reliably sends. + // Neither alone is sufficient: `last-modified` has one-second granularity and + // `content-length` is unchanged by a same-size rebuild. + const lastModified = readHeader('last-modified'); + const contentLength = readHeader('content-length'); + if (lastModified && contentLength) { + return `mtime:${lastModified};length:${contentLength}`; + } + + return undefined; +} + export function getGitHubHttpHeaders(): OutgoingHttpHeaders { const resolvedToken = core.getInput('token') || process.env.GITHUB_TOKEN; const auth = !resolvedToken ? undefined : `token ${resolvedToken}`;