Detect cached folders from multiple directories (#735)

* Add project-dir

* Fix find lock file

* Remove package-dir input

* format & resolve conflicts

* Add unit tests

* build dist

* Apply change request fixes

* handle non-dir cache-dependency-path

* bump cache version

* run checks

* Handle globs in cacheDependencyPath

* refactor, introduce `cacheDependencyPathToProjectsDirectories`

it is necessary for the next PR related yarn optimization

* Changes requests

* Apply fixes

* review fixes

* add e2e

* Add unique

* review updates

* review updates second stage

* Review fixes 3

* imporve e2e tests
This commit is contained in:
Sergey Dolin 2023-06-21 17:52:17 +02:00 committed by GitHub
parent 698d50532e
commit 8170e22e8f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 3445 additions and 1496 deletions

View File

@ -134,3 +134,30 @@ jobs:
- name: Verify node and yarn - name: Verify node and yarn
run: __tests__/verify-node.sh "${{ matrix.node-version }}" run: __tests__/verify-node.sh "${{ matrix.node-version }}"
shell: bash shell: bash
yarn-subprojects:
name: Test yarn subprojects
strategy:
matrix:
node-version: [12, 14, 16]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: prepare sub-projects
run: __tests__/prepare-subprojects.sh
# expect
# - no errors
# - log
# ##[debug]Cache Paths:
# ##[debug]["sub2/.yarn/cache","sub3/.yarn/cache","../../../.cache/yarn/v6"]
- name: Setup Node
uses: ./
with:
node-version: ${{ matrix.node-version }}
cache: 'yarn'
cache-dependency-path: |
**/*.lock
yarn.lock

View File

@ -1,9 +1,10 @@
import os from 'os'; import os from 'os';
import * as fs from 'fs'; import fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import * as core from '@actions/core'; import * as core from '@actions/core';
import * as io from '@actions/io'; import * as io from '@actions/io';
import * as auth from '../src/authutil'; import * as auth from '../src/authutil';
import * as cacheUtils from '../src/cache-utils';
let rcFile: string; let rcFile: string;

View File

@ -32,13 +32,13 @@ describe('cache-restore', () => {
function findCacheFolder(command: string) { function findCacheFolder(command: string) {
switch (command) { switch (command) {
case utils.supportedPackageManagers.npm.getCacheFolderCommand: case 'npm config get cache':
return npmCachePath; return npmCachePath;
case utils.supportedPackageManagers.pnpm.getCacheFolderCommand: case 'pnpm store path --silent':
return pnpmCachePath; return pnpmCachePath;
case utils.supportedPackageManagers.yarn1.getCacheFolderCommand: case 'yarn cache dir':
return yarn1CachePath; return yarn1CachePath;
case utils.supportedPackageManagers.yarn2.getCacheFolderCommand: case 'yarn config get cacheFolder':
return yarn2CachePath; return yarn2CachePath;
default: default:
return 'packge/not/found'; return 'packge/not/found';
@ -108,7 +108,7 @@ describe('cache-restore', () => {
it.each([['npm7'], ['npm6'], ['pnpm6'], ['yarn1'], ['yarn2'], ['random']])( it.each([['npm7'], ['npm6'], ['pnpm6'], ['yarn1'], ['yarn2'], ['random']])(
'Throw an error because %s is not supported', 'Throw an error because %s is not supported',
async packageManager => { async packageManager => {
await expect(restoreCache(packageManager)).rejects.toThrow( await expect(restoreCache(packageManager, '')).rejects.toThrow(
`Caching for '${packageManager}' is not supported` `Caching for '${packageManager}' is not supported`
); );
} }
@ -132,7 +132,7 @@ describe('cache-restore', () => {
} }
}); });
await restoreCache(packageManager); await restoreCache(packageManager, '');
expect(hashFilesSpy).toHaveBeenCalled(); expect(hashFilesSpy).toHaveBeenCalled();
expect(infoSpy).toHaveBeenCalledWith( expect(infoSpy).toHaveBeenCalledWith(
`Cache restored from key: node-cache-${platform}-${packageManager}-${fileHash}` `Cache restored from key: node-cache-${platform}-${packageManager}-${fileHash}`
@ -163,7 +163,7 @@ describe('cache-restore', () => {
}); });
restoreCacheSpy.mockImplementationOnce(() => undefined); restoreCacheSpy.mockImplementationOnce(() => undefined);
await restoreCache(packageManager); await restoreCache(packageManager, '');
expect(hashFilesSpy).toHaveBeenCalled(); expect(hashFilesSpy).toHaveBeenCalled();
expect(infoSpy).toHaveBeenCalledWith( expect(infoSpy).toHaveBeenCalledWith(
`${packageManager} cache is not found` `${packageManager} cache is not found`

View File

@ -107,18 +107,20 @@ describe('run', () => {
describe('Validate unchanged cache is not saved', () => { describe('Validate unchanged cache is not saved', () => {
it('should not save cache for yarn1', async () => { it('should not save cache for yarn1', async () => {
inputs['cache'] = 'yarn'; inputs['cache'] = 'yarn';
getStateSpy.mockImplementation(() => yarnFileHash); getStateSpy.mockImplementation(key =>
getCommandOutputSpy key === State.CachePrimaryKey || key === State.CacheMatchedKey
.mockImplementationOnce(() => '1.2.3') ? yarnFileHash
.mockImplementationOnce(() => `${commonPath}/yarn1`); : key === State.CachePaths
? '["/foo/bar"]'
: 'not expected'
);
await run(); await run();
expect(getInputSpy).toHaveBeenCalled(); expect(getInputSpy).toHaveBeenCalled();
expect(getStateSpy).toHaveBeenCalledTimes(2); expect(getStateSpy).toHaveBeenCalledTimes(3);
expect(getCommandOutputSpy).toHaveBeenCalledTimes(2); expect(getCommandOutputSpy).toHaveBeenCalledTimes(0);
expect(debugSpy).toHaveBeenCalledWith(`yarn path is ${commonPath}/yarn1`); expect(debugSpy).toHaveBeenCalledTimes(0);
expect(debugSpy).toHaveBeenCalledWith('Consumed yarn version is 1.2.3');
expect(infoSpy).toHaveBeenCalledWith( expect(infoSpy).toHaveBeenCalledWith(
`Cache hit occurred on the primary key ${yarnFileHash}, not saving cache.` `Cache hit occurred on the primary key ${yarnFileHash}, not saving cache.`
); );
@ -127,18 +129,20 @@ describe('run', () => {
it('should not save cache for yarn2', async () => { it('should not save cache for yarn2', async () => {
inputs['cache'] = 'yarn'; inputs['cache'] = 'yarn';
getStateSpy.mockImplementation(() => yarnFileHash); getStateSpy.mockImplementation(key =>
getCommandOutputSpy key === State.CachePrimaryKey || key === State.CacheMatchedKey
.mockImplementationOnce(() => '2.2.3') ? yarnFileHash
.mockImplementationOnce(() => `${commonPath}/yarn2`); : key === State.CachePaths
? '["/foo/bar"]'
: 'not expected'
);
await run(); await run();
expect(getInputSpy).toHaveBeenCalled(); expect(getInputSpy).toHaveBeenCalled();
expect(getStateSpy).toHaveBeenCalledTimes(2); expect(getStateSpy).toHaveBeenCalledTimes(3);
expect(getCommandOutputSpy).toHaveBeenCalledTimes(2); expect(getCommandOutputSpy).toHaveBeenCalledTimes(0);
expect(debugSpy).toHaveBeenCalledWith(`yarn path is ${commonPath}/yarn2`); expect(debugSpy).toHaveBeenCalledTimes(0);
expect(debugSpy).toHaveBeenCalledWith('Consumed yarn version is 2.2.3');
expect(infoSpy).toHaveBeenCalledWith( expect(infoSpy).toHaveBeenCalledWith(
`Cache hit occurred on the primary key ${yarnFileHash}, not saving cache.` `Cache hit occurred on the primary key ${yarnFileHash}, not saving cache.`
); );
@ -147,35 +151,40 @@ describe('run', () => {
it('should not save cache for npm', async () => { it('should not save cache for npm', async () => {
inputs['cache'] = 'npm'; inputs['cache'] = 'npm';
getStateSpy.mockImplementation(() => npmFileHash); getStateSpy.mockImplementation(key =>
key === State.CachePrimaryKey || key === State.CacheMatchedKey
? yarnFileHash
: key === State.CachePaths
? '["/foo/bar"]'
: 'not expected'
);
getCommandOutputSpy.mockImplementationOnce(() => `${commonPath}/npm`); getCommandOutputSpy.mockImplementationOnce(() => `${commonPath}/npm`);
await run(); await run();
expect(getInputSpy).toHaveBeenCalled(); expect(getInputSpy).toHaveBeenCalled();
expect(getStateSpy).toHaveBeenCalledTimes(2); expect(getStateSpy).toHaveBeenCalledTimes(3);
expect(getCommandOutputSpy).toHaveBeenCalledTimes(1); expect(getCommandOutputSpy).toHaveBeenCalledTimes(0);
expect(debugSpy).toHaveBeenCalledWith(`npm path is ${commonPath}/npm`); expect(debugSpy).toHaveBeenCalledTimes(0);
expect(infoSpy).toHaveBeenCalledWith(
`Cache hit occurred on the primary key ${npmFileHash}, not saving cache.`
);
expect(setFailedSpy).not.toHaveBeenCalled(); expect(setFailedSpy).not.toHaveBeenCalled();
}); });
it('should not save cache for pnpm', async () => { it('should not save cache for pnpm', async () => {
inputs['cache'] = 'pnpm'; inputs['cache'] = 'pnpm';
getStateSpy.mockImplementation(() => pnpmFileHash); getStateSpy.mockImplementation(key =>
getCommandOutputSpy.mockImplementationOnce(() => `${commonPath}/pnpm`); key === State.CachePrimaryKey || key === State.CacheMatchedKey
? yarnFileHash
: key === State.CachePaths
? '["/foo/bar"]'
: 'not expected'
);
await run(); await run();
expect(getInputSpy).toHaveBeenCalled(); expect(getInputSpy).toHaveBeenCalled();
expect(getStateSpy).toHaveBeenCalledTimes(2); expect(getStateSpy).toHaveBeenCalledTimes(3);
expect(getCommandOutputSpy).toHaveBeenCalledTimes(1); expect(getCommandOutputSpy).toHaveBeenCalledTimes(0);
expect(debugSpy).toHaveBeenCalledWith(`pnpm path is ${commonPath}/pnpm`); expect(debugSpy).toHaveBeenCalledTimes(0);
expect(infoSpy).toHaveBeenCalledWith(
`Cache hit occurred on the primary key ${pnpmFileHash}, not saving cache.`
);
expect(setFailedSpy).not.toHaveBeenCalled(); expect(setFailedSpy).not.toHaveBeenCalled();
}); });
}); });
@ -183,24 +192,22 @@ describe('run', () => {
describe('action saves the cache', () => { describe('action saves the cache', () => {
it('saves cache from yarn 1', async () => { it('saves cache from yarn 1', async () => {
inputs['cache'] = 'yarn'; inputs['cache'] = 'yarn';
getStateSpy.mockImplementation((name: string) => { getStateSpy.mockImplementation((key: string) =>
if (name === State.CacheMatchedKey) { key === State.CacheMatchedKey
return yarnFileHash; ? yarnFileHash
} else { : key === State.CachePrimaryKey
return npmFileHash; ? npmFileHash
} : key === State.CachePaths
}); ? '["/foo/bar"]'
getCommandOutputSpy : 'not expected'
.mockImplementationOnce(() => '1.2.3') );
.mockImplementationOnce(() => `${commonPath}/yarn1`);
await run(); await run();
expect(getInputSpy).toHaveBeenCalled(); expect(getInputSpy).toHaveBeenCalled();
expect(getStateSpy).toHaveBeenCalledTimes(2); expect(getStateSpy).toHaveBeenCalledTimes(3);
expect(getCommandOutputSpy).toHaveBeenCalledTimes(2); expect(getCommandOutputSpy).toHaveBeenCalledTimes(0);
expect(debugSpy).toHaveBeenCalledWith(`yarn path is ${commonPath}/yarn1`); expect(debugSpy).toHaveBeenCalledTimes(0);
expect(debugSpy).toHaveBeenCalledWith('Consumed yarn version is 1.2.3');
expect(infoSpy).not.toHaveBeenCalledWith( expect(infoSpy).not.toHaveBeenCalledWith(
`Cache hit occurred on the primary key ${yarnFileHash}, not saving cache.` `Cache hit occurred on the primary key ${yarnFileHash}, not saving cache.`
); );
@ -213,24 +220,22 @@ describe('run', () => {
it('saves cache from yarn 2', async () => { it('saves cache from yarn 2', async () => {
inputs['cache'] = 'yarn'; inputs['cache'] = 'yarn';
getStateSpy.mockImplementation((name: string) => { getStateSpy.mockImplementation((key: string) =>
if (name === State.CacheMatchedKey) { key === State.CacheMatchedKey
return yarnFileHash; ? yarnFileHash
} else { : key === State.CachePrimaryKey
return npmFileHash; ? npmFileHash
} : key === State.CachePaths
}); ? '["/foo/bar"]'
getCommandOutputSpy : 'not expected'
.mockImplementationOnce(() => '2.2.3') );
.mockImplementationOnce(() => `${commonPath}/yarn2`);
await run(); await run();
expect(getInputSpy).toHaveBeenCalled(); expect(getInputSpy).toHaveBeenCalled();
expect(getStateSpy).toHaveBeenCalledTimes(2); expect(getStateSpy).toHaveBeenCalledTimes(3);
expect(getCommandOutputSpy).toHaveBeenCalledTimes(2); expect(getCommandOutputSpy).toHaveBeenCalledTimes(0);
expect(debugSpy).toHaveBeenCalledWith(`yarn path is ${commonPath}/yarn2`); expect(debugSpy).toHaveBeenCalledTimes(0);
expect(debugSpy).toHaveBeenCalledWith('Consumed yarn version is 2.2.3');
expect(infoSpy).not.toHaveBeenCalledWith( expect(infoSpy).not.toHaveBeenCalledWith(
`Cache hit occurred on the primary key ${yarnFileHash}, not saving cache.` `Cache hit occurred on the primary key ${yarnFileHash}, not saving cache.`
); );
@ -243,21 +248,22 @@ describe('run', () => {
it('saves cache from npm', async () => { it('saves cache from npm', async () => {
inputs['cache'] = 'npm'; inputs['cache'] = 'npm';
getStateSpy.mockImplementation((name: string) => { getStateSpy.mockImplementation((key: string) =>
if (name === State.CacheMatchedKey) { key === State.CacheMatchedKey
return npmFileHash; ? npmFileHash
} else { : key === State.CachePrimaryKey
return yarnFileHash; ? yarnFileHash
} : key === State.CachePaths
}); ? '["/foo/bar"]'
getCommandOutputSpy.mockImplementationOnce(() => `${commonPath}/npm`); : 'not expected'
);
await run(); await run();
expect(getInputSpy).toHaveBeenCalled(); expect(getInputSpy).toHaveBeenCalled();
expect(getStateSpy).toHaveBeenCalledTimes(2); expect(getStateSpy).toHaveBeenCalledTimes(3);
expect(getCommandOutputSpy).toHaveBeenCalledTimes(1); expect(getCommandOutputSpy).toHaveBeenCalledTimes(0);
expect(debugSpy).toHaveBeenCalledWith(`npm path is ${commonPath}/npm`); expect(debugSpy).toHaveBeenCalledTimes(0);
expect(infoSpy).not.toHaveBeenCalledWith( expect(infoSpy).not.toHaveBeenCalledWith(
`Cache hit occurred on the primary key ${npmFileHash}, not saving cache.` `Cache hit occurred on the primary key ${npmFileHash}, not saving cache.`
); );
@ -270,21 +276,22 @@ describe('run', () => {
it('saves cache from pnpm', async () => { it('saves cache from pnpm', async () => {
inputs['cache'] = 'pnpm'; inputs['cache'] = 'pnpm';
getStateSpy.mockImplementation((name: string) => { getStateSpy.mockImplementation((key: string) =>
if (name === State.CacheMatchedKey) { key === State.CacheMatchedKey
return pnpmFileHash; ? pnpmFileHash
} else { : key === State.CachePrimaryKey
return npmFileHash; ? npmFileHash
} : key === State.CachePaths
}); ? '["/foo/bar"]'
getCommandOutputSpy.mockImplementationOnce(() => `${commonPath}/pnpm`); : 'not expected'
);
await run(); await run();
expect(getInputSpy).toHaveBeenCalled(); expect(getInputSpy).toHaveBeenCalled();
expect(getStateSpy).toHaveBeenCalledTimes(2); expect(getStateSpy).toHaveBeenCalledTimes(3);
expect(getCommandOutputSpy).toHaveBeenCalledTimes(1); expect(getCommandOutputSpy).toHaveBeenCalledTimes(0);
expect(debugSpy).toHaveBeenCalledWith(`pnpm path is ${commonPath}/pnpm`); expect(debugSpy).toHaveBeenCalledTimes(0);
expect(infoSpy).not.toHaveBeenCalledWith( expect(infoSpy).not.toHaveBeenCalledWith(
`Cache hit occurred on the primary key ${pnpmFileHash}, not saving cache.` `Cache hit occurred on the primary key ${pnpmFileHash}, not saving cache.`
); );
@ -297,14 +304,15 @@ describe('run', () => {
it('save with -1 cacheId , should not fail workflow', async () => { it('save with -1 cacheId , should not fail workflow', async () => {
inputs['cache'] = 'npm'; inputs['cache'] = 'npm';
getStateSpy.mockImplementation((name: string) => { getStateSpy.mockImplementation((key: string) =>
if (name === State.CacheMatchedKey) { key === State.CacheMatchedKey
return npmFileHash; ? npmFileHash
} else { : key === State.CachePrimaryKey
return yarnFileHash; ? yarnFileHash
} : key === State.CachePaths
}); ? '["/foo/bar"]'
getCommandOutputSpy.mockImplementationOnce(() => `${commonPath}/npm`); : 'not expected'
);
saveCacheSpy.mockImplementation(() => { saveCacheSpy.mockImplementation(() => {
return -1; return -1;
}); });
@ -312,9 +320,9 @@ describe('run', () => {
await run(); await run();
expect(getInputSpy).toHaveBeenCalled(); expect(getInputSpy).toHaveBeenCalled();
expect(getStateSpy).toHaveBeenCalledTimes(2); expect(getStateSpy).toHaveBeenCalledTimes(3);
expect(getCommandOutputSpy).toHaveBeenCalledTimes(1); expect(getCommandOutputSpy).toHaveBeenCalledTimes(0);
expect(debugSpy).toHaveBeenCalledWith(`npm path is ${commonPath}/npm`); expect(debugSpy).toHaveBeenCalledTimes(0);
expect(infoSpy).not.toHaveBeenCalledWith( expect(infoSpy).not.toHaveBeenCalledWith(
`Cache hit occurred on the primary key ${npmFileHash}, not saving cache.` `Cache hit occurred on the primary key ${npmFileHash}, not saving cache.`
); );
@ -327,14 +335,15 @@ describe('run', () => {
it('saves with error from toolkit, should fail workflow', async () => { it('saves with error from toolkit, should fail workflow', async () => {
inputs['cache'] = 'npm'; inputs['cache'] = 'npm';
getStateSpy.mockImplementation((name: string) => { getStateSpy.mockImplementation((key: string) =>
if (name === State.CacheMatchedKey) { key === State.CacheMatchedKey
return npmFileHash; ? npmFileHash
} else { : key === State.CachePrimaryKey
return yarnFileHash; ? yarnFileHash
} : key === State.CachePaths
}); ? '["/foo/bar"]'
getCommandOutputSpy.mockImplementationOnce(() => `${commonPath}/npm`); : 'not expected'
);
saveCacheSpy.mockImplementation(() => { saveCacheSpy.mockImplementation(() => {
throw new cache.ValidationError('Validation failed'); throw new cache.ValidationError('Validation failed');
}); });
@ -342,9 +351,9 @@ describe('run', () => {
await run(); await run();
expect(getInputSpy).toHaveBeenCalled(); expect(getInputSpy).toHaveBeenCalled();
expect(getStateSpy).toHaveBeenCalledTimes(2); expect(getStateSpy).toHaveBeenCalledTimes(3);
expect(getCommandOutputSpy).toHaveBeenCalledTimes(1); expect(getCommandOutputSpy).toHaveBeenCalledTimes(0);
expect(debugSpy).toHaveBeenCalledWith(`npm path is ${commonPath}/npm`); expect(debugSpy).toHaveBeenCalledTimes(0);
expect(infoSpy).not.toHaveBeenCalledWith( expect(infoSpy).not.toHaveBeenCalledWith(
`Cache hit occurred on the primary key ${npmFileHash}, not saving cache.` `Cache hit occurred on the primary key ${npmFileHash}, not saving cache.`
); );

View File

@ -2,7 +2,17 @@ import * as core from '@actions/core';
import * as cache from '@actions/cache'; import * as cache from '@actions/cache';
import path from 'path'; import path from 'path';
import * as utils from '../src/cache-utils'; import * as utils from '../src/cache-utils';
import {PackageManagerInfo, isCacheFeatureAvailable} from '../src/cache-utils'; import {
PackageManagerInfo,
isCacheFeatureAvailable,
supportedPackageManagers,
getCommandOutput
} from '../src/cache-utils';
import fs from 'fs';
import * as cacheUtils from '../src/cache-utils';
import * as glob from '@actions/glob';
import {Globber} from '@actions/glob';
import {MockGlobber} from './mock/glob-mock';
describe('cache-utils', () => { describe('cache-utils', () => {
const versionYarn1 = '1.2.3'; const versionYarn1 = '1.2.3';
@ -30,7 +40,7 @@ describe('cache-utils', () => {
it.each<[string, PackageManagerInfo | null]>([ it.each<[string, PackageManagerInfo | null]>([
['npm', utils.supportedPackageManagers.npm], ['npm', utils.supportedPackageManagers.npm],
['pnpm', utils.supportedPackageManagers.pnpm], ['pnpm', utils.supportedPackageManagers.pnpm],
['yarn', utils.supportedPackageManagers.yarn1], ['yarn', utils.supportedPackageManagers.yarn],
['yarn1', null], ['yarn1', null],
['yarn2', null], ['yarn2', null],
['npm7', null] ['npm7', null]
@ -72,4 +82,261 @@ describe('cache-utils', () => {
jest.resetAllMocks(); jest.resetAllMocks();
jest.clearAllMocks(); jest.clearAllMocks();
}); });
describe('getCacheDirectoriesPaths', () => {
let existsSpy: jest.SpyInstance;
let lstatSpy: jest.SpyInstance;
let globCreateSpy: jest.SpyInstance;
beforeEach(() => {
existsSpy = jest.spyOn(fs, 'existsSync');
existsSpy.mockImplementation(() => true);
lstatSpy = jest.spyOn(fs, 'lstatSync');
lstatSpy.mockImplementation(arg => ({
isDirectory: () => true
}));
globCreateSpy = jest.spyOn(glob, 'create');
globCreateSpy.mockImplementation(
(pattern: string): Promise<Globber> =>
MockGlobber.create(['/foo', '/bar'])
);
});
afterEach(() => {
existsSpy.mockRestore();
lstatSpy.mockRestore();
globCreateSpy.mockRestore();
});
it.each([
[supportedPackageManagers.npm, ''],
[supportedPackageManagers.npm, '/dir/file.lock'],
[supportedPackageManagers.npm, '/**/file.lock'],
[supportedPackageManagers.pnpm, ''],
[supportedPackageManagers.pnpm, '/dir/file.lock'],
[supportedPackageManagers.pnpm, '/**/file.lock']
])(
'getCacheDirectoriesPaths should return one dir for non yarn',
async (packageManagerInfo, cacheDependency) => {
getCommandOutputSpy.mockImplementation(() => 'foo');
const dirs = await cacheUtils.getCacheDirectories(
packageManagerInfo,
cacheDependency
);
expect(dirs).toEqual(['foo']);
// to do not call for a version
// call once for get cache folder
expect(getCommandOutputSpy).toHaveBeenCalledTimes(1);
}
);
it('getCacheDirectoriesPaths should return one dir for yarn without cacheDependency', async () => {
getCommandOutputSpy.mockImplementation(() => 'foo');
const dirs = await cacheUtils.getCacheDirectories(
supportedPackageManagers.yarn,
''
);
expect(dirs).toEqual(['foo']);
});
it.each([
[supportedPackageManagers.npm, ''],
[supportedPackageManagers.npm, '/dir/file.lock'],
[supportedPackageManagers.npm, '/**/file.lock'],
[supportedPackageManagers.pnpm, ''],
[supportedPackageManagers.pnpm, '/dir/file.lock'],
[supportedPackageManagers.pnpm, '/**/file.lock'],
[supportedPackageManagers.yarn, ''],
[supportedPackageManagers.yarn, '/dir/file.lock'],
[supportedPackageManagers.yarn, '/**/file.lock']
])(
'getCacheDirectoriesPaths should throw for getCommandOutput returning empty',
async (packageManagerInfo, cacheDependency) => {
getCommandOutputSpy.mockImplementation((command: string) =>
// return empty string to indicate getCacheFolderPath failed
// --version still works
command.includes('version') ? '1.' : ''
);
await expect(
cacheUtils.getCacheDirectories(packageManagerInfo, cacheDependency)
).rejects.toThrow(); //'Could not get cache folder path for /dir');
}
);
it.each([
[supportedPackageManagers.yarn, '/dir/file.lock'],
[supportedPackageManagers.yarn, '/**/file.lock']
])(
'getCacheDirectoriesPaths should nothrow in case of having not directories',
async (packageManagerInfo, cacheDependency) => {
lstatSpy.mockImplementation(arg => ({
isDirectory: () => false
}));
await cacheUtils.getCacheDirectories(
packageManagerInfo,
cacheDependency
);
expect(warningSpy).toHaveBeenCalledTimes(1);
expect(warningSpy).toHaveBeenCalledWith(
`No existing directories found containing cache-dependency-path="${cacheDependency}"`
);
}
);
it.each(['1.1.1', '2.2.2'])(
'getCacheDirectoriesPaths yarn v%s should return one dir without cacheDependency',
async version => {
getCommandOutputSpy.mockImplementationOnce(() => version);
getCommandOutputSpy.mockImplementationOnce(() => `foo${version}`);
const dirs = await cacheUtils.getCacheDirectories(
supportedPackageManagers.yarn,
''
);
expect(dirs).toEqual([`foo${version}`]);
}
);
it.each(['1.1.1', '2.2.2'])(
'getCacheDirectoriesPaths yarn v%s should return 2 dirs with globbed cacheDependency',
async version => {
let dirNo = 1;
getCommandOutputSpy.mockImplementation((command: string) =>
command.includes('version') ? version : `file_${version}_${dirNo++}`
);
globCreateSpy.mockImplementation(
(pattern: string): Promise<Globber> =>
MockGlobber.create(['/tmp/dir1/file', '/tmp/dir2/file'])
);
const dirs = await cacheUtils.getCacheDirectories(
supportedPackageManagers.yarn,
'/tmp/**/file'
);
expect(dirs).toEqual([`file_${version}_1`, `file_${version}_2`]);
}
);
it.each(['1.1.1', '2.2.2'])(
'getCacheDirectoriesPaths yarn v%s should return 2 dirs with globbed cacheDependency expanding to duplicates',
async version => {
let dirNo = 1;
getCommandOutputSpy.mockImplementation((command: string) =>
command.includes('version') ? version : `file_${version}_${dirNo++}`
);
globCreateSpy.mockImplementation(
(pattern: string): Promise<Globber> =>
MockGlobber.create([
'/tmp/dir1/file',
'/tmp/dir2/file',
'/tmp/dir1/file'
])
);
const dirs = await cacheUtils.getCacheDirectories(
supportedPackageManagers.yarn,
'/tmp/**/file'
);
expect(dirs).toEqual([`file_${version}_1`, `file_${version}_2`]);
}
);
it.each(['1.1.1', '2.2.2'])(
'getCacheDirectoriesPaths yarn v%s should return 2 uniq dirs despite duplicate cache directories',
async version => {
let dirNo = 1;
getCommandOutputSpy.mockImplementation((command: string) =>
command.includes('version')
? version
: `file_${version}_${dirNo++ % 2}`
);
globCreateSpy.mockImplementation(
(pattern: string): Promise<Globber> =>
MockGlobber.create([
'/tmp/dir1/file',
'/tmp/dir2/file',
'/tmp/dir3/file'
])
);
const dirs = await cacheUtils.getCacheDirectories(
supportedPackageManagers.yarn,
'/tmp/**/file'
);
expect(dirs).toEqual([`file_${version}_1`, `file_${version}_0`]);
expect(getCommandOutputSpy).toHaveBeenCalledTimes(6);
expect(getCommandOutputSpy).toHaveBeenCalledWith(
'yarn --version',
'/tmp/dir1'
);
expect(getCommandOutputSpy).toHaveBeenCalledWith(
'yarn --version',
'/tmp/dir2'
);
expect(getCommandOutputSpy).toHaveBeenCalledWith(
'yarn --version',
'/tmp/dir3'
);
expect(getCommandOutputSpy).toHaveBeenCalledWith(
version.startsWith('1.')
? 'yarn cache dir'
: 'yarn config get cacheFolder',
'/tmp/dir1'
);
expect(getCommandOutputSpy).toHaveBeenCalledWith(
version.startsWith('1.')
? 'yarn cache dir'
: 'yarn config get cacheFolder',
'/tmp/dir2'
);
expect(getCommandOutputSpy).toHaveBeenCalledWith(
version.startsWith('1.')
? 'yarn cache dir'
: 'yarn config get cacheFolder',
'/tmp/dir3'
);
}
);
it.each(['1.1.1', '2.2.2'])(
'getCacheDirectoriesPaths yarn v%s should return 4 dirs with multiple globs',
async version => {
// simulate wrong indents
const cacheDependencyPath = `/tmp/dir1/file
/tmp/dir2/file
/tmp/**/file
`;
globCreateSpy.mockImplementation(
(pattern: string): Promise<Globber> =>
MockGlobber.create([
'/tmp/dir1/file',
'/tmp/dir2/file',
'/tmp/dir3/file',
'/tmp/dir4/file'
])
);
let dirNo = 1;
getCommandOutputSpy.mockImplementation((command: string) =>
command.includes('version') ? version : `file_${version}_${dirNo++}`
);
const dirs = await cacheUtils.getCacheDirectories(
supportedPackageManagers.yarn,
cacheDependencyPath
);
expect(dirs).toEqual([
`file_${version}_1`,
`file_${version}_2`,
`file_${version}_3`,
`file_${version}_4`
]);
}
);
});
}); });

