Compare commits

...

11 Commits

Author SHA1 Message Date
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
bot-githubaction 54d3208a40 [bot] Update dist directory 2026-06-13 01:52:50 +00:00
Daz DeBoer e993c93d71 Render configuration-cache status in the caching Job Summary (#989)
Render the configuration-cache restore-state in the caching Job Summary,
driven by the new `CacheReport.configurationCache` field produced by the
`gradle-actions-caching` provider.

## What's here

- `cache-service.ts`: add a `ConfigurationCacheStatus` type
(`not-active` / `restored` / `not-restored` / `restore-incomplete`) and
an optional `configurationCache` field on `CacheReport`.
- `caching-report.ts`: a `CONFIG_CACHE_COPY` map and a prominent status
line in `renderCachingReport`, beside the cleanup line. The `not-active`
case links to the `#cache-encryption-key` docs.

## Cross-repo dependency

The field is populated by gradle/actions-caching PR #75 ("Restore
configuration-cache support for simple builds"). This rendering compiles
independently (it uses this repo's own `CacheReport` type) and renders
nothing until the vendored `gradle-actions-caching` bundle is refreshed
from that branch — so this should land with/after the vendor refresh.

## Verification

`npm run check` clean; full Jest suite (366 tests) passes, including 3
new rendering tests.

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

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 19:51:57 -06:00
19 changed files with 484 additions and 327 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+54 -54
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+101 -101
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())
}
+10
View File
@@ -8,6 +8,8 @@ export interface CacheOptions {
strictMatch: boolean
cleanup: string
encryptionKey?: string
develocityAccessToken?: string
develocityServerUrl?: string
includes: string[]
excludes: string[]
}
@@ -27,6 +29,13 @@ export type CacheCleanupStatus =
| 'disabled-config-cache-hit'
| 'disabled-readonly'
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
requestedKey?: string
@@ -47,6 +56,7 @@ export interface CacheEntryReport {
export interface CacheReport {
status: CacheStatus
cleanup?: CacheCleanupStatus
projectCache?: ProjectCacheStatus
entries: CacheEntryReport[]
}
+16 -2
View File
@@ -1,4 +1,4 @@
import {CacheCleanupStatus, CacheEntryReport, CacheReport, CacheStatus} 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,6 +28,14 @@ const CLEANUP_COPY: Record<CacheCleanupStatus, string> = {
'disabled-readonly': `[Cache cleanup](${DOCS}#configuring-cache-cleanup) is always disabled when the cache is read-only.`
}
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.`
}
/**
* Renders a cache report into the unified Job Summary markdown, with a consistent
* skeleton across every variant: a section heading, a status line, an integrated
@@ -69,6 +77,11 @@ function renderCleanupLine(cleanup?: CacheCleanupStatus): string | undefined {
return cleanup ? CLEANUP_COPY[cleanup] : 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 {
if (!providerNote) {
return undefined
@@ -88,9 +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 projectCache = renderProjectCacheLine(report.projectCache)
const table = renderEntryTable(report.entries)
const pre = `<pre>\n${renderEntryDetails(report.entries)}</pre>`
const body = [STATUS_COPY[report.status], cleanup, 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()
}
+35
View File
@@ -79,6 +79,41 @@ describe('renderCachingReport', () => {
expect(md).toContain('<summary>Entries: 1 restored, 0 saved - Expand for more details</summary>')
})
it('renders the project-cache status line inside the details', () => {
const report: CacheReport = {
status: 'enabled',
cleanup: 'enabled',
projectCache: 'enabled',
entries: [entry()]
}
const md = renderCachingReport(report, ENHANCED)
const detailsBody = md.slice(md.indexOf('</summary>'))
expect(detailsBody).toContain(
'Caching of project state (build-logic and configuration cache) was enabled.'
)
})
it('renders nothing for the not-enabled project-cache status', () => {
const report: CacheReport = {
status: 'enabled',
cleanup: 'enabled',
projectCache: 'not-enabled',
entries: [entry()]
}
const md = renderCachingReport(report, ENHANCED)
expect(md).not.toContain('Project state')
expect(md).not.toContain('build-logic')
})
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('Project state')
})
it('renders a compact disabled report with no note and no details', () => {
const report: CacheReport = {status: 'disabled', entries: []}
const md = renderCachingReport(report, undefined)
+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",