From f4925085d0ee474f36b01139f20f8d0b7419b01d Mon Sep 17 00:00:00 2001 From: Daz DeBoer Date: Sun, 14 Jun 2026 22:23:37 -0600 Subject: [PATCH] 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) --- sources/src/develocity/build-scan.ts | 9 +-- sources/src/develocity/short-lived-token.ts | 79 ++++++++++----------- sources/src/setup-gradle.ts | 29 +++++--- sources/test/jest/short-lived-token.test.ts | 44 ++++++------ 4 files changed, 81 insertions(+), 80 deletions(-) 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 9413cd93..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 { @@ -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 } diff --git a/sources/src/setup-gradle.ts b/sources/src/setup-gradle.ts index 5ea7d07d..a8255ae5 100644 --- a/sources/src/setup-gradle.ts +++ b/sources/src/setup-gradle.ts @@ -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(), diff --git a/sources/test/jest/short-lived-token.test.ts b/sources/test/jest/short-lived-token.test.ts index 8f590924..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, 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() }) })