diff --git a/.github/workflows/e2e-cache.yml b/.github/workflows/e2e-cache.yml index 07a1c644..a60fde6d 100644 --- a/.github/workflows/e2e-cache.yml +++ b/.github/workflows/e2e-cache.yml @@ -62,6 +62,7 @@ jobs: distribution: 'adopt' java-version: '11' cache: gradle + cache-read-only: true - name: Confirm that ~/.gradle/caches directory has been made run: bash __tests__/check-dir.sh "$HOME/.gradle/caches" maven-save: @@ -105,6 +106,7 @@ jobs: distribution: 'adopt' java-version: '11' cache: maven + cache-read-only: true - name: Confirm that ~/.m2/repository directory has been made run: bash __tests__/check-dir.sh "$HOME/.m2/repository" sbt-save: @@ -169,6 +171,7 @@ jobs: distribution: 'adopt' java-version: '11' cache: sbt + cache-read-only: true - name: Confirm that ~/Library/Caches/Coursier directory has been made if: matrix.os == 'macos-15-intel' diff --git a/README.md b/README.md index bebb9386..27e7de46 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,8 @@ For more details, see the full release notes on the [releases page](https://git - `cache-dependency-path`: The path to a dependency file: pom.xml, build.gradle, build.sbt, etc. This option can be used with the `cache` option. If this option is omitted, the action searches for the dependency file in the entire repository. This option supports wildcards and a list of file names for caching multiple dependencies. + - `cache-read-only`: Restore dependency caches without saving changes in the post action. Defaults to `false`. Use this for pull requests, merge queues, short-lived branches, and fan-out jobs that should consume caches populated by a default-branch or seed job. + #### Maven options The action has a bunch of inputs to generate maven's [settings.xml](https://maven.apache.org/settings.html) on the fly and pass the values to Apache Maven GPG Plugin as well as Apache Maven Toolchains. See [advanced usage](docs/advanced-usage.md) for more. @@ -192,6 +194,54 @@ The workflow output `cache-primary-key` exposes the primary cache key computed b The cache input is optional, and caching is turned off by default. +Set `cache-read-only: true` to restore the main dependency cache and any Maven +or Gradle wrapper cache without archiving or uploading changes after the job. +For example, a workflow can allow only the default branch to write caches while +pull requests, merge queues, and short-lived branches remain read-only: + +```yaml +- uses: actions/setup-java@v6 + with: + distribution: 'temurin' + java-version: '25' + cache: 'maven' + cache-read-only: ${{ github.ref != 'refs/heads/main' }} +``` + +For a fan-out matrix, use one seed job to populate a complete cache and make +every matrix job a read-only consumer. The seed and consumers must use the same +runner OS and cache dependency inputs so they compute the same key: + +```yaml +jobs: + seed-cache: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-java@v6 + with: + distribution: 'temurin' + java-version: '25' + cache: 'maven' + - run: mvn dependency:go-offline dependency:resolve-plugins + + build: + needs: seed-cache + runs-on: ubuntu-latest + strategy: + matrix: + goal: [test, verify, package] + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-java@v6 + with: + distribution: 'temurin' + java-version: '25' + cache: 'maven' + cache-read-only: true + - run: mvn ${{ matrix.goal }} +``` + **Maven Wrapper:** when `cache: 'maven'` is enabled, the action also caches and restores the Maven Wrapper distribution downloaded to `~/.m2/wrapper/dists` (in addition to the local repository), so wrapper-based (`./mvnw`) builds don't re-download the Maven distribution. The wrapper distribution is stored in a **separate** cache entry keyed only on `**/.mvn/wrapper/maven-wrapper.properties`, so it stays cached across the frequent `pom.xml` changes that rotate the main dependency cache key. #### Caching gradle dependencies diff --git a/__tests__/cleanup-java.test.ts b/__tests__/cleanup-java.test.ts index 65b75498..8a4c0458 100644 --- a/__tests__/cleanup-java.test.ts +++ b/__tests__/cleanup-java.test.ts @@ -120,6 +120,49 @@ describe('cleanup', () => { await cleanup(); expect(spyCacheSave).toHaveBeenCalled(); }); + + it.each(['maven', 'gradle', 'sbt'])( + 'does not save the %s cache in read-only mode', + async packageManager => { + createStateForSuccessfulRestoreWithWrapper(packageManager); + (core.getInput as jest.Mock).mockImplementation((name: string) => { + switch (name) { + case 'cache': + return packageManager; + case 'cache-read-only': + return 'true'; + default: + return ''; + } + }); + + await cleanup(); + + expect(spyCacheSave).not.toHaveBeenCalled(); + expect(core.getState).not.toHaveBeenCalled(); + expect(spyInfo).toHaveBeenCalledWith( + 'Cache saving is skipped because cache-read-only is enabled.' + ); + } + ); + + it('saves the cache when read-only mode is explicitly disabled', async () => { + spyCacheSave.mockResolvedValue(0); + (core.getInput as jest.Mock).mockImplementation((name: string) => { + switch (name) { + case 'cache': + return 'maven'; + case 'cache-read-only': + return 'false'; + default: + return ''; + } + }); + + await cleanup(); + + expect(spyCacheSave).toHaveBeenCalled(); + }); }); function resetState() { @@ -141,3 +184,18 @@ function createStateForSuccessfulRestore() { } }); } + +function createStateForSuccessfulRestoreWithWrapper(packageManager: string) { + (core.getState as jest.Mock).mockImplementation((name: any) => { + switch (name) { + case 'cache-primary-key': + return 'setup-java-cache-primary-key'; + case 'cache-matched-key': + return 'setup-java-cache-matched-key'; + case `cache-primary-key-${packageManager}-wrapper`: + return `setup-java-${packageManager}-wrapper-primary-key`; + default: + return ''; + } + }); +} diff --git a/action.yml b/action.yml index d980a9dd..78ccfbce 100644 --- a/action.yml +++ b/action.yml @@ -87,6 +87,10 @@ inputs: cache-dependency-path: description: 'The path to a dependency file: pom.xml, build.gradle, build.sbt, etc. This option can be used with the `cache` option. If this option is omitted, the action searches for the dependency file in the entire repository. This option supports wildcards and a list of file names for caching multiple dependencies.' required: false + cache-read-only: + description: 'Restore dependency caches without saving cache changes in the post action.' + required: false + default: false job-status: description: 'Workaround to pass job status to post job step. This variable is not intended for manual setting' required: false diff --git a/dist/cleanup/index.js b/dist/cleanup/index.js index b2fa7b01..5ef71f66 100644 --- a/dist/cleanup/index.js +++ b/dist/cleanup/index.js @@ -97338,6 +97338,7 @@ const MAVEN_GPG_PASSPHRASE_DEFAULT_ENV = 'MAVEN_GPG_PASSPHRASE'; const GPG_PASSPHRASE_PROFILE_ID = 'setup-java-gpg'; const INPUT_CACHE = 'cache'; const INPUT_CACHE_DEPENDENCY_PATH = 'cache-dependency-path'; +const INPUT_CACHE_READ_ONLY = 'cache-read-only'; const INPUT_JOB_STATUS = 'job-status'; const STATE_GPG_PRIVATE_KEY_FINGERPRINT = 'gpg-private-key-fingerprint'; const M2_DIR = '.m2'; @@ -97365,7 +97366,7 @@ function getTempDir() { return tempDirectory; } function util_getBooleanInput(inputName, defaultValue = false) { - const inputValue = core.getInput(inputName); + const inputValue = getInput(inputName); const normalizedValue = inputValue.trim().toLowerCase(); if (!normalizedValue) { return defaultValue; @@ -98076,7 +98077,14 @@ async function removePrivateKeyFromKeychain() { async function cleanup_java_saveCache() { const jobStatus = isJobStatusSuccess(); const cache = getInput(INPUT_CACHE); - return jobStatus && cache ? save(cache) : Promise.resolve(); + if (!jobStatus || !cache) { + return; + } + if (util_getBooleanInput(INPUT_CACHE_READ_ONLY, false)) { + info('Cache saving is skipped because cache-read-only is enabled.'); + return; + } + await save(cache); } /** * The save process is best-effort, and it should not make the workflow fail diff --git a/dist/setup/index.js b/dist/setup/index.js index 09aad7f9..51ae9a2d 100644 --- a/dist/setup/index.js +++ b/dist/setup/index.js @@ -72120,6 +72120,7 @@ const MAVEN_GPG_PASSPHRASE_DEFAULT_ENV = 'MAVEN_GPG_PASSPHRASE'; const GPG_PASSPHRASE_PROFILE_ID = 'setup-java-gpg'; const INPUT_CACHE = 'cache'; const INPUT_CACHE_DEPENDENCY_PATH = 'cache-dependency-path'; +const INPUT_CACHE_READ_ONLY = 'cache-read-only'; const constants_INPUT_JOB_STATUS = 'job-status'; const STATE_GPG_PRIVATE_KEY_FINGERPRINT = 'gpg-private-key-fingerprint'; const M2_DIR = '.m2'; diff --git a/src/cleanup-java.ts b/src/cleanup-java.ts index 5503e26e..0848e417 100644 --- a/src/cleanup-java.ts +++ b/src/cleanup-java.ts @@ -1,7 +1,7 @@ import * as core from '@actions/core'; import * as gpg from './gpg.js'; import * as constants from './constants.js'; -import {isJobStatusSuccess} from './util.js'; +import {getBooleanInput, isJobStatusSuccess} from './util.js'; import {save} from './cache.js'; import {fileURLToPath} from 'url'; @@ -28,7 +28,16 @@ async function removePrivateKeyFromKeychain() { async function saveCache() { const jobStatus = isJobStatusSuccess(); const cache = core.getInput(constants.INPUT_CACHE); - return jobStatus && cache ? save(cache) : Promise.resolve(); + if (!jobStatus || !cache) { + return; + } + + if (getBooleanInput(constants.INPUT_CACHE_READ_ONLY, false)) { + core.info('Cache saving is skipped because cache-read-only is enabled.'); + return; + } + + await save(cache); } /** diff --git a/src/constants.ts b/src/constants.ts index e1688db4..81b29861 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -38,6 +38,7 @@ export const GPG_PASSPHRASE_PROFILE_ID = 'setup-java-gpg'; export const INPUT_CACHE = 'cache'; export const INPUT_CACHE_DEPENDENCY_PATH = 'cache-dependency-path'; +export const INPUT_CACHE_READ_ONLY = 'cache-read-only'; export const INPUT_JOB_STATUS = 'job-status'; export const STATE_GPG_PRIVATE_KEY_FINGERPRINT = 'gpg-private-key-fingerprint';