Skip to content

Commit

Permalink
Use cache instead of artifacts
Browse files Browse the repository at this point in the history
  • Loading branch information
dsame committed Jun 27, 2023
1 parent c7d4376 commit 98ef5c7
Show file tree
Hide file tree
Showing 9 changed files with 1,155 additions and 972 deletions.
1,681 changes: 734 additions & 947 deletions dist/index.js

Large diffs are not rendered by default.

19 changes: 19 additions & 0 deletions src/classes/actions-cache/http-responses.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import {TypedResponse} from '@actions/http-client/lib/interfaces';

Check failure on line 1 in src/classes/actions-cache/http-responses.ts

View workflow job for this annotation

GitHub Actions / Basic validation / build (ubuntu-latest)

"@actions/http-client" is extraneous

Check failure on line 1 in src/classes/actions-cache/http-responses.ts

View workflow job for this annotation

GitHub Actions / Basic validation / build (windows-latest)

"@actions/http-client" is extraneous

Check failure on line 1 in src/classes/actions-cache/http-responses.ts

View workflow job for this annotation

GitHub Actions / Basic validation / build (macos-latest)

"@actions/http-client" is extraneous
import {HttpClientError} from '@actions/http-client';

Check failure on line 2 in src/classes/actions-cache/http-responses.ts

View workflow job for this annotation

GitHub Actions / Basic validation / build (ubuntu-latest)

"@actions/http-client" is extraneous

Check failure on line 2 in src/classes/actions-cache/http-responses.ts

View workflow job for this annotation

GitHub Actions / Basic validation / build (windows-latest)

"@actions/http-client" is extraneous

Check failure on line 2 in src/classes/actions-cache/http-responses.ts

View workflow job for this annotation

GitHub Actions / Basic validation / build (macos-latest)

"@actions/http-client" is extraneous

export const 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 interface TypedResponseWithError<T> extends TypedResponse<T> {
error?: HttpClientError;
}
234 changes: 234 additions & 0 deletions src/classes/actions-cache/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
import * as core from '@actions/core';
import fs from 'fs';
import {HttpClient, HttpClientResponse} from '@actions/http-client';

Check warning on line 3 in src/classes/actions-cache/index.ts

View workflow job for this annotation

GitHub Actions / Basic validation / build (ubuntu-latest)

'HttpClientResponse' is defined but never used

Check failure on line 3 in src/classes/actions-cache/index.ts

View workflow job for this annotation

GitHub Actions / Basic validation / build (ubuntu-latest)

"@actions/http-client" is extraneous

Check warning on line 3 in src/classes/actions-cache/index.ts

View workflow job for this annotation

GitHub Actions / Basic validation / build (windows-latest)

'HttpClientResponse' is defined but never used

Check failure on line 3 in src/classes/actions-cache/index.ts

View workflow job for this annotation

GitHub Actions / Basic validation / build (windows-latest)

"@actions/http-client" is extraneous

Check warning on line 3 in src/classes/actions-cache/index.ts

View workflow job for this annotation

GitHub Actions / Basic validation / build (macos-latest)

'HttpClientResponse' is defined but never used

Check failure on line 3 in src/classes/actions-cache/index.ts

View workflow job for this annotation

GitHub Actions / Basic validation / build (macos-latest)

"@actions/http-client" is extraneous
import {BearerCredentialHandler} from '@actions/http-client/lib/auth';

Check failure on line 4 in src/classes/actions-cache/index.ts

View workflow job for this annotation

GitHub Actions / Basic validation / build (ubuntu-latest)

"@actions/http-client" is extraneous

Check failure on line 4 in src/classes/actions-cache/index.ts

View workflow job for this annotation

GitHub Actions / Basic validation / build (windows-latest)

"@actions/http-client" is extraneous

Check failure on line 4 in src/classes/actions-cache/index.ts

View workflow job for this annotation

GitHub Actions / Basic validation / build (macos-latest)

"@actions/http-client" is extraneous
import {
RequestOptions,
TypedResponse
} from '@actions/http-client/lib/interfaces';

Check failure on line 8 in src/classes/actions-cache/index.ts

View workflow job for this annotation

GitHub Actions / Basic validation / build (ubuntu-latest)

"@actions/http-client" is extraneous

Check failure on line 8 in src/classes/actions-cache/index.ts

View workflow job for this annotation

GitHub Actions / Basic validation / build (windows-latest)

"@actions/http-client" is extraneous

Check failure on line 8 in src/classes/actions-cache/index.ts

View workflow job for this annotation

GitHub Actions / Basic validation / build (macos-latest)

"@actions/http-client" is extraneous
import {ReserveCacheError, ValidationError} from '@actions/cache';

Check failure on line 9 in src/classes/actions-cache/index.ts

View workflow job for this annotation

GitHub Actions / Check dist/ / check-dist

Cannot find module '@actions/cache' or its corresponding type declarations.
import {isSuccessStatusCode, TypedResponseWithError} from './http-responses';
import {retryHttpClientResponse, retryTypedResponse} from './retry';
import {IncomingMessage} from 'http';
import {getOctokit} from '@actions/github';
import {retry} from '@octokit/plugin-retry';

