Compare commits

...

10 Commits

Author SHA1 Message Date
bot-githubaction 3335e6e1a8 [bot] Update dist directory 2026-06-14 05:17:33 +00:00
Daz DeBoer 2f5a111e6f Pass DV credentials on restore 2026-06-13 23:16:08 -06:00
Daz DeBoer 4ecda0ee35 Add missing cache status 2026-06-13 23:16:08 -06:00
Daz DeBoer 79c65f4225 Prettier and fix tests 2026-06-13 23:16:08 -06:00
Daz DeBoer cb8f8c9bc3 Mirron status changes in gradle-actions-caching 2026-06-13 23:16:08 -06:00
Daz DeBoer 88020b1ce1 Make resolveAccessKeyForServer tolerate short-lived tokens
At save time the Develocity access key has been swapped for a short-lived token
(host=<JWT>), whose value the strict DevelocityAccessCredentials parser rejects
(it requires host=\w+). Parse the host=value pairs leniently instead, so the
per-host token resolves for both long-lived keys and short-lived tokens.

Without this, threading credentials into the project-cache trial check fails
with trial-not-licensed on every real save (caught by the configuration-cache
integ-test). Adds a unit case for a JWT-shaped token value.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 23:16:08 -06:00
Daz DeBoer d885df93e3 Thread Develocity trial credentials into the save-path cache options
Supplies develocityAccessToken / develocityServerUrl to the gated
project-entry caching feature, on the save path only. Restore stays ungated,
so it keeps passing no DevelocityConfig and the credentials remain undefined.

- cacheOptionsFrom gains an optional develocityConfig; when present it sets
  develocityServerUrl from getDevelocityUrl() and resolves develocityAccessToken
  via resolveAccessKeyForServer (fail-closed when the URL or matching key is
  absent).
- complete() takes a DevelocityConfig and passes it through to cacheOptionsFrom
  at the save call; setup()/restore continues to call cacheOptionsFrom with no
  DevelocityConfig.
- Both post actions (setup-gradle, dependency-submission) pass
  new DevelocityConfig() to complete().

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 23:16:08 -06:00
Daz DeBoer fccb7323bf Add resolveAccessKeyForServer for the project-cache trial
Resolves the Develocity access key matching a given server URL, for threading
into the new develocityAccessToken cache option (next commit). Reuses
DevelocityAccessCredentials.parse and fails closed (returns undefined) when the
access key is empty/malformed, the server URL is empty/unparseable, or no key
matches the server host. Tolerates a bare hostname with no scheme.

Extends short-lived-token.test.ts with coverage for full URLs, bare hostnames,
host-only matching (ignoring scheme/port/path), multi-key selection, and the
fail-closed cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 23:16:07 -06:00
Daz DeBoer ab6eefcd4a Mirror project-cache type and report changes from the caching library
Prepares the consumer for the gated project-entry caching feature in the
gradle-actions-caching library. Source-only; the vendored bundle is refreshed
in a later commit.

- cache-service.ts: add develocityAccessToken / develocityServerUrl to the
  local CacheOptions mirror; replace ConfigurationCacheStatus (4 values) with
  the 9-value ProjectCacheStatus (not-active retired); rename
  CacheReport.configurationCache to projectCache.
- caching-report.ts: PROJECT_CACHE_COPY is an exhaustive
  Record<ProjectCacheStatus, string> so a missed status fails compilation;
  not-enabled maps to '' (dropped by the existing .filter(Boolean)).
  renderConfigCacheLine becomes renderProjectCacheLine, reading
  report.projectCache.
- Tests updated for the renamed field and new copy, including a not-enabled
  case that renders nothing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 23:16:07 -06:00
