Skip to content

Commit 0903e49

Browse files
authored
Merge pull request #324 from crazy-max/build-summary
github: write build summary
2 parents 3a2e4a8 + 1e903f8 commit 0903e49

File tree

8 files changed

+289
-3
lines changed

8 files changed

+289
-3
lines changed

__tests__/buildx/history.test.itg.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ maybe('exportBuild', () => {
8686
expect(exportRes?.dockerbuildFilename).toBeDefined();
8787
expect(exportRes?.dockerbuildSize).toBeDefined();
8888
expect(fs.existsSync(exportRes?.dockerbuildFilename)).toBe(true);
89+
expect(exportRes?.summaries).toBeDefined();
8990
});
9091

9192
// prettier-ignore
@@ -147,5 +148,6 @@ maybe('exportBuild', () => {
147148
expect(exportRes?.dockerbuildFilename).toBeDefined();
148149
expect(exportRes?.dockerbuildSize).toBeDefined();
149150
expect(fs.existsSync(exportRes?.dockerbuildFilename)).toBe(true);
151+
expect(exportRes?.summaries).toBeDefined();
150152
});
151153
});

__tests__/github.test.itg.ts

Lines changed: 156 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,22 @@
1414
* limitations under the License.
1515
*/
1616

17-
import {beforeEach, describe, expect, it, jest} from '@jest/globals';
17+
import {beforeEach, describe, expect, it, jest, test} from '@jest/globals';
18+
import fs from 'fs';
1819
import * as path from 'path';
1920

21+
import {Buildx} from '../src/buildx/buildx';
22+
import {Bake} from '../src/buildx/bake';
23+
import {Build} from '../src/buildx/build';
24+
import {Exec} from '../src/exec';
2025
import {GitHub} from '../src/github';
26+
import {History} from '../src/buildx/history';
2127

2228
const fixturesDir = path.join(__dirname, 'fixtures');
2329

30+
// prettier-ignore
31+
const tmpDir = path.join(process.env.TEMP || '/tmp', 'github-jest');
32+
2433
const maybe = !process.env.GITHUB_ACTIONS || (process.env.GITHUB_ACTIONS === 'true' && process.env.ImageOS && process.env.ImageOS.startsWith('ubuntu')) ? describe : describe.skip;
2534