View File

@ -0,0 +1,18 @@
import {MockGlobber} from './glob-mock';
describe('mocked globber tests', () => {
it('globber should return generator', async () => {
const globber = new MockGlobber(['aaa', 'bbb', 'ccc']);
const generator = globber.globGenerator();
const result: string[] = [];
for await (const itemPath of generator) {
result.push(itemPath);
}
expect(result).toEqual(['aaa', 'bbb', 'ccc']);
});
it('globber should return glob', async () => {
const globber = new MockGlobber(['aaa', 'bbb', 'ccc']);
const result: string[] = await globber.glob();
expect(result).toEqual(['aaa', 'bbb', 'ccc']);
});
});

View File

@ -0,0 +1,29 @@
import {Globber} from '@actions/glob';
export class MockGlobber implements Globber {
private readonly expected: string[];
constructor(expected: string[]) {
this.expected = expected;
}
getSearchPaths(): string[] {
return this.expected.slice();
}
async glob(): Promise<string[]> {
const result: string[] = [];
for await (const itemPath of this.globGenerator()) {
result.push(itemPath);
}
return result;
}
async *globGenerator(): AsyncGenerator<string, void> {
for (const e of this.expected) {
yield e;
}
}
static async create(expected: string[]): Promise<MockGlobber> {
return new MockGlobber(expected);
}
}

