Add open-source 'basic' cache provider and revamp licensing documentation (#930)

## Summary

- **New `basic` cache provider**: Adds an open-source (MIT-licensed)
caching implementation built on `@actions/cache` as an alternative to
the proprietary Enhanced Caching. Users can opt in with `cache-provider:
basic` on both `setup-gradle` and `dependency-submission` actions.
- **Revamped licensing & distribution docs**: Replaces the verbose
licensing notice block (previously shown in README, docs, and logs) with
a friendlier callout and a new dedicated
[DISTRIBUTION.md](./DISTRIBUTION.md) covering component licensing, usage
tiers, data privacy ("Safe Harbor"), and opt-out instructions.
- **Improved messaging**: Enhanced Caching and Basic Caching each
display concise, informative log messages and job summary notes instead
of the previous wall-of-text license warning.
- **New integration tests**: Adds `integ-test-basic-cache-provider.yml`
workflow that seeds and verifies the basic cache provider across
platforms, plus unit tests for `BasicCacheService` and `getCacheService`
selection logic.
- **CI workflow reorganization**: Dependency-submission integration
tests extracted into their own reusable suite
(`suite-integ-test-dependency-submission.yml`); sample project tests
moved into the caching suite.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Daz DeBoer
2026-04-02 21:36:01 -06:00
committed by GitHub
parent ac396bf1a8
commit ff9ae24c39
23 changed files with 723 additions and 119 deletions
+85
View File
@@ -0,0 +1,85 @@
import * as cache from '@actions/cache'
import * as core from '@actions/core'
import * as glob from '@actions/glob'
import * as path from 'path'
import {BuildResult} from './build-results'
import {CacheOptions, CacheService} from './cache-service'
const PRIMARY_KEY_STATE = 'BASIC_CACHE_PRIMARY_KEY'
const RESTORED_KEY_STATE = 'BASIC_CACHE_RESTORED_KEY'
const CACHE_KEY_PREFIX = 'setup-java'
const GRADLE_BUILD_FILE_PATTERNS = [
'**/*.gradle*',
'**/gradle-wrapper.properties',
'buildSrc/**/Versions.kt',
'buildSrc/**/Dependencies.kt',
'gradle/*.versions.toml',
'**/versions.properties'
]
export class BasicCacheService implements CacheService {
async restore(gradleUserHome: string, _cacheOptions: CacheOptions): Promise<void> {
const cachePaths = getCachePaths(gradleUserHome)
const primaryKey = await computeCacheKey()
core.saveState(PRIMARY_KEY_STATE, primaryKey)
// No "restoreKeys" is set, to start with a clear cache after dependency update
// See https://github.com/actions/setup-java/issues/269
try {
const restoredKey = await cache.restoreCache(cachePaths, primaryKey)
if (restoredKey) {
core.saveState(RESTORED_KEY_STATE, restoredKey)
core.info(`Basic caching restored from cache key: ${restoredKey}`)
} else {
core.info('Basic caching did not find an entry to restore. Will start with empty state.')
}
} catch (error) {
core.warning(`Basic caching failed to restore from cache: ${error}`)
}
}
async save(gradleUserHome: string, _buildResults: BuildResult[], cacheOptions: CacheOptions): Promise<string> {
if (cacheOptions.readOnly) {
const restoredKey = core.getState(RESTORED_KEY_STATE)
if (restoredKey) {
return `Basic caching was read-only. Restored from cache key \`${restoredKey}\`.`
}
return 'Basic caching was read-only. No cache entry was found to restore.'
}
const primaryKey = core.getState(PRIMARY_KEY_STATE)
const restoredKey = core.getState(RESTORED_KEY_STATE)
if (restoredKey === primaryKey) {
core.info(`Basic caching restored entry with key \`${primaryKey}\`. Save was skipped.`)
return `Basic caching restored entry with key \`${primaryKey}\`. Save was skipped.`
}
const cachePaths = getCachePaths(gradleUserHome)
try {
await cache.saveCache(cachePaths, primaryKey)
core.info(`Basic caching saved entry with key: ${primaryKey}`)
return `Basic caching saved entry with key \`${primaryKey}\`.`
} catch (error) {
core.warning(`Basic caching failed to save entry with key \`${primaryKey}\`: ${error}`)
return `Basic caching save failed: ${error}`
}
}
}
function getCachePaths(gradleUserHome: string): string[] {
return [path.join(gradleUserHome, 'caches'), path.join(gradleUserHome, 'wrapper')]
}
async function computeCacheKey(): Promise<string> {
const fileHash = await glob.hashFiles(GRADLE_BUILD_FILE_PATTERNS.join('\n'))
if (!fileHash) {
throw new Error(
`No file in ${process.cwd()} matched to [${GRADLE_BUILD_FILE_PATTERNS}], make sure you have checked out the target repository`
)
}
return `${CACHE_KEY_PREFIX}-${process.env['RUNNER_OS']}-${process.arch}-gradle-${fileHash}`
}
+31 -34
View File
@@ -2,7 +2,8 @@ import * as fs from 'fs'
import * as path from 'path'
import {pathToFileURL} from 'url'
import {CacheConfig} from './configuration'
import {CacheConfig, CacheProvider} from './configuration'
import {BasicCacheService} from './cache-service-basic'
import {BuildResult} from './build-results'
import {CacheOptions, CacheService} from './cache-service'
@@ -10,36 +11,24 @@ const NOOP_CACHING_REPORT = `
[Cache was disabled](https://github.com/gradle/actions/blob/main/docs/setup-gradle.md#disabling-caching). Gradle User Home was not restored from or saved to the cache.
`
const CACHE_LICENSE_WARNING = `
***********************************************************
LICENSING NOTICE
const ENHANCED_CACHE_MESSAGE = `Enhanced Caching: This build is using the proprietary 'gradle-actions-caching' provider for optimized caching support. See https://github.com/gradle/actions/blob/main/DISTRIBUTION.md for terms of use and opt-out instructions.`
The caching functionality in \`gradle-actions\` has been extracted into \`gradle-actions-caching\`, a proprietary commercial component that is not covered by the MIT License.
The bundled \`gradle-actions-caching\` component is licensed and governed by a separate license, available at https://gradle.com/legal/terms-of-use/.
The \`gradle-actions-caching\` component is used only when caching is enabled and is not loaded or used when caching is disabled.
Use of the \`gradle-actions-caching\` component is subject to a separate license, available at https://gradle.com/legal/terms-of-use/.
If you do not agree to these license terms, do not use the \`gradle-actions-caching\` component.
You can suppress this message by accepting the terms in your action configuration: see https://github.com/gradle/actions/blob/main/README.md
***********************************************************
const ENHANCED_CACHE_SUMMARY = `
> [!NOTE]
> ### ⚡️ Enhanced Caching enabled
> This build provides optimized caching support via the proprietary **gradle-actions-caching** provider.
> See [DISTRIBUTION.md](https://github.com/gradle/actions/blob/main/DISTRIBUTION.md) for terms of use and opt-out instructions.
`
const CACHE_LICENSE_SUMMARY = `
> [!IMPORTANT]
> #### Licensing notice
>
> The caching functionality in \`gradle-actions\` has been extracted into \`gradle-actions-caching\`, a proprietary commercial component that is not covered by the MIT License.
> The bundled \`gradle-actions-caching\` component is licensed and governed by a separate license, available at https://gradle.com/legal/terms-of-use/.
>
> The \`gradle-actions-caching\` component is used only when caching is enabled and is not loaded or used when caching is disabled.
>
> Use of the \`gradle-actions-caching\` component is subject to a separate license, available at https://gradle.com/legal/terms-of-use/.
> If you do not agree to these license terms, do not use the \`gradle-actions-caching\` component.
>
>You can suppress this message by [accepting the terms in your action configuration](https://github.com/gradle/actions/blob/main/README.md).
`
const BASIC_CACHE_MESSAGE = `Basic Caching: This build uses the open-source caching provider for reliable, path-based caching of Gradle dependencies. Upgrade available: for faster builds and advanced features, consider switching to the Enhanced Caching provider. See https://github.com/gradle/actions/blob/main/DISTRIBUTION.md for details.`
const BASIC_CACHE_SUMMARY = `
> [!NOTE]
> ### 🛡️ Basic Caching enabled
> This build uses the open-source caching provider for reliable, path-based caching of Gradle dependencies.
>
> **Upgrade Available:** For faster builds and advanced features, consider switching to the **Enhanced Caching** provider.
> See [DISTRIBUTION.md](https://github.com/gradle/actions/blob/main/DISTRIBUTION.md) for details.`
class NoOpCacheService implements CacheService {
async restore(_gradleUserHome: string, _cacheOptions: CacheOptions): Promise<void> {
@@ -53,9 +42,11 @@ class NoOpCacheService implements CacheService {
class LicenseWarningCacheService implements CacheService {
private delegate: CacheService
private summary: string
constructor(delegate: CacheService) {
constructor(delegate: CacheService, summary: string) {
this.delegate = delegate
this.summary = summary
}
async restore(gradleUserHome: string, cacheOptions: CacheOptions): Promise<void> {
@@ -64,22 +55,28 @@ class LicenseWarningCacheService implements CacheService {
async save(gradleUserHome: string, buildResults: BuildResult[], cacheOptions: CacheOptions): Promise<string> {
const cachingReport = await this.delegate.save(gradleUserHome, buildResults, cacheOptions)
return `${cachingReport}\n${CACHE_LICENSE_SUMMARY}`
return `${cachingReport}\n${this.summary}`
}
}
export async function getCacheService(cacheConfig: CacheConfig): Promise<CacheService> {
if (cacheConfig.isCacheDisabled()) {
logCacheMessage('Cache is disabled: will not restore state from previous builds.')
return new NoOpCacheService()
}
if (cacheConfig.getCacheProvider() === CacheProvider.Basic) {
logCacheMessage(BASIC_CACHE_MESSAGE)
return new LicenseWarningCacheService(new BasicCacheService(), BASIC_CACHE_SUMMARY)
}
logCacheMessage(ENHANCED_CACHE_MESSAGE)
const cacheService = await loadVendoredCacheService()
if (cacheConfig.isCacheLicenseAccepted()) {
return cacheService
}
await logCacheLicenseWarning()
return new LicenseWarningCacheService(cacheService)
return new LicenseWarningCacheService(cacheService, ENHANCED_CACHE_SUMMARY)
}
export async function loadVendoredCacheService(): Promise<CacheService> {
@@ -99,6 +96,6 @@ function findVendoredLibraryPath(): string {
throw new Error(`Unable to locate vendored cache library at ${absolutePath}.`)
}
export async function logCacheLicenseWarning(): Promise<void> {
console.info(CACHE_LICENSE_WARNING)
export function logCacheMessage(message: string): void {
console.info(message)
}
+17
View File
@@ -171,6 +171,23 @@ export class CacheConfig {
const dvConfig = new DevelocityConfig()
return dvConfig.getDevelocityAccessKey() !== '' || dvConfig.hasTermsOfUseAgreement()
}
getCacheProvider(): CacheProvider {
const val = core.getInput('cache-provider')
switch (val.toLowerCase().trim()) {
case 'basic':
return CacheProvider.Basic
case 'enhanced':
case '':
return CacheProvider.Enhanced
}
throw TypeError(`The value '${val}' is not valid for 'cache-provider'. Valid values are: [basic, enhanced].`)
}
}
export enum CacheProvider {
Basic = 'basic',
Enhanced = 'enhanced'
}
export enum CacheCleanupOption {
@@ -0,0 +1,234 @@
import {describe, expect, it, jest, beforeEach} from '@jest/globals'
// Mock @actions/cache
const mockRestoreCache = jest.fn<(paths: string[], primaryKey: string, restoreKeys?: string[]) => Promise<string | undefined>>()
const mockSaveCache = jest.fn<(paths: string[], key: string) => Promise<number>>()
jest.unstable_mockModule('@actions/cache', () => ({
restoreCache: mockRestoreCache,
saveCache: mockSaveCache
}))
// Mock @actions/core
const mockInfo = jest.fn<(message: string) => void>()
const mockWarning = jest.fn<(message: string) => void>()
const mockSaveState = jest.fn<(name: string, value: string) => void>()
const mockGetState = jest.fn<(name: string) => string>()
jest.unstable_mockModule('@actions/core', () => ({
info: mockInfo,
warning: mockWarning,
saveState: mockSaveState,
getState: mockGetState
}))
// Mock @actions/glob
const mockHashFiles = jest.fn<(pattern: string) => Promise<string>>()
jest.unstable_mockModule('@actions/glob', () => ({
hashFiles: mockHashFiles
}))
const {BasicCacheService} = await import('../../src/cache-service-basic')
const HASH = 'abc123def456'
const PRIMARY_KEY = `setup-java-Linux-${process.arch}-gradle-${HASH}`
describe('BasicCacheService', () => {
let service: InstanceType<typeof BasicCacheService>
beforeEach(() => {
jest.clearAllMocks()
service = new BasicCacheService()
process.env['RUNNER_OS'] = 'Linux'
mockHashFiles.mockResolvedValue(HASH)
})
describe('restore', () => {
it('restores cache without restoreKeys and saves both keys to state', async () => {
mockRestoreCache.mockResolvedValue(PRIMARY_KEY)
await service.restore('/home/.gradle', {
disabled: false,
readOnly: false,
writeOnly: false,
overwriteExisting: false,
strictMatch: false,
cleanup: 'never',
includes: [],
excludes: []
})
// No restoreKeys parameter — exact match only (setup-java#269)
expect(mockRestoreCache).toHaveBeenCalledWith(
['/home/.gradle/caches', '/home/.gradle/wrapper'],
PRIMARY_KEY
)
expect(mockSaveState).toHaveBeenCalledWith('BASIC_CACHE_PRIMARY_KEY', PRIMARY_KEY)
expect(mockSaveState).toHaveBeenCalledWith('BASIC_CACHE_RESTORED_KEY', PRIMARY_KEY)
})
it('saves primary key to state even on cache miss', async () => {
mockRestoreCache.mockResolvedValue(undefined)
await service.restore('/home/.gradle', {
disabled: false,
readOnly: false,
writeOnly: false,
overwriteExisting: false,
strictMatch: false,
cleanup: 'never',
includes: [],
excludes: []
})
expect(mockSaveState).toHaveBeenCalledWith('BASIC_CACHE_PRIMARY_KEY', PRIMARY_KEY)
expect(mockSaveState).not.toHaveBeenCalledWith('BASIC_CACHE_RESTORED_KEY', expect.anything())
expect(mockInfo).toHaveBeenCalledWith(
expect.stringContaining('did not find')
)
})
it('warns on restore failure instead of throwing', async () => {
mockRestoreCache.mockRejectedValue(new Error('Network error'))
await service.restore('/home/.gradle', {
disabled: false,
readOnly: false,
writeOnly: false,
overwriteExisting: false,
strictMatch: false,
cleanup: 'never',
includes: [],
excludes: []
})
expect(mockWarning).toHaveBeenCalledWith(
expect.stringContaining('failed to restore')
)
})
it('throws when no build files are found', async () => {
mockHashFiles.mockResolvedValue('')
await expect(
service.restore('/home/.gradle', {
disabled: false,
readOnly: false,
writeOnly: false,
overwriteExisting: false,
strictMatch: false,
cleanup: 'never',
includes: [],
excludes: []
})
).rejects.toThrow('No file in')
})
})
describe('save', () => {
it('reports readOnly with restored key when cache was hit', async () => {
mockGetState.mockReturnValue(PRIMARY_KEY)
const report = await service.save('/home/.gradle', [], {
disabled: false,
readOnly: true,
writeOnly: false,
overwriteExisting: false,
strictMatch: false,
cleanup: 'never',
includes: [],
excludes: []
})
expect(mockSaveCache).not.toHaveBeenCalled()
expect(report).toContain('read-only')
expect(report).toContain(PRIMARY_KEY)
})
it('reports readOnly with no restore when cache was missed', async () => {
mockGetState.mockReturnValue('')
const report = await service.save('/home/.gradle', [], {
disabled: false,
readOnly: true,
writeOnly: false,
overwriteExisting: false,
strictMatch: false,
cleanup: 'never',
includes: [],
excludes: []
})
expect(mockSaveCache).not.toHaveBeenCalled()
expect(report).toContain('read-only')
expect(report).toContain('No cache entry')
})
it('skips save when restored key equals primary key', async () => {
mockGetState.mockImplementation((name: string) => {
if (name === 'BASIC_CACHE_PRIMARY_KEY') return PRIMARY_KEY
if (name === 'BASIC_CACHE_RESTORED_KEY') return PRIMARY_KEY
return ''
})
const report = await service.save('/home/.gradle', [], {
disabled: false,
readOnly: false,
writeOnly: false,
overwriteExisting: false,
strictMatch: false,
cleanup: 'never',
includes: [],
excludes: []
})
expect(mockSaveCache).not.toHaveBeenCalled()
expect(report).toContain('Save was skipped')
})
it('saves cache and returns report on success', async () => {
mockGetState.mockImplementation((name: string) => {
if (name === 'BASIC_CACHE_PRIMARY_KEY') return PRIMARY_KEY
return ''
})
mockSaveCache.mockResolvedValue(0)
const report = await service.save('/home/.gradle', [], {
disabled: false,
readOnly: false,
writeOnly: false,
overwriteExisting: false,
strictMatch: false,
cleanup: 'never',
includes: [],
excludes: []
})
expect(mockSaveCache).toHaveBeenCalledWith(
['/home/.gradle/caches', '/home/.gradle/wrapper'],
PRIMARY_KEY
)
expect(report).toContain('saved entry with key')
})
it('warns on save failure instead of throwing', async () => {
mockGetState.mockImplementation((name: string) => {
if (name === 'BASIC_CACHE_PRIMARY_KEY') return PRIMARY_KEY
return ''
})
mockSaveCache.mockRejectedValue(new Error('Storage full'))
const report = await service.save('/home/.gradle', [], {
disabled: false,
readOnly: false,
writeOnly: false,
overwriteExisting: false,
strictMatch: false,
cleanup: 'never',
includes: [],
excludes: []
})
expect(mockWarning).toHaveBeenCalledWith(
expect.stringContaining('failed to save')
)
expect(report).toContain('failed')
})
})
})
@@ -0,0 +1,50 @@
import {describe, expect, it, jest, beforeEach} from '@jest/globals'
import {CacheProvider} from '../../src/configuration'
import type {CacheConfig} from '../../src/configuration'
describe('getCacheService selection logic', () => {
beforeEach(() => {
jest.restoreAllMocks()
})
it('returns NoOpCacheService when cache is disabled', async () => {
const {getCacheService} = await import('../../src/cache-service-loader')
const mockConfig = {
isCacheDisabled: () => true,
getCacheProvider: () => CacheProvider.Enhanced,
isCacheLicenseAccepted: () => true
} as unknown as CacheConfig
const service = await getCacheService(mockConfig)
const report = await service.save('/home/.gradle', [], {
disabled: true,
readOnly: false,
writeOnly: false,
overwriteExisting: false,
strictMatch: false,
cleanup: 'never',
includes: [],
excludes: []
})
// NoOpCacheService returns a specific report mentioning cache was disabled
expect(report).toContain('Cache was disabled')
})
it('wraps BasicCacheService with LicenseWarningCacheService when cache-provider is basic', async () => {
const {getCacheService} = await import('../../src/cache-service-loader')
const mockConfig = {
isCacheDisabled: () => false,
getCacheProvider: () => CacheProvider.Basic,
isCacheLicenseAccepted: () => false
} as unknown as CacheConfig
const service = await getCacheService(mockConfig)
// The service should not be a bare BasicCacheService — it should be wrapped
// with LicenseWarningCacheService that appends the basic caching summary
const {BasicCacheService} = await import('../../src/cache-service-basic')
expect(service).not.toBeInstanceOf(BasicCacheService)
})
})