Compare commits

..
Author SHA1 Message Date
Daz DeBoerandClaude Opus 5 528e626588 Move non-smoke restore-gradle-home tests back to the integ-test suite
Partial revert of the extraction in #1027. The caching smoke test should
answer one question quickly: does a seeded cache let a later build run
--offline? Everything else is integration-level.

smoke-test-restore-gradle-home now has just two jobs: seed the cache, then
verify --offline. The build-cache, no-extracted-cache-entries-restored and
pre-existing-gradle-home jobs move to a restored
integ-test-restore-gradle-home, wired back into suite-integ-test-caching.

The seed-build job is intentionally duplicated across the two workflows.
They use distinct cache keys, since both suites run concurrently and would
otherwise write to the same entry.

pre-existing-gradle-home stays pinned to ubuntu-latest, keeping the reason
recorded: pre-creating ~/.gradle is what stops setup-gradle relocating the
Gradle User Home to D:\a\.gradle on Windows, so the job looks for a cache
entry rooted at a different path than the seed build saved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 18:41:15 -06:00
Daz DeBoerandClaude Opus 5 3711048846 Run pre-existing-gradle-home smoke test on Linux only
This job pre-creates ~/.gradle, which is exactly what stops setup-gradle
relocating the Gradle User Home to D:\a\.gradle on Windows. The seed build
saves its cache entry rooted at D:\a\.gradle, and cache entries are
identified by key *and* by a version derived from the cache paths, so the
relocated Gradle User Home never matches: the job requests a byte-identical
key and still gets "no match found", then fails the --offline build.

The other Windows jobs in this workflow are unaffected, and the ubuntu leg
passes because Linux has no equivalent relocation.

