diff --git a/__tests__/buildx/history.test.itg.ts b/__tests__/buildx/history.test.itg.ts index a23e043..009056b 100644 --- a/__tests__/buildx/history.test.itg.ts +++ b/__tests__/buildx/history.test.itg.ts @@ -86,6 +86,7 @@ maybe('exportBuild', () => { expect(exportRes?.dockerbuildFilename).toBeDefined(); expect(exportRes?.dockerbuildSize).toBeDefined(); expect(fs.existsSync(exportRes?.dockerbuildFilename)).toBe(true); + expect(exportRes?.summaries).toBeDefined(); }); // prettier-ignore @@ -147,5 +148,6 @@ maybe('exportBuild', () => { expect(exportRes?.dockerbuildFilename).toBeDefined(); expect(exportRes?.dockerbuildSize).toBeDefined(); expect(fs.existsSync(exportRes?.dockerbuildFilename)).toBe(true); + expect(exportRes?.summaries).toBeDefined(); }); }); diff --git a/__tests__/github.test.itg.ts b/__tests__/github.test.itg.ts index b133ec2..852e0a6 100644 --- a/__tests__/github.test.itg.ts +++ b/__tests__/github.test.itg.ts @@ -14,13 +14,22 @@ * limitations under the License. */ -import {beforeEach, describe, expect, it, jest} from '@jest/globals'; +import {beforeEach, describe, expect, it, jest, test} from '@jest/globals'; +import fs from 'fs'; import * as path from 'path'; +import {Buildx} from '../src/buildx/buildx'; +import {Bake} from '../src/buildx/bake'; +import {Build} from '../src/buildx/build'; +import {Exec} from '../src/exec'; import {GitHub} from '../src/github'; +import {History} from '../src/buildx/history'; const fixturesDir = path.join(__dirname, 'fixtures'); +// prettier-ignore +const tmpDir = path.join(process.env.TEMP || '/tmp', 'github-jest'); + const maybe = !process.env.GITHUB_ACTIONS || (process.env.GITHUB_ACTIONS === 'true' && process.env.ImageOS && process.env.ImageOS.startsWith('ubuntu')) ? describe : describe.skip; beforeEach(() => { @@ -39,3 +48,149 @@ maybe('uploadArtifact', () => { expect(res?.url).toBeDefined(); }); }); + +maybe('writeBuildSummary', () => { + // prettier-ignore + test.each([ + [ + "single", + [ + 'build', + '-f', path.join(fixturesDir, 'hello.Dockerfile'), + fixturesDir + ], + ], + [ + "multiplatform", + [ + 'build', + '-f', path.join(fixturesDir, 'hello.Dockerfile'), + '--platform', 'linux/amd64,linux/arm64', + fixturesDir + ], + ] + ])('write build summary %p', async (_, bargs) => { + const buildx = new Buildx(); + const build = new Build({buildx: buildx}); + + fs.mkdirSync(tmpDir, {recursive: true}); + await expect( + (async () => { + // prettier-ignore + const buildCmd = await buildx.getCommand([ + '--builder', process.env.CTN_BUILDER_NAME ?? 'default', + ...bargs, + '--metadata-file', build.getMetadataFilePath() + ]); + await Exec.exec(buildCmd.command, buildCmd.args); + })() + ).resolves.not.toThrow(); + + const metadata = build.resolveMetadata(); + expect(metadata).toBeDefined(); + const buildRef = build.resolveRef(metadata); + expect(buildRef).toBeDefined(); + + const history = new History({buildx: buildx}); + const exportRes = await history.export({ + refs: [buildRef ?? ''] + }); + expect(exportRes).toBeDefined(); + expect(exportRes?.dockerbuildFilename).toBeDefined(); + expect(exportRes?.dockerbuildSize).toBeDefined(); + expect(exportRes?.summaries).toBeDefined(); + + const uploadRes = await GitHub.uploadArtifact({ + filename: exportRes?.dockerbuildFilename, + mimeType: 'application/gzip', + retentionDays: 1 + }); + expect(uploadRes).toBeDefined(); + expect(uploadRes?.url).toBeDefined(); + + await GitHub.writeBuildSummary({ + exportRes: exportRes, + uploadRes: uploadRes, + inputs: { + context: fixturesDir, + file: path.join(fixturesDir, 'hello.Dockerfile') + } + }); + }); + + // prettier-ignore + test.each([ + [ + 'single', + [ + 'bake', + '-f', path.join(fixturesDir, 'hello-bake.hcl'), + 'hello' + ], + ], + [ + 'group', + [ + 'bake', + '-f', path.join(fixturesDir, 'hello-bake.hcl'), + 'hello-all' + ], + ], + [ + 'matrix', + [ + 'bake', + '-f', path.join(fixturesDir, 'hello-bake.hcl'), + 'hello-matrix' + ], + ] + ])('write bake summary %p', async (_, bargs) => { + const buildx = new Buildx(); + const bake = new Bake({buildx: buildx}); + + fs.mkdirSync(tmpDir, {recursive: true}); + await expect( + (async () => { + // prettier-ignore + const buildCmd = await buildx.getCommand([ + '--builder', process.env.CTN_BUILDER_NAME ?? 'default', + ...bargs, + '--metadata-file', bake.getMetadataFilePath() + ]); + await Exec.exec(buildCmd.command, buildCmd.args, { + cwd: fixturesDir + }); + })() + ).resolves.not.toThrow(); + + const metadata = bake.resolveMetadata(); + expect(metadata).toBeDefined(); + const buildRefs = bake.resolveRefs(metadata); + expect(buildRefs).toBeDefined(); + + const history = new History({buildx: buildx}); + const exportRes = await history.export({ + refs: buildRefs ?? [] + }); + expect(exportRes).toBeDefined(); + expect(exportRes?.dockerbuildFilename).toBeDefined(); + expect(exportRes?.dockerbuildSize).toBeDefined(); + expect(exportRes?.summaries).toBeDefined(); + + const uploadRes = await GitHub.uploadArtifact({ + filename: exportRes?.dockerbuildFilename, + mimeType: 'application/gzip', + retentionDays: 1 + }); + expect(uploadRes).toBeDefined(); + expect(uploadRes?.url).toBeDefined(); + + await GitHub.writeBuildSummary({ + exportRes: exportRes, + uploadRes: uploadRes, + inputs: { + files: path.join(fixturesDir, 'hello-bake.hcl') + } + }); + }); +}); diff --git a/package.json b/package.json index f4733d0..b2c614c 100644 --- a/package.json +++ b/package.json @@ -59,12 +59,14 @@ "async-retry": "^1.3.3", "csv-parse": "^5.5.6", "handlebars": "^4.7.8", + "js-yaml": "^4.1.0", "jwt-decode": "^4.0.0", "semver": "^7.6.2", "tmp": "^0.2.3" }, "devDependencies": { "@types/csv-parse": "^1.2.2", + "@types/js-yaml": "^4.0.9", "@types/node": "^20.12.10", "@types/semver": "^7.5.8", "@types/tmp": "^0.2.6", diff --git a/src/buildx/history.ts b/src/buildx/history.ts index 45299b4..435b003 100644 --- a/src/buildx/history.ts +++ b/src/buildx/history.ts @@ -27,7 +27,7 @@ import {Docker} from '../docker/docker'; import {Exec} from '../exec'; import {GitHub} from '../github'; -import {ExportRecordOpts, ExportRecordResponse} from '../types/history'; +import {ExportRecordOpts, ExportRecordResponse, Summaries} from '../types/history'; export interface HistoryOpts { buildx?: Buildx; @@ -95,6 +95,7 @@ export class History { buildxDialStdioProc.stdout.pipe(fs.createWriteStream(buildxOutFifoPath)); const tmpDockerbuildFilename = path.join(outDir, 'rec.dockerbuild'); + const summaryFilename = path.join(outDir, 'summary.json'); await new Promise((resolve, reject) => { const ebargs: Array = ['--ref-state-dir=/buildx-refs', `--node=${builderName}/${nodeName}`]; @@ -145,9 +146,14 @@ export class History { fs.renameSync(tmpDockerbuildFilename, dockerbuildPath); const dockerbuildStats = fs.statSync(dockerbuildPath); + core.info(`Parsing ${summaryFilename}`); + fs.statSync(summaryFilename); + const summaries = JSON.parse(fs.readFileSync(summaryFilename, {encoding: 'utf-8'})); + return { dockerbuildFilename: dockerbuildPath, dockerbuildSize: dockerbuildStats.size, + summaries: summaries, builderName: builderName, nodeName: nodeName, refs: refs diff --git a/src/github.ts b/src/github.ts index 2e6f745..ae107bf 100644 --- a/src/github.ts +++ b/src/github.ts @@ -16,6 +16,8 @@ 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'; @@ -23,6 +25,7 @@ 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'; @@ -30,7 +33,9 @@ import {TransferProgressEvent} from '@azure/core-http'; import {BlobClient, BlobHTTPHeaders} from '@azure/storage-blob'; import {jwtDecode, JwtPayload} from 'jwt-decode'; -import {GitHubActionsRuntimeToken, GitHubActionsRuntimeTokenAC, GitHubRepo, UploadArtifactOpts, UploadArtifactResponse} from './types/github'; +import {Util} from './util'; + +import {BuildSummaryOpts, GitHubActionsRuntimeToken, GitHubActionsRuntimeTokenAC, GitHubRepo, UploadArtifactOpts, UploadArtifactResponse} from './types/github'; export interface GitHubOpts { token?: string; @@ -190,4 +195,84 @@ export class GitHub { 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(); + } } diff --git a/src/types/github.ts b/src/types/github.ts index 0508cf1..81ad475 100644 --- a/src/types/github.ts +++ b/src/types/github.ts @@ -17,6 +17,9 @@ import {components as OctoOpenApiTypes} from '@octokit/openapi-types'; import {JwtPayload} from 'jwt-decode'; +import {BakeDefinition} from './bake'; +import {ExportRecordResponse} from './history'; + export interface GitHubRelease { id: number; tag_name: string; @@ -47,3 +50,11 @@ export interface UploadArtifactResponse { size: number; url: string; } + +export interface BuildSummaryOpts { + exportRes: ExportRecordResponse; + uploadRes: UploadArtifactResponse; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + inputs?: any; + bakeDefinition?: BakeDefinition; +} diff --git a/src/types/history.ts b/src/types/history.ts index 23097b5..67fbe68 100644 --- a/src/types/history.ts +++ b/src/types/history.ts @@ -22,7 +22,23 @@ export interface ExportRecordOpts { export interface ExportRecordResponse { dockerbuildFilename: string; dockerbuildSize: number; + summaries: Summaries; builderName: string; nodeName: string; refs: Array; } + +export interface Summaries { + [ref: string]: RecordSummary; +} + +export interface RecordSummary { + name: string; + status: string; + duration: string; + numCachedSteps: number; + numTotalSteps: number; + numCompletedSteps: number; + frontendAttrs: Record; + error?: string; +} diff --git a/yarn.lock b/yarn.lock index 4f4bcbb..4d5a14a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1111,6 +1111,7 @@ __metadata: "@octokit/core": ^5.1.0 "@octokit/plugin-rest-endpoint-methods": ^10.4.0 "@types/csv-parse": ^1.2.2 + "@types/js-yaml": ^4.0.9 "@types/node": ^20.12.10 "@types/semver": ^7.5.8 "@types/tmp": ^0.2.6 @@ -1126,6 +1127,7 @@ __metadata: eslint-plugin-prettier: ^5.1.3 handlebars: ^4.7.8 jest: ^29.7.0 + js-yaml: ^4.1.0 jwt-decode: ^4.0.0 prettier: ^3.2.5 rimraf: ^5.0.5 @@ -2185,6 +2187,13 @@ __metadata: languageName: node linkType: hard +"@types/js-yaml@npm:^4.0.9": + version: 4.0.9 + resolution: "@types/js-yaml@npm:4.0.9" + checksum: e5e5e49b5789a29fdb1f7d204f82de11cb9e8f6cb24ab064c616da5d6e1b3ccfbf95aa5d1498a9fbd3b9e745564e69b4a20b6c530b5a8bbb2d4eb830cda9bc69 + languageName: node + linkType: hard + "@types/json-schema@npm:^7.0.15": version: 7.0.15 resolution: "@types/json-schema@npm:7.0.15"