2635
beforeEach(() => {
@@ -39,3 +48,149 @@ maybe('uploadArtifact', () => {
3948
expect(res?.url).toBeDefined();
4049
});
4150
});
51+
52+
maybe('writeBuildSummary', () => {
53+
// prettier-ignore
54+
test.each([
55+
[
56+
"single",
57+
[
58+
'build',
59+
'-f', path.join(fixturesDir, 'hello.Dockerfile'),
60+
fixturesDir
61+
],
62+
],
63+
[
64+
"multiplatform",
65+
[
66+
'build',
67+
'-f', path.join(fixturesDir, 'hello.Dockerfile'),
68+
'--platform', 'linux/amd64,linux/arm64',
69+
fixturesDir
70+
],
71+
]
72+
])('write build summary %p', async (_, bargs) => {
73+
const buildx = new Buildx();
74+
const build = new Build({buildx: buildx});
75+
76+
fs.mkdirSync(tmpDir, {recursive: true});
77+
await expect(
78+
(async () => {
79+
// prettier-ignore
80+
const buildCmd = await buildx.getCommand([
81+
'--builder', process.env.CTN_BUILDER_NAME ?? 'default',
82+
...bargs,
83+
'--metadata-file', build.getMetadataFilePath()
84+
]);
85+
await Exec.exec(buildCmd.command, buildCmd.args);
86+
})()
87+
).resolves.not.toThrow();
88+
89+
const metadata = build.resolveMetadata();
90+
expect(metadata).toBeDefined();
91+
const buildRef = build.resolveRef(metadata);
92+
expect(buildRef).toBeDefined();
93+
94+
const history = new History({buildx: buildx});
95+
const exportRes = await history.export({
96+
refs: [buildRef ?? '']
97+
});
98+
expect(exportRes).toBeDefined();
99+
expect(exportRes?.dockerbuildFilename).toBeDefined();
100+
expect(exportRes?.dockerbuildSize).toBeDefined();
101+
expect(exportRes?.summaries).toBeDefined();
102+
103+
const uploadRes = await GitHub.uploadArtifact({
104+
filename: exportRes?.dockerbuildFilename,
105+
mimeType: 'application/gzip',
106+
retentionDays: 1
107+
});
108+
expect(uploadRes).toBeDefined();
109+
expect(uploadRes?.url).toBeDefined();
110+
111+
await GitHub.writeBuildSummary({
112+
exportRes: exportRes,
113+
uploadRes: uploadRes,
114+
inputs: {
115+
context: fixturesDir,
116+
file: path.join(fixturesDir, 'hello.Dockerfile')
117+
}
118+
});
119+
});
120+
121+
// prettier-ignore
122+
test.each([
123+
[
124+
'single',
125+
[
126+
'bake',
127+
'-f', path.join(fixturesDir, 'hello-bake.hcl'),
128+
'hello'
129+
],
130+
],
131+
[
132+
'group',
133+
[
134+
'bake',
135+
'-f', path.join(fixturesDir, 'hello-bake.hcl'),
136+
'hello-all'
137+
],
138+
],
139+
[
140+
'matrix',
141+
[
142+
'bake',
143+
'-f', path.join(fixturesDir, 'hello-bake.hcl'),
144+
'hello-matrix'
145+
],
146+
]
147+
])('write bake summary %p', async (_, bargs) => {
148+
const buildx = new Buildx();
149+
const bake = new Bake({buildx: buildx});
150+
151+
fs.mkdirSync(tmpDir, {recursive: true});
152+
await expect(
153+
(async () => {
154+
// prettier-ignore
155+
const buildCmd = await buildx.getCommand([
156+
'--builder', process.env.CTN_BUILDER_NAME ?? 'default',
157+
...bargs,
158+
'--metadata-file', bake.getMetadataFilePath()
159+
]);
160+
await Exec.exec(buildCmd.command, buildCmd.args, {
161+
cwd: fixturesDir
162+
});
163+
})()
164+
).resolves.not.toThrow();
165+
166+
const metadata = bake.resolveMetadata();
167+
expect(metadata).toBeDefined();
168+
const buildRefs = bake.resolveRefs(metadata);
169+
expect(buildRefs).toBeDefined();
170+
171+
const history = new History({buildx: buildx});
172+
const exportRes = await history.export({
173+
refs: buildRefs ?? []
174+
});
175+
expect(exportRes).toBeDefined();
176+
expect(exportRes?.dockerbuildFilename).toBeDefined();
177+
expect(exportRes?.dockerbuildSize).toBeDefined();
178+
expect(exportRes?.summaries).toBeDefined();
179+
180+
const uploadRes = await GitHub.uploadArtifact({
181+
filename: exportRes?.dockerbuildFilename,
182+
mimeType: 'application/gzip',
183+
retentionDays: 1
184+
});
185+
expect(uploadRes).toBeDefined();
186+
expect(uploadRes?.url).toBeDefined();
187+
188+
await GitHub.writeBuildSummary({
189+
exportRes: exportRes,
190+
uploadRes: uploadRes,
191+
inputs: {
192+
files: path.join(fixturesDir, 'hello-bake.hcl')
193+
}
194+
});
195+
});
196+
});

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,12 +59,14 @@
5959
"async-retry": "^1.3.3",
6060
"csv-parse": "^5.5.6",
6161
"handlebars": "^4.7.8",
62+
"js-yaml": "^4.1.0",
6263
"jwt-decode": "^4.0.0",
6364
"semver": "^7.6.2",
6465
"tmp": "^0.2.3"
6566
},
6667
"devDependencies": {
6768
"@types/csv-parse": "^1.2.2",
69+
"@types/js-yaml": "^4.0.9",
6870
"@types/node": "^20.12.10",
6971
"@types/semver": "^7.5.8",
7072
"@types/tmp": "^0.2.6",

src/buildx/history.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import {Docker} from '../docker/docker';
2727
import {Exec} from '../exec';
2828
import {GitHub} from '../github';
2929

30-
import {ExportRecordOpts, ExportRecordResponse} from '../types/history';
30+
import {ExportRecordOpts, ExportRecordResponse, Summaries} from '../types/history';
3131

3232
export interface HistoryOpts {
3333
buildx?: Buildx;
@@ -95,6 +95,7 @@ export class History {
9595
buildxDialStdioProc.stdout.pipe(fs.createWriteStream(buildxOutFifoPath));
9696

9797
const tmpDockerbuildFilename = path.join(outDir, 'rec.dockerbuild');
98+
const summaryFilename = path.join(outDir, 'summary.json');
9899

99100
await new Promise<void>((resolve, reject) => {
100101
const ebargs: Array<string> = ['--ref-state-dir=/buildx-refs', `--node=${builderName}/${nodeName}`];
@@ -145,9 +146,14 @@ export class History {
145146
fs.renameSync(tmpDockerbuildFilename, dockerbuildPath);
146147
const dockerbuildStats = fs.statSync(dockerbuildPath);
147148

149+
core.info(`Parsing ${summaryFilename}`);
150+
fs.statSync(summaryFilename);
151+
const summaries = <Summaries>JSON.parse(fs.readFileSync(summaryFilename, {encoding: 'utf-8'}));
152+
148153
return {
149154
dockerbuildFilename: dockerbuildPath,
150155
dockerbuildSize: dockerbuildStats.size,
156+
summaries: summaries,
151157
builderName: builderName,
152158
nodeName: nodeName,
153159
refs: refs

src/github.ts

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,21 +16,26 @@
1616

1717
import crypto from 'crypto';
1818
import fs from 'fs';
19+
import jsyaml from 'js-yaml';
20+
import os from 'os';
1921
import path from 'path';
2022
import {CreateArtifactRequest, FinalizeArtifactRequest, StringValue} from '@actions/artifact/lib/generated';
2123
import {internalArtifactTwirpClient} from '@actions/artifact/lib/internal/shared/artifact-twirp-client';
2224
import {getBackendIdsFromToken} from '@actions/artifact/lib/internal/shared/util';
2325
import {getExpiration} from '@actions/artifact/lib/internal/upload/retention';
2426
import {InvalidResponseError, NetworkError} from '@actions/artifact';
2527
import * as core from '@actions/core';
28+
import {SummaryTableCell} from '@actions/core/lib/summary';
2629
import * as github from '@actions/github';
2730
import {GitHub as Octokit} from '@actions/github/lib/utils';
2831
import {Context} from '@actions/github/lib/context';
2932
import {TransferProgressEvent} from '@azure/core-http';
3033
import {BlobClient, BlobHTTPHeaders} from '@azure/storage-blob';
3134
import {jwtDecode, JwtPayload} from 'jwt-decode';
3235

33-
import {GitHubActionsRuntimeToken, GitHubActionsRuntimeTokenAC, GitHubRepo, UploadArtifactOpts, UploadArtifactResponse} from './types/github';
36+
import {Util} from './util';
37+
38+
import {BuildSummaryOpts, GitHubActionsRuntimeToken, GitHubActionsRuntimeTokenAC, GitHubRepo, UploadArtifactOpts, UploadArtifactResponse} from './types/github';
3439

3540
export interface GitHubOpts {
3641
token?: string;
@@ -190,4 +195,84 @@ export class GitHub {
190195
url: artifactURL
191196
};
192197
}
198+
199+
public static async writeBuildSummary(opts: BuildSummaryOpts): Promise<void> {
200+
// can't use original core.summary.addLink due to the need to make
201+
// EOL optional
202+
const addLink = function (text: string, url: string, addEOL = false): string {
203+
return `<a href="${url}">${text}</a>` + (addEOL ? os.EOL : '');
204+
};
205+
206+
const refsSize = Object.keys(opts.exportRes.refs).length;
207+
208+
// prettier-ignore
209+
const sum = core.summary
210+
.addHeading('Docker Build summary', 1)
211+
.addRaw(`<p>`)
212+
.addRaw(`For a detailed look at the build, download the following build record archive and import it into Docker Desktop's Builds view. `)
213+
.addBreak()
214+
.addRaw(`Build records include details such as timing, dependencies, results, logs, traces, and other information about a build. `)
215+
.addRaw(addLink('Learn more', 'https://docs.docker.com/go/build-summary/'))
216+
.addRaw('</p>')
217+
.addRaw(`<p>`)
218+
.addRaw(`:arrow_down: ${addLink(`<strong>${opts.uploadRes.filename}</strong>`, opts.uploadRes.url)} (${Util.formatFileSize(opts.uploadRes.size)})`)
219+
.addBreak()
220+
.addRaw(`This file includes <strong>${refsSize} build record${refsSize > 1 ? 's' : ''}</strong>.`)
221+
.addRaw(`</p>`)
222+
.addRaw(`<p>`)
223+
.addRaw(`Find this useful? `)
224+
.addRaw(addLink('Let us know', 'https://docs.docker.com/feedback/gha-build-summary'))
225+
.addRaw('</p>');
226+
227+
sum.addHeading('Preview', 2);
228+
229+
const summaryTableData: Array<Array<SummaryTableCell>> = [
230+
[
231+
{header: true, data: 'ID'},
232+
{header: true, data: 'Name'},
233+
{header: true, data: 'Status'},
234+
{header: true, data: 'Cached'},
235+
{header: true, data: 'Duration'}
236+
]
237+
];
238+
let summaryError: string | undefined;
239+
for (const ref in opts.exportRes.summaries) {
240+
if (Object.prototype.hasOwnProperty.call(opts.exportRes.summaries, ref)) {
241+
const summary = opts.exportRes.summaries[ref];
242+
// prettier-ignore
243+
summaryTableData.push([
244+
{data: `<code>${ref.substring(0, 6).toUpperCase()}</code>`},
245+
{data: `<strong>${summary.name}</strong>`},
246+
{data: `${summary.status === 'completed' ? ':white_check_mark:' : summary.status === 'canceled' ? ':no_entry_sign:' : ':x:'} ${summary.status}`},
247+
{data: `${summary.numCachedSteps > 0 ? Math.round((summary.numCachedSteps / summary.numTotalSteps) * 100) : 0}%`},
248+
{data: summary.duration}
249+
]);
250+
if (summary.error) {
251+
summaryError = summary.error;
252+
}
253+
}
254+
}
255+
sum.addTable([...summaryTableData]);
256+
if (summaryError) {
257+
sum.addHeading('Error', 4);
258+
sum.addCodeBlock(summaryError, 'text');
259+
}
260+
261+
if (opts.inputs) {
262+
sum.addHeading('Build inputs', 2).addCodeBlock(
263+
jsyaml.dump(opts.inputs, {
264+
indent: 2,
265+
lineWidth: -1
266+
}),
267+
'yaml'
268+
);
269+
}
270+
271+
if (opts.bakeDefinition) {
272+
sum.addHeading('Bake definition', 2).addCodeBlock(JSON.stringify(opts.bakeDefinition, null, 2), 'json');
273+
}
274+
275+
core.info(`Writing summary`);
276+
await sum.addSeparator().write();
277+
}
193278
}

src/types/github.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@
1717
import {components as OctoOpenApiTypes} from '@octokit/openapi-types';
1818
import {JwtPayload} from 'jwt-decode';
1919

20+
import {BakeDefinition} from './bake';
21+
import {ExportRecordResponse} from './history';
22+
2023
export interface GitHubRelease {
2124
id: number;
2225
tag_name: string;
@@ -47,3 +50,11 @@ export interface UploadArtifactResponse {
4750
size: number;
4851
url: string;
4952
}
53+
54+
export interface BuildSummaryOpts {
55+
exportRes: ExportRecordResponse;
56+
uploadRes: UploadArtifactResponse;
57+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
58+
inputs?: any;
59+
bakeDefinition?: BakeDefinition;
60+
}

src/types/history.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,23 @@ export interface ExportRecordOpts {
2222
export interface ExportRecordResponse {
2323
dockerbuildFilename: string;
2424
dockerbuildSize: number;
25+
summaries: Summaries;
2526
builderName: string;
2627
nodeName: string;
2728
refs: Array<string>;
2829
}
30+
31+
export interface Summaries {
32+
[ref: string]: RecordSummary;
33+
}
34+
35+
export interface RecordSummary {
36+
name: string;
37+
status: string;
38+
duration: string;
39+
numCachedSteps: number;
40+
numTotalSteps: number;
41+
numCompletedSteps: number;
42+
frontendAttrs: Record<string, string>;
43+
error?: string;
44+
}

0 commit comments

Comments
 (0)