feat: split Go module and build caches

Reuse downloaded modules across Go versions, runner architectures, and
runner images on the same OS instead of duplicating them in each build
cache archive.

Restore and save each entry independently, running operations in parallel
when safe. Serialize restores for overlapping or aliased paths, preserve
the original paths for the post step, and report cache-hit only when both
entries match their primary keys.

Update the documented manual restore keys and generated action bundles.
This commit is contained in:
qmuntal
2026-10-05 15:05:38 +02:00
parent 90ad2b35f6
commit ad9941188f
13 changed files with 1035 additions and 335 deletions

View File

@@ -1,5 +1,8 @@
/// <reference types="node" />
import {jest, describe, it, expect, beforeEach, afterEach} from '@jest/globals';
import fs from 'fs';
import path from 'path';
jest.unstable_mockModule('@actions/cache', () => ({
saveCache: jest.fn(),
@@ -50,91 +53,349 @@ const core = await import('@actions/core');
const glob = await import('@actions/glob');
const cacheRestore = await import('../src/cache-restore.js');
const cacheUtils = await import('../src/cache-utils.js');
import type {PackageManagerInfo} from '../src/package-managers.js';
const {run: saveCaches} = await import('../src/cache-save.js');
import type {CacheDirectory} from '../src/cache-utils.js';
function deferred() {
let resolve!: () => void;
const promise = new Promise<void>(done => {
resolve = done;
});
return {promise, resolve};
}
describe('restoreCache', () => {
let hashFilesSpy: jest.Mock<typeof glob.hashFiles>;
let getCacheDirectoryPathSpy: jest.Mock<
typeof cacheUtils.getCacheDirectoryPath
const hashFilesSpy = glob.hashFiles as jest.Mock<typeof glob.hashFiles>;
const getCacheDirectoryPathSpy =
cacheUtils.getCacheDirectoryPath as jest.Mock<
typeof cacheUtils.getCacheDirectoryPath
>;
const restoreCacheSpy = cache.restoreCache as jest.Mock<
typeof cache.restoreCache
>;
let restoreCacheSpy: jest.Mock<typeof cache.restoreCache>;
let infoSpy: jest.Mock<typeof core.info>;
let setOutputSpy: jest.Mock<typeof core.setOutput>;
const saveCacheSpy = cache.saveCache as jest.Mock<typeof cache.saveCache>;
const infoSpy = core.info as jest.Mock<typeof core.info>;
const setOutputSpy = core.setOutput as jest.Mock<typeof core.setOutput>;
const versionSpec = '1.13.1';
const packageManager = 'default';
const cacheDependencyPath = 'path';
let originalWorkspace: string | undefined;
const modulePath = 'module_cache_directory';
const buildPath = 'build_cache_directory';
const directories: CacheDirectory[] = [
{kind: 'modules', path: modulePath},
{kind: 'build', path: buildPath}
];
const originalEnv = {...process.env};
const originalArch = Object.getOwnPropertyDescriptor(process, 'arch')!;
const originalPlatform = Object.getOwnPropertyDescriptor(
process,
'platform'
)!;
beforeEach(() => {
originalWorkspace = process.env.GITHUB_WORKSPACE;
process.env.GITHUB_WORKSPACE = '/test/workspace';
//Arrange
hashFilesSpy = glob.hashFiles as jest.Mock<typeof glob.hashFiles>;
getCacheDirectoryPathSpy = cacheUtils.getCacheDirectoryPath as jest.Mock<
typeof cacheUtils.getCacheDirectoryPath
>;
restoreCacheSpy = cache.restoreCache as jest.Mock<
typeof cache.restoreCache
>;
infoSpy = core.info as jest.Mock<typeof core.info>;
setOutputSpy = core.setOutput as jest.Mock<typeof core.setOutput>;
getCacheDirectoryPathSpy.mockImplementation(
(PackageManager: PackageManagerInfo) => {
return Promise.resolve([
'cache_directory_path',
'cache_directory_path'
]);
}
);
jest.resetAllMocks();
process.env = {
...originalEnv,
GITHUB_WORKSPACE: '/test/workspace',
RUNNER_OS: 'Linux',
ImageOS: 'ubuntu24'
};
Object.defineProperty(process, 'arch', {value: 'x64'});
Object.defineProperty(process, 'platform', {value: 'linux'});
hashFilesSpy.mockResolvedValue('file_hash');
getCacheDirectoryPathSpy.mockResolvedValue(directories);
restoreCacheSpy.mockResolvedValue(undefined);
saveCacheSpy.mockResolvedValue(1);
});
afterEach(() => {
process.env.GITHUB_WORKSPACE = originalWorkspace;
process.env = {...originalEnv};
Object.defineProperty(process, 'arch', originalArch);
Object.defineProperty(process, 'platform', originalPlatform);
jest.restoreAllMocks();
});
const restore = (version = versionSpec) =>
cacheRestore.restoreCache(version, packageManager, cacheDependencyPath);
const requestedKeys = () => {
const moduleCall = restoreCacheSpy.mock.calls.find(
([paths]) => paths[0] === modulePath
);
const buildCall = restoreCacheSpy.mock.calls.find(
([paths]) => paths[0] === buildPath
);
expect(moduleCall).toBeDefined();
expect(buildCall).toBeDefined();
return {modules: moduleCall![1], build: buildCall![1]};
};
it('should throw if dependency file path is not valid', async () => {
// Arrange
hashFilesSpy.mockImplementation(() => Promise.resolve(''));
// Act + Assert
await expect(
cacheRestore.restoreCache(
versionSpec,
packageManager,
cacheDependencyPath
)
).rejects.toThrow(
hashFilesSpy.mockResolvedValue('');
await expect(restore()).rejects.toThrow(
'Some specified paths were not resolved, unable to cache dependencies.'
);
expect(restoreCacheSpy).not.toHaveBeenCalled();
expect(setOutputSpy).toHaveBeenLastCalledWith('cache-hit', false);
});
it('should inform if cache hit is not occurred', async () => {
// Arrange
hashFilesSpy.mockImplementation(() => Promise.resolve('file_hash'));
restoreCacheSpy.mockImplementation(() => Promise.resolve(''));
// Act + Assert
it('restores modules and build outputs as separate cache entries', async () => {
await restore();
const keys = requestedKeys();
expect(keys.modules).not.toBe(keys.build);
expect(restoreCacheSpy).toHaveBeenCalledTimes(2);
expect(restoreCacheSpy).toHaveBeenCalledWith([modulePath], keys.modules);
expect(restoreCacheSpy).toHaveBeenCalledWith([buildPath], keys.build);
expect(infoSpy).toHaveBeenCalledWith('modules cache is not found');
expect(infoSpy).toHaveBeenCalledWith('build cache is not found');
});
it.each([
{platform: 'linux', firstFails: false},
{platform: 'linux', firstFails: true},
{platform: 'win32', firstFails: false},
{platform: 'win32', firstFails: true}
])(
'starts both restores concurrently on $platform and waits for both (first fails=$firstFails)',
async ({platform, firstFails}) => {
Object.defineProperty(process, 'platform', {value: platform});
const started = [deferred(), deferred()];
const release = [deferred(), deferred()];
let completed = false;
restoreCacheSpy.mockImplementation(async (paths, key) => {
const index = paths[0] === modulePath ? 0 : 1;
started[index].resolve();
await release[index].promise;
if (index === 0 && firstFails) {
throw new Error('Module cache unavailable');
}
return key;
});
const pending = restore().then(() => {
completed = true;
});
try {
await Promise.all(started.map(signal => signal.promise));
release[0].resolve();
// Let the first completion propagate while the second stays blocked.
await new Promise<void>(resolve => setImmediate(resolve));
expect(completed).toBe(false);
expect(core.saveState).not.toHaveBeenCalled();
expect(setOutputSpy).not.toHaveBeenCalledWith('cache-hit', true);
} finally {
release.forEach(signal => signal.resolve());
await pending;
}
expect(setOutputSpy).toHaveBeenLastCalledWith('cache-hit', !firstFails);
expect(core.saveState).toHaveBeenCalledTimes(1);
}
);
it.each(['linux', 'win32'])(
'restores overlapping directories sequentially on %s',
async platform => {
Object.defineProperty(process, 'platform', {value: platform});
getCacheDirectoryPathSpy.mockResolvedValue([
directories[0],
{kind: 'build', path: path.join(modulePath, 'build')}
]);
const firstStarted = deferred();
const releaseFirst = deferred();
restoreCacheSpy.mockImplementation(async (paths, key) => {
if (paths[0] === modulePath) {
firstStarted.resolve();
await releaseFirst.promise;
}
return key;
});
const pending = restore();
try {
await firstStarted.promise;
expect(restoreCacheSpy).toHaveBeenCalledTimes(1);
} finally {
releaseFirst.resolve();
await pending;
}
expect(restoreCacheSpy).toHaveBeenCalledTimes(2);
expect(setOutputSpy).toHaveBeenLastCalledWith('cache-hit', true);
}
);
it.each([
{modules: false, build: false},
{modules: true, build: false},
{modules: false, build: true},
{modules: true, build: true}
])(
'reports hits and saves misses independently (modules=$modules, build=$build)',
async hits => {
const state = new Map<string, string>();
(core.saveState as jest.Mock<typeof core.saveState>).mockImplementation(
(name, value) => {
state.set(name, JSON.stringify(value));
}
);
(core.getState as jest.Mock<typeof core.getState>).mockImplementation(
name => state.get(name) || ''
);
(
core.getBooleanInput as jest.Mock<typeof core.getBooleanInput>
).mockReturnValue(true);
jest.spyOn(fs, 'existsSync').mockReturnValue(true);
restoreCacheSpy.mockImplementation(async (paths, key) =>
hits[paths[0] === modulePath ? 'modules' : 'build'] ? key : undefined
);
await restore();
const keys = requestedKeys();
expect(setOutputSpy).toHaveBeenLastCalledWith(
'cache-hit',
hits.modules && hits.build
);
// Changing the Go environment later must not change the saved paths.
process.env.GOMODCACHE = 'different_module_cache';
process.env.GOCACHE = 'different_build_cache';
await saveCaches();
for (const {kind, path} of directories) {
if (hits[kind]) {
expect(saveCacheSpy).not.toHaveBeenCalledWith([path], keys[kind]);
} else {
expect(saveCacheSpy).toHaveBeenCalledWith([path], keys[kind]);
}
}
expect(saveCacheSpy).toHaveBeenCalledTimes(
Number(!hits.modules) + Number(!hits.build)
);
}
);
it('does not report a full hit for a non-primary cache match', async () => {
restoreCacheSpy.mockImplementation(async (_paths, key) => `${key}-other`);
await restore();
expect(setOutputSpy).toHaveBeenLastCalledWith('cache-hit', false);
});
it.each(directories)(
'continues restoring when the $kind cache fails',
async ({kind, path}) => {
restoreCacheSpy.mockImplementation(async (paths, key) => {
if (paths[0] === path) {
throw new Error('Unable to reach the service');
}
return key;
});
await restore();
expect(restoreCacheSpy).toHaveBeenCalledTimes(2);
expect(core.warning).toHaveBeenCalledWith(
`Restore ${kind} cache failed: Unable to reach the service`
);
expect(setOutputSpy).toHaveBeenLastCalledWith('cache-hit', false);
}
);
it.each(directories)(
'restores only $kind when the other directory cannot be resolved',
async directory => {
getCacheDirectoryPathSpy.mockResolvedValue([directory]);
restoreCacheSpy.mockImplementation(async (_paths, key) => key);
await restore();
expect(restoreCacheSpy).toHaveBeenCalledTimes(1);
expect(restoreCacheSpy).toHaveBeenCalledWith(
[directory.path],
expect.any(String)
);
expect(setOutputSpy).toHaveBeenLastCalledWith('cache-hit', false);
}
);
it('reuses modules across Go versions without reusing build outputs', async () => {
await restore();
const before = requestedKeys();
restoreCacheSpy.mockClear();
await restore('1.26.1');
const after = requestedKeys();
expect(after.modules).toBe(before.modules);
expect(after.build).not.toBe(before.build);
});
it.each([
{
name: 'runner architecture',
change: () => Object.defineProperty(process, 'arch', {value: 'arm64'})
},
{
name: 'runner image',
change: () => {
process.env.ImageOS = 'ubuntu26';
}
}
])('reuses only modules when the $name changes', async ({change}) => {
await restore();
const before = requestedKeys();
restoreCacheSpy.mockClear();
change();
await restore();
const after = requestedKeys();
expect(after.modules).toBe(before.modules);
expect(after.build).not.toBe(before.build);
});
it('keeps both cache entries isolated by runner OS', async () => {
await restore();
const before = requestedKeys();
restoreCacheSpy.mockClear();
process.env.RUNNER_OS = 'Windows';
await restore();
const after = requestedKeys();
expect(after.modules).not.toBe(before.modules);
expect(after.build).not.toBe(before.build);
});
it('invalidates both cache entries when the dependency hash changes', async () => {
await restore();
const before = requestedKeys();
restoreCacheSpy.mockClear();
hashFilesSpy.mockResolvedValue('different_file_hash');
await restore();
const after = requestedKeys();
expect(after.modules).not.toBe(before.modules);
expect(after.build).not.toBe(before.build);
});
it('hashes the configured dependency paths for both cache entries', async () => {
const dependencyPaths = 'go.sum\ntools/go.sum';
await cacheRestore.restoreCache(
versionSpec,
packageManager,
cacheDependencyPath
dependencyPaths
);
expect(infoSpy).toHaveBeenCalledWith('Cache is not found');
});
it('should set output if cache hit is occurred', async () => {
// Arrange
hashFilesSpy.mockImplementation(() => Promise.resolve('file_hash'));
restoreCacheSpy.mockImplementation(() => Promise.resolve('cache_key'));
// Act + Assert
await cacheRestore.restoreCache(
versionSpec,
packageManager,
cacheDependencyPath
);
expect(setOutputSpy).toHaveBeenCalledWith('cache-hit', true);
expect(hashFilesSpy).toHaveBeenCalledWith(dependencyPaths);
expect(restoreCacheSpy).toHaveBeenCalledTimes(2);
});
it('should throw if dependency file is not found in workspace', async () => {