From 9c445f57df832c1704916a2831b378dc3bd6a38b Mon Sep 17 00:00:00 2001 From: Daz DeBoer Date: Tue, 16 Jun 2026 12:14:19 -0600 Subject: [PATCH] Support experimental project-entry caching (configuration-cache + build-logic) (#994) Pass develocityAccessToken and develocityServerUrl the `gradle-actions-caching`: required to support project-entry caching (build-logic + configuration-cache), which has experimental support in 'gradle-actions-cache@v0.8.0. This support is not yet released and will be available as a restricted trial. --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../src/actions/dependency-submission/post.ts | 4 +- sources/src/actions/setup-gradle/post.ts | 4 +- sources/src/cache-service.ts | 11 ++- sources/src/caching-report.ts | 22 +++--- sources/src/develocity/build-scan.ts | 9 +-- sources/src/develocity/short-lived-token.ts | 72 +++++++++++++------ sources/src/job-summary.ts | 16 ++--- sources/src/setup-gradle.ts | 35 +++++++-- sources/test/jest/caching-report.test.ts | 20 +++--- sources/test/jest/short-lived-token.test.ts | 41 ++++++++++- 10 files changed, 166 insertions(+), 68 deletions(-) diff --git a/sources/src/actions/dependency-submission/post.ts b/sources/src/actions/dependency-submission/post.ts index f47f2983..4138f26a 100644 --- a/sources/src/actions/dependency-submission/post.ts +++ b/sources/src/actions/dependency-submission/post.ts @@ -1,6 +1,6 @@ import * as setupGradle from '../../setup-gradle' -import {CacheConfig, SummaryConfig} from '../../configuration' +import {CacheConfig, DevelocityConfig, SummaryConfig} from '../../configuration' import {handlePostActionError} from '../../errors' import {forceExit} from '../../force-exit' @@ -14,7 +14,7 @@ process.on('uncaughtException', e => handlePostActionError(e)) */ export async function run(): Promise { try { - await setupGradle.complete(new CacheConfig(), new SummaryConfig()) + await setupGradle.complete(new CacheConfig(), new DevelocityConfig(), new SummaryConfig()) } catch (error) { handlePostActionError(error) } diff --git a/sources/src/actions/setup-gradle/post.ts b/sources/src/actions/setup-gradle/post.ts index 17870d97..734a426f 100644 --- a/sources/src/actions/setup-gradle/post.ts +++ b/sources/src/actions/setup-gradle/post.ts @@ -1,7 +1,7 @@ import * as setupGradle from '../../setup-gradle' import * as dependencyGraph from '../../dependency-graph' -import {CacheConfig, DependencyGraphConfig, SummaryConfig} from '../../configuration' +import {CacheConfig, DependencyGraphConfig, DevelocityConfig, SummaryConfig} from '../../configuration' import {handlePostActionError} from '../../errors' import {emitDeprecationWarnings, restoreDeprecationState} from '../../deprecation-collector' import {forceExit} from '../../force-exit' @@ -19,7 +19,7 @@ export async function run(): Promise { restoreDeprecationState() emitDeprecationWarnings() - if (await setupGradle.complete(new CacheConfig(), new SummaryConfig())) { + if (await setupGradle.complete(new CacheConfig(), new DevelocityConfig(), new SummaryConfig())) { // Only submit the dependency graphs once per job await dependencyGraph.complete(new DependencyGraphConfig()) } diff --git a/sources/src/cache-service.ts b/sources/src/cache-service.ts index a0d21595..2a824497 100644 --- a/sources/src/cache-service.ts +++ b/sources/src/cache-service.ts @@ -8,6 +8,8 @@ export interface CacheOptions { strictMatch: boolean cleanup: string encryptionKey?: string + develocityAccessToken?: string + develocityServerUrl?: string includes: string[] excludes: string[] } @@ -27,7 +29,12 @@ export type CacheCleanupStatus = | 'disabled-config-cache-hit' | 'disabled-readonly' -export type ConfigurationCacheStatus = 'not-active' | 'restored' | 'not-restored' | 'restore-incomplete' +export type ProjectCacheStatus = + | 'not-enabled' // the hidden opt-in env var was not set (rendered as nothing) + | 'trial-expired' // past the hard trial expiry + | 'trial-not-licensed' // Develocity trial token missing or invalid + | 'no-encryption-key' // Cannot store due to missing encryption key + | 'enabled' // Trial in effect: will attempt to save project state export interface CacheEntryReport { entryName: string @@ -49,7 +56,7 @@ export interface CacheEntryReport { export interface CacheReport { status: CacheStatus cleanup?: CacheCleanupStatus - configurationCache?: ConfigurationCacheStatus + projectCache?: ProjectCacheStatus entries: CacheEntryReport[] } diff --git a/sources/src/caching-report.ts b/sources/src/caching-report.ts index daaaffd5..36cbb99e 100644 --- a/sources/src/caching-report.ts +++ b/sources/src/caching-report.ts @@ -1,4 +1,4 @@ -import {CacheCleanupStatus, CacheEntryReport, CacheReport, CacheStatus, ConfigurationCacheStatus} from './cache-service' +import {CacheCleanupStatus, CacheEntryReport, CacheReport, CacheStatus, ProjectCacheStatus} from './cache-service' const DOCS = 'https://github.com/gradle/actions/blob/main/docs/setup-gradle.md' const DISTRIBUTION = 'https://github.com/gradle/actions/blob/main/DISTRIBUTION.md' @@ -28,11 +28,12 @@ const CLEANUP_COPY: Record = { 'disabled-readonly': `[Cache cleanup](${DOCS}#configuring-cache-cleanup) is always disabled when the cache is read-only.` } -const CONFIG_CACHE_COPY: Record = { - 'not-active': `Configuration cache state was not cached — set a [cache-encryption-key](${DOCS}#cache-encryption-key) to enable configuration-cache caching.`, - restored: `Configuration cache state was restored from the cache.`, - 'not-restored': `Configuration cache state was not restored — no cached data was available (e.g. the first run for this cache key).`, - 'restore-incomplete': `Configuration cache state was not restored — the Gradle User Home was not fully restored.` +const PROJECT_CACHE_COPY: Record = { + 'not-enabled': ``, + 'trial-expired': `Project state (build-logic and configuration cache) was not cached - the Develocity caching trial has expired.`, + 'trial-not-licensed': `Project state (build-logic and configuration cache) was not cached - a develocity-access-key and develocity-server-url is required.`, + 'no-encryption-key': `Project state (build-logic and configuration cache) was not cached - a [cache-encryption-key](${DOCS}#cache-encryption-key) is required.`, + enabled: `Caching of project state (build-logic and configuration cache) was enabled.` } /** @@ -76,8 +77,9 @@ function renderCleanupLine(cleanup?: CacheCleanupStatus): string | undefined { return cleanup ? CLEANUP_COPY[cleanup] : undefined } -function renderConfigCacheLine(configurationCache?: ConfigurationCacheStatus): string | undefined { - return configurationCache ? CONFIG_CACHE_COPY[configurationCache] : undefined +function renderProjectCacheLine(projectCache?: ProjectCacheStatus): string | undefined { + // PROJECT_CACHE_COPY['not-enabled'] is '', which the .filter(Boolean) at the call site drops. + return projectCache ? PROJECT_CACHE_COPY[projectCache] : undefined } function renderProviderNote(providerNote?: ProviderNote): string | undefined { @@ -99,10 +101,10 @@ function renderDetails(report: CacheReport): string { : `Entries: ${restored} restored, ${saved} saved - Expand for more details` const cleanup = report.status === 'enabled' ? renderCleanupLine(report.cleanup) : undefined - const configCache = renderConfigCacheLine(report.configurationCache) + const projectCache = renderProjectCacheLine(report.projectCache) const table = renderEntryTable(report.entries) const pre = `
\n${renderEntryDetails(report.entries)}
` - const body = [STATUS_COPY[report.status], cleanup, configCache, table, pre].filter(Boolean).join('\n\n') + const body = [STATUS_COPY[report.status], cleanup, projectCache, table, pre].filter(Boolean).join('\n\n') return `
${summary} diff --git a/sources/src/develocity/build-scan.ts b/sources/src/develocity/build-scan.ts index 24118102..15a11a5d 100644 --- a/sources/src/develocity/build-scan.ts +++ b/sources/src/develocity/build-scan.ts @@ -1,8 +1,7 @@ import * as core from '@actions/core' import {DevelocityConfig} from '../configuration' -import {setupToken} from './short-lived-token' -export async function setup(config: DevelocityConfig): Promise { +export function setup(config: DevelocityConfig): void { maybeExportVariable('DEVELOCITY_INJECTION_INIT_SCRIPT_NAME', 'gradle-actions.inject-develocity.init.gradle') maybeExportVariable('DEVELOCITY_INJECTION_CUSTOM_VALUE', 'gradle-actions') @@ -39,12 +38,6 @@ export async function setup(config: DevelocityConfig): Promise { maybeExportVariable('DEVELOCITY_INJECTION_TERMS_OF_USE_URL', config.getTermsOfUseUrl()) maybeExportVariable('DEVELOCITY_INJECTION_TERMS_OF_USE_AGREE', config.getTermsOfUseAgree()) } - - return setupToken( - config.getDevelocityAccessKey(), - config.getDevelocityAllowUntrustedServer(), - config.getDevelocityTokenExpiry() - ) } function maybeExportVariable(variableName: string, value: unknown): void { diff --git a/sources/src/develocity/short-lived-token.ts b/sources/src/develocity/short-lived-token.ts index 3708887f..d662fafd 100644 --- a/sources/src/develocity/short-lived-token.ts +++ b/sources/src/develocity/short-lived-token.ts @@ -3,28 +3,41 @@ import * as httpm from '@actions/http-client' import {DevelocityConfig} from '../configuration' import {recordDeprecation} from '../deprecation-collector' -export async function setupToken( - develocityAccessKey: string, - develocityAllowUntrustedServer: boolean | undefined, - develocityTokenExpiry: string -): Promise { - if (develocityAccessKey) { - try { - core.debug('Fetching short-lived token...') - const tokens = await getToken(develocityAccessKey, develocityAllowUntrustedServer, develocityTokenExpiry) - if (tokens != null && !tokens.isEmpty()) { - core.debug(`Got token(s), setting the access key env vars`) - const token = tokens.raw() - core.setSecret(token) - exportAccessKeyEnvVars(token) - } else { - handleMissingAccessToken() - } - } catch (e) { - handleMissingAccessToken() - core.warning(`Failed to fetch short-lived token, reason: ${e}`) - } +/** + * Exchange the configured Develocity access key(s) for short-lived tokens, export them as the access + * key env vars, and return the short-lived token matching the configured Develocity server URL (for + * use as the `develocityAccessToken` cache option). Returns `undefined` when there is no access key, + * token fetching fails, or no token matches the configured server. + */ +export async function setupToken(config: DevelocityConfig): Promise { + const develocityAccessKey = config.getDevelocityAccessKey() + if (!develocityAccessKey) { + return undefined } + try { + core.debug('Fetching short-lived token...') + const tokens = await getToken( + develocityAccessKey, + config.getDevelocityAllowUntrustedServer(), + config.getDevelocityTokenExpiry() + ) + if (tokens != null && !tokens.isEmpty()) { + core.debug(`Got token(s), setting the access key env vars`) + const token = tokens.raw() + core.setSecret(token) + exportAccessKeyEnvVars(token) + for (const k of tokens.keys) { + core.setSecret(k.key) + } + const serverUrl = config.getDevelocityUrl() + return serverUrl ? resolveTokenForServer(tokens, serverUrl) : undefined + } + handleMissingAccessToken() + } catch (e) { + handleMissingAccessToken() + core.warning(`Failed to fetch short-lived token, reason: ${e}`) + } + return undefined } function exportAccessKeyEnvVars(value: string): void { @@ -174,3 +187,20 @@ export class DevelocityAccessCredentials { return this.accessKeyRegexp.test(allKeys) } } + +/** + * Resolve the token whose hostname matches a given Develocity server URL. Returns `undefined` + * (fail-closed) when the server URL is empty or no token matches the server's host. + */ +export function resolveTokenForServer(tokens: DevelocityAccessCredentials, serverUrl: string): string | undefined { + if (!serverUrl) { + return undefined + } + let host: string + try { + host = new URL(serverUrl).hostname + } catch { + host = serverUrl // tolerate a bare hostname (no scheme) + } + return tokens.keys.find(k => k.hostname === host)?.key +} diff --git a/sources/src/job-summary.ts b/sources/src/job-summary.ts index 6050904b..4411018d 100644 --- a/sources/src/job-summary.ts +++ b/sources/src/job-summary.ts @@ -13,6 +13,8 @@ export async function generateJobSummary( providerNote: ProviderNote | undefined, config: SummaryConfig ): Promise { + core.startGroup('Generating Job Summary') + const errors = renderErrors() if (errors) { core.summary.addRaw(errors) @@ -23,19 +25,17 @@ export async function generateJobSummary( const summaryTable = renderSummaryTable(buildResults) const cachingReport = renderCachingReport(cacheReport, providerNote) const hasFailure = anyFailed(buildResults) - if (config.shouldGenerateJobSummary(hasFailure)) { - core.info('Generating Job Summary') + core.info(summaryTable) + core.info('============================') + core.info(cachingReport) + + if (config.shouldGenerateJobSummary(hasFailure)) { core.summary.addRaw(summaryTable) core.summary.addRaw(cachingReport) await core.summary.write() - } else { - core.info('============================') - core.info(summaryTable) - core.info('============================') - core.info(cachingReport) - core.info('============================') } + core.endGroup() if (config.canAddPRComment()) { await minimizeObsoletePRComments() diff --git a/sources/src/setup-gradle.ts b/sources/src/setup-gradle.ts index b42d54e1..a8255ae5 100644 --- a/sources/src/setup-gradle.ts +++ b/sources/src/setup-gradle.ts @@ -5,6 +5,7 @@ import * as path from 'path' import * as os from 'os' import * as jobSummary from './job-summary' import * as buildScan from './develocity/build-scan' +import {setupToken} from './develocity/short-lived-token' import {loadBuildResults, markBuildResultsProcessed} from './build-results' import {getCacheService, getProviderNote} from './cache-service-loader' @@ -21,6 +22,8 @@ import {initializeGradleUserHome} from './gradle-user-home' const GRADLE_SETUP_VAR = 'GRADLE_BUILD_ACTION_SETUP_COMPLETED' const GRADLE_USER_HOME = 'GRADLE_USER_HOME' +// Short-lived Develocity token for the configured server, resolved during setup and reused on save. +const DEVELOCITY_CACHE_TOKEN = 'DEVELOCITY_CACHE_TOKEN' export async function setup( cacheConfig: CacheConfig, @@ -44,17 +47,27 @@ export async function setup( initializeGradleUserHome(userHome, gradleUserHome, cacheConfig.getCacheEncryptionKey()) + // Exchange the long-lived access key(s) for short-lived tokens, resolving the token for the + // configured Develocity server and retaining it for the post-action (save) step. + const develocityServerUrl = develocityConfig.getDevelocityUrl() || undefined + const cacheToken = await setupToken(develocityConfig) + core.saveState(DEVELOCITY_CACHE_TOKEN, cacheToken ?? '') + const cacheService = await getCacheService(cacheConfig) - await cacheService.restore(gradleUserHome, cacheOptionsFrom(cacheConfig)) + await cacheService.restore(gradleUserHome, cacheOptionsFrom(cacheConfig, develocityServerUrl, cacheToken)) await wrapperValidator.validateWrappers(wrapperValidationConfig, getWorkspaceDirectory(), gradleUserHome) - await buildScan.setup(develocityConfig) + buildScan.setup(develocityConfig) return true } -export async function complete(cacheConfig: CacheConfig, summaryConfig: SummaryConfig): Promise { +export async function complete( + cacheConfig: CacheConfig, + develocityConfig: DevelocityConfig, + summaryConfig: SummaryConfig +): Promise { if (!core.getState(GRADLE_SETUP_VAR)) { core.info('Gradle setup post-action only performed for first gradle/actions step in workflow.') return false @@ -64,8 +77,14 @@ export async function complete(cacheConfig: CacheConfig, summaryConfig: SummaryC const buildResults = loadBuildResults() const gradleUserHome = core.getState(GRADLE_USER_HOME) + const develocityServerUrl = develocityConfig.getDevelocityUrl() || undefined + const cacheToken = core.getState(DEVELOCITY_CACHE_TOKEN) || undefined const cacheService = await getCacheService(cacheConfig) - const cacheReport = await cacheService.save(gradleUserHome, buildResults, cacheOptionsFrom(cacheConfig)) + const cacheReport = await cacheService.save( + gradleUserHome, + buildResults, + cacheOptionsFrom(cacheConfig, develocityServerUrl, cacheToken) + ) await jobSummary.generateJobSummary(buildResults, cacheReport, getProviderNote(cacheConfig), summaryConfig) markBuildResultsProcessed() @@ -75,7 +94,11 @@ export async function complete(cacheConfig: CacheConfig, summaryConfig: SummaryC return true } -function cacheOptionsFrom(config: CacheConfig): CacheOptions { +function cacheOptionsFrom( + config: CacheConfig, + develocityServerUrl: string | undefined, + develocityAccessToken: string | undefined +): CacheOptions { return { disabled: config.isCacheDisabled(), readOnly: config.isCacheReadOnly(), @@ -84,6 +107,8 @@ function cacheOptionsFrom(config: CacheConfig): CacheOptions { strictMatch: config.isCacheStrictMatch(), cleanup: config.getCacheCleanupOption(), encryptionKey: config.getCacheEncryptionKey() || undefined, + develocityAccessToken, + develocityServerUrl, includes: config.getCacheIncludes(), excludes: config.getCacheExcludes() } diff --git a/sources/test/jest/caching-report.test.ts b/sources/test/jest/caching-report.test.ts index f03fc7cc..db7c926c 100644 --- a/sources/test/jest/caching-report.test.ts +++ b/sources/test/jest/caching-report.test.ts @@ -79,37 +79,39 @@ describe('renderCachingReport', () => { expect(md).toContain('Entries: 1 restored, 0 saved - Expand for more details') }) - it('renders the configuration-cache status line inside the details', () => { + it('renders the project-cache status line inside the details', () => { const report: CacheReport = { status: 'enabled', cleanup: 'enabled', - configurationCache: 'restored', + projectCache: 'enabled', entries: [entry()] } const md = renderCachingReport(report, ENHANCED) const detailsBody = md.slice(md.indexOf('')) - expect(detailsBody).toContain('Configuration cache state was restored from the cache.') + expect(detailsBody).toContain( + 'Caching of project state (build-logic and configuration cache) was enabled.' + ) }) - it('explains an inactive configuration cache with a link to the encryption key docs', () => { + it('renders nothing for the not-enabled project-cache status', () => { const report: CacheReport = { status: 'enabled', cleanup: 'enabled', - configurationCache: 'not-active', + projectCache: 'not-enabled', entries: [entry()] } const md = renderCachingReport(report, ENHANCED) - expect(md).toContain('Configuration cache state was not cached') - expect(md).toContain('#cache-encryption-key') + expect(md).not.toContain('Project state') + expect(md).not.toContain('build-logic') }) - it('omits the configuration-cache line when the status is absent', () => { + it('omits the project-cache line when the status is absent', () => { const report: CacheReport = {status: 'enabled', cleanup: 'enabled', entries: [entry()]} const md = renderCachingReport(report, ENHANCED) - expect(md).not.toContain('Configuration cache state') + expect(md).not.toContain('Project state') }) it('renders a compact disabled report with no note and no details', () => { diff --git a/sources/test/jest/short-lived-token.test.ts b/sources/test/jest/short-lived-token.test.ts index db9867a8..faca95ac 100644 --- a/sources/test/jest/short-lived-token.test.ts +++ b/sources/test/jest/short-lived-token.test.ts @@ -1,7 +1,7 @@ import nock from "nock"; import {describe, expect, it} from '@jest/globals' -import {DevelocityAccessCredentials, getToken} from "../../src/develocity/short-lived-token"; +import {DevelocityAccessCredentials, getToken, resolveTokenForServer} from "../../src/develocity/short-lived-token"; describe('short lived tokens', () => { it('parse valid access key should return an object', async () => { @@ -134,3 +134,42 @@ describe('short lived tokens with retry', () => { .toBeNull() }) }) + +describe('resolveTokenForServer', () => { + const credentials = (...pairs: [string, string][]): DevelocityAccessCredentials => + DevelocityAccessCredentials.of(pairs.map(([hostname, key]) => ({hostname, key}))) + + it('returns the token matching the server host from a full URL', () => { + const tokens = credentials(['ge.example.com', 'key1'], ['other', 'key2']) + expect(resolveTokenForServer(tokens, 'https://ge.example.com')).toBe('key1') + }) + + it('matches on hostname, ignoring scheme, port and path', () => { + const tokens = credentials(['ge.example.com', 'key1']) + expect(resolveTokenForServer(tokens, 'https://ge.example.com:8443/path')).toBe('key1') + }) + + it('tolerates a bare hostname with no scheme', () => { + const tokens = credentials(['ge.example.com', 'key1']) + expect(resolveTokenForServer(tokens, 'ge.example.com')).toBe('key1') + }) + + it('selects the matching token when multiple are present', () => { + const tokens = credentials(['dev', 'key1'], ['ge.example.com', 'key2']) + expect(resolveTokenForServer(tokens, 'https://ge.example.com')).toBe('key2') + }) + + it('returns undefined when no token matches the server host', () => { + const tokens = credentials(['ge.example.com', 'key1']) + expect(resolveTokenForServer(tokens, 'https://other.example.com')).toBeUndefined() + }) + + it('returns undefined for an empty server URL', () => { + const tokens = credentials(['ge.example.com', 'key1']) + expect(resolveTokenForServer(tokens, '')).toBeUndefined() + }) + + it('returns undefined when there are no tokens', () => { + expect(resolveTokenForServer(credentials(), 'https://ge.example.com')).toBeUndefined() + }) +})