diff --git a/.github/workflows/benchmark-cache-restore.yml b/.github/workflows/benchmark-cache-restore.yml new file mode 100644 index 00000000..463bf0fd --- /dev/null +++ b/.github/workflows/benchmark-cache-restore.yml @@ -0,0 +1,209 @@ +name: Benchmark cache restore + +on: + workflow_dispatch: + inputs: + baseline-ref: + description: Git ref containing the sequential restore implementation + required: true + default: main + type: string + candidate-ref: + description: Git ref containing the concurrent restore implementation (defaults to the dispatched ref) + required: false + type: string + +permissions: + contents: read + +defaults: + run: + shell: bash + +jobs: + warm-caches: + name: Warm ${{ matrix.tool }} ${{ matrix.profile }} caches (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [macos-15-intel, windows-latest, ubuntu-latest] + tool: [maven, gradle] + profile: [small, large] + steps: + - name: Checkout benchmark workflow + uses: actions/checkout@v7 + with: + persist-credentials: false + - name: Checkout baseline + uses: actions/checkout@v7 + with: + path: baseline + persist-credentials: false + ref: ${{ inputs.baseline-ref }} + - name: Checkout candidate + uses: actions/checkout@v7 + with: + path: candidate + persist-credentials: false + ref: ${{ inputs.candidate-ref || github.ref }} + - name: Prepare benchmark inputs + run: bash __tests__/benchmark-cache-restore.sh prepare "${{ matrix.tool }}" "${{ matrix.profile }}" + - name: Prepare cache save + uses: ./candidate + with: + distribution: temurin + java-version: '17' + cache: ${{ matrix.tool }} + cache-dependency-path: benchmark/${{ matrix.tool == 'maven' && 'pom.xml' || 'build.gradle' }} + - name: Populate benchmark caches + run: | + bash __tests__/benchmark-cache-restore.sh reset "${{ matrix.tool }}" + bash __tests__/benchmark-cache-restore.sh populate "${{ matrix.tool }}" "${{ matrix.profile }}" + + benchmark: + name: Benchmark ${{ matrix.tool }} ${{ matrix.profile }} (${{ matrix.os }}) + needs: warm-caches + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [macos-15-intel, windows-latest, ubuntu-latest] + tool: [maven, gradle] + profile: [small, large] + steps: + - name: Checkout benchmark workflow + uses: actions/checkout@v7 + with: + persist-credentials: false + - name: Checkout baseline + uses: actions/checkout@v7 + with: + path: baseline + persist-credentials: false + ref: ${{ inputs.baseline-ref }} + - name: Checkout candidate + uses: actions/checkout@v7 + with: + path: candidate + persist-credentials: false + ref: ${{ inputs.candidate-ref || github.ref }} + - name: Prepare benchmark inputs + run: bash __tests__/benchmark-cache-restore.sh prepare "${{ matrix.tool }}" "${{ matrix.profile }}" + + - name: Reset caches for baseline iteration 1 + run: bash __tests__/benchmark-cache-restore.sh reset "${{ matrix.tool }}" + - name: Start baseline iteration 1 timer + run: bash __tests__/benchmark-cache-restore.sh start "${{ matrix.tool }}" + - name: Restore with baseline iteration 1 + id: baseline-1 + uses: ./baseline + with: + distribution: temurin + java-version: '17' + cache: ${{ matrix.tool }} + cache-dependency-path: benchmark/${{ matrix.tool == 'maven' && 'pom.xml' || 'build.gradle' }} + cache-read-only: true + - name: Record baseline iteration 1 + env: + CACHE_HIT: ${{ steps.baseline-1.outputs.cache-hit }} + run: bash __tests__/benchmark-cache-restore.sh record "${{ matrix.tool }}" "${{ matrix.os }}" "${{ matrix.profile }}" baseline 1 "$CACHE_HIT" + + - name: Reset caches for candidate iteration 1 + run: bash __tests__/benchmark-cache-restore.sh reset "${{ matrix.tool }}" + - name: Start candidate iteration 1 timer + run: bash __tests__/benchmark-cache-restore.sh start "${{ matrix.tool }}" + - name: Restore with candidate iteration 1 + id: candidate-1 + uses: ./candidate + with: + distribution: temurin + java-version: '17' + cache: ${{ matrix.tool }} + cache-dependency-path: benchmark/${{ matrix.tool == 'maven' && 'pom.xml' || 'build.gradle' }} + cache-read-only: true + - name: Record candidate iteration 1 + env: + CACHE_HIT: ${{ steps.candidate-1.outputs.cache-hit }} + run: bash __tests__/benchmark-cache-restore.sh record "${{ matrix.tool }}" "${{ matrix.os }}" "${{ matrix.profile }}" candidate 1 "$CACHE_HIT" + + - name: Reset caches for candidate iteration 2 + run: bash __tests__/benchmark-cache-restore.sh reset "${{ matrix.tool }}" + - name: Start candidate iteration 2 timer + run: bash __tests__/benchmark-cache-restore.sh start "${{ matrix.tool }}" + - name: Restore with candidate iteration 2 + id: candidate-2 + uses: ./candidate + with: + distribution: temurin + java-version: '17' + cache: ${{ matrix.tool }} + cache-dependency-path: benchmark/${{ matrix.tool == 'maven' && 'pom.xml' || 'build.gradle' }} + cache-read-only: true + - name: Record candidate iteration 2 + env: + CACHE_HIT: ${{ steps.candidate-2.outputs.cache-hit }} + run: bash __tests__/benchmark-cache-restore.sh record "${{ matrix.tool }}" "${{ matrix.os }}" "${{ matrix.profile }}" candidate 2 "$CACHE_HIT" + + - name: Reset caches for baseline iteration 2 + run: bash __tests__/benchmark-cache-restore.sh reset "${{ matrix.tool }}" + - name: Start baseline iteration 2 timer + run: bash __tests__/benchmark-cache-restore.sh start "${{ matrix.tool }}" + - name: Restore with baseline iteration 2 + id: baseline-2 + uses: ./baseline + with: + distribution: temurin + java-version: '17' + cache: ${{ matrix.tool }} + cache-dependency-path: benchmark/${{ matrix.tool == 'maven' && 'pom.xml' || 'build.gradle' }} + cache-read-only: true + - name: Record baseline iteration 2 + env: + CACHE_HIT: ${{ steps.baseline-2.outputs.cache-hit }} + run: bash __tests__/benchmark-cache-restore.sh record "${{ matrix.tool }}" "${{ matrix.os }}" "${{ matrix.profile }}" baseline 2 "$CACHE_HIT" + + - name: Reset caches for baseline iteration 3 + run: bash __tests__/benchmark-cache-restore.sh reset "${{ matrix.tool }}" + - name: Start baseline iteration 3 timer + run: bash __tests__/benchmark-cache-restore.sh start "${{ matrix.tool }}" + - name: Restore with baseline iteration 3 + id: baseline-3 + uses: ./baseline + with: + distribution: temurin + java-version: '17' + cache: ${{ matrix.tool }} + cache-dependency-path: benchmark/${{ matrix.tool == 'maven' && 'pom.xml' || 'build.gradle' }} + cache-read-only: true + - name: Record baseline iteration 3 + env: + CACHE_HIT: ${{ steps.baseline-3.outputs.cache-hit }} + run: bash __tests__/benchmark-cache-restore.sh record "${{ matrix.tool }}" "${{ matrix.os }}" "${{ matrix.profile }}" baseline 3 "$CACHE_HIT" + + - name: Reset caches for candidate iteration 3 + run: bash __tests__/benchmark-cache-restore.sh reset "${{ matrix.tool }}" + - name: Start candidate iteration 3 timer + run: bash __tests__/benchmark-cache-restore.sh start "${{ matrix.tool }}" + - name: Restore with candidate iteration 3 + id: candidate-3 + uses: ./candidate + with: + distribution: temurin + java-version: '17' + cache: ${{ matrix.tool }} + cache-dependency-path: benchmark/${{ matrix.tool == 'maven' && 'pom.xml' || 'build.gradle' }} + cache-read-only: true + - name: Record candidate iteration 3 + env: + CACHE_HIT: ${{ steps.candidate-3.outputs.cache-hit }} + run: bash __tests__/benchmark-cache-restore.sh record "${{ matrix.tool }}" "${{ matrix.os }}" "${{ matrix.profile }}" candidate 3 "$CACHE_HIT" + + - name: Summarize benchmark + run: bash __tests__/benchmark-cache-restore.sh summarize "${{ matrix.tool }}" "$GITHUB_STEP_SUMMARY" + - name: Upload raw timings + uses: actions/upload-artifact@v6 + with: + name: cache-restore-${{ matrix.os }}-${{ matrix.tool }}-${{ matrix.profile }} + path: .benchmark-results/timings.csv + if-no-files-found: error diff --git a/.github/workflows/e2e-cache.yml b/.github/workflows/e2e-cache.yml index a60fde6d..2bab56dc 100644 --- a/.github/workflows/e2e-cache.yml +++ b/.github/workflows/e2e-cache.yml @@ -42,7 +42,10 @@ jobs: # https://github.com/actions/cache/issues/454#issuecomment-840493935 run: | gradle downloadDependencies --no-daemon -p __tests__/cache/gradle1 + mkdir -p "$HOME/.gradle/wrapper/dists/setup-java-e2e" + echo "gradle wrapper cache" > "$HOME/.gradle/wrapper/dists/setup-java-e2e/payload" bash __tests__/check-dir.sh "$HOME/.gradle/caches" + bash __tests__/check-dir.sh "$HOME/.gradle/wrapper/dists" gradle-restore: runs-on: ${{ matrix.os }} strategy: @@ -65,6 +68,8 @@ jobs: cache-read-only: true - name: Confirm that ~/.gradle/caches directory has been made run: bash __tests__/check-dir.sh "$HOME/.gradle/caches" + - name: Confirm that the Gradle Wrapper cache has been restored + run: bash __tests__/check-dir.sh "$HOME/.gradle/wrapper/dists" maven-save: runs-on: ${{ matrix.os }} strategy: @@ -86,7 +91,10 @@ jobs: - name: Create files to cache run: | mvn verify -f __tests__/cache/maven/pom.xml + mkdir -p "$HOME/.m2/wrapper/dists/setup-java-e2e" + echo "maven wrapper cache" > "$HOME/.m2/wrapper/dists/setup-java-e2e/payload" bash __tests__/check-dir.sh "$HOME/.m2/repository" + bash __tests__/check-dir.sh "$HOME/.m2/wrapper/dists" maven-restore: runs-on: ${{ matrix.os }} strategy: @@ -109,6 +117,8 @@ jobs: cache-read-only: true - name: Confirm that ~/.m2/repository directory has been made run: bash __tests__/check-dir.sh "$HOME/.m2/repository" + - name: Confirm that the Maven Wrapper cache has been restored + run: bash __tests__/check-dir.sh "$HOME/.m2/wrapper/dists" sbt-save: runs-on: ${{ matrix.os }} defaults: @@ -206,7 +216,10 @@ jobs: # https://github.com/actions/cache/issues/454#issuecomment-840493935 run: | gradle downloadDependencies --no-daemon -p __tests__/cache/gradle1 + mkdir -p "$HOME/.gradle/wrapper/dists/setup-java-e2e-gradle1" + echo "gradle wrapper cache gradle1" > "$HOME/.gradle/wrapper/dists/setup-java-e2e-gradle1/payload" bash __tests__/check-dir.sh "$HOME/.gradle/caches" + bash __tests__/check-dir.sh "$HOME/.gradle/wrapper/dists" gradle1-restore: runs-on: ${{ matrix.os }} strategy: @@ -229,6 +242,8 @@ jobs: cache-dependency-path: __tests__/cache/gradle1/*.gradle* - name: Confirm that ~/.gradle/caches directory has been made run: bash __tests__/check-dir.sh "$HOME/.gradle/caches" + - name: Confirm that the Gradle Wrapper cache has been restored + run: bash __tests__/check-dir.sh "$HOME/.gradle/wrapper/dists" gradle2-restore: runs-on: ${{ matrix.os }} strategy: @@ -273,7 +288,10 @@ jobs: - name: Create files to cache run: | mvn verify -f __tests__/cache/maven/pom.xml + mkdir -p "$HOME/.m2/wrapper/dists/setup-java-e2e-maven1" + echo "maven wrapper cache maven1" > "$HOME/.m2/wrapper/dists/setup-java-e2e-maven1/payload" bash __tests__/check-dir.sh "$HOME/.m2/repository" + bash __tests__/check-dir.sh "$HOME/.m2/wrapper/dists" maven1-restore: runs-on: ${{ matrix.os }} strategy: @@ -296,6 +314,8 @@ jobs: cache-dependency-path: __tests__/cache/maven/pom.xml - name: Confirm that ~/.m2/repository directory has been made run: bash __tests__/check-dir.sh "$HOME/.m2/repository" + - name: Confirm that the Maven Wrapper cache has been restored + run: bash __tests__/check-dir.sh "$HOME/.m2/wrapper/dists" maven2-restore: runs-on: ${{ matrix.os }} strategy: diff --git a/__tests__/benchmark-cache-restore.sh b/__tests__/benchmark-cache-restore.sh new file mode 100644 index 00000000..46470eaf --- /dev/null +++ b/__tests__/benchmark-cache-restore.sh @@ -0,0 +1,135 @@ +#!/usr/bin/env bash + +set -euo pipefail + +command=${1:?command is required} +tool=${2:?tool is required} + +case "$tool" in + maven) + dependency_cache="$HOME/.m2/repository" + wrapper_cache="$HOME/.m2/wrapper/dists" + dependency_file="benchmark/pom.xml" + wrapper_file="benchmark/.mvn/wrapper/maven-wrapper.properties" + ;; + gradle) + dependency_cache="$HOME/.gradle/caches" + wrapper_cache="$HOME/.gradle/wrapper" + dependency_file="benchmark/build.gradle" + wrapper_file="benchmark/gradle/wrapper/gradle-wrapper.properties" + ;; + *) + echo "Unsupported tool: $tool" >&2 + exit 1 + ;; +esac + +case "$command" in + prepare) + profile=${3:?profile is required} + mkdir -p "$(dirname "$dependency_file")" "$(dirname "$wrapper_file")" + printf '// setup-java cache benchmark v1: %s\n' "$profile" > "$dependency_file" + printf '# setup-java cache benchmark v1: %s\n' "$profile" > "$wrapper_file" + ;; + reset) + rm -rf "$dependency_cache" "$wrapper_cache" + ;; + populate) + profile=${3:?profile is required} + case "$profile" in + small) + dependency_megabytes=8 + wrapper_megabytes=2 + ;; + large) + dependency_megabytes=128 + wrapper_megabytes=32 + ;; + *) + echo "Unsupported profile: $profile" >&2 + exit 1 + ;; + esac + mkdir -p "$dependency_cache/setup-java-benchmark" + mkdir -p "$wrapper_cache/setup-java-benchmark" + dd if=/dev/urandom \ + of="$dependency_cache/setup-java-benchmark/payload" \ + bs=1048576 count="$dependency_megabytes" 2>/dev/null + dd if=/dev/urandom \ + of="$wrapper_cache/setup-java-benchmark/payload" \ + bs=1048576 count="$wrapper_megabytes" 2>/dev/null + ;; + start) + node -e "require('fs').writeFileSync('.benchmark-start', String(Date.now()))" + ;; + record) + os=${3:?os is required} + profile=${4:?profile is required} + implementation=${5:?implementation is required} + iteration=${6:?iteration is required} + cache_hit=${7:?cache-hit output is required} + if [ "$cache_hit" != "true" ]; then + echo "Expected an exact dependency-cache hit for $implementation" >&2 + exit 1 + fi + test -f "$dependency_cache/setup-java-benchmark/payload" + started=$(cat .benchmark-start) + finished=$(node -e "process.stdout.write(String(Date.now()))") + elapsed=$((finished - started)) + mkdir -p .benchmark-results + printf '%s,%s,%s,%s,%s,%s\n' \ + "$os" "$tool" "$profile" "$implementation" "$iteration" "$elapsed" \ + >> .benchmark-results/timings.csv + ;; + summarize) + summary_file=${3:?summary file is required} + results_file=".benchmark-results/timings.csv" + node --input-type=module - "$results_file" "$summary_file" <<'NODE' +import fs from 'node:fs'; + +const [, , resultsFile, summaryFile] = process.argv; +const rows = fs + .readFileSync(resultsFile, 'utf8') + .trim() + .split('\n') + .map(line => { + const [os, tool, profile, implementation, iteration, elapsed] = + line.split(','); + return {os, tool, profile, implementation, iteration, elapsed: +elapsed}; + }); +const average = implementation => { + const values = rows + .filter(row => row.implementation === implementation) + .map(row => row.elapsed); + if (values.length === 0) { + throw new Error(`No ${implementation} benchmark results were recorded`); + } + return Math.round(values.reduce((sum, value) => sum + value, 0) / values.length); +}; +const baseline = average('baseline'); +const candidate = average('candidate'); +const change = (((candidate - baseline) / baseline) * 100).toFixed(1); +const {os, tool, profile} = rows[0]; +const lines = [ + `### ${tool} ${profile} cache restore on ${os}`, + '', + '| Implementation | Iteration | Wall time (ms) |', + '| --- | ---: | ---: |', + ...rows.map( + row => + `| ${row.implementation} | ${row.iteration} | ${row.elapsed} |` + ), + `| **baseline average** | | **${baseline}** |`, + `| **candidate average** | | **${candidate}** |`, + '', + `Candidate change from baseline: **${change}%**`, + '' +]; +fs.appendFileSync(summaryFile, `${lines.join('\n')}\n`); +NODE + ;; + *) + echo "Unsupported command: $command" >&2 + exit 1 + ;; +esac diff --git a/__tests__/cache.test.ts b/__tests__/cache.test.ts index 5bac4d61..5957f2df 100644 --- a/__tests__/cache.test.ts +++ b/__tests__/cache.test.ts @@ -237,6 +237,83 @@ describe('dependency cache', () => { expect(spyGlobHashFiles).toHaveBeenCalledWith( '**/.mvn/wrapper/maven-wrapper.properties' ); + expect(spyInfo).toHaveBeenCalledWith( + 'maven-wrapper cache is not found' + ); + }); + it('starts maven dependency and wrapper restores before either completes', async () => { + createDirectory(join(workspace, '.mvn')); + createDirectory(join(workspace, '.mvn', 'wrapper')); + createFile( + join(workspace, '.mvn', 'wrapper', 'maven-wrapper.properties') + ); + const dependencyRestore = deferred(); + const wrapperRestore = deferred(); + const bothRestoresStarted = deferred(); + let restoreCount = 0; + spyCacheRestore.mockImplementation((paths: string[]) => { + restoreCount++; + if (restoreCount === 2) { + bothRestoresStarted.resolve(); + } + return paths.includes(join(os.homedir(), '.m2', 'repository')) + ? dependencyRestore.promise + : wrapperRestore.promise; + }); + + const restorePromise = restore('maven', ''); + await bothRestoresStarted.promise; + + expect(spyCacheRestore).toHaveBeenCalledTimes(2); + expect(spySaveState).toHaveBeenCalledWith( + 'cache-primary-key', + expect.any(String) + ); + expect(spySaveState).toHaveBeenCalledWith( + 'cache-primary-key-maven-wrapper', + expect.any(String) + ); + + wrapperRestore.resolve('maven-wrapper-hit'); + dependencyRestore.resolve('maven-dependency-hit'); + await restorePromise; + + expect(spySaveState).toHaveBeenCalledWith( + 'cache-matched-key-maven-wrapper', + 'maven-wrapper-hit' + ); + expect(spySaveState).toHaveBeenCalledWith( + 'cache-matched-key', + 'maven-dependency-hit' + ); + expect(spySetOutput).toHaveBeenCalledWith('cache-hit', false); + }); + it('propagates a wrapper restore failure after starting both restores', async () => { + createDirectory(join(workspace, '.mvn')); + createDirectory(join(workspace, '.mvn', 'wrapper')); + createFile( + join(workspace, '.mvn', 'wrapper', 'maven-wrapper.properties') + ); + const dependencyRestore = deferred(); + const wrapperRestore = deferred(); + const bothRestoresStarted = deferred(); + let restoreCount = 0; + spyCacheRestore.mockImplementation((paths: string[]) => { + restoreCount++; + if (restoreCount === 2) { + bothRestoresStarted.resolve(); + } + return paths.includes(join(os.homedir(), '.m2', 'repository')) + ? dependencyRestore.promise + : wrapperRestore.promise; + }); + + const restorePromise = restore('maven', ''); + await bothRestoresStarted.promise; + wrapperRestore.reject(new Error('wrapper restore failed')); + dependencyRestore.resolve(undefined); + + await expect(restorePromise).rejects.toThrow('wrapper restore failed'); }); it('skips the maven wrapper cache when no wrapper properties exist', async () => { createFile(join(workspace, 'pom.xml')); @@ -333,6 +410,50 @@ describe('dependency cache', () => { '**/gradle-wrapper.properties' ); }); + it('starts gradle dependency and wrapper restores before either completes', async () => { + createFile(join(workspace, 'build.gradle')); + createFile(join(workspace, 'gradle-wrapper.properties')); + const dependencyRestore = deferred(); + const wrapperRestore = deferred(); + const bothRestoresStarted = deferred(); + let restoreCount = 0; + spyCacheRestore.mockImplementation((paths: string[]) => { + restoreCount++; + if (restoreCount === 2) { + bothRestoresStarted.resolve(); + } + return paths.includes(join(os.homedir(), '.gradle', 'caches')) + ? dependencyRestore.promise + : wrapperRestore.promise; + }); + + const restorePromise = restore('gradle', ''); + await bothRestoresStarted.promise; + + expect(spyCacheRestore).toHaveBeenCalledTimes(2); + expect(spySaveState).toHaveBeenCalledWith( + 'cache-primary-key', + expect.any(String) + ); + expect(spySaveState).toHaveBeenCalledWith( + 'cache-primary-key-gradle-wrapper', + expect.any(String) + ); + + dependencyRestore.resolve('gradle-dependency-hit'); + wrapperRestore.resolve('gradle-wrapper-hit'); + await restorePromise; + + expect(spySaveState).toHaveBeenCalledWith( + 'cache-matched-key', + 'gradle-dependency-hit' + ); + expect(spySaveState).toHaveBeenCalledWith( + 'cache-matched-key-gradle-wrapper', + 'gradle-wrapper-hit' + ); + expect(spySetOutput).toHaveBeenCalledWith('cache-hit', false); + }); it('skips the gradle wrapper cache when no wrapper properties exist', async () => { createFile(join(workspace, 'build.gradle')); spyGlobHashFiles.mockImplementation((pattern: string) => @@ -609,6 +730,36 @@ describe('dependency cache', () => { ); expect(spyWarning).not.toHaveBeenCalled(); }); + it('continues with primary cache save when additional cache save fails unexpectedly', async () => { + createFile(join(workspace, 'pom.xml')); + (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-maven-wrapper': + return 'setup-java-maven-wrapper-key'; + default: + return ''; + } + }); + spyCacheSave.mockImplementation((paths: string[], key: string) => { + if (paths[0] === 'wrapper-path') { + return Promise.reject(new Error('wrapper save exploded')); + } + return Promise.resolve(0); + }); + + await expect(save('maven')).resolves.toBeUndefined(); + expect(spyWarning).toHaveBeenCalledWith( + 'Failed to save maven-wrapper cache: wrapper save exploded. Continuing with primary cache save.' + ); + expect(spyCacheSave).toHaveBeenCalledWith( + [join(os.homedir(), '.m2', 'repository')], + 'setup-java-cache-primary-key' + ); + }); }); describe('for gradle', () => { it('uploads cache even if no build.gradle found', async () => { @@ -817,6 +968,16 @@ function createFile(path: string) { fs.writeFileSync(path, ''); } +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve; + reject = promiseReject; + }); + return {promise, resolve, reject}; +} + function createDirectory(path: string) { core.info(`created a directory at ${path}`); fs.mkdirSync(path); diff --git a/__tests__/cache/gradle1/gradle/wrapper/gradle-wrapper.properties b/__tests__/cache/gradle1/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..e7838088 --- /dev/null +++ b/__tests__/cache/gradle1/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1 @@ +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip diff --git a/__tests__/cache/maven/.mvn/wrapper/maven-wrapper.properties b/__tests__/cache/maven/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 00000000..2b8cd3d6 --- /dev/null +++ b/__tests__/cache/maven/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1 @@ +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.12/apache-maven-3.9.12-bin.zip diff --git a/dist/cleanup/index.js b/dist/cleanup/index.js index 5ef71f66..a78aa719 100644 --- a/dist/cleanup/index.js +++ b/dist/cleanup/index.js @@ -97899,10 +97899,23 @@ async function computeAdditionalCacheKey(additionalCache) { */ async function restore(id, cacheDependencyPath) { const packageManager = findPackageManager(id); - const primaryKey = await computeCacheKey(packageManager, cacheDependencyPath); + const [primaryKey, preparedAdditionalCaches] = await Promise.all([ + computeCacheKey(packageManager, cacheDependencyPath), + prepareAdditionalCaches(packageManager.additionalCaches ?? []) + ]); core.debug(`primary key is ${primaryKey}`); core.saveState(STATE_CACHE_PRIMARY_KEY, primaryKey); core.setOutput(STATE_CACHE_PRIMARY_KEY, primaryKey); + for (const preparedCache of preparedAdditionalCaches) { + core.debug(`${preparedCache.cache.name} primary key is ${preparedCache.primaryKey}`); + core.saveState(additionalCachePrimaryKeyState(preparedCache.cache.name), preparedCache.primaryKey); + } + await Promise.all([ + restorePrimaryCache(packageManager, primaryKey), + ...preparedAdditionalCaches.map(preparedCache => restoreAdditionalCache(preparedCache)) + ]); +} +async function restorePrimaryCache(packageManager, primaryKey) { // No "restoreKeys" is set, to start with a clear cache after dependency update (see https://github.com/actions/setup-java/issues/269) const matchedKey = await cache.restoreCache(packageManager.path, primaryKey); if (matchedKey) { @@ -97914,29 +97927,35 @@ async function restore(id, cacheDependencyPath) { core.setOutput('cache-hit', false); core.info(`${packageManager.id} cache is not found`); } - for (const additionalCache of packageManager.additionalCaches ?? []) { - await restoreAdditionalCache(additionalCache); - } } /** - * Restore an additional cache (e.g. a build-tool wrapper distribution) that is - * keyed independently of the main dependency cache so that it survives changes - * to volatile dependency files. Skips silently when the project does not use - * the corresponding feature. + * Compute keys for additional caches (e.g. build-tool wrapper distributions). + * Additional caches without a matching configuration file are omitted. */ -async function restoreAdditionalCache(additionalCache) { - const primaryKey = await computeAdditionalCacheKey(additionalCache); - if (!primaryKey) { - core.debug(`No file matched [${additionalCache.pattern}] for the ${additionalCache.name} cache, skipping.`); - return; - } - core.debug(`${additionalCache.name} primary key is ${primaryKey}`); - core.saveState(additionalCachePrimaryKeyState(additionalCache.name), primaryKey); +async function prepareAdditionalCaches(additionalCaches) { + const preparedCaches = await Promise.all(additionalCaches.map(async (additionalCache) => { + const primaryKey = await computeAdditionalCacheKey(additionalCache); + if (!primaryKey) { + core.debug(`No file matched [${additionalCache.pattern}] for the ${additionalCache.name} cache, skipping.`); + return undefined; + } + return { cache: additionalCache, primaryKey }; + })); + return preparedCaches.filter((preparedCache) => preparedCache !== undefined); +} +/** + * Restore an additional cache keyed independently of the main dependency cache. + */ +async function restoreAdditionalCache(preparedCache) { + const { cache: additionalCache, primaryKey } = preparedCache; const matchedKey = await cache.restoreCache(additionalCache.path, primaryKey); if (matchedKey) { core.saveState(additionalCacheMatchedKeyState(additionalCache.name), matchedKey); core.info(`${additionalCache.name} cache restored from key: ${matchedKey}`); } + else { + core.info(`${additionalCache.name} cache is not found`); + } } /** * Save the dependency cache @@ -97948,7 +97967,13 @@ async function save(id) { // Inputs are re-evaluated before the post action, so we want the original key used for restore const primaryKey = getState(STATE_CACHE_PRIMARY_KEY); for (const additionalCache of packageManager.additionalCaches ?? []) { - await saveAdditionalCache(packageManager, additionalCache); + try { + await saveAdditionalCache(packageManager, additionalCache); + } + catch (error) { + const err = error; + warning(`Failed to save ${additionalCache.name} cache: ${err.message}. Continuing with primary cache save.`); + } } if (!primaryKey) { warning('Error retrieving key from state.'); @@ -98030,7 +98055,7 @@ async function saveAdditionalCache(packageManager, additionalCache) { } else { if (isProbablyGradleDaemonProblem(packageManager, err)) { - warning('Failed to save Gradle cache on Windows. If tar.exe reported "Permission denied", try to run Gradle with `--no-daemon` option. Refer to https://github.com/actions/cache/issues/454 for details.'); + warning(`Failed to save ${additionalCache.name} cache on Windows. If tar.exe reported "Permission denied", try to run Gradle with \`--no-daemon\` option. Refer to https://github.com/actions/cache/issues/454 for details.`); } throw error; } diff --git a/dist/setup/index.js b/dist/setup/index.js index 51ae9a2d..5830f946 100644 --- a/dist/setup/index.js +++ b/dist/setup/index.js @@ -129154,10 +129154,23 @@ async function computeAdditionalCacheKey(additionalCache) { */ async function restore(id, cacheDependencyPath) { const packageManager = findPackageManager(id); - const primaryKey = await computeCacheKey(packageManager, cacheDependencyPath); + const [primaryKey, preparedAdditionalCaches] = await Promise.all([ + computeCacheKey(packageManager, cacheDependencyPath), + prepareAdditionalCaches(packageManager.additionalCaches ?? []) + ]); core_debug(`primary key is ${primaryKey}`); saveState(STATE_CACHE_PRIMARY_KEY, primaryKey); setOutput(STATE_CACHE_PRIMARY_KEY, primaryKey); + for (const preparedCache of preparedAdditionalCaches) { + core_debug(`${preparedCache.cache.name} primary key is ${preparedCache.primaryKey}`); + saveState(additionalCachePrimaryKeyState(preparedCache.cache.name), preparedCache.primaryKey); + } + await Promise.all([ + restorePrimaryCache(packageManager, primaryKey), + ...preparedAdditionalCaches.map(preparedCache => restoreAdditionalCache(preparedCache)) + ]); +} +async function restorePrimaryCache(packageManager, primaryKey) { // No "restoreKeys" is set, to start with a clear cache after dependency update (see https://github.com/actions/setup-java/issues/269) const matchedKey = await restoreCache(packageManager.path, primaryKey); if (matchedKey) { @@ -129169,29 +129182,35 @@ async function restore(id, cacheDependencyPath) { setOutput('cache-hit', false); info(`${packageManager.id} cache is not found`); } - for (const additionalCache of packageManager.additionalCaches ?? []) { - await restoreAdditionalCache(additionalCache); - } } /** - * Restore an additional cache (e.g. a build-tool wrapper distribution) that is - * keyed independently of the main dependency cache so that it survives changes - * to volatile dependency files. Skips silently when the project does not use - * the corresponding feature. + * Compute keys for additional caches (e.g. build-tool wrapper distributions). + * Additional caches without a matching configuration file are omitted. */ -async function restoreAdditionalCache(additionalCache) { - const primaryKey = await computeAdditionalCacheKey(additionalCache); - if (!primaryKey) { - core_debug(`No file matched [${additionalCache.pattern}] for the ${additionalCache.name} cache, skipping.`); - return; - } - core_debug(`${additionalCache.name} primary key is ${primaryKey}`); - saveState(additionalCachePrimaryKeyState(additionalCache.name), primaryKey); +async function prepareAdditionalCaches(additionalCaches) { + const preparedCaches = await Promise.all(additionalCaches.map(async (additionalCache) => { + const primaryKey = await computeAdditionalCacheKey(additionalCache); + if (!primaryKey) { + core_debug(`No file matched [${additionalCache.pattern}] for the ${additionalCache.name} cache, skipping.`); + return undefined; + } + return { cache: additionalCache, primaryKey }; + })); + return preparedCaches.filter((preparedCache) => preparedCache !== undefined); +} +/** + * Restore an additional cache keyed independently of the main dependency cache. + */ +async function restoreAdditionalCache(preparedCache) { + const { cache: additionalCache, primaryKey } = preparedCache; const matchedKey = await restoreCache(additionalCache.path, primaryKey); if (matchedKey) { saveState(additionalCacheMatchedKeyState(additionalCache.name), matchedKey); info(`${additionalCache.name} cache restored from key: ${matchedKey}`); } + else { + info(`${additionalCache.name} cache is not found`); + } } /** * Save the dependency cache @@ -129203,7 +129222,13 @@ async function save(id) { // Inputs are re-evaluated before the post action, so we want the original key used for restore const primaryKey = core.getState(STATE_CACHE_PRIMARY_KEY); for (const additionalCache of packageManager.additionalCaches ?? []) { - await saveAdditionalCache(packageManager, additionalCache); + try { + await saveAdditionalCache(packageManager, additionalCache); + } + catch (error) { + const err = error; + core.warning(`Failed to save ${additionalCache.name} cache: ${err.message}. Continuing with primary cache save.`); + } } if (!primaryKey) { core.warning('Error retrieving key from state.'); @@ -129285,7 +129310,7 @@ async function saveAdditionalCache(packageManager, additionalCache) { } else { if (isProbablyGradleDaemonProblem(packageManager, err)) { - core.warning('Failed to save Gradle cache on Windows. If tar.exe reported "Permission denied", try to run Gradle with `--no-daemon` option. Refer to https://github.com/actions/cache/issues/454 for details.'); + core.warning(`Failed to save ${additionalCache.name} cache on Windows. If tar.exe reported "Permission denied", try to run Gradle with \`--no-daemon\` option. Refer to https://github.com/actions/cache/issues/454 for details.`); } throw error; } diff --git a/src/cache.ts b/src/cache.ts index e5951a3a..c5d0e0e4 100644 --- a/src/cache.ts +++ b/src/cache.ts @@ -36,6 +36,11 @@ interface AdditionalCache { pattern: string[]; } +interface PreparedAdditionalCache { + cache: AdditionalCache; + primaryKey: string; +} + interface PackageManager { id: 'maven' | 'gradle' | 'sbt'; /** @@ -189,11 +194,37 @@ async function computeAdditionalCacheKey( */ export async function restore(id: string, cacheDependencyPath: string) { const packageManager = findPackageManager(id); - const primaryKey = await computeCacheKey(packageManager, cacheDependencyPath); + const [primaryKey, preparedAdditionalCaches] = await Promise.all([ + computeCacheKey(packageManager, cacheDependencyPath), + prepareAdditionalCaches(packageManager.additionalCaches ?? []) + ]); + core.debug(`primary key is ${primaryKey}`); core.saveState(STATE_CACHE_PRIMARY_KEY, primaryKey); core.setOutput(STATE_CACHE_PRIMARY_KEY, primaryKey); + for (const preparedCache of preparedAdditionalCaches) { + core.debug( + `${preparedCache.cache.name} primary key is ${preparedCache.primaryKey}` + ); + core.saveState( + additionalCachePrimaryKeyState(preparedCache.cache.name), + preparedCache.primaryKey + ); + } + + await Promise.all([ + restorePrimaryCache(packageManager, primaryKey), + ...preparedAdditionalCaches.map(preparedCache => + restoreAdditionalCache(preparedCache) + ) + ]); +} + +async function restorePrimaryCache( + packageManager: PackageManager, + primaryKey: string +) { // No "restoreKeys" is set, to start with a clear cache after dependency update (see https://github.com/actions/setup-java/issues/269) const matchedKey = await cache.restoreCache(packageManager.path, primaryKey); if (matchedKey) { @@ -204,32 +235,39 @@ export async function restore(id: string, cacheDependencyPath: string) { core.setOutput('cache-hit', false); core.info(`${packageManager.id} cache is not found`); } - - for (const additionalCache of packageManager.additionalCaches ?? []) { - await restoreAdditionalCache(additionalCache); - } } /** - * Restore an additional cache (e.g. a build-tool wrapper distribution) that is - * keyed independently of the main dependency cache so that it survives changes - * to volatile dependency files. Skips silently when the project does not use - * the corresponding feature. + * Compute keys for additional caches (e.g. build-tool wrapper distributions). + * Additional caches without a matching configuration file are omitted. */ -async function restoreAdditionalCache(additionalCache: AdditionalCache) { - const primaryKey = await computeAdditionalCacheKey(additionalCache); - if (!primaryKey) { - core.debug( - `No file matched [${additionalCache.pattern}] for the ${additionalCache.name} cache, skipping.` - ); - return; - } - core.debug(`${additionalCache.name} primary key is ${primaryKey}`); - core.saveState( - additionalCachePrimaryKeyState(additionalCache.name), - primaryKey +async function prepareAdditionalCaches( + additionalCaches: AdditionalCache[] +): Promise { + const preparedCaches = await Promise.all( + additionalCaches.map(async additionalCache => { + const primaryKey = await computeAdditionalCacheKey(additionalCache); + if (!primaryKey) { + core.debug( + `No file matched [${additionalCache.pattern}] for the ${additionalCache.name} cache, skipping.` + ); + return undefined; + } + return {cache: additionalCache, primaryKey}; + }) ); + return preparedCaches.filter( + (preparedCache): preparedCache is PreparedAdditionalCache => + preparedCache !== undefined + ); +} + +/** + * Restore an additional cache keyed independently of the main dependency cache. + */ +async function restoreAdditionalCache(preparedCache: PreparedAdditionalCache) { + const {cache: additionalCache, primaryKey} = preparedCache; const matchedKey = await cache.restoreCache(additionalCache.path, primaryKey); if (matchedKey) { core.saveState( @@ -237,6 +275,8 @@ async function restoreAdditionalCache(additionalCache: AdditionalCache) { matchedKey ); core.info(`${additionalCache.name} cache restored from key: ${matchedKey}`); + } else { + core.info(`${additionalCache.name} cache is not found`); } } @@ -252,7 +292,14 @@ export async function save(id: string) { const primaryKey = core.getState(STATE_CACHE_PRIMARY_KEY); for (const additionalCache of packageManager.additionalCaches ?? []) { - await saveAdditionalCache(packageManager, additionalCache); + try { + await saveAdditionalCache(packageManager, additionalCache); + } catch (error) { + const err = error as Error; + core.warning( + `Failed to save ${additionalCache.name} cache: ${err.message}. Continuing with primary cache save.` + ); + } } if (!primaryKey) { @@ -360,7 +407,7 @@ async function saveAdditionalCache( } else { if (isProbablyGradleDaemonProblem(packageManager, err)) { core.warning( - 'Failed to save Gradle cache on Windows. If tar.exe reported "Permission denied", try to run Gradle with `--no-daemon` option. Refer to https://github.com/actions/cache/issues/454 for details.' + `Failed to save ${additionalCache.name} cache on Windows. If tar.exe reported "Permission denied", try to run Gradle with \`--no-daemon\` option. Refer to https://github.com/actions/cache/issues/454 for details.` ); } throw error;