View File

@ -0,0 +1,48 @@
#!/bin/sh -e
export YARN_ENABLE_IMMUTABLE_INSTALLS=false
rm package.json
rm package-lock.json
echo "create yarn2 project in the sub2"
mkdir sub2
cd sub2
cat <<EOT >package.json
{
"name": "subproject",
"dependencies": {
"random": "^3.0.6",
"uuid": "^9.0.0"
}
}
EOT
yarn set version 2.4.3
yarn install
echo "create yarn3 project in the sub3"
cd ..
mkdir sub3
cd sub3
cat <<EOT >package.json
{
"name": "subproject",
"dependencies": {
"random": "^3.0.6",
"uuid": "^9.0.0"
}
}
EOT
yarn set version 3.5.1
yarn install
echo "create yarn1 project in the root"
cd ..
cat <<EOT >package.json
{
"name": "subproject",
"dependencies": {
"random": "^3.0.6",
"uuid": "^9.0.0"
}
}
EOT
yarn set version 1.22.19
yarn install

View File

@ -25,7 +25,7 @@ inputs:
description: 'Used to specify a package manager for caching in the default directory. Supported values: npm, yarn, pnpm.' description: 'Used to specify a package manager for caching in the default directory. Supported values: npm, yarn, pnpm.'
cache-dependency-path: cache-dependency-path:
description: 'Used to specify the path to a dependency file: package-lock.json, yarn.lock, etc. Supports wildcards or a list of file names for caching multiple dependencies.' description: 'Used to specify the path to a dependency file: package-lock.json, yarn.lock, etc. Supports wildcards or a list of file names for caching multiple dependencies.'
# TODO: add input to control forcing to pull from cloud or dist. # TODO: add input to control forcing to pull from cloud or dist.
# escape valve for someone having issues or needing the absolute latest which isn't cached yet # escape valve for someone having issues or needing the absolute latest which isn't cached yet
outputs: outputs:
cache-hit: cache-hit:

