Consolidate short-lived token handling and resolve cache token via state

Have setupToken take the DevelocityConfig and return the short-lived JWT
matching the configured Develocity server, replacing resolveAccessKeyForServer
with resolveTokenForServer. Call setupToken directly from setup-gradle's
setup() (split out of buildScan.setup), persist the resolved token via
core.saveState, and read it back in complete(). cacheOptionsFrom now takes
the server URL and access token as parameters.

This ensures CacheOptions.develocityAccessToken is always the resolved
short-lived JWT on both restore and save, regardless of whether the access
key was supplied as an action input or env var.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Daz DeBoer
2026-06-14 22:23:37 -06:00
parent 9173e33285
commit f4925085d0
4 changed files with 81 additions and 80 deletions
+1 -8
View File
@@ -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<void> {
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<void> {
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 {
+39 -40
View File
@@ -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<void> {
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<string | undefined> {
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 {
@@ -176,16 +189,11 @@ export class DevelocityAccessCredentials {
}
/**
* 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.
* 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 resolveAccessKeyForServer(accessKey: string, serverUrl: string): string | undefined {
if (!accessKey || !serverUrl) {
export function resolveTokenForServer(tokens: DevelocityAccessCredentials, serverUrl: string): string | undefined {
if (!serverUrl) {
return undefined
}
let host: string
@@ -194,14 +202,5 @@ export function resolveAccessKeyForServer(accessKey: string, serverUrl: string):
} 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
return tokens.keys.find(k => k.hostname === host)?.key
}
+19 -10
View File
@@ -5,7 +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 {setupToken} from './develocity/short-lived-token'
import {loadBuildResults, markBuildResultsProcessed} from './build-results'
import {getCacheService, getProviderNote} from './cache-service-loader'
@@ -22,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,
@@ -45,12 +47,18 @@ 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, develocityConfig))
await cacheService.restore(gradleUserHome, cacheOptionsFrom(cacheConfig, develocityServerUrl, cacheToken))
await wrapperValidator.validateWrappers(wrapperValidationConfig, getWorkspaceDirectory(), gradleUserHome)
await buildScan.setup(develocityConfig)
buildScan.setup(develocityConfig)
return true
}
@@ -69,11 +77,13 @@ export async function complete(
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, develocityConfig)
cacheOptionsFrom(cacheConfig, develocityServerUrl, cacheToken)
)
await jobSummary.generateJobSummary(buildResults, cacheReport, getProviderNote(cacheConfig), summaryConfig)
@@ -84,12 +94,11 @@ export async function complete(
return true
}
function cacheOptionsFrom(config: CacheConfig, develocityConfig: DevelocityConfig): CacheOptions {
const develocityServerUrl = develocityConfig?.getDevelocityUrl() || undefined
const develocityAccessToken =
develocityConfig && develocityServerUrl
? resolveAccessKeyForServer(develocityConfig.getDevelocityAccessKey(), develocityServerUrl)
: undefined
function cacheOptionsFrom(
config: CacheConfig,
develocityServerUrl: string | undefined,
develocityAccessToken: string | undefined
): CacheOptions {
return {
disabled: config.isCacheDisabled(),
readOnly: config.isCacheReadOnly(),
+22 -22
View File
@@ -1,7 +1,7 @@
import nock from "nock";
import {describe, expect, it} from '@jest/globals'
import {DevelocityAccessCredentials, getToken, resolveAccessKeyForServer} 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 () => {
@@ -135,41 +135,41 @@ describe('short lived tokens with retry', () => {
})
})
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')
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', () => {
expect(resolveAccessKeyForServer('ge.example.com=key1', 'https://ge.example.com:8443/path')).toBe('key1')
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', () => {
expect(resolveAccessKeyForServer('ge.example.com=key1', 'ge.example.com')).toBe('key1')
const tokens = credentials(['ge.example.com', 'key1'])
expect(resolveTokenForServer(tokens, '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('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('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 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', () => {
expect(resolveAccessKeyForServer('ge.example.com=key1', '')).toBeUndefined()
const tokens = credentials(['ge.example.com', 'key1'])
expect(resolveTokenForServer(tokens, '')).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()
it('returns undefined when there are no tokens', () => {
expect(resolveTokenForServer(credentials(), 'https://ge.example.com')).toBeUndefined()
})
})