1
0
Fork 0

Merge pull request #1487 from actions/bethanyj28/add-artifact-api-logic

Utilize client to create and finalize artifact
pull/1488/head
Bethany 2023-08-10 10:19:24 -04:00 committed by GitHub
commit ab78839e86
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 327 additions and 7 deletions

View File

@ -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)
}
})
})

View File

@ -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'
)
})
})

View File

@ -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",

View File

@ -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"
}
}
}

View File

@ -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<UploadResponse> {
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
}
}
}
}

View File

@ -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}`)
}
}

View File

@ -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'
}

View File

@ -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<ActionsToken>(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
}

View File

@ -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
}

View File

@ -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<UploadResponse> {
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