Bot Githubaction ce13e2edb5 Test with UNRELEASED gradle-actions-caching library v0.8.0-cc 2026-06-14 05:06:55 +00:00
19 changed files with 583 additions and 474 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+107 -107
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+97 -97
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+127 -127
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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<void> {
try {
await setupGradle.complete(new CacheConfig(), new SummaryConfig())
await setupGradle.complete(new CacheConfig(), new DevelocityConfig(), new SummaryConfig())
} catch (error) {
handlePostActionError(error)
}
+2 -2
View File
@@ -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<void> {
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())
}
+9 -2
View File
@@ -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[]
}
+12 -10
View File
@@ -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<CacheCleanupStatus, string> = {
'disabled-readonly': `[Cache cleanup](${DOCS}#configuring-cache-cleanup) is always disabled when the cache is read-only.`
}
const CONFIG_CACHE_COPY: Record<ConfigurationCacheStatus, string> = {
'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<ProjectCacheStatus, string> = {
'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 = `<pre>\n${renderEntryDetails(report.entries)}</pre>`
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 `<details>
<summary>${summary}</summary>
@@ -174,3 +174,34 @@ export class DevelocityAccessCredentials {
return this.accessKeyRegexp.test(allKeys)
}
}
/**
* Resolve the access key that matches a given Develocity server, for use as the
* `develocityAccessToken` cache option. Returns `undefined` (fail-closed) when the access key is
* empty, the server URL is empty/unparseable, or no entry matches the server's host.
*
* Parses the `host=value;host=value` form leniently rather than via `DevelocityAccessCredentials`:
* at save time the value is typically a short-lived token (a JWT whose `.`/`-` characters the strict
* parser rejects), but it is still a valid access token for that host.
*/
export function resolveAccessKeyForServer(accessKey: string, serverUrl: string): string | undefined {
if (!accessKey || !serverUrl) {
return undefined
}
let host: string
try {
host = new URL(serverUrl).hostname
} catch {
host = serverUrl // tolerate a bare hostname (no scheme)
}
for (const entry of accessKey.split(';')) {
const sep = entry.indexOf('=')
if (sep < 1) {
continue // skip blanks / malformed entries with no `host=` prefix
}
if (entry.slice(0, sep) === host) {
return entry.slice(sep + 1) || undefined
}
}
return undefined
}
+20 -4
View File
@@ -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 {resolveAccessKeyForServer} from './develocity/short-lived-token'
import {loadBuildResults, markBuildResultsProcessed} from './build-results'
import {getCacheService, getProviderNote} from './cache-service-loader'
@@ -45,7 +46,7 @@ export async function setup(
initializeGradleUserHome(userHome, gradleUserHome, cacheConfig.getCacheEncryptionKey())
const cacheService = await getCacheService(cacheConfig)
await cacheService.restore(gradleUserHome, cacheOptionsFrom(cacheConfig))
await cacheService.restore(gradleUserHome, cacheOptionsFrom(cacheConfig, develocityConfig))
await wrapperValidator.validateWrappers(wrapperValidationConfig, getWorkspaceDirectory(), gradleUserHome)
@@ -54,7 +55,11 @@ export async function setup(
return true
}
export async function complete(cacheConfig: CacheConfig, summaryConfig: SummaryConfig): Promise<boolean> {
export async function complete(
cacheConfig: CacheConfig,
develocityConfig: DevelocityConfig,
summaryConfig: SummaryConfig
): Promise<boolean> {
if (!core.getState(GRADLE_SETUP_VAR)) {
core.info('Gradle setup post-action only performed for first gradle/actions step in workflow.')
return false
@@ -65,7 +70,11 @@ export async function complete(cacheConfig: CacheConfig, summaryConfig: SummaryC
const gradleUserHome = core.getState(GRADLE_USER_HOME)
const cacheService = await getCacheService(cacheConfig)
const cacheReport = await cacheService.save(gradleUserHome, buildResults, cacheOptionsFrom(cacheConfig))
const cacheReport = await cacheService.save(
gradleUserHome,
buildResults,
cacheOptionsFrom(cacheConfig, develocityConfig)
)
await jobSummary.generateJobSummary(buildResults, cacheReport, getProviderNote(cacheConfig), summaryConfig)
markBuildResultsProcessed()
@@ -75,7 +84,12 @@ export async function complete(cacheConfig: CacheConfig, summaryConfig: SummaryC
return true
}
function cacheOptionsFrom(config: CacheConfig): CacheOptions {
function cacheOptionsFrom(config: CacheConfig, develocityConfig: DevelocityConfig): CacheOptions {
const develocityServerUrl = develocityConfig?.getDevelocityUrl() || undefined
const develocityAccessToken =
develocityConfig && develocityServerUrl
? resolveAccessKeyForServer(develocityConfig.getDevelocityAccessKey(), develocityServerUrl)
: undefined
return {
disabled: config.isCacheDisabled(),
readOnly: config.isCacheReadOnly(),
@@ -84,6 +98,8 @@ function cacheOptionsFrom(config: CacheConfig): CacheOptions {
strictMatch: config.isCacheStrictMatch(),
cleanup: config.getCacheCleanupOption(),
encryptionKey: config.getCacheEncryptionKey() || undefined,
develocityAccessToken,
develocityServerUrl,
includes: config.getCacheIncludes(),
excludes: config.getCacheExcludes()
}
+11 -9
View File
@@ -79,37 +79,39 @@ describe('renderCachingReport', () => {
expect(md).toContain('<summary>Entries: 1 restored, 0 saved - Expand for more details</summary>')
})
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('</summary>'))
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', () => {
+40 -1
View File
@@ -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, resolveAccessKeyForServer} 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('resolveAccessKeyForServer', () => {
it('returns the key matching the server host from a full URL', () => {
expect(resolveAccessKeyForServer('ge.example.com=key1;other=key2', 'https://ge.example.com')).toBe('key1')
})
it('matches on hostname, ignoring scheme, port and path', () => {
expect(resolveAccessKeyForServer('ge.example.com=key1', 'https://ge.example.com:8443/path')).toBe('key1')
})
it('tolerates a bare hostname with no scheme', () => {
expect(resolveAccessKeyForServer('ge.example.com=key1', 'ge.example.com')).toBe('key1')
})
it('selects the matching key when multiple are present', () => {
expect(resolveAccessKeyForServer('dev=key1;ge.example.com=key2', 'https://ge.example.com')).toBe('key2')
})
it('accepts a short-lived token value (JWT with dots and dashes)', () => {
const token = 'eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ4In0.sig-na_ture'
expect(resolveAccessKeyForServer(`ge.example.com=${token}`, 'https://ge.example.com')).toBe(token)
})
it('returns undefined when no key matches the server host', () => {
expect(resolveAccessKeyForServer('ge.example.com=key1', 'https://other.example.com')).toBeUndefined()
})
it('returns undefined for an empty server URL', () => {
expect(resolveAccessKeyForServer('ge.example.com=key1', '')).toBeUndefined()
})
it('returns undefined for an empty access key', () => {
expect(resolveAccessKeyForServer('', 'https://ge.example.com')).toBeUndefined()
})
it('returns undefined for a malformed access key', () => {
expect(resolveAccessKeyForServer('not-a-valid-access-key', 'https://ge.example.com')).toBeUndefined()
})
})
+12
View File
@@ -37,6 +37,8 @@ export declare interface CacheOptions {
strictMatch: boolean;
cleanup: 'always' | 'on-success' | 'never';
encryptionKey?: string;
develocityAccessToken?: string;
develocityServerUrl?: string;
includes: string[];
excludes: string[];
}
@@ -45,12 +47,22 @@ export declare interface CacheOptions {
export declare interface CacheReport {
status: CacheStatus;
cleanup?: CacheCleanupStatus;
projectCache?: ProjectCacheStatus;
entries: CacheEntryReport[];
}
/** @public */
export declare type CacheStatus = 'enabled' | 'read-only' | 'write-only' | 'disabled' | 'disabled-existing-home' | 'not-available';
/**
* Status of project-entry caching (build-logic artifacts + configuration-cache data) for a run.
* The first three are set on restore (always ungated); the rest are set on save and reflect the
* two-tier gate (opt-in + Develocity trial, then encryption key + Gradle version). Still beta.
*
* @public
*/
declare type ProjectCacheStatus = 'not-enabled' | 'trial-expired' | 'trial-not-licensed' | 'no-encryption-key' | 'enabled';
/** @public */
export declare function restore(gradleUserHome: string, cacheOptions: CacheOptions): Promise<void>;
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gradle-actions-caching",
"version": "0.7.0",
"version": "0.8.0-cc",
"type": "module",
"main": "./index.js",
"types": "./index.d.ts",