Testing Gradle User Home relocation semantics is integration-level rather
than smoke-level behaviour, so run this job on Linux only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 18:34:34 -06:00
bot-githubaction 4318659b28 [bot] Update dist directory 2026-08-01 23:45:55 +00:00
79b19cd50f Fix basic caching smoke test on Windows, and warn on save failure (#1028)
Follow-up to #1027, which added Windows coverage for the caching smoke
tests. `basic-cache-verify-build` failed on Windows for a reason
unrelated to #1013.

## Root cause

The Windows seed job **never uploaded a cache entry, but reported that
it did**. `gh cache list` showed no `setup-java-Windows-*` entry at all,
despite the job logging `Basic caching saved entry with key:
setup-java-Windows-x64-gradle-594edf…`. The keys were never the problem
— seed and verify requested the identical key.

The seed build leaves a Gradle daemon running, holding the `*.lock`
files in the Gradle User Home. On Windows those locks are mandatory, so
`tar` cannot read them:

```
/usr/bin/tar: ../../.gradle/caches/modules-2/modules-2.lock: Read error at byte 0,
              while reading 38 bytes: Device or resource busy
/usr/bin/tar: Exiting with failure status due to previous errors
```

38 bytes is exactly Gradle's lock-file header — the region the daemon
holds via `FileChannel.lock()`. On Linux the lock is advisory and tar
reads straight through, which is why this only ever failed on Windows.

`cache.saveCache()` catches the tar failure, logs it, and returns `-1`
rather than throwing. `BasicCacheService.save()` ignored the return
value, so the seed job went green and the failure surfaced only later —
as a plugin resolution error in the verify job, pointing nowhere near
caching.

## Changes

**1. Warn when the save fails.** Check the returned `cacheId` and, when
it is `-1`, emit a warning and report `(Entry not saved: save failed)`
in the job summary. Caching failures still do not fail the build.

**2. Run the seed build with `--no-daemon`.** Daemon management for
enhanced caching lives in the `gradle-actions-caching` library; basic
caching leaves it to the workflow, so the smoke test now ensures no
daemon is holding locks when the post-action save runs.

## Not related to #1013

The enhanced provider fails differently on Windows — every entry dies at
path validation, before tar runs (`Path Validation Error: Path(s)
specified in the action for caching do(es) not exist`). Same symptom,
different mechanism; that one is unchanged here and is still expected to
be red.

## Verification

`npm run check` and `npm test` pass locally (373 tests). The real check
is this PR's Windows run: `basic-cache-seed-build` and
`basic-cache-verify-build` should both be green on `windows-latest`,
while the `restore-gradle-home-*` Windows jobs stay red pending the
#1013 fix.

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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 17:45:05 -06:00
9d31058114 Add Windows coverage for caching via a new smoke-test suite (#1027)
## Why

Issue #1013 revealed that caching was **never storing content on
Windows**. Nothing caught it because the integ-tests lost their multi-OS
matrices: these workflows used to default to `'["ubuntu-latest",
"windows-latest", "macos-latest"]'`, narrowed to ubuntu-only in bcd07e66
/ d74ee73e (Aug 2024). The Windows code path has been dark ever since.

## What

Extract the two cheapest caching tests — `restore-gradle-home` and
`basic-cache-provider` — into a new `suite-smoke-test-caching` workflow,
and run that suite on both `ubuntu-latest` and `windows-latest`.

Both tests seed a cache in one job and then verify it in a dependent job
with an `--offline` build, so a cache that stores nothing fails the
verify job rather than passing silently.

- Rename `integ-test-{restore-gradle-home,basic-cache-provider}` →
`smoke-test-*` and drop them from `suite-integ-test-caching`
- Add the new suite to both `CI-integ-test` and `CI-integ-test-full`,
each with its own concurrency group matching the sibling suites
- Include `smoke-tests` in the `integ-test-success` aggregate gate
- Ignore the generated `task-configured.txt` marker in
`workflow-samples`
- Drop a dead `needs.determine-suite` guard on the `build-distribution`
step — `CI-integ-test` has no such job, so it always evaluated to true

The suite runs on Windows in PR CI (not just `CI-integ-test-full`)
specifically so the failure is visible on this PR and the fix can be
verified the same way.

## Expected result

This PR is expected to be **red on Windows**.
`restore-gradle-home-dependencies-cache` and `basic-cache-verify-build`
should fail with dependency-resolution errors under `--offline` — that
is the bug from #1013 being caught. The ubuntu legs should stay green.

Cross-OS cache keys are safe: both providers include `RUNNER_OS` in the
key (`sources/src/cache-service-basic.ts:146`), so the matrix legs don't
collide.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 17:15:42 -06:00
bot-githubaction 0088877924 [bot] Update dist directory 2026-07-31 19:09:45 +00:00
Reinhold DegenfellnerandGitHub ff4dbcd5fa Disable Isolated Projects via promoted property in dependency-submission (#1025)
## Problem

The `dependency-submission` action disables Isolated Projects for the
dependency-resolution build by passing
`-Dorg.gradle.unsafe.isolated-projects=false`.

Recent Gradle versions have promoted the property to
`org.gradle.isolated-projects`. A build that enables IP via the promoted
property (e.g. `gradle/gradle`, see [this failing
run](https://github.com/gradle/gradle/actions/runs/30357651750/job/90269499556?pr=38402))
is no longer disabled by the unsafe spelling, so the build runs with IP
enabled and fails:

```
Error resolving plugin [id: 'gradlebuild.build-environment']
> Project ':build-logic-settings' cannot access 'Project.tasks' functionality on subprojects via 'allprojects'
```

(`ForceDependencyResolutionPlugin` is not IP-compatible.)

## Fix

Pass both spellings of the property. `-D` system properties that Gradle
does not recognize are silently ignored, so this is safe for all
supported Gradle versions — the action already passes the unsafe
spelling to pre-IP Gradle versions without issue.
2026-07-31 13:08:52 -06:00
Daz DeBoerandGitHub 6550634d3e Use the latest dependency graph plugin 2026-07-04 21:16:38 -06:00
Bot GithubactionandGitHub b128da9bf0 Update gradle-actions-caching library to v0.9.0 (#996)
## What's Changed

> [!IMPORTANT]
> **Cache invalidation:** The cache protocol version has been bumped to `v2`, invalidating all cache entries written by earlier releases. The first build after upgrading will not find existing caches and will repopulate them from scratch.

### Improvements
* **Consistent cache-entry names in reports** — every extracted cache entry now has a single, unique name reported identically on both restore and save. Previously the restore report could show a raw glob pattern (e.g. `caches/modules-*/files-*/*/*/*/*/`) instead of a friendly name like `dependencies`. Bundled entries are named by artifact type, per-file entries by their Gradle-User-Home-relative path, and per-project entries by `project-<rootProjectName>`.
2026-06-16 19:59:10 +00:00
bot-githubaction ee7ea9a078 [bot] Update dist directory 2026-06-16 18:15:16 +00:00
9c445f57df 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) <noreply@anthropic.com>
2026-06-16 18:14:19 +00:00
Bot GithubactionandGitHub bbaaec0da2 Update gradle-actions-caching library to v0.8.0 (#993)
Updates to the latest gradle-actions-caching library, bringing experimental support for caching of project content.
2026-06-16 18:07:10 +00:00
bot-githubaction 54d3208a40 [bot] Update dist directory 2026-06-13 01:52:50 +00:00
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
32 changed files with 868 additions and 591 deletions
+3
View File
@@ -3,3 +3,6 @@
# Ignore Gradle build output directory
build
# Marker file written when the 'test' task is configured
task-configured.txt
+10
View File
@@ -14,6 +14,16 @@ permissions:
contents: read
jobs:
smoke-tests:
uses: ./.github/workflows/suite-smoke-test-caching.yml
concurrency:
group: CI-smoke-test-caching
cancel-in-progress: false
with:
skip-dist: true
runner-os: '["ubuntu-latest", "windows-latest"]'
secrets: inherit
caching-integ-tests:
uses: ./.github/workflows/suite-integ-test-caching.yml
concurrency:
+13 -1
View File
@@ -21,9 +21,19 @@ jobs:
- name: Checkout sources
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Build and upload distribution
if: ${{ needs.determine-suite.outputs.suite != 'full' }}
uses: ./.github/actions/build-dist
smoke-tests:
needs: build-distribution
uses: ./.github/workflows/suite-smoke-test-caching.yml
concurrency:
group: CI-smoke-test-caching-${{ github.ref }}
cancel-in-progress: false
with:
skip-dist: false
runner-os: '["ubuntu-latest", "windows-latest"]'
secrets: inherit
caching-integ-tests:
needs: build-distribution
uses: ./.github/workflows/suite-integ-test-caching.yml
@@ -64,6 +74,7 @@ jobs:
if: ${{ always() }}
needs:
- build-distribution
- smoke-tests
- caching-integ-tests
- other-integ-tests
- dependency-submission-integ-tests
@@ -74,6 +85,7 @@ jobs:
run: |
echo "One or more integ-test jobs did not succeed:"
echo " build-distribution: ${{ needs.build-distribution.result }}"
echo " smoke-tests: ${{ needs.smoke-tests.result }}"
echo " caching-integ-tests: ${{ needs.caching-integ-tests.result }}"
echo " other-integ-tests: ${{ needs.other-integ-tests.result }}"
echo " dependency-submission-integ-tests: ${{ needs.dependency-submission-integ-tests.result }}"
@@ -43,29 +43,6 @@ jobs:
working-directory: .github/workflow-samples/groovy-dsl
run: ./gradlew test
# Test that the gradle-user-home cache will cache dependencies, by running build with --offline
restore-gradle-home-dependencies-cache:
needs: restore-gradle-home-seed-build
strategy:
max-parallel: 1
fail-fast: false
matrix:
os: ${{fromJSON(inputs.runner-os)}}
runs-on: ${{ matrix.os }}
steps:
- name: Checkout sources
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Initialize integ-test
uses: ./.github/actions/init-integ-test
- name: Setup Gradle
uses: ./setup-gradle
with:
cache-read-only: true
- name: Execute Gradle build with --offline
working-directory: .github/workflow-samples/groovy-dsl
run: ./gradlew test --offline
# Test that the gradle-user-home cache will cache and restore local build-cache
restore-gradle-home-build-cache:
needs: restore-gradle-home-seed-build
@@ -114,15 +91,16 @@ jobs:
working-directory: .github/workflow-samples/groovy-dsl
run: ./gradlew test
# Test that a pre-existing gradle-user-home can be overwritten by the restored cache
# Test that a pre-existing gradle-user-home can be overwritten by the restored cache.
#
# Deliberately not run against the 'runner-os' matrix: creating ~/.gradle up-front is precisely
# what stops setup-gradle relocating the Gradle User Home to D:\a\.gradle on Windows, so this job
# would look for a cache entry rooted at a different path than the one the seed build saved.
# Cache entries are identified by key *and* by a version derived from the cache paths, so that
# never matches.
restore-gradle-home-pre-existing-gradle-home:
needs: restore-gradle-home-seed-build
strategy:
max-parallel: 1
fail-fast: false
matrix:
os: ${{fromJSON(inputs.runner-os)}}
runs-on: ${{ matrix.os }}
runs-on: ubuntu-latest
steps:
- name: Checkout sources
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
@@ -39,9 +39,12 @@ jobs:
with:
cache-provider: basic
cache-read-only: false # For testing, allow writing cache entries on non-default branches
# Basic caching does no daemon management, so the workflow must ensure no daemon is left
# holding locks on the Gradle User Home when the post-action save runs. Without this, `tar`
# cannot read the Gradle '*.lock' files on Windows and the cache entry is never saved.
- name: Build kotlin-dsl project
working-directory: .github/workflow-samples/kotlin-dsl
run: ./gradlew build
run: ./gradlew build --no-daemon
basic-cache-verify-build:
needs: basic-cache-seed-build
@@ -0,0 +1,69 @@
name: Smoke test restore Gradle Home
on:
workflow_call:
inputs:
cache-key-prefix:
type: string
default: '0'
runner-os:
type: string
default: '["ubuntu-latest"]'
skip-dist:
type: boolean
default: false
env:
SKIP_DIST: ${{ inputs.skip-dist }}
# Distinct from the keys used by integ-test-restore-gradle-home.yml, which seeds an equivalent
# cache entry. Both suites run concurrently, so they must not write to the same cache key.
GRADLE_BUILD_ACTION_CACHE_KEY_PREFIX: smoke-test-restore-gradle-home-${{ inputs.cache-key-prefix }}
GRADLE_BUILD_ACTION_CACHE_KEY_JOB: smoke-test-restore-gradle-home
permissions:
contents: read
jobs:
restore-gradle-home-seed-build:
strategy:
max-parallel: 1
fail-fast: false
matrix:
os: ${{fromJSON(inputs.runner-os)}}
runs-on: ${{ matrix.os }}
steps:
- name: Checkout sources
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Initialize integ-test
uses: ./.github/actions/init-integ-test
- name: Setup Gradle
uses: ./setup-gradle
with:
cache-read-only: false # For testing, allow writing cache entries on non-default branches
- name: Build using Gradle wrapper
working-directory: .github/workflow-samples/groovy-dsl
run: ./gradlew test
# Test that the gradle-user-home cache will cache dependencies, by running build with --offline
restore-gradle-home-dependencies-cache:
needs: restore-gradle-home-seed-build
strategy:
max-parallel: 1
fail-fast: false
matrix:
os: ${{fromJSON(inputs.runner-os)}}
runs-on: ${{ matrix.os }}
steps:
- name: Checkout sources
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Initialize integ-test
uses: ./.github/actions/init-integ-test
- name: Setup Gradle
uses: ./setup-gradle
with:
cache-read-only: true
- name: Execute Gradle build with --offline
working-directory: .github/workflow-samples/groovy-dsl
run: ./gradlew test --offline
@@ -57,9 +57,3 @@ jobs:
with:
runner-os: '${{ inputs.runner-os }}'
skip-dist: ${{ inputs.skip-dist }}
basic-cache-provider:
uses: ./.github/workflows/integ-test-basic-cache-provider.yml
with:
runner-os: '${{ inputs.runner-os }}'
skip-dist: ${{ inputs.skip-dist }}
@@ -0,0 +1,27 @@
name: suite-smoke-test-caching
on:
workflow_call:
inputs:
runner-os:
type: string
default: '["ubuntu-latest"]'
skip-dist:
type: boolean
default: false
permissions:
contents: read
jobs:
restore-gradle-home:
uses: ./.github/workflows/smoke-test-restore-gradle-home.yml
with:
runner-os: '${{ inputs.runner-os }}'
skip-dist: ${{ inputs.skip-dist }}
basic-cache-provider:
uses: ./.github/workflows/smoke-test-basic-cache-provider.yml
with:
runner-os: '${{ inputs.runner-os }}'
skip-dist: ${{ inputs.skip-dist }}
+106 -105
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+126 -126
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+105 -105
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+149 -149
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -47,6 +47,7 @@ export async function run(): Promise<void> {
-Dorg.gradle.configureondemand=false
-Dorg.gradle.dependency.verification=off
-Dorg.gradle.unsafe.isolated-projects=false
-Dorg.gradle.isolated-projects=false
${taskList}
${additionalArgs}
`
@@ -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())
}
+22 -1
View File
@@ -80,7 +80,28 @@ export class BasicCacheService implements CacheService {
const cachePaths = getCachePaths(gradleUserHome)
try {
await cache.saveCache(cachePaths, primaryKey)
// A cacheId of -1 means the save failed: `saveCache` reports the underlying cause and returns
// normally, rather than throwing. Warn and continue: caching failures should not fail the build.
const cacheId = await cache.saveCache(cachePaths, primaryKey)
if (cacheId === -1) {
core.warning(
`Basic caching failed to save entry with key \`${primaryKey}\`. See preceding log output for the cause.`
)
return {
status: 'enabled',
entries: [
entryReport({
primaryKey,
restoredKey,
restoredOutcome: restoredKey
? '(Entry restored: exact match found)'
: '(Entry not restored: no match found)',
savedOutcome: '(Entry not saved: save failed)'
})
]
}
}
core.info(`Basic caching saved entry with key: ${primaryKey}`)
return {
status: 'enabled',
+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>
+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 {
+51 -21
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 {
@@ -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
}
+8 -8
View File
@@ -13,6 +13,8 @@ export async function generateJobSummary(
providerNote: ProviderNote | undefined,
config: SummaryConfig
): Promise<void> {
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()
@@ -6,7 +6,7 @@ buildscript {
def pluginRepositoryUrl = getInputParam('gradle.plugin-repository.url') ?: 'https://plugins.gradle.org/m2'
def pluginRepositoryUsername = getInputParam('gradle.plugin-repository.username')
def pluginRepositoryPassword = getInputParam('gradle.plugin-repository.password')
def dependencyGraphPluginVersion = getInputParam('dependency-graph-plugin.version') ?: '1.4.1'
def dependencyGraphPluginVersion = getInputParam('dependency-graph-plugin.version') ?: '1.4.2'
logger.lifecycle("Resolving dependency graph plugin ${dependencyGraphPluginVersion} from plugin repository: ${pluginRepositoryUrl}")
repositories {
+30 -5
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 {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<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
@@ -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()
}
+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, 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()
})
})
+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.9.0",
"type": "module",
"main": "./index.js",
"types": "./index.d.ts",