diff --git a/packages/artifact/__tests__/retention.test.ts b/packages/artifact/__tests__/retention.test.ts new file mode 100644 index 00000000..83de5342 --- /dev/null +++ b/packages/artifact/__tests__/retention.test.ts @@ -0,0 +1,65 @@ +import {Timestamp} from '../src/generated' +import * as retention from '../src/internal/upload/retention' + +describe('retention', () => { + beforeEach(() => { + delete process.env['GITHUB_RETENTION_DAYS'] + }) + it('should return the inputted retention days if it is less than the max retention days', () => { + // setup + const mockDate = new Date('2020-01-01') + jest.useFakeTimers().setSystemTime(mockDate) + process.env['GITHUB_RETENTION_DAYS'] = '90' + + const exp = retention.getExpiration(30) + + expect(exp).toBeDefined() + if (exp) { + const expDate = Timestamp.toDate(exp) + const expected = new Date() + expected.setDate(expected.getDate() + 30) + + expect(expDate).toEqual(expected) + } + }) + + it('should return the max retention days if the inputted retention days is greater than the max retention days', () => { + // setup + const mockDate = new Date('2020-01-01') + jest.useFakeTimers().setSystemTime(mockDate) + process.env['GITHUB_RETENTION_DAYS'] = '90' + + const exp = retention.getExpiration(120) + + expect(exp).toBeDefined() + if (exp) { + const expDate = Timestamp.toDate(exp) // we check whether exp is defined above + const expected = new Date() + expected.setDate(expected.getDate() + 90) + + expect(expDate).toEqual(expected) + } + }) + + it('should return undefined if the inputted retention days is undefined', () => { + const exp = retention.getExpiration() + expect(exp).toBeUndefined() + }) + + it('should return the inputted retention days if there is no max retention days', () => { + // setup + const mockDate = new Date('2020-01-01') + jest.useFakeTimers().setSystemTime(mockDate) + + const exp = retention.getExpiration(30) + + expect(exp).toBeDefined() + if (exp) { + const expDate = Timestamp.toDate(exp) // we check whether exp is defined above + const expected = new Date() + expected.setDate(expected.getDate() + 30) + + expect(expDate).toEqual(expected) + } + }) +}) diff --git a/packages/artifact/__tests__/util.test.ts b/packages/artifact/__tests__/util.test.ts new file mode 100644 index 00000000..76f760fa --- /dev/null +++ b/packages/artifact/__tests__/util.test.ts @@ -0,0 +1,62 @@ +import * as config from '../src/internal/shared/config' +import * as util from '../src/internal/shared/util' + +describe('get-backend-ids-from-token', () => { + it('should return backend ids when the token is valid', () => { + jest + .spyOn(config, 'getRuntimeToken') + .mockReturnValue( + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwic2NwIjoiQWN0aW9ucy5FeGFtcGxlIEFjdGlvbnMuQW5vdGhlckV4YW1wbGU6dGVzdCBBY3Rpb25zLlJlc3VsdHM6Y2U3ZjU0YzctNjFjNy00YWFlLTg4N2YtMzBkYTQ3NWY1ZjFhOmNhMzk1MDg1LTA0MGEtNTI2Yi0yY2U4LWJkYzg1ZjY5Mjc3NCIsImlhdCI6MTUxNjIzOTAyMn0.XYnI_wHPBlUi1mqYveJnnkJhp4dlFjqxzRmISPsqfw8' + ) + + const backendIds = util.getBackendIdsFromToken() + expect(backendIds.workflowRunBackendId).toBe( + 'ce7f54c7-61c7-4aae-887f-30da475f5f1a' + ) + expect(backendIds.workflowJobRunBackendId).toBe( + 'ca395085-040a-526b-2ce8-bdc85f692774' + ) + }) + + it("should throw an error when the token doesn't have the right scope", () => { + jest + .spyOn(config, 'getRuntimeToken') + .mockReturnValue( + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwic2NwIjoiQWN0aW9ucy5FeGFtcGxlIEFjdGlvbnMuQW5vdGhlckV4YW1wbGU6dGVzdCIsImlhdCI6MTUxNjIzOTAyMn0.K0IEoULZteGevF38G94xiaA8zcZ5UlKWfGfqE6q3dhw' + ) + + expect(util.getBackendIdsFromToken).toThrowError( + 'Failed to get backend IDs: The provided JWT token is invalid' + ) + }) + + it('should throw an error when the token has a malformed scope', () => { + jest + .spyOn(config, 'getRuntimeToken') + .mockReturnValue( + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwic2NwIjoiQWN0aW9ucy5FeGFtcGxlIEFjdGlvbnMuQW5vdGhlckV4YW1wbGU6dGVzdCBBY3Rpb25zLlJlc3VsdHM6Y2U3ZjU0YzctNjFjNy00YWFlLTg4N2YtMzBkYTQ3NWY1ZjFhIiwiaWF0IjoxNTE2MjM5MDIyfQ.7D0_LRfRFRZFImHQ7GxH2S6ZyFjjZ5U0ujjGCfle1XE' + ) + + expect(util.getBackendIdsFromToken).toThrowError( + 'Failed to get backend IDs: The provided JWT token is invalid' + ) + }) + + it('should throw an error when the token is in an invalid format', () => { + jest.spyOn(config, 'getRuntimeToken').mockReturnValue('token') + + expect(util.getBackendIdsFromToken).toThrowError('Invalid token specified') + }) + + it("should throw an error when the token doesn't have the right field", () => { + jest + .spyOn(config, 'getRuntimeToken') + .mockReturnValue( + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c' + ) + + expect(util.getBackendIdsFromToken).toThrowError( + 'Failed to get backend IDs: The provided JWT token is invalid' + ) + }) +}) diff --git a/packages/artifact/package-lock.json b/packages/artifact/package-lock.json index 4aa381e2..0933a20c 100644 --- a/packages/artifact/package-lock.json +++ b/packages/artifact/package-lock.json @@ -12,6 +12,7 @@ "@actions/core": "^1.10.0", "@actions/http-client": "^2.1.0", "@protobuf-ts/plugin": "^2.2.3-alpha.1", + "jwt-decode": "^3.1.2", "twirp-ts": "^2.5.0" }, "devDependencies": { @@ -198,6 +199,11 @@ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, + "node_modules/jwt-decode": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz", + "integrity": "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A==" + }, "node_modules/lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", diff --git a/packages/artifact/package.json b/packages/artifact/package.json index d74eddc7..9cd1594b 100644 --- a/packages/artifact/package.json +++ b/packages/artifact/package.json @@ -42,10 +42,11 @@ "@actions/core": "^1.10.0", "@actions/http-client": "^2.1.0", "@protobuf-ts/plugin": "^2.2.3-alpha.1", + "jwt-decode": "^3.1.2", "twirp-ts": "^2.5.0" }, "devDependencies": { "@types/tmp": "^0.2.1", "typescript": "^4.3.0" } -} \ No newline at end of file +} diff --git a/packages/artifact/src/internal/client.ts b/packages/artifact/src/internal/client.ts index bf33120f..f827d882 100644 --- a/packages/artifact/src/internal/client.ts +++ b/packages/artifact/src/internal/client.ts @@ -1,6 +1,8 @@ import {UploadOptions} from './upload/upload-options' import {UploadResponse} from './upload/upload-response' import {uploadArtifact} from './upload/upload-artifact' +import {warning} from '@actions/core' +import {isGhes} from './shared/config' export interface ArtifactClient { /** @@ -39,6 +41,28 @@ export class Client implements ArtifactClient { rootDirectory: string, options?: UploadOptions | undefined ): Promise { - return uploadArtifact(name, files, rootDirectory, options) + if (isGhes()) { + warning( + `@actions/artifact v2 and upload-artifact v4 are not currently supported on GHES.` + ) + return { + success: false + } + } + + try { + return uploadArtifact(name, files, rootDirectory, options) + } catch (error) { + warning( + `Artifact upload failed with error: ${error}. + +Errors can be temporary, so please try again and optionally run the action with debug enabled for more information. + +If the error persists, please check whether Actions is running normally at [https://githubstatus.com](https://www.githubstatus.com).` + ) + return { + success: false + } + } } } diff --git a/packages/artifact/src/internal/shared/artifact-twirp-client.ts b/packages/artifact/src/internal/shared/artifact-twirp-client.ts index d2450183..a77d02a4 100644 --- a/packages/artifact/src/internal/shared/artifact-twirp-client.ts +++ b/packages/artifact/src/internal/shared/artifact-twirp-client.ts @@ -56,8 +56,6 @@ class ArtifactHttpClient implements Rpc { const headers = { 'Content-Type': contentType } - info(`Making request to ${url} with data: ${JSON.stringify(data)}`) - try { const response = await this.retryableRequest(async () => this.httpClient.post(url, JSON.stringify(data), headers) @@ -65,7 +63,7 @@ class ArtifactHttpClient implements Rpc { const body = await response.readBody() return JSON.parse(body) } catch (error) { - throw new Error(error.message) + throw new Error(`Failed to ${method}: ${error.message}`) } } diff --git a/packages/artifact/src/internal/shared/config.ts b/packages/artifact/src/internal/shared/config.ts index 23949f20..d1946e96 100644 --- a/packages/artifact/src/internal/shared/config.ts +++ b/packages/artifact/src/internal/shared/config.ts @@ -13,3 +13,10 @@ export function getResultsServiceUrl(): string { } return resultsUrl } + +export function isGhes(): boolean { + const ghUrl = new URL( + process.env['GITHUB_SERVER_URL'] || 'https://github.com' + ) + return ghUrl.hostname.toUpperCase() !== 'GITHUB.COM' +} diff --git a/packages/artifact/src/internal/shared/util.ts b/packages/artifact/src/internal/shared/util.ts new file mode 100644 index 00000000..bc98abde --- /dev/null +++ b/packages/artifact/src/internal/shared/util.ts @@ -0,0 +1,65 @@ +import {getRuntimeToken} from './config' +import jwt_decode from 'jwt-decode' + +export interface BackendIds { + workflowRunBackendId: string + workflowJobRunBackendId: string +} + +interface ActionsToken { + scp: string +} + +const InvalidJwtError = new Error( + 'Failed to get backend IDs: The provided JWT token is invalid' +) + +// uses the JWT token claims to get the +// workflow run and workflow job run backend ids +export function getBackendIdsFromToken(): BackendIds { + const token = getRuntimeToken() + const decoded = jwt_decode(token) + if (!decoded.scp) { + throw InvalidJwtError + } + + /* + * example decoded: + * { + * scp: "Actions.ExampleScope Actions.Results:ce7f54c7-61c7-4aae-887f-30da475f5f1a:ca395085-040a-526b-2ce8-bdc85f692774" + * } + */ + + const scpParts = decoded.scp.split(' ') + if (scpParts.length === 0) { + throw InvalidJwtError + } + /* + * example scpParts: + * ["Actions.ExampleScope", "Actions.Results:ce7f54c7-61c7-4aae-887f-30da475f5f1a:ca395085-040a-526b-2ce8-bdc85f692774"] + */ + + for (const scopes of scpParts) { + const scopeParts = scopes.split(':') + /* + * example scopeParts: + * ["Actions.Results", "ce7f54c7-61c7-4aae-887f-30da475f5f1a", "ca395085-040a-526b-2ce8-bdc85f692774"] + */ + if (scopeParts.length !== 3) { + // not the Actions.Results scope + continue + } + + if (scopeParts[0] !== 'Actions.Results') { + // not the Actions.Results scope + continue + } + + return { + workflowRunBackendId: scopeParts[1], + workflowJobRunBackendId: scopeParts[2] + } + } + + throw InvalidJwtError +} diff --git a/packages/artifact/src/internal/upload/retention.ts b/packages/artifact/src/internal/upload/retention.ts new file mode 100644 index 00000000..aa16ba1b --- /dev/null +++ b/packages/artifact/src/internal/upload/retention.ts @@ -0,0 +1,34 @@ +import {Timestamp} from '../../generated' +import * as core from '@actions/core' + +export function getExpiration(retentionDays?: number): Timestamp | undefined { + if (!retentionDays) { + return undefined + } + + const maxRetentionDays = getRetentionDays() + if (maxRetentionDays && maxRetentionDays < retentionDays) { + core.warning( + `Retention days cannot be greater than the maximum allowed retention set within the repository. Using ${maxRetentionDays} instead.` + ) + retentionDays = maxRetentionDays + } + + const expirationDate = new Date() + expirationDate.setDate(expirationDate.getDate() + retentionDays) + + return Timestamp.fromDate(expirationDate) +} + +function getRetentionDays(): number | undefined { + const retentionDays = process.env['GITHUB_RETENTION_DAYS'] + if (!retentionDays) { + return undefined + } + const days = parseInt(retentionDays) + if (isNaN(days)) { + return undefined + } + + return days +} diff --git a/packages/artifact/src/internal/upload/upload-artifact.ts b/packages/artifact/src/internal/upload/upload-artifact.ts index 07835c99..bdef8f84 100644 --- a/packages/artifact/src/internal/upload/upload-artifact.ts +++ b/packages/artifact/src/internal/upload/upload-artifact.ts @@ -1,18 +1,22 @@ import * as core from '@actions/core' import {UploadOptions} from './upload-options' import {UploadResponse} from './upload-response' +import {getExpiration} from './retention' import {validateArtifactName} from './path-and-artifact-name-validation' +import {createArtifactTwirpClient} from '../shared/artifact-twirp-client' import { UploadZipSpecification, getUploadZipSpecification, validateRootDirectory } from './upload-zip-specification' +import {getBackendIdsFromToken} from '../shared/util' +import {CreateArtifactRequest} from 'src/generated' export async function uploadArtifact( name: string, files: string[], rootDirectory: string, - options?: UploadOptions | undefined // eslint-disable-line @typescript-eslint/no-unused-vars + options?: UploadOptions | undefined ): Promise { validateArtifactName(name) validateRootDirectory(rootDirectory) @@ -28,12 +32,66 @@ export async function uploadArtifact( } } + // get the IDs needed for the artifact creation + const backendIds = getBackendIdsFromToken() + if (!backendIds.workflowRunBackendId || !backendIds.workflowJobRunBackendId) { + core.warning(`Failed to get backend ids`) + return { + success: false + } + } + core.debug(`Workflow Run Backend ID: ${backendIds.workflowRunBackendId}`) + core.debug( + `Workflow Job Run Backend ID: ${backendIds.workflowJobRunBackendId}` + ) + + // create the artifact client + const artifactClient = createArtifactTwirpClient('upload') + + // create the artifact + const createArtifactReq: CreateArtifactRequest = { + workflowRunBackendId: backendIds.workflowRunBackendId, + workflowJobRunBackendId: backendIds.workflowJobRunBackendId, + name, + version: 4 + } + + // if there is a retention period, add it to the request + const expiresAt = getExpiration(options?.retentionDays) + if (expiresAt) { + createArtifactReq.expiresAt = expiresAt + } + + const createArtifactResp = await artifactClient.CreateArtifact( + createArtifactReq + ) + if (!createArtifactResp.ok) { + core.warning(`Failed to create artifact`) + return { + success: false + } + } + // TODO - Implement upload functionality + // finalize the artifact + const finalizeArtifactResp = await artifactClient.FinalizeArtifact({ + workflowRunBackendId: backendIds.workflowRunBackendId, + workflowJobRunBackendId: backendIds.workflowJobRunBackendId, + name, + size: '0' // TODO - Add size + }) + if (!finalizeArtifactResp.ok) { + core.warning(`Failed to finalize artifact`) + return { + success: false + } + } + const uploadResponse: UploadResponse = { success: true, size: 0, - id: 0 + id: parseInt(finalizeArtifactResp.artifactId) // TODO - will this be a problem due to the id being a bigint? } return uploadResponse