const uploadChunk = async (
httpClient: HttpClient,
resourceUrl: string,
openStream: () => NodeJS.ReadableStream,
start: number,
end: number
): Promise<void> => {
// 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/*
const contentRange = `bytes ${start}-${end}/*`;
core.debug(
`Uploading chunk of size ${
end - start + 1
} bytes at offset ${start} with content range: ${contentRange}`
);
const additionalHeaders = {
'Content-Type': 'application/octet-stream',
'Content-Range': contentRange
};

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.`
);
}
};

const 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;
};

const createAcceptHeader = (type: string, apiVersion: string): string =>
`${type};api-version=${apiVersion}`;

const getRequestOptions = (): RequestOptions => ({
headers: {
Accept: createAcceptHeader('application/json', '6.0-preview.1')
}
});

const createHttpClient = (): HttpClient => {
const token = process.env['ACTIONS_RUNTIME_TOKEN'] || '';
const bearerCredentialHandler = new BearerCredentialHandler(token);

return new HttpClient(
'actions/cache',
[bearerCredentialHandler],
getRequestOptions()
);
};

const uploadFile = async (
httpClient: HttpClient,
cacheId: number,
filePath: string,
fileSize: number
): Promise<void> => {
// Upload Chunks
const resourceUrl = getCacheApiUrl(`caches/${cacheId.toString()}`);
const fd = fs.openSync(filePath, 'r');

try {
const chunkSize = fileSize;
const start = 0;
const end = chunkSize - 1;

await uploadChunk(
httpClient,
resourceUrl,
() =>
fs
.createReadStream(filePath, {
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;
};

const CACHE_KEY = '_state';

const resetCacheWithOctokit = async (): Promise<void> => {
const token = core.getInput('repo-token');
const client = getOctokit(token, undefined, retry);
const repo = process.env['GITHUB_REPOSITORY'];
const result = await client.request(
`DELETE /repos/${repo}/actions/caches?key=${CACHE_KEY}`
);
console.log('============> delete');

Check failure on line 137 in src/classes/actions-cache/index.ts

View workflow job for this annotation

GitHub Actions / Basic validation / build (ubuntu-latest)

Unexpected console statement

Check failure on line 137 in src/classes/actions-cache/index.ts

View workflow job for this annotation

GitHub Actions / Basic validation / build (windows-latest)

Unexpected console statement

Check failure on line 137 in src/classes/actions-cache/index.ts

View workflow job for this annotation

GitHub Actions / Basic validation / build (macos-latest)

Unexpected console statement
console.log(result);

Check failure on line 138 in src/classes/actions-cache/index.ts

View workflow job for this annotation

GitHub Actions / Basic validation / build (ubuntu-latest)

Unexpected console statement

Check failure on line 138 in src/classes/actions-cache/index.ts

View workflow job for this annotation

GitHub Actions / Basic validation / build (windows-latest)

Unexpected console statement

Check failure on line 138 in src/classes/actions-cache/index.ts

View workflow job for this annotation

GitHub Actions / Basic validation / build (macos-latest)

Unexpected console statement
};

const resetCache = async (httpClient: HttpClient): Promise<void> => {

Check warning on line 141 in src/classes/actions-cache/index.ts

View workflow job for this annotation

GitHub Actions / Basic validation / build (ubuntu-latest)

'resetCache' is assigned a value but never used

Check warning on line 141 in src/classes/actions-cache/index.ts

View workflow job for this annotation

GitHub Actions / Basic validation / build (windows-latest)

'resetCache' is assigned a value but never used

Check warning on line 141 in src/classes/actions-cache/index.ts

View workflow job for this annotation

GitHub Actions / Basic validation / build (macos-latest)

'resetCache' is assigned a value but never used
await retryTypedResponse('resetCache', async () => {
const result = await httpClient.del(
getCacheApiUrl(`caches?key=${CACHE_KEY}`)
);
console.log('============> delete');

Check failure on line 146 in src/classes/actions-cache/index.ts

View workflow job for this annotation

GitHub Actions / Basic validation / build (ubuntu-latest)

Unexpected console statement

Check failure on line 146 in src/classes/actions-cache/index.ts

View workflow job for this annotation

GitHub Actions / Basic validation / build (windows-latest)

Unexpected console statement

Check failure on line 146 in src/classes/actions-cache/index.ts

View workflow job for this annotation

GitHub Actions / Basic validation / build (macos-latest)

Unexpected console statement
console.log(result.message);

Check failure on line 147 in src/classes/actions-cache/index.ts

View workflow job for this annotation

GitHub Actions / Basic validation / build (ubuntu-latest)

Unexpected console statement

Check failure on line 147 in src/classes/actions-cache/index.ts

View workflow job for this annotation

GitHub Actions / Basic validation / build (windows-latest)

Unexpected console statement

Check failure on line 147 in src/classes/actions-cache/index.ts

View workflow job for this annotation

GitHub Actions / Basic validation / build (macos-latest)

Unexpected console statement
return result.message as unknown as TypedResponseWithError<IncomingMessage>;
});
};
interface ReserveCacheResponse {
cacheId: number;
}

const reserveCache = async (
httpClient: HttpClient,
fileSize: number
): Promise<number> => {
const reserveCacheRequest = {
key: CACHE_KEY,
version: new Date().getDate().toString(),
cacheSize: fileSize
};
const response = (await retryTypedResponse('reserveCache', async () =>
httpClient.postJson<ReserveCacheResponse>(
getCacheApiUrl('caches'),
reserveCacheRequest
)
)) as TypedResponseWithError<ReserveCacheResponse>;

if (response?.statusCode === 400)
throw new Error(
response?.error?.message ??
`Cache size of ~${Math.round(
fileSize / (1024 * 1024)
)} MB (${fileSize} B) is over the data cap limit, not saving cache.`
);

const cacheId = response?.result?.cacheId;

if (cacheId === undefined)
throw new ReserveCacheError(
`Unable to reserve cache with key ${CACHE_KEY}, another job may be creating this cache. More details: ${response?.error?.message}`
);
return cacheId;
};

interface CommitCacheRequest {
size: number;
}

const commitCache = async (
httpClient: HttpClient,
cacheId: number,
filesize: number
): Promise<void> => {
const commitCacheRequest: CommitCacheRequest = {size: filesize};
const response = (await retryTypedResponse('commitCache', async () =>
httpClient.postJson<null>(
getCacheApiUrl(`caches/${cacheId.toString()}`),
commitCacheRequest
)
)) as TypedResponse<null>;
if (!isSuccessStatusCode(response.statusCode)) {
throw new Error(
`Cache service responded with ${response.statusCode} during commit cache.`
);
}
};

export const saveFileAsActionsCache = async (filePath: string) => {
try {
await resetCacheWithOctokit();
const httpClient = createHttpClient();

// await resetCache(httpClient);

const fileSize = fs.statSync(filePath).size;
const cacheId = await reserveCache(httpClient, fileSize);

await uploadFile(httpClient, cacheId, filePath, fileSize);

await commitCache(httpClient, cacheId, fileSize);
} catch (error) {
const typedError = error as Error;
if (typedError.name === ValidationError.name) {
throw error;
} else if (typedError.name === ReserveCacheError.name) {
core.info(`Failed to save: ${typedError.message}`);
} else {
core.warning(`Failed to save: ${typedError.message}`);
}
}
};
127 changes: 127 additions & 0 deletions src/classes/actions-cache/retry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import {
HttpClientError,
HttpClientResponse,
HttpCodes
} from '@actions/http-client';

Check failure on line 5 in src/classes/actions-cache/retry.ts

View workflow job for this annotation

GitHub Actions / Basic validation / build (ubuntu-latest)

"@actions/http-client" is extraneous

Check failure on line 5 in src/classes/actions-cache/retry.ts

View workflow job for this annotation

GitHub Actions / Basic validation / build (windows-latest)

"@actions/http-client" is extraneous

Check failure on line 5 in src/classes/actions-cache/retry.ts

View workflow job for this annotation

GitHub Actions / Basic validation / build (macos-latest)

"@actions/http-client" is extraneous
import {
isServerErrorStatusCode,
TypedResponseWithError
} from './http-responses';
import * as core from '@actions/core';

const isRetryableStatusCode = (statusCode?: number): boolean => {
if (!statusCode) {
return false;
}
const retryableStatusCodes = [
HttpCodes.BadGateway,
HttpCodes.ServiceUnavailable,
HttpCodes.GatewayTimeout
];
return retryableStatusCodes.includes(statusCode);
};

const sleep = (milliseconds: number): Promise<void> =>
new Promise(resolve => setTimeout(resolve, milliseconds));
// The default number of retry attempts.
const DefaultRetryAttempts = 2;
// The default delay in milliseconds between retry attempts.
const DefaultRetryDelay = 5000;

const retry = async <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) {
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 const retryHttpClientResponse = async (
name: string,
method: () => Promise<HttpClientResponse>,
maxAttempts = DefaultRetryAttempts,
delay = DefaultRetryDelay
): Promise<HttpClientResponse> => {
return await retry(
name,
method,
(response: HttpClientResponse) => response.message.statusCode,
maxAttempts,
delay
);
};
export const retryTypedResponse = <T>(
name: string,
method: () => Promise<TypedResponseWithError<T>>,
maxAttempts = DefaultRetryAttempts,
delay = DefaultRetryDelay
): Promise<TypedResponseWithError<T>> =>
retry(
name,
method,
(response: TypedResponseWithError<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;
}
}
);
2 changes: 1 addition & 1 deletion src/classes/issue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export class Issue implements IIssue {

constructor(
options: Readonly<IIssuesProcessorOptions>,
issue: Readonly<OctokitIssue> | Readonly<IIssue>
issue: Readonly<OctokitIssue> // | Readonly<IIssue>
) {
this._options = options;
this.title = issue.title;
Expand Down

0 comments on commit 98ef5c7

Please sign in to comment.