Implemented

pull/1766/head
Boris Staal 3 years ago
parent 88522ab9f3
commit f12187b90c
No known key found for this signature in database

@ -1,12 +1,12 @@
import * as cache from "@actions/cache";
import * as core from "@actions/core";
import * as cache from "../src/backend";
import { Events, RefKey } from "../src/constants";
import * as actionUtils from "../src/utils/actionUtils";
import * as testUtils from "../src/utils/testUtils";
jest.mock("@actions/core");
jest.mock("@actions/cache");
jest.mock("../src/backend");
beforeAll(() => {
jest.spyOn(core, "getInput").mockImplementation((name, options) => {

@ -1,6 +1,6 @@
import * as cache from "@actions/cache";
import * as core from "@actions/core";
import * as cache from "../src/backend";
import { Events, RefKey } from "../src/constants";
import run from "../src/restore";
import * as actionUtils from "../src/utils/actionUtils";
@ -81,7 +81,8 @@ test("restore with no cache found", async () => {
{
lookupOnly: false
},
false
{ credentials: { accessKeyId: "", secretAccessKey: "" }, region: "" },
""
);
expect(stateMock).toHaveBeenCalledWith("CACHE_KEY", key);
@ -124,7 +125,8 @@ test("restore with restore keys and no cache found", async () => {
{
lookupOnly: false
},
false
{ credentials: { accessKeyId: "", secretAccessKey: "" }, region: "" },
""
);
expect(stateMock).toHaveBeenCalledWith("CACHE_KEY", key);
@ -166,7 +168,8 @@ test("restore with cache found for key", async () => {
{
lookupOnly: false
},
false
{ credentials: { accessKeyId: "", secretAccessKey: "" }, region: "" },
""
);
expect(stateMock).toHaveBeenCalledWith("CACHE_KEY", key);
@ -211,7 +214,8 @@ test("restore with cache found for restore key", async () => {
{
lookupOnly: false
},
false
{ credentials: { accessKeyId: "", secretAccessKey: "" }, region: "" },
""
);
expect(stateMock).toHaveBeenCalledWith("CACHE_KEY", key);
@ -256,7 +260,8 @@ test("Fail restore when fail on cache miss is enabled and primary + restore keys
{
lookupOnly: false
},
false
{ credentials: { accessKeyId: "", secretAccessKey: "" }, region: "" },
""
);
expect(stateMock).toHaveBeenCalledWith("CACHE_KEY", key);
@ -299,7 +304,8 @@ test("restore when fail on cache miss is enabled and primary key doesn't match r
{
lookupOnly: false
},
false
{ credentials: { accessKeyId: "", secretAccessKey: "" }, region: "" },
""
);
expect(stateMock).toHaveBeenCalledWith("CACHE_KEY", key);
@ -345,7 +351,8 @@ test("restore with fail on cache miss disabled and no cache found", async () =>
{
lookupOnly: false
},
false
{ credentials: { accessKeyId: "", secretAccessKey: "" }, region: "" },
""
);
expect(stateMock).toHaveBeenCalledWith("CACHE_KEY", key);

@ -1,6 +1,6 @@
import * as cache from "@actions/cache";
import * as core from "@actions/core";
import * as cache from "../src/backend";
import { Events, Inputs, RefKey } from "../src/constants";
import run from "../src/restoreImpl";
import { StateProvider } from "../src/stateProvider";
@ -129,7 +129,8 @@ test("restore on GHES with AC available ", async () => {
{
lookupOnly: false
},
false
{ credentials: { accessKeyId: "", secretAccessKey: "" }, region: "" },
""
);
expect(stateMock).toHaveBeenCalledWith("CACHE_KEY", key);
@ -183,7 +184,8 @@ test("restore with too many keys should fail", async () => {
{
lookupOnly: false
},
false
{ credentials: { accessKeyId: "", secretAccessKey: "" }, region: "" },
""
);
expect(failedMock).toHaveBeenCalledWith(
`Key Validation Error: Keys are limited to a maximum of 10.`
@ -209,7 +211,8 @@ test("restore with large key should fail", async () => {
{
lookupOnly: false
},
false
{ credentials: { accessKeyId: "", secretAccessKey: "" }, region: "" },
""
);
expect(failedMock).toHaveBeenCalledWith(
`Key Validation Error: ${key} cannot be larger than 512 characters.`
@ -235,7 +238,8 @@ test("restore with invalid key should fail", async () => {
{
lookupOnly: false
},
false
{ credentials: { accessKeyId: "", secretAccessKey: "" }, region: "" },
""
);
expect(failedMock).toHaveBeenCalledWith(
`Key Validation Error: ${key} cannot contain commas.`
@ -270,7 +274,8 @@ test("restore with no cache found", async () => {
{
lookupOnly: false
},
false
{ credentials: { accessKeyId: "", secretAccessKey: "" }, region: "" },
""
);
expect(stateMock).toHaveBeenCalledWith("CACHE_KEY", key);
@ -311,7 +316,8 @@ test("restore with restore keys and no cache found", async () => {
{
lookupOnly: false
},
false
{ credentials: { accessKeyId: "", secretAccessKey: "" }, region: "" },
""
);
expect(stateMock).toHaveBeenCalledWith("CACHE_KEY", key);
@ -351,7 +357,8 @@ test("restore with cache found for key", async () => {
{
lookupOnly: false
},
false
{ credentials: { accessKeyId: "", secretAccessKey: "" }, region: "" },
""
);
expect(stateMock).toHaveBeenCalledWith("CACHE_KEY", key);
@ -393,7 +400,8 @@ test("restore with cache found for restore key", async () => {
{
lookupOnly: false
},
false
{ credentials: { accessKeyId: "", secretAccessKey: "" }, region: "" },
""
);
expect(stateMock).toHaveBeenCalledWith("CACHE_KEY", key);
@ -434,7 +442,8 @@ test("restore with lookup-only set", async () => {
{
lookupOnly: true
},
false
{ credentials: { accessKeyId: "", secretAccessKey: "" }, region: "" },
""
);
expect(stateMock).toHaveBeenCalledWith("CACHE_KEY", key);

@ -1,6 +1,6 @@
import * as cache from "@actions/cache";
import * as core from "@actions/core";
import * as cache from "../src/backend";
import { Events, RefKey } from "../src/constants";
import run from "../src/restoreOnly";
import * as actionUtils from "../src/utils/actionUtils";
@ -82,7 +82,8 @@ test("restore with no cache found", async () => {
{
lookupOnly: false
},
false
{ credentials: { accessKeyId: "", secretAccessKey: "" }, region: "" },
""
);
expect(outputMock).toHaveBeenCalledWith("cache-primary-key", key);
@ -124,7 +125,8 @@ test("restore with restore keys and no cache found", async () => {
{
lookupOnly: false
},
false
{ credentials: { accessKeyId: "", secretAccessKey: "" }, region: "" },
""
);
expect(outputMock).toHaveBeenCalledWith("cache-primary-key", key);
@ -163,7 +165,8 @@ test("restore with cache found for key", async () => {
{
lookupOnly: false
},
false
{ credentials: { accessKeyId: "", secretAccessKey: "" }, region: "" },
""
);
expect(outputMock).toHaveBeenCalledWith("cache-primary-key", key);
@ -206,7 +209,8 @@ test("restore with cache found for restore key", async () => {
{
lookupOnly: false
},
false
{ credentials: { accessKeyId: "", secretAccessKey: "" }, region: "" },
""
);
expect(outputMock).toHaveBeenCalledWith("cache-primary-key", key);

@ -1,13 +1,13 @@
import * as cache from "@actions/cache";
import * as core from "@actions/core";
import * as cache from "../src/backend";
import { Events, Inputs, RefKey } from "../src/constants";
import run from "../src/save";
import * as actionUtils from "../src/utils/actionUtils";
import * as testUtils from "../src/utils/testUtils";
jest.mock("@actions/core");
jest.mock("@actions/cache");
jest.mock("../src/backend");
jest.mock("../src/utils/actionUtils");
beforeAll(() => {
@ -109,7 +109,8 @@ test("save with valid inputs uploads a cache", async () => {
{
uploadChunkSize: 4000000
},
false
{ credentials: { accessKeyId: "", secretAccessKey: "" }, region: "" },
""
);
expect(failedMock).toHaveBeenCalledTimes(0);

@ -1,6 +1,6 @@
import * as cache from "@actions/cache";
import * as core from "@actions/core";
import * as cache from "../src/backend";
import { Events, Inputs, RefKey } from "../src/constants";
import run from "../src/saveImpl";
import { StateProvider } from "../src/stateProvider";
@ -8,7 +8,7 @@ import * as actionUtils from "../src/utils/actionUtils";
import * as testUtils from "../src/utils/testUtils";
jest.mock("@actions/core");
jest.mock("@actions/cache");
jest.mock("../src/backend");
jest.mock("../src/utils/actionUtils");
beforeAll(() => {
@ -170,7 +170,8 @@ test("save on GHES with AC available", async () => {
{
uploadChunkSize: 4000000
},
false
{ credentials: { accessKeyId: "", secretAccessKey: "" }, region: "" },
""
);
expect(failedMock).toHaveBeenCalledTimes(0);
@ -266,7 +267,8 @@ test("save with large cache outputs warning", async () => {
[inputPath],
primaryKey,
expect.anything(),
false
{ credentials: { accessKeyId: "", secretAccessKey: "" }, region: "" },
""
);
expect(logWarningMock).toHaveBeenCalledTimes(1);
@ -299,7 +301,7 @@ test("save with reserve cache failure outputs warning", async () => {
const saveCacheMock = jest
.spyOn(cache, "saveCache")
.mockImplementationOnce(() => {
const actualCache = jest.requireActual("@actions/cache");
const actualCache = jest.requireActual("../src/backend");
const error = new actualCache.ReserveCacheError(
`Unable to reserve cache with key ${primaryKey}, another job may be creating this cache.`
);
@ -313,7 +315,8 @@ test("save with reserve cache failure outputs warning", async () => {
[inputPath],
primaryKey,
expect.anything(),
false
{ credentials: { accessKeyId: "", secretAccessKey: "" }, region: "" },
""
);
expect(logWarningMock).toHaveBeenCalledWith(
@ -356,7 +359,8 @@ test("save with server error outputs warning", async () => {
[inputPath],
primaryKey,
expect.anything(),
false
{ credentials: { accessKeyId: "", secretAccessKey: "" }, region: "" },
""
);
expect(logWarningMock).toHaveBeenCalledTimes(1);
@ -401,7 +405,8 @@ test("save with valid inputs uploads a cache", async () => {
{
uploadChunkSize: 4000000
},
false
{ credentials: { accessKeyId: "", secretAccessKey: "" }, region: "" },
""
);
expect(failedMock).toHaveBeenCalledTimes(0);

@ -1,13 +1,13 @@
import * as cache from "@actions/cache";
import * as core from "@actions/core";
import * as cache from "../src/backend";
import { Events, Inputs, RefKey } from "../src/constants";
import run from "../src/saveOnly";
import * as actionUtils from "../src/utils/actionUtils";
import * as testUtils from "../src/utils/testUtils";
jest.mock("@actions/core");
jest.mock("@actions/cache");
jest.mock("../src/backend");
jest.mock("../src/utils/actionUtils");
beforeAll(() => {
@ -99,7 +99,8 @@ test("save with valid inputs uploads a cache", async () => {
{
uploadChunkSize: 4000000
},
false
{ credentials: { accessKeyId: "", secretAccessKey: "" }, region: "" },
""
);
expect(failedMock).toHaveBeenCalledTimes(0);
@ -131,7 +132,8 @@ test("save failing logs the warning message", async () => {
{
uploadChunkSize: 4000000
},
false
{ credentials: { accessKeyId: "", secretAccessKey: "" }, region: "" },
""
);
expect(warningMock).toHaveBeenCalledTimes(1);

@ -26,6 +26,18 @@ inputs:
description: 'Check if a cache entry exists for the given input(s) (key, restore-keys) without downloading the cache'
default: 'false'
required: false
aws-bucket:
description: 'An AWS S3 bucket to save cache'
required: true
aws-access-key-id:
description: 'An AWS access key id to access the bucket'
required: true
aws-secret-access-key:
description: 'An AWS secret access key to access the bucket'
required: true
aws-region:
description: 'An AWS region where the bucket is located'
required: true
outputs:
cache-hit:
description: 'A boolean value to indicate an exact match was found for the primary key'

File diff suppressed because one or more lines are too long

120372
dist/restore/index.js vendored

File diff suppressed because one or more lines are too long

121580
dist/save-only/index.js vendored

File diff suppressed because one or more lines are too long

120460
dist/save/index.js vendored

File diff suppressed because one or more lines are too long

15924
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -7,7 +7,7 @@
"scripts": {
"build": "tsc && ncc build -o dist/restore src/restore.ts && ncc build -o dist/save src/save.ts && ncc build -o dist/restore-only src/restoreOnly.ts && ncc build -o dist/save-only src/saveOnly.ts",
"test": "tsc --noEmit && jest --coverage",
"lint": "eslint **/*.ts --cache",
"lint": "eslint **/*.ts --cache --fix",
"format": "prettier --write **/*.ts",
"format-check": "prettier --check **/*.ts"
},
@ -23,18 +23,26 @@
"author": "GitHub",
"license": "MIT",
"dependencies": {
"@actions/cache": "^3.2.1",
"@actions/core": "^1.10.0",
"@actions/exec": "^1.1.1",
"@actions/io": "^1.1.2"
"@actions/glob": "^0.4.0",
"@actions/http-client": "^2.1.0",
"@actions/io": "^1.1.2",
"@aws-sdk/client-s3": "^3.309.0",
"@aws-sdk/lib-storage": "^3.309.0",
"semver": "^7.3.8",
"uuid": "^9.0.0"
},
"devDependencies": {
"@types/jest": "^27.5.2",
"@types/nock": "^11.1.0",
"@types/node": "^16.18.3",
"@types/semver": "^7.3.13",
"@types/uuid": "^9.0.1",
"@typescript-eslint/eslint-plugin": "^5.45.0",
"@typescript-eslint/parser": "^5.45.0",
"@zeit/ncc": "^0.20.5",
"@aws-sdk/types": "^3.306.0",
"@vercel/ncc": "^0.36.1",
"eslint": "^8.28.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-import": "^2.26.0",
@ -46,6 +54,6 @@
"nock": "^13.2.9",
"prettier": "^2.8.0",
"ts-jest": "^28.0.8",
"typescript": "^4.9.3"
"typescript": "^4.9.5"
}
}

@ -23,6 +23,18 @@ inputs:
description: 'Check if a cache entry exists for the given input(s) (key, restore-keys) without downloading the cache'
default: 'false'
required: false
aws-bucket:
description: 'An AWS S3 bucket to save cache'
required: true
aws-access-key-id:
description: 'An AWS access key id to access the bucket'
required: true
aws-secret-access-key:
description: 'An AWS secret access key to access the bucket'
required: true
aws-region:
description: 'An AWS region where the bucket is located'
required: true
outputs:
cache-hit:
description: 'A boolean value to indicate an exact match was found for the primary key'

@ -15,6 +15,18 @@ inputs:
description: 'An optional boolean when enabled, allows windows runners to save caches that can be restored on other platforms'
default: 'false'
required: false
aws-bucket:
description: 'An AWS S3 bucket to save cache'
required: true
aws-access-key-id:
description: 'An AWS access key id to access the bucket'
required: true
aws-secret-access-key:
description: 'An AWS secret access key to access the bucket'
required: true
aws-region:
description: 'An AWS region where the bucket is located'
required: true
runs:
using: 'node16'
main: '../dist/save-only/index.js'

@ -0,0 +1,253 @@
import * as core from "@actions/core";
import { S3ClientConfig } from "@aws-sdk/client-s3";
import * as path from "path";
import * as utils from "./utils/cacheUtils";
import * as cacheHttpClient from "./utils/clientUtils";
import { DownloadOptions, UploadOptions } from "./utils/contracts";
import { createTar, extractTar, listTar } from "./utils/tar";
export class ValidationError extends Error {
constructor(message: string) {
super(message);
this.name = "ValidationError";
Object.setPrototypeOf(this, ValidationError.prototype);
}
}
export class ReserveCacheError extends Error {
constructor(message: string) {
super(message);
this.name = "ReserveCacheError";
Object.setPrototypeOf(this, ReserveCacheError.prototype);
}
}
function checkPaths(paths: string[]): void {
if (!paths || paths.length === 0) {
throw new ValidationError(
`Path Validation Error: At least one directory or file path is required`
);
}
}
function checkKey(key: string): void {
if (key.length > 512) {
throw new ValidationError(
`Key Validation Error: ${key} cannot be larger than 512 characters.`
);
}
const regex = /^[^,]*$/;
if (!regex.test(key)) {
throw new ValidationError(
`Key Validation Error: ${key} cannot contain commas.`
);
}
}
/**
* isFeatureAvailable to check the presence of Actions cache service
*
* @returns boolean return true if Actions cache service feature is available, otherwise false
*/
export function isFeatureAvailable(): boolean {
return !!process.env["ACTIONS_CACHE_URL"];
}
/**
* Restores cache from keys
*
* @param paths a list of file paths to restore from the cache
* @param primaryKey an explicit key for restoring the cache
* @param restoreKeys an optional ordered list of keys to use for restoring the cache if no cache hit occurred for key
* @param options cache download options
* @param s3Options upload options for AWS S3
* @param s3BucketName a name of AWS S3 bucket
* @returns string returns the key for the cache hit, otherwise returns undefined
*/
export async function restoreCache(
paths: string[],
primaryKey: string,
restoreKeys?: string[],
options?: DownloadOptions,
s3Options?: S3ClientConfig,
s3BucketName?: string
): Promise<string | undefined> {
checkPaths(paths);
restoreKeys = restoreKeys || [];
const keys = [primaryKey, ...restoreKeys];
core.debug("Resolved Keys:");
core.debug(JSON.stringify(keys));
if (keys.length > 10) {
throw new ValidationError(
`Key Validation Error: Keys are limited to a maximum of 10.`
);
}
for (const key of keys) {
checkKey(key);
}
const compressionMethod = await utils.getCompressionMethod();
// path are needed to compute version
const cacheEntry = await cacheHttpClient.getCacheEntry(
keys,
paths,
{
compressionMethod
},
s3Options,
s3BucketName
);
if (!cacheEntry || (!cacheEntry.archiveLocation && !cacheEntry.cacheKey)) {
// Cache not found
return undefined;
}
const archivePath = path.join(
await utils.createTempDirectory(),
utils.getCacheFileName(compressionMethod)
);
core.debug(`Archive Path: ${archivePath}`);
try {
// Download the cache from the cache entry
await cacheHttpClient.downloadCache(
cacheEntry,
archivePath,
options,
s3Options,
s3BucketName
);
if (core.isDebug()) {
await listTar(archivePath, compressionMethod);
}
const archiveFileSize = utils.getArchiveFileSizeInBytes(archivePath);
core.info(
`Cache Size: ~${Math.round(
archiveFileSize / (1024 * 1024)
)} MB (${archiveFileSize} B)`
);
await extractTar(archivePath, compressionMethod);
core.info("Cache restored successfully");
} finally {
// Try to delete the archive to save space
try {
await utils.unlinkFile(archivePath);
} catch (error) {
core.debug(`Failed to delete archive: ${error}`);
}
}
return cacheEntry.cacheKey;
}
/**
* Saves a list of files with the specified key
*
* @param paths a list of file paths to be cached
* @param key an explicit key for restoring the cache
* @param options cache upload options
* @param s3Options upload options for AWS S3
* @param s3BucketName a name of AWS S3 bucket
* @returns number returns cacheId if the cache was saved successfully and throws an error if save fails
*/
export async function saveCache(
paths: string[],
key: string,
options?: UploadOptions,
s3Options?: S3ClientConfig,
s3BucketName?: string
): Promise<number> {
checkPaths(paths);
checkKey(key);
const compressionMethod = await utils.getCompressionMethod();
let cacheId = 0;
const cachePaths = await utils.resolvePaths(paths);
core.debug("Cache Paths:");
core.debug(`${JSON.stringify(cachePaths)}`);
const archiveFolder = await utils.createTempDirectory();
const archivePath = path.join(
archiveFolder,
utils.getCacheFileName(compressionMethod)
);
core.debug(`Archive Path: ${archivePath}`);
try {
await createTar(archiveFolder, cachePaths, compressionMethod);
if (core.isDebug()) {
await listTar(archivePath, compressionMethod);
}
const fileSizeLimit = 10 * 1024 * 1024 * 1024; // 10GB per repo limit
const archiveFileSize = utils.getArchiveFileSizeInBytes(archivePath);
core.debug(`File Size: ${archiveFileSize}`);
// For GHES, this check will take place in ReserveCache API with enterprise file size limit
if (archiveFileSize > fileSizeLimit) {
throw new Error(
`Cache size of ~${Math.round(
archiveFileSize / (1024 * 1024)
)} MB (${archiveFileSize} B) is over the 10GB limit, not saving cache.`
);
}
if (!(s3Options && s3BucketName)) {
core.debug("Reserving Cache");
const reserveCacheResponse = await cacheHttpClient.reserveCache(
key,
paths,
{
compressionMethod,
cacheSize: archiveFileSize
},
s3Options,
s3BucketName
);
if (reserveCacheResponse?.result?.cacheId) {
cacheId = reserveCacheResponse?.result?.cacheId;
} else if (reserveCacheResponse?.statusCode === 400) {
throw new Error(
reserveCacheResponse?.error?.message ??
`Cache size of ~${Math.round(
archiveFileSize / (1024 * 1024)
)} MB (${archiveFileSize} B) is over the data cap limit, not saving cache.`
);
} else {
throw new ReserveCacheError(
`Unable to reserve cache with key ${key}, another job may be creating this cache. More details: ${reserveCacheResponse?.error?.message}`
);
}
}
core.debug(`Saving Cache (ID: ${cacheId})`);
await cacheHttpClient.saveCache(
cacheId,
archivePath,
key,
options,
s3Options,
s3BucketName
);
} finally {
// Try to delete the archive to save space
try {
await utils.unlinkFile(archivePath);
} catch (error) {
core.debug(`Failed to delete archive: ${error}`);
}
}
return cacheId;
}

@ -5,7 +5,11 @@ export enum Inputs {
UploadChunkSize = "upload-chunk-size", // Input for cache, save action
EnableCrossOsArchive = "enableCrossOsArchive", // Input for cache, restore, save action
FailOnCacheMiss = "fail-on-cache-miss", // Input for cache, restore action
LookupOnly = "lookup-only" // Input for cache, restore action
LookupOnly = "lookup-only", // Input for cache, restore action
AwsBucket = "aws-bucket",
AwsAccessKeyId = "aws-access-key-id",
AwsSecretAccessKey = "aws-secret-access-key",
AwsRegion = "aws-region"
}
export enum Outputs {

@ -1,6 +1,7 @@
import * as cache from "@actions/cache";
import * as core from "@actions/core";
import { S3ClientConfig } from "@aws-sdk/client-s3";
import * as cache from "./backend";
import { Events, Inputs, Outputs, State } from "./constants";
import { IStateProvider } from "./stateProvider";
import * as utils from "./utils/actionUtils";
@ -31,9 +32,16 @@ async function restoreImpl(
const cachePaths = utils.getInputAsArray(Inputs.Path, {
required: true
});
const enableCrossOsArchive = utils.getInputAsBool(
Inputs.EnableCrossOsArchive
);
const s3Config = {
credentials: {
accessKeyId: core.getInput(Inputs.AwsAccessKeyId),
secretAccessKey: core.getInput(Inputs.AwsSecretAccessKey)
},
region: core.getInput(Inputs.AwsRegion)
} as S3ClientConfig;
const s3Bucket = core.getInput(Inputs.AwsBucket);
const failOnCacheMiss = utils.getInputAsBool(Inputs.FailOnCacheMiss);
const lookupOnly = utils.getInputAsBool(Inputs.LookupOnly);
@ -42,7 +50,8 @@ async function restoreImpl(
primaryKey,
restoreKeys,
{ lookupOnly: lookupOnly },
enableCrossOsArchive
s3Config,
s3Bucket
);
if (!cacheKey) {

@ -1,6 +1,7 @@
import * as cache from "@actions/cache";
import * as core from "@actions/core";
import { S3ClientConfig } from "@aws-sdk/client-s3";
import * as cache from "./backend";
import { Events, Inputs, State } from "./constants";
import { IStateProvider } from "./stateProvider";
import * as utils from "./utils/actionUtils";
@ -52,15 +53,22 @@ async function saveImpl(stateProvider: IStateProvider): Promise<number | void> {
required: true
});
const enableCrossOsArchive = utils.getInputAsBool(
Inputs.EnableCrossOsArchive
);
const s3Config = {
credentials: {
accessKeyId: core.getInput(Inputs.AwsAccessKeyId),
secretAccessKey: core.getInput(Inputs.AwsSecretAccessKey)
},
region: core.getInput(Inputs.AwsRegion)
} as S3ClientConfig;
const s3Bucket = core.getInput(Inputs.AwsBucket);
cacheId = await cache.saveCache(
cachePaths,
primaryKey,
{ uploadChunkSize: utils.getInputAsInt(Inputs.UploadChunkSize) },
enableCrossOsArchive
s3Config,
s3Bucket
);
if (cacheId != -1) {

@ -1,6 +1,6 @@
import * as cache from "@actions/cache";
import * as core from "@actions/core";
import * as cache from "../backend";
import { RefKey } from "../constants";
export function isGhes(): boolean {

@ -0,0 +1,154 @@
import * as core from "@actions/core";
import * as exec from "@actions/exec";
import * as io from "@actions/io";
import * as glob from "@actions/glob";
import * as semver from "semver";
import * as path from "path";
import * as fs from "fs";
import { v4 as uuidV4 } from "uuid";
export enum CacheFilename {
Gzip = "cache.tgz",
Zstd = "cache.tzst"
}
export enum CompressionMethod {
Gzip = "gzip",
// Long range mode was added to zstd in v1.3.2.
// This enum is for earlier version of zstd that does not have --long support
ZstdWithoutLong = "zstd-without-long",
Zstd = "zstd"
}
// The default number of retry attempts.
export const DefaultRetryAttempts = 2;
// The default delay in milliseconds between retry attempts.
export const DefaultRetryDelay = 5000;
// Socket timeout in milliseconds during download. If no traffic is received
// over the socket during this period, the socket is destroyed and the download
// is aborted.
export const SocketTimeout = 5000;
async function getVersion(app: string): Promise<string> {
core.debug(`Checking ${app} --version`);
let versionOutput = "";
try {
await exec.exec(`${app} --version`, [], {
ignoreReturnCode: true,
silent: true,
listeners: {
stdout: (data: Buffer): string =>
(versionOutput += data.toString()),
stderr: (data: Buffer): string =>
(versionOutput += data.toString())
}
});
} catch (err: any) {
core.debug(err.message);
}
versionOutput = versionOutput.trim();
core.debug(versionOutput);
return versionOutput;
}
export function getArchiveFileSizeInBytes(filePath: string): number {
return fs.statSync(filePath).size;
}
export function getCacheFileName(compressionMethod: CompressionMethod): string {
return compressionMethod === CompressionMethod.Gzip
? CacheFilename.Gzip
: CacheFilename.Zstd;
}
export async function createTempDirectory(): Promise<string> {
const IS_WINDOWS = process.platform === "win32";
let tempDirectory: string = process.env["RUNNER_TEMP"] || "";
if (!tempDirectory) {
let baseLocation: string;
if (IS_WINDOWS) {
// On Windows use the USERPROFILE env variable
baseLocation = process.env["USERPROFILE"] || "C:\\";
} else {
if (process.platform === "darwin") {
baseLocation = "/Users";
} else {
baseLocation = "/home";
}
}
tempDirectory = path.join(baseLocation, "actions", "temp");
}
const dest = path.join(tempDirectory, uuidV4());
await io.mkdirP(dest);
return dest;
}
export async function resolvePaths(patterns: string[]): Promise<string[]> {
const paths: string[] = [];
const workspace = process.env["GITHUB_WORKSPACE"] ?? process.cwd();
const globber = await glob.create(patterns.join("\n"), {
implicitDescendants: false
});
for await (const file of globber.globGenerator()) {
const relativeFile = path
.relative(workspace, file)
.replace(new RegExp(`\\${path.sep}`, "g"), "/");
core.debug(`Matched: ${relativeFile}`);
// Paths are made relative so the tar entries are all relative to the root of the workspace.
paths.push(`${relativeFile}`);
}
return paths;
}
export async function isZstdInstalled(): Promise<boolean> {
try {
await io.which("zstd", true);
return true;
} catch (error) {
return false;
}
}
export async function isGnuTarInstalled(): Promise<boolean> {
const versionOutput = await getVersion("tar");
return versionOutput.toLowerCase().includes("gnu tar");
}
export async function getCompressionMethod(): Promise<CompressionMethod> {
if (
(process.platform === "win32" && !(await isGnuTarInstalled())) ||
!(await isZstdInstalled())
) {
// Disable zstd due to bug https://github.com/actions/cache/issues/301
return CompressionMethod.Gzip;
}
const versionOutput = await getVersion("zstd");
const version = semver.clean(versionOutput);
// zstd is installed but using a version earlier than v1.3.2
// v1.3.2 is required to use the `--long` options in zstd
return !version || semver.lt(version, "v1.3.2")
? CompressionMethod.ZstdWithoutLong
: CompressionMethod.Zstd;
}
export async function unlinkFile(filePath: fs.PathLike): Promise<void> {
return await fs.promises.unlink(filePath);
}
export function assertDefined<T>(name: string, value?: T): T {
if (value === undefined) {
throw Error(`Expected ${name} but value was undefiend`);
}
return value;
}

@ -0,0 +1,558 @@
import * as core from "@actions/core";
import { HttpClient } from "@actions/http-client";
import { BearerCredentialHandler } from "@actions/http-client/lib/auth";
import {
RequestOptions,
TypedResponse
} from "@actions/http-client/lib/interfaces";
import { DownloadOptions, UploadOptions } from "./contracts";
import {
ListObjectsV2Command,
ListObjectsV2CommandInput,
ListObjectsV2CommandOutput,
S3Client,
S3ClientConfig,
_Object
} from "@aws-sdk/client-s3";
import { Progress, Upload } from "@aws-sdk/lib-storage";
import * as crypto from "crypto";
import * as fs from "fs";
import { URL } from "url";
import * as utils from "./cacheUtils";
import { CompressionMethod } from "./cacheUtils";
import {
ArtifactCacheEntry,
InternalCacheOptions,
CommitCacheRequest,
ReserveCacheRequest,
ReserveCacheResponse,
ITypedResponseWithError
} from "./contracts";
import {
downloadCacheHttpClient,
downloadCacheStorageS3
} from "./downloadUtils";
import {
getDownloadOptions,
getUploadOptions
} from "./options";
import {
isSuccessStatusCode,
retryHttpClientResponse,
retryTypedResponse
} from "./requestUtils";
const versionSalt = "1.0";
function getCacheApiUrl(resource: string): string {
const baseUrl: string = process.env["ACTIONS_CACHE_URL"] || "";
if (!baseUrl) {
throw new Error(
"Cache Service Url not found, unable to restore cache."
);
}
const url = `${baseUrl}_apis/artifactcache/${resource}`;
core.debug(`Resource Url: ${url}`);
return url;
}
function createAcceptHeader(type: string, apiVersion: string): string {
return `${type};api-version=${apiVersion}`;
}
function getRequestOptions(): RequestOptions {
const requestOptions: RequestOptions = {
headers: {
Accept: createAcceptHeader("application/json", "6.0-preview.1")
}
};
return requestOptions;
}
function createHttpClient(): HttpClient {
const token = process.env["ACTIONS_RUNTIME_TOKEN"] || "";
const bearerCredentialHandler = new BearerCredentialHandler(token);
return new HttpClient(
"actions/cache",
[bearerCredentialHandler],
getRequestOptions()
);
}
export function getCacheVersion(
paths: string[],
compressionMethod?: CompressionMethod
): string {
const components = paths.concat(
!compressionMethod || compressionMethod === CompressionMethod.Gzip
? []
: [compressionMethod]
);
// Add salt to cache version to support breaking changes in cache entry
components.push(versionSalt);
return crypto
.createHash("sha256")
.update(components.join("|"))
.digest("hex");
}
interface _content {
Key?: string;
LastModified?: Date;
}
async function getCacheEntryS3(
s3Options: S3ClientConfig,
s3BucketName: string,
keys: string[],
paths: string[]
): Promise<ArtifactCacheEntry | null> {
const primaryKey = keys[0];
const s3client = new S3Client(s3Options);
let contents: _content[] = new Array();
let s3ContinuationToken: string | undefined | null = null;
let count = 0;
const param = {
Bucket: s3BucketName
} as ListObjectsV2CommandInput;
for (;;) {
core.debug(`ListObjects Count: ${count}`);
if (s3ContinuationToken != null) {
param.ContinuationToken = s3ContinuationToken;
}
let response: ListObjectsV2CommandOutput;
try {
response = await s3client.send(new ListObjectsV2Command(param));
} catch (e) {
throw new Error(`Error from S3: ${e}`);
}
if (!response.Contents) {
throw new Error(`Cannot found object in bucket ${s3BucketName}`);
}
core.debug(`Found objects ${response.Contents.length}`);
const found = response.Contents.find(
(content: _Object) => content.Key === primaryKey
);
if (found && found.LastModified) {
return {
cacheKey: primaryKey,
creationTime: found.LastModified.toString()
};
}
response.Contents.map((obj: _Object) =>
contents.push({
Key: obj.Key,
LastModified: obj.LastModified
})
);
core.debug(`Total objects ${contents.length}`);
if (response.IsTruncated) {
s3ContinuationToken = response.NextContinuationToken;
} else {
break;
}
count++;
}
core.debug("Not found in primary key, will fallback to restore keys");
const notPrimaryKey = keys.slice(1);
const found = searchRestoreKeyEntry(notPrimaryKey, contents);
if (found != null && found.LastModified) {
return {
cacheKey: found.Key,
creationTime: found.LastModified.toString()
};
}
return null;
}
function searchRestoreKeyEntry(
notPrimaryKey: string[],
entries: _content[]
): _content | null {
for (const k of notPrimaryKey) {
const found = _searchRestoreKeyEntry(k, entries);
if (found != null) {
return found;
}
}
return null;
}
function _searchRestoreKeyEntry(
notPrimaryKey: string,
entries: _content[]
): _content | null {
let matchPrefix: _content[] = new Array();
for (const entry of entries) {
if (entry.Key === notPrimaryKey) {
// extractly match, Use this entry
return entry;
}
if (entry.Key?.startsWith(notPrimaryKey)) {
matchPrefix.push(entry);
}
}
if (matchPrefix.length === 0) {
// not found, go to next key
return null;
}
matchPrefix.sort(function (i, j) {
if (i.LastModified == undefined || j.LastModified == undefined) {
return 0;
}
if (i.LastModified?.getTime() === j.LastModified?.getTime()) {
return 0;
}
if (i.LastModified?.getTime() > j.LastModified?.getTime()) {
return -1;
}
if (i.LastModified?.getTime() < j.LastModified?.getTime()) {
return 1;
}
return 0;
});
// return newest entry
return matchPrefix[0];
}
export async function getCacheEntry(
keys: string[],
paths: string[],
options?: InternalCacheOptions,
s3Options?: S3ClientConfig,
s3BucketName?: string
): Promise<ArtifactCacheEntry | null> {
if (s3Options && s3BucketName) {
return await getCacheEntryS3(s3Options, s3BucketName, keys, paths);
}
const httpClient = createHttpClient();
const version = getCacheVersion(paths, options?.compressionMethod);
const resource = `cache?keys=${encodeURIComponent(
keys.join(",")
)}&version=${version}`;
const response = await retryTypedResponse("getCacheEntry", async () =>
httpClient.getJson<ArtifactCacheEntry>(getCacheApiUrl(resource))
);
if (response.statusCode === 204) {
return null;
}
if (!isSuccessStatusCode(response.statusCode)) {
throw new Error(`Cache service responded with ${response.statusCode}`);
}
const cacheResult = response.result;
const cacheDownloadUrl = cacheResult?.archiveLocation;
if (!cacheDownloadUrl) {
throw new Error("Cache not found.");
}
core.setSecret(cacheDownloadUrl);
core.debug(`Cache Result:`);
core.debug(JSON.stringify(cacheResult));
return cacheResult;
}
export async function downloadCache(
cacheEntry: ArtifactCacheEntry,
archivePath: string,
options?: DownloadOptions,
s3Options?: S3ClientConfig,
s3BucketName?: string
): Promise<void> {
const archiveLocation = cacheEntry.archiveLocation ?? "https://example.com"; // for dummy
const archiveUrl = new URL(archiveLocation);
const downloadOptions = getDownloadOptions(options);
if (s3Options && s3BucketName && cacheEntry.cacheKey) {
await downloadCacheStorageS3(
cacheEntry.cacheKey,
archivePath,
s3Options,
s3BucketName
);
} else {
// Otherwise, download using the Actions http-client.
await downloadCacheHttpClient(archiveLocation, archivePath);
}
}
// Reserve Cache
export async function reserveCache(
key: string,
paths: string[],
options?: InternalCacheOptions,
s3Options?: S3ClientConfig,
s3BucketName?: string
): Promise<ITypedResponseWithError<ReserveCacheResponse>> {
if (s3Options && s3BucketName) {
return {
statusCode: 200,
result: null,
headers: {}
};
}
const httpClient = createHttpClient();
const version = getCacheVersion(paths, options?.compressionMethod);
const reserveCacheRequest: ReserveCacheRequest = {
key,
version,
cacheSize: options?.cacheSize
};
const response = await retryTypedResponse("reserveCache", async () =>
httpClient.postJson<ReserveCacheResponse>(
getCacheApiUrl("caches"),
reserveCacheRequest
)
);
return response;
}
function getContentRange(start: number, end: number): string {
// Format: `bytes start-end/filesize
// start and end are inclusive
// filesize can be *
// For a 200 byte chunk starting at byte 0:
// Content-Range: bytes 0-199/*
return `bytes ${start}-${end}/*`;
}
async function uploadChunk(
httpClient: HttpClient,
resourceUrl: string,
openStream: () => NodeJS.ReadableStream,
start: number,
end: number
): Promise<void> {
core.debug(
`Uploading chunk of size ${
end - start + 1
} bytes at offset ${start} with content range: ${getContentRange(
start,
end
)}`
);
const additionalHeaders = {
"Content-Type": "application/octet-stream",
"Content-Range": getContentRange(start, end)
};
const uploadChunkResponse = await retryHttpClientResponse(
`uploadChunk (start: ${start}, end: ${end})`,
async () =>
httpClient.sendStream(
"PATCH",
resourceUrl,
openStream(),
additionalHeaders
)
);
if (!isSuccessStatusCode(uploadChunkResponse.message.statusCode)) {
throw new Error(
`Cache service responded with ${uploadChunkResponse.message.statusCode} during upload chunk.`
);
}
}
async function uploadFileS3(
s3options: S3ClientConfig,
s3BucketName: string,
archivePath: string,
key: string,
concurrency: number,
maxChunkSize: number
): Promise<void> {
core.debug(`Start upload to S3 (bucket: ${s3BucketName})`);
const fileStream = fs.createReadStream(archivePath);
try {
const parallelUpload = new Upload({
client: new S3Client(s3options),
queueSize: concurrency,
partSize: maxChunkSize,
params: {
Bucket: s3BucketName,
Key: key,
Body: fileStream
}
});
parallelUpload.on("httpUploadProgress", (progress: Progress) => {
core.debug(`Uploading chunk progress: ${JSON.stringify(progress)}`);
});
await parallelUpload.done();
} catch (error) {
throw new Error(`Cache upload failed because ${error}`);
}
return;
}
async function uploadFile(
httpClient: HttpClient,
cacheId: number,
archivePath: string,
key: string,
options?: UploadOptions,
s3options?: S3ClientConfig,
s3BucketName?: string
): Promise<void> {
// Upload Chunks
const uploadOptions = getUploadOptions(options);
const concurrency = utils.assertDefined(
"uploadConcurrency",
uploadOptions.uploadConcurrency
);
const maxChunkSize = utils.assertDefined(
"uploadChunkSize",
uploadOptions.uploadChunkSize
);
const parallelUploads = [...new Array(concurrency).keys()];
core.debug("Awaiting all uploads");
let offset = 0;
if (s3options && s3BucketName) {
await uploadFileS3(
s3options,
s3BucketName,
archivePath,
key,
concurrency,
maxChunkSize
);
return;
}
const fileSize = utils.getArchiveFileSizeInBytes(archivePath);
const resourceUrl = getCacheApiUrl(`caches/${cacheId.toString()}`);
const fd = fs.openSync(archivePath, "r");
try {
await Promise.all(
parallelUploads.map(async () => {
while (offset < fileSize) {
const chunkSize = Math.min(fileSize - offset, maxChunkSize);
const start = offset;
const end = offset + chunkSize - 1;
offset += maxChunkSize;
await uploadChunk(
httpClient,
resourceUrl,
() =>
fs
.createReadStream(archivePath, {
fd,
start,
end,
autoClose: false
})
.on("error", error => {
throw new Error(
`Cache upload failed because file read failed with ${error.message}`
);
}),
start,
end
);
}
})
);
} finally {
fs.closeSync(fd);
}
return;
}
async function commitCache(
httpClient: HttpClient,
cacheId: number,
filesize: number
): Promise<TypedResponse<null>> {
const commitCacheRequest: CommitCacheRequest = { size: filesize };
return await retryTypedResponse("commitCache", async () =>
httpClient.postJson<null>(
getCacheApiUrl(`caches/${cacheId.toString()}`),
commitCacheRequest
)
);
}
export async function saveCache(
cacheId: number,
archivePath: string,
key: string,
options?: UploadOptions,
s3Options?: S3ClientConfig,
s3BucketName?: string
): Promise<void> {
const httpClient = createHttpClient();
core.debug("Upload cache");
await uploadFile(
httpClient,
cacheId,
archivePath,
key,
options,
s3Options,
s3BucketName
);
// Commit Cache
core.debug("Commiting cache");
const cacheSize = utils.getArchiveFileSizeInBytes(archivePath);
core.info(
`Cache Size: ~${Math.round(
cacheSize / (1024 * 1024)
)} MB (${cacheSize} B)`
);
if (!s3Options) {
// already commit on S3
const commitCacheResponse = await commitCache(
httpClient,
cacheId,
cacheSize
);
if (!isSuccessStatusCode(commitCacheResponse.statusCode)) {
throw new Error(
`Cache service responded with ${commitCacheResponse.statusCode} during commit cache.`
);
}
}
core.info("Cache saved successfully");
}

@ -0,0 +1,84 @@
import { CompressionMethod } from "./cacheUtils";
import { TypedResponse } from "@actions/http-client/lib/interfaces";
import { HttpClientError } from "@actions/http-client";
export interface ITypedResponseWithError<T> extends TypedResponse<T> {
error?: HttpClientError;
}
export interface ArtifactCacheEntry {
cacheKey?: string;
scope?: string;
creationTime?: string;
archiveLocation?: string;
}
export interface CommitCacheRequest {
size: number;
}
export interface ReserveCacheRequest {
key: string;
version?: string;
cacheSize?: number;
}
export interface ReserveCacheResponse {
cacheId: number;
}
export interface InternalCacheOptions {
compressionMethod?: CompressionMethod;
cacheSize?: number;
}
/**
* Options to control cache upload
*/
export interface UploadOptions {
/**
* Number of parallel cache upload
*
* @default 4
*/
uploadConcurrency?: number
/**
* Maximum chunk size in bytes for cache upload
*
* @default 32MB
*/
uploadChunkSize?: number
}
/**
* Options to control cache download
*/
export interface DownloadOptions {
/**
* Indicates whether to use the Azure Blob SDK to download caches
* that are stored on Azure Blob Storage to improve reliability and
* performance
*
* @default true
*/
useAzureSdk?: boolean
/**
* Number of parallel downloads (this option only applies when using
* the Azure SDK)
*
* @default 8
*/
downloadConcurrency?: number
/**
* Maximum time for each download request, in milliseconds (this
* option only applies when using the Azure SDK)
*
* @default 30000
*/
timeoutInMs?: number,
lookupOnly?: boolean
}

@ -0,0 +1,106 @@
import * as core from "@actions/core";
import { HttpClient, HttpClientResponse } from "@actions/http-client";
import { GetObjectCommand, S3Client, S3ClientConfig } from "@aws-sdk/client-s3";
import * as buffer from "buffer";
import * as fs from "fs";
import * as stream from "stream";
import * as util from "util";
import * as utils from "./cacheUtils";
import { SocketTimeout } from "./cacheUtils";
import { DownloadOptions, UploadOptions } from "./contracts";
import { retryHttpClientResponse } from "./requestUtils";
/**
* Pipes the body of a HTTP response to a stream
*
* @param response the HTTP response
* @param output the writable stream
*/
async function pipeResponseToStream(
response: HttpClientResponse,
output: NodeJS.WritableStream
): Promise<void> {
const pipeline = util.promisify(stream.pipeline);
await pipeline(response.message, output);
}
/**
* Download the cache using the Actions toolkit http-client
*
* @param archiveLocation the URL for the cache
* @param archivePath the local path where the cache is saved
*/
export async function downloadCacheHttpClient(
archiveLocation: string,
archivePath: string
): Promise<void> {
const writeStream = fs.createWriteStream(archivePath);
const httpClient = new HttpClient("actions/cache");
const downloadResponse = await retryHttpClientResponse(
"downloadCache",
async () => httpClient.get(archiveLocation)
);
// Abort download if no traffic received over the socket.
downloadResponse.message.socket.setTimeout(SocketTimeout, () => {
downloadResponse.message.destroy();
core.debug(
`Aborting download, socket timed out after ${SocketTimeout} ms`
);
});
await pipeResponseToStream(downloadResponse, writeStream);
// Validate download size.
const contentLengthHeader =
downloadResponse.message.headers["content-length"];
if (contentLengthHeader) {
const expectedLength = parseInt(contentLengthHeader);
const actualLength = utils.getArchiveFileSizeInBytes(archivePath);
if (actualLength !== expectedLength) {
throw new Error(
`Incomplete download. Expected file size: ${expectedLength}, actual file size: ${actualLength}`
);
}
} else {
core.debug("Unable to validate download, no Content-Length header");
}
}
/**
* Download the cache using the AWS S3. Only call this method if the use S3.
*
* @param key the key for the cache in S3
* @param archivePath the local path where the cache is saved
* @param s3Options: the option for AWS S3 client
* @param s3BucketName: the name of bucket in AWS S3
*/
export async function downloadCacheStorageS3(
key: string,
archivePath: string,
s3Options: S3ClientConfig,
s3BucketName: string
): Promise<void> {
const s3client = new S3Client(s3Options);
const param = {
Bucket: s3BucketName,
Key: key
};
const response = await s3client.send(new GetObjectCommand(param));
if (!response.Body) {
throw new Error(
`Incomplete download. response.Body is undefined from S3.`
);
}
const fileStream = fs.createWriteStream(archivePath);
const pipeline = util.promisify(stream.pipeline);
await pipeline(response.Body as stream.Readable, fileStream);
return;
}

@ -0,0 +1,62 @@
import * as core from '@actions/core'
import { DownloadOptions, UploadOptions } from "./contracts";
/**
* Returns a copy of the upload options with defaults filled in.
*
* @param copy the original upload options
*/
export function getUploadOptions(copy?: UploadOptions): UploadOptions {
const result: UploadOptions = {
uploadConcurrency: 4,
uploadChunkSize: 32 * 1024 * 1024
}
if (copy) {
if (typeof copy.uploadConcurrency === 'number') {
result.uploadConcurrency = copy.uploadConcurrency
}
if (typeof copy.uploadChunkSize === 'number') {
result.uploadChunkSize = copy.uploadChunkSize
}
}
core.debug(`Upload concurrency: ${result.uploadConcurrency}`)
core.debug(`Upload chunk size: ${result.uploadChunkSize}`)
return result
}
/**
* Returns a copy of the download options with defaults filled in.
*
* @param copy the original download options
*/
export function getDownloadOptions(copy?: DownloadOptions): DownloadOptions {
const result: DownloadOptions = {
useAzureSdk: true,
downloadConcurrency: 8,
timeoutInMs: 30000
}
if (copy) {
if (typeof copy.useAzureSdk === 'boolean') {
result.useAzureSdk = copy.useAzureSdk
}
if (typeof copy.downloadConcurrency === 'number') {
result.downloadConcurrency = copy.downloadConcurrency
}
if (typeof copy.timeoutInMs === 'number') {
result.timeoutInMs = copy.timeoutInMs
}
}
core.debug(`Use Azure SDK: ${result.useAzureSdk}`)
core.debug(`Download concurrency: ${result.downloadConcurrency}`)
core.debug(`Request timeout (ms): ${result.timeoutInMs}`)
return result
}

@ -0,0 +1,138 @@
import * as core from "@actions/core";
import {
HttpCodes,
HttpClientError,
HttpClientResponse
} from "@actions/http-client";
import { DefaultRetryDelay, DefaultRetryAttempts } from "./cacheUtils";
import { ITypedResponseWithError } from "./contracts";
export function isSuccessStatusCode(statusCode?: number): boolean {
if (!statusCode) {
return false;
}
return statusCode >= 200 && statusCode < 300;
}
export function isServerErrorStatusCode(statusCode?: number): boolean {
if (!statusCode) {
return true;
}
return statusCode >= 500;
}
export function isRetryableStatusCode(statusCode?: number): boolean {
if (!statusCode) {
return false;
}
const retryableStatusCodes = [
HttpCodes.BadGateway,
HttpCodes.ServiceUnavailable,
HttpCodes.GatewayTimeout
];
return retryableStatusCodes.includes(statusCode);
}
async function sleep(milliseconds: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, milliseconds));
}
export async function retry<T>(
name: string,
method: () => Promise<T>,
getStatusCode: (arg0: T) => number | undefined,
maxAttempts = DefaultRetryAttempts,
delay = DefaultRetryDelay,
onError: ((arg0: Error) => T | undefined) | undefined = undefined
): Promise<T> {
let errorMessage = "";
let attempt = 1;
while (attempt <= maxAttempts) {
let response: T | undefined = undefined;
let statusCode: number | undefined = undefined;
let isRetryable = false;
try {
response = await method();
} catch (error: any) {
if (onError) {
response = onError(error);
}
isRetryable = true;
errorMessage = error.message;
}
if (response) {
statusCode = getStatusCode(response);
if (!isServerErrorStatusCode(statusCode)) {
return response;
}
}
if (statusCode) {
isRetryable = isRetryableStatusCode(statusCode);
errorMessage = `Cache service responded with ${statusCode}`;
}
core.debug(
`${name} - Attempt ${attempt} of ${maxAttempts} failed with error: ${errorMessage}`
);
if (!isRetryable) {
core.debug(`${name} - Error is not retryable`);
break;
}
await sleep(delay);
attempt++;
}
throw Error(`${name} failed: ${errorMessage}`);
}
export async function retryTypedResponse<T>(
name: string,
method: () => Promise<ITypedResponseWithError<T>>,
maxAttempts = DefaultRetryAttempts,
delay = DefaultRetryDelay
): Promise<ITypedResponseWithError<T>> {
return await retry(
name,
method,
(response: ITypedResponseWithError<T>) => response.statusCode,
maxAttempts,
delay,
// If the error object contains the statusCode property, extract it and return
// an TypedResponse<T> so it can be processed by the retry logic.
(error: Error) => {
if (error instanceof HttpClientError) {
return {
statusCode: error.statusCode,
result: null,
headers: {},
error
};
} else {
return undefined;
}
}
);
}
export async function retryHttpClientResponse(
name: string,
method: () => Promise<HttpClientResponse>,
maxAttempts = DefaultRetryAttempts,
delay = DefaultRetryDelay
): Promise<HttpClientResponse> {
return await retry(
name,
method,
(response: HttpClientResponse) => response.message.statusCode,
maxAttempts,
delay
);
}

@ -0,0 +1,157 @@
import { exec } from "@actions/exec";
import * as io from "@actions/io";
import { existsSync, writeFileSync } from "fs";
import * as path from "path";
import * as utils from "./cacheUtils";
import { CompressionMethod } from "./cacheUtils";
async function getTarPath(
args: string[],
compressionMethod: CompressionMethod
): Promise<string> {
switch (process.platform) {
case "win32": {
const systemTar = `${process.env["windir"]}\\System32\\tar.exe`;
if (compressionMethod !== CompressionMethod.Gzip) {
// We only use zstandard compression on windows when gnu tar is installed due to
// a bug with compressing large files with bsdtar + zstd
args.push("--force-local");
} else if (existsSync(systemTar)) {
return systemTar;
} else if (await utils.isGnuTarInstalled()) {
args.push("--force-local");
}
break;
}
case "darwin": {
const gnuTar = await io.which("gtar", false);
if (gnuTar) {
// fix permission denied errors when extracting BSD tar archive with GNU tar - https://github.com/actions/cache/issues/527
args.push("--delay-directory-restore");
return gnuTar;
}
break;
}
default:
break;
}
return await io.which("tar", true);
}
async function execTar(
args: string[],
compressionMethod: CompressionMethod,
cwd?: string
): Promise<void> {
try {
await exec(`"${await getTarPath(args, compressionMethod)}"`, args, {
cwd
});
} catch (error: any) {
throw new Error(`Tar failed with error: ${error?.message}`);
}
}
function getWorkingDirectory(): string {
return process.env["GITHUB_WORKSPACE"] ?? process.cwd();
}
export async function extractTar(
archivePath: string,
compressionMethod: CompressionMethod
): Promise<void> {
// Create directory to extract tar into
const workingDirectory = getWorkingDirectory();
await io.mkdirP(workingDirectory);
// --d: Decompress.
// --long=#: Enables long distance matching with # bits. Maximum is 30 (1GB) on 32-bit OS and 31 (2GB) on 64-bit.
// Using 30 here because we also support 32-bit self-hosted runners.
function getCompressionProgram(): string[] {
switch (compressionMethod) {
case CompressionMethod.Zstd:
return ["--use-compress-program", "zstd -d --long=30"];
case CompressionMethod.ZstdWithoutLong:
return ["--use-compress-program", "zstd -d"];
default:
return ["-z"];
}
}
const args = [
...getCompressionProgram(),
"-xf",
archivePath.replace(new RegExp(`\\${path.sep}`, "g"), "/"),
"-P",
"-C",
workingDirectory.replace(new RegExp(`\\${path.sep}`, "g"), "/")
];
await execTar(args, compressionMethod);
}
export async function createTar(
archiveFolder: string,
sourceDirectories: string[],
compressionMethod: CompressionMethod
): Promise<void> {
// Write source directories to manifest.txt to avoid command length limits
const manifestFilename = "manifest.txt";
const cacheFileName = utils.getCacheFileName(compressionMethod);
writeFileSync(
path.join(archiveFolder, manifestFilename),
sourceDirectories.join("\n")
);
const workingDirectory = getWorkingDirectory();
// -T#: Compress using # working thread. If # is 0, attempt to detect and use the number of physical CPU cores.
// --long=#: Enables long distance matching with # bits. Maximum is 30 (1GB) on 32-bit OS and 31 (2GB) on 64-bit.
// Using 30 here because we also support 32-bit self-hosted runners.
// Long range mode is added to zstd in v1.3.2 release, so we will not use --long in older version of zstd.
function getCompressionProgram(): string[] {
switch (compressionMethod) {
case CompressionMethod.Zstd:
return ["--use-compress-program", "zstd -T0 --long=30"];
case CompressionMethod.ZstdWithoutLong:
return ["--use-compress-program", "zstd -T0"];
default:
return ["-z"];
}
}
const args = [
"--posix",
...getCompressionProgram(),
"-cf",
cacheFileName.replace(new RegExp(`\\${path.sep}`, "g"), "/"),
"-P",
"-C",
workingDirectory.replace(new RegExp(`\\${path.sep}`, "g"), "/"),
"--files-from",
manifestFilename
];
await execTar(args, compressionMethod, archiveFolder);
}
export async function listTar(
archivePath: string,
compressionMethod: CompressionMethod
): Promise<void> {
// --d: Decompress.
// --long=#: Enables long distance matching with # bits.
// Maximum is 30 (1GB) on 32-bit OS and 31 (2GB) on 64-bit.
// Using 30 here because we also support 32-bit self-hosted runners.
function getCompressionProgram(): string[] {
switch (compressionMethod) {
case CompressionMethod.Zstd:
return ["--use-compress-program", "zstd -d --long=30"];
case CompressionMethod.ZstdWithoutLong:
return ["--use-compress-program", "zstd -d"];
default:
return ["-z"];
}
}
const args = [
...getCompressionProgram(),
"-tf",
archivePath.replace(new RegExp(`\\${path.sep}`, "g"), "/"),
"-P"
];
await execTar(args, compressionMethod);
}

@ -2,8 +2,8 @@
"compilerOptions": {
/* Basic Options */
// "incremental": true, /* Enable incremental compilation */
"target": "es6", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */
"module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */
"target": "ES6", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */
"module": "CommonJS", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */
// "allowJs": true, /* Allow javascript files to be compiled. */
// "checkJs": true, /* Report errors in .js files. */
// "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */
@ -20,6 +20,7 @@
// "importHelpers": true, /* Import emit helpers from 'tslib'. */
// "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
// "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
"newLine": "lf",
/* Strict Type-Checking Options */
"strict": true, /* Enable all strict type-checking options. */

Loading…
Cancel
Save