-
Notifications
You must be signed in to change notification settings - Fork 156
/
write.ts
615 lines (531 loc) · 20.6 KB
/
write.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
import { promises as fs } from 'fs';
import * as path from 'path';
import * as io from '@actions/io';
import * as core from '@actions/core';
import * as github from '@actions/github';
import * as git from './git';
import { Benchmark, BenchmarkResult } from './extract';
import { Config, ToolType } from './config';
import { DEFAULT_INDEX_HTML } from './default_index_html';
import { SummaryTableRow } from '@actions/core/lib/summary';
export type BenchmarkSuites = { [name: string]: Benchmark[] };
export interface DataJson {
lastUpdate: number;
repoUrl: string;
entries: BenchmarkSuites;
}
export const SCRIPT_PREFIX = 'window.BENCHMARK_DATA = ';
const DEFAULT_DATA_JSON = {
lastUpdate: 0,
repoUrl: '',
entries: {},
};
async function loadDataJs(dataPath: string): Promise<DataJson> {
try {
const script = await fs.readFile(dataPath, 'utf8');
const json = script.slice(SCRIPT_PREFIX.length);
const parsed = JSON.parse(json);
core.debug(`Loaded data.js at ${dataPath}`);
return parsed;
} catch (err) {
console.log(`Could not find data.js at ${dataPath}. Using empty default: ${err}`);
return { ...DEFAULT_DATA_JSON };
}
}
async function storeDataJs(dataPath: string, data: DataJson) {
const script = SCRIPT_PREFIX + JSON.stringify(data, null, 2);
await fs.writeFile(dataPath, script, 'utf8');
core.debug(`Overwrote ${dataPath} for adding new data`);
}
async function addIndexHtmlIfNeeded(additionalGitArguments: string[], dir: string, baseDir: string) {
const indexHtmlRelativePath = path.join(dir, 'index.html');
const indexHtmlFullPath = path.join(baseDir, indexHtmlRelativePath);
try {
await fs.stat(indexHtmlFullPath);
core.debug(`Skipped to create default index.html since it is already existing: ${indexHtmlFullPath}`);
return;
} catch (_) {
// Continue
}
await fs.writeFile(indexHtmlFullPath, DEFAULT_INDEX_HTML, 'utf8');
await git.cmd(additionalGitArguments, 'add', indexHtmlRelativePath);
console.log('Created default index.html at', indexHtmlFullPath);
}
function biggerIsBetter(tool: ToolType): boolean {
switch (tool) {
case 'cargo':
return false;
case 'go':
return false;
case 'benchmarkjs':
return true;
case 'benchmarkluau':
return false;
case 'pytest':
return true;
case 'googlecpp':
return false;
case 'catch2':
return false;
case 'julia':
return false;
case 'jmh':
return false;
case 'benchmarkdotnet':
return false;
case 'customBiggerIsBetter':
return true;
case 'customSmallerIsBetter':
return false;
}
}
interface Alert {
current: BenchmarkResult;
prev: BenchmarkResult;
ratio: number;
}
function findAlerts(curSuite: Benchmark, prevSuite: Benchmark, threshold: number): Alert[] {
core.debug(`Comparing current:${curSuite.commit.id} and prev:${prevSuite.commit.id} for alert`);
const alerts = [];
for (const current of curSuite.benches) {
const prev = prevSuite.benches.find((b) => b.name === current.name);
if (prev === undefined) {
core.debug(`Skipped because benchmark '${current.name}' is not found in previous benchmarks`);
continue;
}
const ratio = biggerIsBetter(curSuite.tool)
? prev.value / current.value // e.g. current=100, prev=200
: current.value / prev.value; // e.g. current=200, prev=100
if (ratio > threshold) {
core.warning(
`Performance alert! Previous value was ${prev.value} and current value is ${current.value}.` +
` It is ${ratio}x worse than previous exceeding a ratio threshold ${threshold}`,
);
alerts.push({ current, prev, ratio });
}
}
return alerts;
}
function getCurrentRepoMetadata() {
const { repo, owner } = github.context.repo;
const serverUrl = git.getServerUrl(github.context.payload.repository?.html_url);
return {
name: repo,
owner: {
login: owner,
},
// eslint-disable-next-line @typescript-eslint/naming-convention
html_url: `${serverUrl}/${owner}/${repo}`,
};
}
function floatStr(n: number) {
if (Number.isInteger(n)) {
return n.toFixed(0);
}
if (n > 0.1) {
return n.toFixed(2);
}
return n.toString();
}
function strVal(b: BenchmarkResult): string {
let s = `\`${b.value}\` ${b.unit}`;
if (b.range) {
s += ` (\`${b.range}\`)`;
}
return s;
}
function commentFooter(): string {
const repoMetadata = getCurrentRepoMetadata();
const repoUrl = repoMetadata.html_url ?? '';
const actionUrl = repoUrl + '/actions?query=workflow%3A' + encodeURIComponent(github.context.workflow);
return `This comment was automatically generated by [workflow](${actionUrl}) using [github-action-benchmark](https://github.com/marketplace/actions/continuous-benchmark).`;
}
function buildComment(benchName: string, curSuite: Benchmark, prevSuite: Benchmark): string {
const lines = [
`# ${benchName}`,
'',
'<details>',
'',
`| Benchmark suite | Current: ${curSuite.commit.id} | Previous: ${prevSuite.commit.id} | Ratio |`,
'|-|-|-|-|',
];
for (const current of curSuite.benches) {
let line;
const prev = prevSuite.benches.find((i) => i.name === current.name);
if (prev) {
const ratio = biggerIsBetter(curSuite.tool)
? prev.value / current.value // e.g. current=100, prev=200
: current.value / prev.value;
line = `| \`${current.name}\` | ${strVal(current)} | ${strVal(prev)} | \`${floatStr(ratio)}\` |`;
} else {
line = `| \`${current.name}\` | ${strVal(current)} | | |`;
}
lines.push(line);
}
// Footer
lines.push('', '</details>', '', commentFooter());
return lines.join('\n');
}
function buildAlertComment(
alerts: Alert[],
benchName: string,
curSuite: Benchmark,
prevSuite: Benchmark,
threshold: number,
cc: string[],
): string {
// Do not show benchmark name if it is the default value 'Benchmark'.
const benchmarkText = benchName === 'Benchmark' ? '' : ` **'${benchName}'**`;
const title = threshold === 0 ? '# Performance Report' : '# :warning: **Performance Alert** :warning:';
const thresholdString = floatStr(threshold);
const lines = [
title,
'',
`Possible performance regression was detected for benchmark${benchmarkText}.`,
`Benchmark result of this commit is worse than the previous benchmark result exceeding threshold \`${thresholdString}\`.`,
'',
`| Benchmark suite | Current: ${curSuite.commit.id} | Previous: ${prevSuite.commit.id} | Ratio |`,
'|-|-|-|-|',
];
for (const alert of alerts) {
const { current, prev, ratio } = alert;
const line = `| \`${current.name}\` | ${strVal(current)} | ${strVal(prev)} | \`${floatStr(ratio)}\` |`;
lines.push(line);
}
// Footer
lines.push('', commentFooter());
if (cc.length > 0) {
lines.push('', `CC: ${cc.join(' ')}`);
}
return lines.join('\n');
}
async function leaveComment(commitId: string, body: string, token: string) {
core.debug('Sending comment:\n' + body);
const repoMetadata = getCurrentRepoMetadata();
const repoUrl = repoMetadata.html_url ?? '';
const client = github.getOctokit(token);
const res = await client.rest.repos.createCommitComment({
owner: repoMetadata.owner.login,
repo: repoMetadata.name,
// eslint-disable-next-line @typescript-eslint/naming-convention
commit_sha: commitId,
body,
});
const commitUrl = `${repoUrl}/commit/${commitId}`;
console.log(`Comment was sent to ${commitUrl}. Response:`, res.status, res.data);
return res;
}
async function handleComment(benchName: string, curSuite: Benchmark, prevSuite: Benchmark, config: Config) {
const { commentAlways, githubToken } = config;
if (!commentAlways) {
core.debug('Comment check was skipped because comment-always is disabled');
return;
}
if (!githubToken) {
throw new Error("'comment-always' input is set but 'github-token' input is not set");
}
core.debug('Commenting about benchmark comparison');
const body = buildComment(benchName, curSuite, prevSuite);
await leaveComment(curSuite.commit.id, body, githubToken);
}
async function handleAlert(benchName: string, curSuite: Benchmark, prevSuite: Benchmark, config: Config) {
const { alertThreshold, githubToken, commentOnAlert, failOnAlert, alertCommentCcUsers, failThreshold } = config;
if (!commentOnAlert && !failOnAlert) {
core.debug('Alert check was skipped because both comment-on-alert and fail-on-alert were disabled');
return;
}
const alerts = findAlerts(curSuite, prevSuite, alertThreshold);
if (alerts.length === 0) {
core.debug('No performance alert found happily');
return;
}
core.debug(`Found ${alerts.length} alerts`);
const body = buildAlertComment(alerts, benchName, curSuite, prevSuite, alertThreshold, alertCommentCcUsers);
let message = body;
let url = null;
if (commentOnAlert) {
if (!githubToken) {
throw new Error("'comment-on-alert' input is set but 'github-token' input is not set");
}
const res = await leaveComment(curSuite.commit.id, body, githubToken);
url = res.data.html_url;
message = body + `\nComment was generated at ${url}`;
}
if (failOnAlert) {
// Note: alertThreshold is smaller than failThreshold. It was checked in config.ts
const len = alerts.length;
const threshold = floatStr(failThreshold);
const failures = alerts.filter((a) => a.ratio > failThreshold);
if (failures.length > 0) {
core.debug('Mark this workflow as fail since one or more fatal alerts found');
if (failThreshold !== alertThreshold) {
// Prepend message that explains how these alerts were detected with different thresholds
message = `${failures.length} of ${len} alerts exceeded the failure threshold \`${threshold}\` specified by fail-threshold input:\n\n${message}`;
}
throw new Error(message);
} else {
core.debug(
`${len} alerts exceeding the alert threshold ${alertThreshold} were found but` +
` all of them did not exceed the failure threshold ${threshold}`,
);
}
}
}
function addBenchmarkToDataJson(
benchName: string,
bench: Benchmark,
data: DataJson,
maxItems: number | null,
): Benchmark | null {
const repoMetadata = getCurrentRepoMetadata();
const htmlUrl = repoMetadata.html_url ?? '';
let prevBench: Benchmark | null = null;
data.lastUpdate = Date.now();
data.repoUrl = htmlUrl;
// Add benchmark result
if (data.entries[benchName] === undefined) {
data.entries[benchName] = [bench];
core.debug(`No suite was found for benchmark '${benchName}' in existing data. Created`);
} else {
const suites = data.entries[benchName];
// Get last suite which has different commit ID for alert comment
for (const e of suites.slice().reverse()) {
if (e.commit.id !== bench.commit.id) {
prevBench = e;
break;
}
}
suites.push(bench);
if (maxItems !== null && suites.length > maxItems) {
suites.splice(0, suites.length - maxItems);
core.debug(
`Number of data items for '${benchName}' was truncated to ${maxItems} due to max-items-in-charts`,
);
}
}
return prevBench;
}
function isRemoteRejectedError(err: unknown) {
if (err instanceof Error) {
return ['[remote rejected]', '[rejected]'].some((l) => err.message.includes(l));
}
return false;
}
async function writeBenchmarkToGitHubPagesWithRetry(
bench: Benchmark,
config: Config,
retry: number,
): Promise<Benchmark | null> {
const {
name,
tool,
ghPagesBranch,
ghRepository,
benchmarkDataDirPath,
githubToken,
autoPush,
skipFetchGhPages,
maxItemsInChart,
} = config;
// FIXME: This payload is not available on `schedule:` or `workflow_dispatch:` events.
const isPrivateRepo = github.context.payload.repository?.private ?? false;
let benchmarkBaseDir = './';
let extraGitArguments: string[] = [];
if (githubToken && !skipFetchGhPages && ghRepository) {
benchmarkBaseDir = './benchmark-data-repository';
await git.clone(githubToken, ghRepository, benchmarkBaseDir);
extraGitArguments = [`--work-tree=${benchmarkBaseDir}`, `--git-dir=${benchmarkBaseDir}/.git`];
await git.checkout(ghPagesBranch, extraGitArguments);
} else if (!skipFetchGhPages && (!isPrivateRepo || githubToken)) {
await git.pull(githubToken, ghPagesBranch);
} else if (isPrivateRepo && !skipFetchGhPages) {
core.warning(
"'git pull' was skipped. If you want to ensure GitHub Pages branch is up-to-date " +
"before generating a commit, please set 'github-token' input to pull GitHub pages branch",
);
} else {
console.warn('NOTHING EXECUTED:', {
skipFetchGhPages,
ghRepository,
isPrivateRepo,
githubToken: !!githubToken,
});
}
// `benchmarkDataDirPath` is an absolute path at this stage,
// so we need to convert it to relative to be able to prepend the `benchmarkBaseDir`
const benchmarkDataRelativeDirPath = path.relative(process.cwd(), benchmarkDataDirPath);
const benchmarkDataDirFullPath = path.join(benchmarkBaseDir, benchmarkDataRelativeDirPath);
const dataPath = path.join(benchmarkDataDirFullPath, 'data.js');
await io.mkdirP(benchmarkDataDirFullPath);
const data = await loadDataJs(dataPath);
const prevBench = addBenchmarkToDataJson(name, bench, data, maxItemsInChart);
await storeDataJs(dataPath, data);
await git.cmd(extraGitArguments, 'add', path.join(benchmarkDataRelativeDirPath, 'data.js'));
await addIndexHtmlIfNeeded(extraGitArguments, benchmarkDataRelativeDirPath, benchmarkBaseDir);
await git.cmd(extraGitArguments, 'commit', '-m', `add ${name} (${tool}) benchmark result for ${bench.commit.id}`);
if (githubToken && autoPush) {
try {
await git.push(githubToken, ghRepository, ghPagesBranch, extraGitArguments);
console.log(
`Automatically pushed the generated commit to ${ghPagesBranch} branch since 'auto-push' is set to true`,
);
} catch (err: any) {
if (!isRemoteRejectedError(err)) {
throw err;
}
// Fall through
core.warning(`Auto-push failed because the remote ${ghPagesBranch} was updated after git pull`);
if (retry > 0) {
core.debug('Rollback the auto-generated commit before retry');
await git.cmd(extraGitArguments, 'reset', '--hard', 'HEAD~1');
core.warning(
`Retrying to generate a commit and push to remote ${ghPagesBranch} with retry count ${retry}...`,
);
return await writeBenchmarkToGitHubPagesWithRetry(bench, config, retry - 1); // Recursively retry
} else {
core.warning(`Failed to add benchmark data to '${name}' data: ${JSON.stringify(bench)}`);
throw new Error(
`Auto-push failed 3 times since the remote branch ${ghPagesBranch} rejected pushing all the time. Last exception was: ${err.message}`,
);
}
}
} else {
core.debug(
`Auto-push to ${ghPagesBranch} is skipped because it requires both 'github-token' and 'auto-push' inputs`,
);
}
return prevBench;
}
async function writeBenchmarkToGitHubPages(bench: Benchmark, config: Config): Promise<Benchmark | null> {
const { ghPagesBranch, skipFetchGhPages, ghRepository, githubToken } = config;
if (!ghRepository) {
if (!skipFetchGhPages) {
await git.fetch(githubToken, ghPagesBranch);
}
await git.cmd([], 'switch', ghPagesBranch);
}
try {
return await writeBenchmarkToGitHubPagesWithRetry(bench, config, 10);
} finally {
if (!ghRepository) {
// `git switch` does not work for backing to detached head
await git.cmd([], 'checkout', '-');
}
}
}
async function loadDataJson(jsonPath: string): Promise<DataJson> {
try {
const content = await fs.readFile(jsonPath, 'utf8');
const json: DataJson = JSON.parse(content);
core.debug(`Loaded external JSON file at ${jsonPath}`);
return json;
} catch (err) {
core.warning(
`Could not find external JSON file for benchmark data at ${jsonPath}. Using empty default: ${err}`,
);
return { ...DEFAULT_DATA_JSON };
}
}
async function writeBenchmarkToExternalJson(
bench: Benchmark,
jsonFilePath: string,
config: Config,
): Promise<Benchmark | null> {
const { name, maxItemsInChart, saveDataFile } = config;
const data = await loadDataJson(jsonFilePath);
const prevBench = addBenchmarkToDataJson(name, bench, data, maxItemsInChart);
if (!saveDataFile) {
core.debug('Skipping storing benchmarks in external data file');
return prevBench;
}
try {
const jsonDirPath = path.dirname(jsonFilePath);
await io.mkdirP(jsonDirPath);
await fs.writeFile(jsonFilePath, JSON.stringify(data, null, 2), 'utf8');
} catch (err) {
throw new Error(`Could not store benchmark data as JSON at ${jsonFilePath}: ${err}`);
}
return prevBench;
}
export async function writeBenchmark(bench: Benchmark, config: Config) {
const { name, externalDataJsonPath } = config;
const prevBench = externalDataJsonPath
? await writeBenchmarkToExternalJson(bench, externalDataJsonPath, config)
: await writeBenchmarkToGitHubPages(bench, config);
// Put this after `git push` for reducing possibility to get conflict on push. Since sending
// comment take time due to API call, do it after updating remote branch.
if (prevBench === null) {
core.debug('Alert check was skipped because previous benchmark result was not found');
} else {
await handleComment(name, bench, prevBench, config);
await handleAlert(name, bench, prevBench, config);
}
}
export async function writeSummary(bench: Benchmark, config: Config): Promise<void> {
const { name, externalDataJsonPath } = config;
const prevBench = externalDataJsonPath
? await writeBenchmarkToExternalJson(bench, externalDataJsonPath, config)
: await writeBenchmarkToGitHubPages(bench, config);
if (prevBench === null) {
core.debug('Alert check was skipped because previous benchmark result was not found');
return;
}
const headers = [
{
data: 'Benchmark Suite',
header: true,
},
{
data: `Current: "${bench.commit.id}"`,
header: true,
},
{
data: `Previous: "${prevBench.commit.id}"`,
header: true,
},
{
data: 'Ratio',
header: true,
},
];
const rows: SummaryTableRow[] = bench.benches.map((bench) => {
const previousBench = prevBench.benches.find((pb) => pb.name === bench.name);
if (previousBench) {
const ratio = biggerIsBetter(config.tool)
? previousBench.value / bench.value
: bench.value / previousBench.value;
return [
{
data: bench.name,
},
{
data: strVal(bench),
},
{
data: strVal(previousBench),
},
{
data: floatStr(ratio),
},
];
}
return [
{
data: bench.name,
},
{
data: strVal(bench),
},
{
data: '-',
},
{
data: '-',
},
];
});
await core.summary
.addHeading(`Benchmarks: ${name}`)
.addTable([headers, ...rows])
.write();
}