feat: Cache NuGet global-packages folder (#303)

* feat: cache NuGet global-packages folder

* fix: remove unused files

* docs: fix incorrect action

* ci: add e2e test for cache

* docs: accept suggested changes on README

* docs: add simple cache example

* build: change main script path

* fix: change relative path to install scripts

* fix: change relative path to problem matcher

* refactor: accept changes on cache-utils

* fix: revert main script path changes

* test: fix cache-utils unit test

* test: fix cache-utils unit test

* feat: add `cache-dependency-path` variables

* build: change main script dist path

* ci: add `cache-dependency-path` e2e test & missing lock file

* fix: accept change suggestions

* ci: copy NuGet lock file to root

to pass "test-setup-with-cache" e2e test

* docs: change README guide

* fix: apply suggestions from code review

Co-authored-by: Ivan <98037481+IvanZosimov@users.noreply.github.com>

* test: fix some failed unit tests

- fix `restoreCache()` test for 9703c8
- update installer script

* build: rebuild dist

* Update unit-tests
- Additional unit test were added to setup-dotnet.test.ts

* Update unit tests for unix systems

* Format and lint unit tests

* fix: avoid use '/' on `path.join`

* fix: rebuild dist

* fix: apply suggestions from code review

Co-authored-by: Ivan <98037481+IvanZosimov@users.noreply.github.com>

* build: add `DisableImplicitNuGetFallbackFolder` option

also add guide on README

* docs: highlight warnings and notes

* docs: update note about handling NU1403

---------

Co-authored-by: Ivan <98037481+IvanZosimov@users.noreply.github.com>
Co-authored-by: IvanZosimov <ivanzosimov@github.com>
This commit is contained in:
Nogic
2023-05-29 19:43:18 +09:00
committed by GitHub
parent 916351aac9
commit 3447fd6a9f
69 changed files with 136594 additions and 22559 deletions

50
src/cache-restore.ts Normal file
View File

@ -0,0 +1,50 @@
import {readdir} from 'node:fs/promises';
import {join} from 'node:path';
import * as cache from '@actions/cache';
import * as core from '@actions/core';
import * as glob from '@actions/glob';
import {getNuGetFolderPath} from './cache-utils';
import {lockFilePatterns, State, Outputs} from './constants';
export const restoreCache = async (cacheDependencyPath?: string) => {
const lockFilePath = cacheDependencyPath || (await findLockFile());
const fileHash = await glob.hashFiles(lockFilePath);
if (!fileHash) {
throw new Error(
'Some specified paths were not resolved, unable to cache dependencies.'
);
}
const platform = process.env.RUNNER_OS;
const primaryKey = `dotnet-cache-${platform}-${fileHash}`;
core.debug(`primary key is ${primaryKey}`);
core.saveState(State.CachePrimaryKey, primaryKey);
const {'global-packages': cachePath} = await getNuGetFolderPath();
const cacheKey = await cache.restoreCache([cachePath], primaryKey);
core.setOutput(Outputs.CacheHit, Boolean(cacheKey));
if (!cacheKey) {
core.info('Dotnet cache is not found');
return;
}
core.saveState(State.CacheMatchedKey, cacheKey);
core.info(`Cache restored from key: ${cacheKey}`);
};
const findLockFile = async () => {
const workspace = process.env.GITHUB_WORKSPACE!;
const rootContent = await readdir(workspace);
const lockFile = lockFilePatterns.find(item => rootContent.includes(item));
if (!lockFile) {
throw new Error(
`Dependencies lock file is not found in ${workspace}. Supported file patterns: ${lockFilePatterns.toString()}`
);
}
return join(workspace, lockFile);
};

57
src/cache-save.ts Normal file
View File

@ -0,0 +1,57 @@
import * as core from '@actions/core';
import * as cache from '@actions/cache';
import fs from 'node:fs';
import {getNuGetFolderPath} from './cache-utils';
import {State} from './constants';
// 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
// throw an uncaught exception. Instead of failing this action, just warn.
process.on('uncaughtException', e => {
const warningPrefix = '[warning]';
core.info(`${warningPrefix}${e.message}`);
});
export async function run() {
try {
if (core.getBooleanInput('cache')) {
await cachePackages();
}
} catch (error) {
core.setFailed(error.message);
}
}
const cachePackages = async () => {
const state = core.getState(State.CacheMatchedKey);
const primaryKey = core.getState(State.CachePrimaryKey);
if (!primaryKey) {
core.info('Primary key was not generated, not saving cache.');
return;
}
const {'global-packages': cachePath} = await getNuGetFolderPath();
if (!fs.existsSync(cachePath)) {
throw new Error(
`Cache folder path is retrieved for .NET CLI but doesn't exist on disk: ${cachePath}`
);
}
if (primaryKey === state) {
core.info(
`Cache hit occurred on the primary key ${primaryKey}, not saving cache.`
);
return;
}
const cacheId = await cache.saveCache([cachePath], primaryKey);
if (cacheId == -1) {
return;
}
core.info(`Cache saved with the key: ${primaryKey}`);
};
run();

98
src/cache-utils.ts Normal file
View File

@ -0,0 +1,98 @@
import * as cache from '@actions/cache';
import * as core from '@actions/core';
import * as exec from '@actions/exec';
import {cliCommand} from './constants';
type NuGetFolderName =
| 'http-cache'
| 'global-packages'
| 'temp'
| 'plugins-cache';
/**
* Get NuGet global packages, cache, and temp folders from .NET CLI.
* @returns (Folder Name)-(Path) mappings
* @see https://docs.microsoft.com/nuget/consume-packages/managing-the-global-packages-and-cache-folders
* @example
* Windows
* ```json
* {
* "http-cache": "C:\\Users\\user1\\AppData\\Local\\NuGet\\v3-cache",
* "global-packages": "C:\\Users\\user1\\.nuget\\packages\\",
* "temp": "C:\\Users\\user1\\AppData\\Local\\Temp\\NuGetScratch",
* "plugins-cache": "C:\\Users\\user1\\AppData\\Local\\NuGet\\plugins-cache"
* }
* ```
*
* Mac/Linux
* ```json
* {
* "http-cache": "/home/user1/.local/share/NuGet/v3-cache",
* "global-packages": "/home/user1/.nuget/packages/",
* "temp": "/tmp/NuGetScratch",
* "plugins-cache": "/home/user1/.local/share/NuGet/plugins-cache"
* }
* ```
*/
export const getNuGetFolderPath = async () => {
const {stdout, stderr, exitCode} = await exec.getExecOutput(
cliCommand,
undefined,
{ignoreReturnCode: true, silent: true}
);
if (exitCode) {
throw new Error(
!stderr.trim()
? `The '${cliCommand}' command failed with exit code: ${exitCode}`
: stderr
);
}
const result: Record<NuGetFolderName, string> = {
'http-cache': '',
'global-packages': '',
temp: '',
'plugins-cache': ''
};
const regex = /(?:^|\s)(?<key>[a-z-]+): (?<path>.+[/\\].+)$/gm;
let match: RegExpExecArray | null;
while ((match = regex.exec(stdout)) !== null) {
const key = match.groups!.key;
if ((key as NuGetFolderName) in result) {
result[key] = match.groups!.path;
}
}
return result;
};
export function isCacheFeatureAvailable(): boolean {
if (cache.isFeatureAvailable()) {
return true;
}
if (isGhes()) {
core.warning(
'Cache action is only supported on GHES version >= 3.5. If you are on version >=3.5 Please check with GHES admin if Actions cache service is enabled or not.'
);
return false;
}
core.warning(
'The runner was not able to contact the cache service. Caching will be skipped'
);
return false;
}
/**
* Returns this action runs on GitHub Enterprise Server or not.
* (port from https://github.com/actions/toolkit/blob/457303960f03375db6f033e214b9f90d79c3fe5c/packages/cache/src/internal/cacheUtils.ts#L134)
*/
function isGhes(): boolean {
const url = process.env['GITHUB_SERVER_URL'] || 'https://github.com';
return new URL(url).hostname.toUpperCase() !== 'GITHUB.COM';
}

19
src/constants.ts Normal file
View File

@ -0,0 +1,19 @@
/** NuGet lock file patterns */
export const lockFilePatterns = ['packages.lock.json'];
/**
* .NET CLI command to list local NuGet resources.
* @see https://docs.microsoft.com/dotnet/core/tools/dotnet-nuget-locals
*/
export const cliCommand =
'dotnet nuget locals all --list --force-english-output';
export enum State {
CachePrimaryKey = 'CACHE_KEY',
CacheMatchedKey = 'CACHE_RESULT'
}
export enum Outputs {
CacheHit = 'cache-hit',
DotnetVersion = 'dotnet-version'
}

View File

@ -203,7 +203,7 @@ export class DotnetCoreInstaller {
];
const scriptName = IS_WINDOWS ? 'install-dotnet.ps1' : 'install-dotnet.sh';
const escapedScript = path
.join(__dirname, '..', 'externals', scriptName)
.join(__dirname, '..', '..', 'externals', scriptName)
.replace(/'/g, "''");
let scriptArguments: string[];
let scriptPath = '';

View File

@ -4,6 +4,9 @@ import * as fs from 'fs';
import path from 'path';
import semver from 'semver';
import * as auth from './authutil';
import {isCacheFeatureAvailable} from './cache-utils';
import {restoreCache} from './cache-restore';
import {Outputs} from './constants';
const qualityOptions = [
'daily',
@ -80,7 +83,12 @@ export async function run() {
outputInstalledVersion(installedDotnetVersions, globalJsonFileInput);
const matchersPath = path.join(__dirname, '..', '.github');
if (core.getBooleanInput('cache') && isCacheFeatureAvailable()) {
const cacheDependencyPath = core.getInput('cache-dependency-path');
await restoreCache(cacheDependencyPath);
}
const matchersPath = path.join(__dirname, '..', '..', '.github');
core.info(`##[add-matcher]${path.join(matchersPath, 'csc.json')}`);
} catch (error) {
core.setFailed(error.message);
@ -109,20 +117,20 @@ function outputInstalledVersion(
globalJsonFileInput: string
): void {
if (!installedVersions.length) {
core.info(`The 'dotnet-version' output will not be set.`);
core.info(`The '${Outputs.DotnetVersion}' output will not be set.`);
return;
}
if (installedVersions.includes(null)) {
core.warning(
`Failed to output the installed version of .NET. The 'dotnet-version' output will not be set.`
`Failed to output the installed version of .NET. The '${Outputs.DotnetVersion}' output will not be set.`
);
return;
}
if (globalJsonFileInput) {
const versionToOutput = installedVersions.at(-1); // .NET SDK version parsed from the global.json file is installed last
core.setOutput('dotnet-version', versionToOutput);
core.setOutput(Outputs.DotnetVersion, versionToOutput);
return;
}
@ -134,7 +142,7 @@ function outputInstalledVersion(
}
);
core.setOutput('dotnet-version', versionToOutput);
core.setOutput(Outputs.DotnetVersion, versionToOutput);
}
run();