/** * Copyright 2023 actions-toolkit authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import crypto from 'crypto'; import fs from 'fs'; import jsyaml from 'js-yaml'; import os from 'os'; import path from 'path'; import {CreateArtifactRequest, FinalizeArtifactRequest, StringValue} from '@actions/artifact/lib/generated'; import {internalArtifactTwirpClient} from '@actions/artifact/lib/internal/shared/artifact-twirp-client'; import {getBackendIdsFromToken} from '@actions/artifact/lib/internal/shared/util'; import {getExpiration} from '@actions/artifact/lib/internal/upload/retention'; import {InvalidResponseError, NetworkError} from '@actions/artifact'; import * as core from '@actions/core'; import {SummaryTableCell} from '@actions/core/lib/summary'; import * as github from '@actions/github'; import {GitHub as Octokit} from '@actions/github/lib/utils'; import {Context} from '@actions/github/lib/context'; import {TransferProgressEvent} from '@azure/core-http'; import {BlobClient, BlobHTTPHeaders} from '@azure/storage-blob'; import {jwtDecode, JwtPayload} from 'jwt-decode'; import {Util} from './util'; import {BuildSummaryOpts, GitHubActionsRuntimeToken, GitHubActionsRuntimeTokenAC, GitHubRepo, UploadArtifactOpts, UploadArtifactResponse} from './types/github'; export interface GitHubOpts { token?: string; } export class GitHub { public readonly octokit: InstanceType; constructor(opts?: GitHubOpts) { this.octokit = github.getOctokit(`${opts?.token}`); } public repoData(): Promise { return this.octokit.rest.repos.get({...github.context.repo}).then(response => response.data as GitHubRepo); } static get context(): Context { return github.context; } static get serverURL(): string { return process.env.GITHUB_SERVER_URL || 'https://github.com'; } static get apiURL(): string { return process.env.GITHUB_API_URL || 'https://api.github.com'; } static get workflowRunURL(): string { const runID = process.env.GITHUB_RUN_ID || github.context.runId; const runAttempt = process.env.GITHUB_RUN_ATTEMPT || 1; return `${GitHub.serverURL}/${github.context.repo.owner}/${github.context.repo.repo}/actions/runs/${runID}/attempts/${runAttempt}`; } static get actionsRuntimeToken(): GitHubActionsRuntimeToken | undefined { const token = process.env['ACTIONS_RUNTIME_TOKEN'] || ''; return token ? (jwtDecode(token) as GitHubActionsRuntimeToken) : undefined; } public static async printActionsRuntimeTokenACs() { let jwt: GitHubActionsRuntimeToken | undefined; try { jwt = GitHub.actionsRuntimeToken; } catch (e) { throw new Error(`Cannot parse GitHub Actions Runtime Token: ${e.message}`); } if (!jwt) { throw new Error(`ACTIONS_RUNTIME_TOKEN not set`); } try { >JSON.parse(`${jwt.ac}`).forEach(ac => { let permission: string; switch (ac.Permission) { case 1: permission = 'read'; break; case 2: permission = 'write'; break; case 3: permission = 'read/write'; break; default: permission = `unimplemented (${ac.Permission})`; } core.info(`${ac.Scope}: ${permission}`); }); } catch (e) { throw new Error(`Cannot parse GitHub Actions Runtime Token ACs: ${e.message}`); } } public static async uploadArtifact(opts: UploadArtifactOpts): Promise { const artifactName = path.basename(opts.filename); const backendIds = getBackendIdsFromToken(); const artifactClient = internalArtifactTwirpClient(); core.info(`Uploading ${artifactName} to blob storage`); const createArtifactReq: CreateArtifactRequest = { workflowRunBackendId: backendIds.workflowRunBackendId, workflowJobRunBackendId: backendIds.workflowJobRunBackendId, name: artifactName, version: 4 }; const expiresAt = getExpiration(opts?.retentionDays); if (expiresAt) { createArtifactReq.expiresAt = expiresAt; } const createArtifactResp = await artifactClient.CreateArtifact(createArtifactReq); if (!createArtifactResp.ok) { throw new InvalidResponseError('cannot create artifact client'); } let uploadByteCount = 0; const blobClient = new BlobClient(createArtifactResp.signedUploadUrl); const blockBlobClient = blobClient.getBlockBlobClient(); const headers: BlobHTTPHeaders = { blobContentDisposition: `attachment; filename="${artifactName}"` }; if (opts.mimeType) { headers.blobContentType = opts.mimeType; } core.debug(`Upload headers: ${JSON.stringify(headers)}`); try { core.info('Beginning upload of artifact content to blob storage'); await blockBlobClient.uploadFile(opts.filename, { blobHTTPHeaders: headers, onProgress: (progress: TransferProgressEvent): void => { core.info(`Uploaded bytes ${progress.loadedBytes}`); uploadByteCount = progress.loadedBytes; } }); } catch (error) { if (NetworkError.isNetworkErrorCode(error?.code)) { throw new NetworkError(error?.code); } throw error; } core.info('Finished uploading artifact content to blob storage!'); const sha256Hash = crypto.createHash('sha256').update(fs.readFileSync(opts.filename)).digest('hex'); core.info(`SHA256 hash of uploaded artifact is ${sha256Hash}`); const finalizeArtifactReq: FinalizeArtifactRequest = { workflowRunBackendId: backendIds.workflowRunBackendId, workflowJobRunBackendId: backendIds.workflowJobRunBackendId, name: artifactName, size: uploadByteCount ? uploadByteCount.toString() : '0' }; if (sha256Hash) { finalizeArtifactReq.hash = StringValue.create({ value: `sha256:${sha256Hash}` }); } core.info(`Finalizing artifact upload`); const finalizeArtifactResp = await artifactClient.FinalizeArtifact(finalizeArtifactReq); if (!finalizeArtifactResp.ok) { throw new InvalidResponseError('Cannot finalize artifact upload'); } const artifactId = BigInt(finalizeArtifactResp.artifactId); core.info(`Artifact successfully finalized (${artifactId})`); const artifactURL = `${GitHub.workflowRunURL}/artifacts/${artifactId}`; core.info(`Artifact download URL: ${artifactURL}`); return { id: Number(artifactId), filename: artifactName, size: uploadByteCount, url: artifactURL }; } public static async writeBuildSummary(opts: BuildSummaryOpts): Promise { // can't use original core.summary.addLink due to the need to make // EOL optional const addLink = function (text: string, url: string, addEOL = false): string { return `${text}` + (addEOL ? os.EOL : ''); }; const refsSize = Object.keys(opts.exportRes.refs).length; // prettier-ignore const sum = core.summary .addHeading('Docker Build summary', 1) .addRaw(`

`) .addRaw(`For a detailed look at the build, download the following build record archive and import it into Docker Desktop's Builds view. `) .addBreak() .addRaw(`Build records include details such as timing, dependencies, results, logs, traces, and other information about a build. `) .addRaw(addLink('Learn more', 'https://docs.docker.com/go/build-summary/')) .addRaw('

') .addRaw(`

`) .addRaw(`:arrow_down: ${addLink(`${opts.uploadRes.filename}`, opts.uploadRes.url)} (${Util.formatFileSize(opts.uploadRes.size)})`) .addBreak() .addRaw(`This file includes ${refsSize} build record${refsSize > 1 ? 's' : ''}.`) .addRaw(`

`) .addRaw(`

`) .addRaw(`Find this useful? `) .addRaw(addLink('Let us know', 'https://docs.docker.com/feedback/gha-build-summary')) .addRaw('

'); sum.addHeading('Preview', 2); const summaryTableData: Array> = [ [ {header: true, data: 'ID'}, {header: true, data: 'Name'}, {header: true, data: 'Status'}, {header: true, data: 'Cached'}, {header: true, data: 'Duration'} ] ]; let summaryError: string | undefined; for (const ref in opts.exportRes.summaries) { if (Object.prototype.hasOwnProperty.call(opts.exportRes.summaries, ref)) { const summary = opts.exportRes.summaries[ref]; // prettier-ignore summaryTableData.push([ {data: `${ref.substring(0, 6).toUpperCase()}`}, {data: `${summary.name}`}, {data: `${summary.status === 'completed' ? ':white_check_mark:' : summary.status === 'canceled' ? ':no_entry_sign:' : ':x:'} ${summary.status}`}, {data: `${summary.numCachedSteps > 0 ? Math.round((summary.numCachedSteps / summary.numTotalSteps) * 100) : 0}%`}, {data: summary.duration} ]); if (summary.error) { summaryError = summary.error; } } } sum.addTable([...summaryTableData]); if (summaryError) { sum.addHeading('Error', 4); sum.addCodeBlock(summaryError, 'text'); } if (opts.inputs) { sum.addHeading('Build inputs', 2).addCodeBlock( jsyaml.dump(opts.inputs, { indent: 2, lineWidth: -1 }), 'yaml' ); } if (opts.bakeDefinition) { sum.addHeading('Bake definition', 2).addCodeBlock(JSON.stringify(opts.bakeDefinition, null, 2), 'json'); } core.info(`Writing summary`); await sum.addSeparator().write(); } }