1792
dist/cache-save/index.js vendored

File diff suppressed because it is too large Load Diff

2289
dist/setup/index.js vendored

File diff suppressed because it is too large Load Diff

View File

@ -6,14 +6,14 @@ import fs from 'fs';
import {State} from './constants'; import {State} from './constants';
import { import {
getCacheDirectoryPath, getCacheDirectories,
getPackageManagerInfo, getPackageManagerInfo,
PackageManagerInfo PackageManagerInfo
} from './cache-utils'; } from './cache-utils';
export const restoreCache = async ( export const restoreCache = async (
packageManager: string, packageManager: string,
cacheDependencyPath?: string cacheDependencyPath: string
) => { ) => {
const packageManagerInfo = await getPackageManagerInfo(packageManager); const packageManagerInfo = await getPackageManagerInfo(packageManager);
if (!packageManagerInfo) { if (!packageManagerInfo) {
@ -21,10 +21,11 @@ export const restoreCache = async (
} }
const platform = process.env.RUNNER_OS; const platform = process.env.RUNNER_OS;
const cachePath = await getCacheDirectoryPath( const cachePaths = await getCacheDirectories(
packageManagerInfo, packageManagerInfo,
packageManager cacheDependencyPath
); );
core.saveState(State.CachePaths, cachePaths);
const lockFilePath = cacheDependencyPath const lockFilePath = cacheDependencyPath
? cacheDependencyPath ? cacheDependencyPath
: findLockFile(packageManagerInfo); : findLockFile(packageManagerInfo);
@ -41,7 +42,7 @@ export const restoreCache = async (
core.saveState(State.CachePrimaryKey, primaryKey); core.saveState(State.CachePrimaryKey, primaryKey);
const cacheKey = await cache.restoreCache([cachePath], primaryKey); const cacheKey = await cache.restoreCache(cachePaths, primaryKey);
core.setOutput('cache-hit', Boolean(cacheKey)); core.setOutput('cache-hit', Boolean(cacheKey));
if (!cacheKey) { if (!cacheKey) {
@ -56,6 +57,7 @@ export const restoreCache = async (
const findLockFile = (packageManager: PackageManagerInfo) => { const findLockFile = (packageManager: PackageManagerInfo) => {
const lockFiles = packageManager.lockFilePatterns; const lockFiles = packageManager.lockFilePatterns;
const workspace = process.env.GITHUB_WORKSPACE!; const workspace = process.env.GITHUB_WORKSPACE!;
const rootContent = fs.readdirSync(workspace); const rootContent = fs.readdirSync(workspace);
const lockFile = lockFiles.find(item => rootContent.includes(item)); const lockFile = lockFiles.find(item => rootContent.includes(item));

View File

@ -1,8 +1,7 @@
import * as core from '@actions/core'; import * as core from '@actions/core';
import * as cache from '@actions/cache'; import * as cache from '@actions/cache';
import fs from 'fs';
import {State} from './constants'; import {State} from './constants';
import {getCacheDirectoryPath, getPackageManagerInfo} from './cache-utils'; import {getPackageManagerInfo} from './cache-utils';
// Catch and log any unhandled exceptions. These exceptions can leak out of the uploadChunk method in // Catch and log any unhandled exceptions. These exceptions can leak out of the uploadChunk method in
// @actions/toolkit when a failed upload closes the file descriptor causing any in-process reads to // @actions/toolkit when a failed upload closes the file descriptor causing any in-process reads to
@ -24,6 +23,7 @@ export async function run() {
const cachePackages = async (packageManager: string) => { const cachePackages = async (packageManager: string) => {
const state = core.getState(State.CacheMatchedKey); const state = core.getState(State.CacheMatchedKey);
const primaryKey = core.getState(State.CachePrimaryKey); const primaryKey = core.getState(State.CachePrimaryKey);
const cachePaths = JSON.parse(core.getState(State.CachePaths) || '[]');
const packageManagerInfo = await getPackageManagerInfo(packageManager); const packageManagerInfo = await getPackageManagerInfo(packageManager);
if (!packageManagerInfo) { if (!packageManagerInfo) {
@ -31,14 +31,12 @@ const cachePackages = async (packageManager: string) => {
return; return;
} }
const cachePath = await getCacheDirectoryPath( if (cachePaths.length === 0) {
packageManagerInfo, // TODO: core.getInput has a bug - it can return undefined despite its definition (tests only?)
packageManager // export declare function getInput(name: string, options?: InputOptions): string;
); const cacheDependencyPath = core.getInput('cache-dependency-path') || '';
if (!fs.existsSync(cachePath)) {
throw new Error( throw new Error(
`Cache folder path is retrieved for ${packageManager} but doesn't exist on disk: ${cachePath}` `Cache folder paths are not retrieved for ${packageManager} with cache-dependency-path = ${cacheDependencyPath}`
); );
} }
@ -49,7 +47,7 @@ const cachePackages = async (packageManager: string) => {
return; return;
} }
const cacheId = await cache.saveCache([cachePath], primaryKey); const cacheId = await cache.saveCache(cachePaths, primaryKey);
if (cacheId == -1) { if (cacheId == -1) {
return; return;
} }

View File

@ -1,40 +1,79 @@
import * as core from '@actions/core'; import * as core from '@actions/core';
import * as exec from '@actions/exec'; import * as exec from '@actions/exec';
import * as cache from '@actions/cache'; import * as cache from '@actions/cache';
import * as glob from '@actions/glob';
type SupportedPackageManagers = { import path from 'path';
[prop: string]: PackageManagerInfo; import fs from 'fs';
}; import {unique} from './util';
export interface PackageManagerInfo { export interface PackageManagerInfo {
name: string;
lockFilePatterns: Array<string>; lockFilePatterns: Array<string>;
getCacheFolderCommand: string; getCacheFolderPath: (projectDir?: string) => Promise<string>;
} }
interface SupportedPackageManagers {
npm: PackageManagerInfo;
pnpm: PackageManagerInfo;
yarn: PackageManagerInfo;
}
export const supportedPackageManagers: SupportedPackageManagers = { export const supportedPackageManagers: SupportedPackageManagers = {
npm: { npm: {
name: 'npm',
lockFilePatterns: ['package-lock.json', 'npm-shrinkwrap.json', 'yarn.lock'], lockFilePatterns: ['package-lock.json', 'npm-shrinkwrap.json', 'yarn.lock'],
getCacheFolderCommand: 'npm config get cache' getCacheFolderPath: () =>
getCommandOutputNotEmpty(
'npm config get cache',
'Could not get npm cache folder path'
)
}, },
pnpm: { pnpm: {
name: 'pnpm',
lockFilePatterns: ['pnpm-lock.yaml'], lockFilePatterns: ['pnpm-lock.yaml'],
getCacheFolderCommand: 'pnpm store path --silent' getCacheFolderPath: () =>
getCommandOutputNotEmpty(
'pnpm store path --silent',
'Could not get pnpm cache folder path'
)
}, },
yarn1: { yarn: {
name: 'yarn',
lockFilePatterns: ['yarn.lock'], lockFilePatterns: ['yarn.lock'],
getCacheFolderCommand: 'yarn cache dir' getCacheFolderPath: async projectDir => {
}, const yarnVersion = await getCommandOutputNotEmpty(
yarn2: { `yarn --version`,
lockFilePatterns: ['yarn.lock'], 'Could not retrieve version of yarn',
getCacheFolderCommand: 'yarn config get cacheFolder' projectDir
);
core.debug(
`Consumed yarn version is ${yarnVersion} (working dir: "${
projectDir || ''
}")`
);
const stdOut = yarnVersion.startsWith('1.')
? await getCommandOutput('yarn cache dir', projectDir)
: await getCommandOutput('yarn config get cacheFolder', projectDir);
if (!stdOut) {
throw new Error(
`Could not get yarn cache folder path for ${projectDir}`
);
}
return stdOut;
}
} }
}; };
export const getCommandOutput = async (toolCommand: string) => { export const getCommandOutput = async (
toolCommand: string,
cwd?: string
): Promise<string> => {
let {stdout, stderr, exitCode} = await exec.getExecOutput( let {stdout, stderr, exitCode} = await exec.getExecOutput(
toolCommand, toolCommand,
undefined, undefined,
{ignoreReturnCode: true} {ignoreReturnCode: true, ...(cwd && {cwd})}
); );
if (exitCode) { if (exitCode) {
@ -47,16 +86,15 @@ export const getCommandOutput = async (toolCommand: string) => {
return stdout.trim(); return stdout.trim();
}; };
const getPackageManagerVersion = async ( export const getCommandOutputNotEmpty = async (
packageManager: string, toolCommand: string,
command: string error: string,
) => { cwd?: string
const stdOut = await getCommandOutput(`${packageManager} ${command}`); ): Promise<string> => {
const stdOut = getCommandOutput(toolCommand, cwd);
if (!stdOut) { if (!stdOut) {
throw new Error(`Could not retrieve version of ${packageManager}`); throw new Error(error);
} }
return stdOut; return stdOut;
}; };
@ -66,35 +104,102 @@ export const getPackageManagerInfo = async (packageManager: string) => {
} else if (packageManager === 'pnpm') { } else if (packageManager === 'pnpm') {
return supportedPackageManagers.pnpm; return supportedPackageManagers.pnpm;
} else if (packageManager === 'yarn') { } else if (packageManager === 'yarn') {
const yarnVersion = await getPackageManagerVersion('yarn', '--version'); return supportedPackageManagers.yarn;
core.debug(`Consumed yarn version is ${yarnVersion}`);
if (yarnVersion.startsWith('1.')) {
return supportedPackageManagers.yarn1;
} else {
return supportedPackageManagers.yarn2;
}
} else { } else {
return null; return null;
} }
}; };
export const getCacheDirectoryPath = async ( /**
* Expands (converts) the string input `cache-dependency-path` to list of directories that
* may be project roots
* @param cacheDependencyPath - either a single string or multiline string with possible glob patterns
* expected to be the result of `core.getInput('cache-dependency-path')`
* @return list of directories and possible
*/
const getProjectDirectoriesFromCacheDependencyPath = async (
cacheDependencyPath: string
): Promise<string[]> => {
const globber = await glob.create(cacheDependencyPath);
const cacheDependenciesPaths = await globber.glob();
const existingDirectories: string[] = cacheDependenciesPaths
.map(path.dirname)
.filter(unique())
.filter(directory => fs.lstatSync(directory).isDirectory());
if (!existingDirectories.length)
core.warning(
`No existing directories found containing cache-dependency-path="${cacheDependencyPath}"`
);
return existingDirectories;
};
/**
* Finds the cache directories configured for the repo if cache-dependency-path is not empty
* @param packageManagerInfo - an object having getCacheFolderPath method specific to given PM
* @param cacheDependencyPath - either a single string or multiline string with possible glob patterns
* expected to be the result of `core.getInput('cache-dependency-path')`
* @return list of files on which the cache depends
*/
const getCacheDirectoriesFromCacheDependencyPath = async (
packageManagerInfo: PackageManagerInfo, packageManagerInfo: PackageManagerInfo,
packageManager: string cacheDependencyPath: string
) => { ): Promise<string[]> => {
const stdOut = await getCommandOutput( const projectDirectories = await getProjectDirectoriesFromCacheDependencyPath(
packageManagerInfo.getCacheFolderCommand cacheDependencyPath
); );
const cacheFoldersPaths = await Promise.all(
projectDirectories.map(async projectDirectory => {
const cacheFolderPath =
packageManagerInfo.getCacheFolderPath(projectDirectory);
core.debug(
`${packageManagerInfo.name}'s cache folder "${cacheFolderPath}" configured for the directory "${projectDirectory}"`
);
return cacheFolderPath;
})
);
// uniq in order to do not cache the same directories twice
return cacheFoldersPaths.filter(unique());
};
if (!stdOut) { /**
throw new Error(`Could not get cache folder path for ${packageManager}`); * Finds the cache directories configured for the repo ignoring cache-dependency-path
* @param packageManagerInfo - an object having getCacheFolderPath method specific to given PM
* @return list of files on which the cache depends
*/
const getCacheDirectoriesForRootProject = async (
packageManagerInfo: PackageManagerInfo
): Promise<string[]> => {
const cacheFolderPath = await packageManagerInfo.getCacheFolderPath();
core.debug(
`${packageManagerInfo.name}'s cache folder "${cacheFolderPath}" configured for the root directory`
);
return [cacheFolderPath];
};
/**
* A function to find the cache directories configured for the repo
* currently it handles only the case of PM=yarn && cacheDependencyPath is not empty
* @param packageManagerInfo - an object having getCacheFolderPath method specific to given PM
* @param cacheDependencyPath - either a single string or multiline string with possible glob patterns
* expected to be the result of `core.getInput('cache-dependency-path')`
* @return list of files on which the cache depends
*/
export const getCacheDirectories = async (
packageManagerInfo: PackageManagerInfo,
cacheDependencyPath: string
): Promise<string[]> => {
// For yarn, if cacheDependencyPath is set, ask information about cache folders in each project
// folder satisfied by cacheDependencyPath https://github.com/actions/setup-node/issues/488
if (packageManagerInfo.name === 'yarn' && cacheDependencyPath) {
return getCacheDirectoriesFromCacheDependencyPath(
packageManagerInfo,
cacheDependencyPath
);
} }
return getCacheDirectoriesForRootProject(packageManagerInfo);
core.debug(`${packageManager} path is ${stdOut}`);
return stdOut.trim();
}; };
export function isGhes(): boolean { export function isGhes(): boolean {

View File

@ -6,7 +6,8 @@ export enum LockType {
export enum State { export enum State {
CachePrimaryKey = 'CACHE_KEY', CachePrimaryKey = 'CACHE_KEY',
CacheMatchedKey = 'CACHE_RESULT' CacheMatchedKey = 'CACHE_RESULT',
CachePaths = 'CACHE_PATHS'
} }
export enum Outputs { export enum Outputs {

View File

@ -61,3 +61,12 @@ async function getToolVersion(tool: string, options: string[]) {
return ''; return '';
} }
} }
export const unique = () => {
const encountered = new Set();
return (value: unknown): boolean => {
if (encountered.has(value)) return false;
encountered.add(value);
return true;
};
};