Publish release QA dashboard

This commit is contained in:
2026-07-05 20:52:54 +09:00
parent df74caac17
commit 2841da35c2
2 changed files with 159 additions and 3 deletions

View File

@@ -1,5 +1,5 @@
import { spawnSync } from 'node:child_process';
import { mkdirSync, writeFileSync } from 'node:fs';
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
const qaVersion = process.env.PUBLIC_QA_VERSION ?? makeQaVersion();
@@ -7,6 +7,7 @@ const releaseCommit = gitValue(['rev-parse', 'HEAD'], 'unknown');
const releaseShortCommit = gitValue(['rev-parse', '--short', 'HEAD'], 'unknown');
const qaReportFile = 'qa-early-report.json';
const qaRepeatReportFile = 'qa-early-repeat-report.json';
const releaseDashboardFile = 'release-dashboard.html';
const qaRepeatRuns = Number(process.env.PUBLIC_QA_REPEAT_RUNS ?? 2);
assertNoTrackedChanges();
@@ -18,7 +19,21 @@ if (qaRepeatRuns > 1) {
QA_REPEAT_REPORT_PATH: join('dist', qaRepeatReportFile)
});
}
writeReleaseManifest(qaVersion, releaseCommit, releaseShortCommit, qaReportFile, qaRepeatRuns > 1 ? qaRepeatReportFile : undefined);
writeReleaseDashboard({
qaVersion,
commit: releaseCommit,
shortCommit: releaseShortCommit,
qaReport: qaReportFile,
qaRepeatReport: qaRepeatRuns > 1 ? qaRepeatReportFile : undefined
});
writeReleaseManifest(
qaVersion,
releaseCommit,
releaseShortCommit,
qaReportFile,
qaRepeatRuns > 1 ? qaRepeatReportFile : undefined,
releaseDashboardFile
);
runPnpmScript('deploy:nas:dist');
runPnpmScript('verify:public-deploy', { PUBLIC_QA_VERSION: qaVersion, PUBLIC_QA_COMMIT: releaseCommit });
@@ -44,7 +59,7 @@ function assertNoTrackedChanges() {
}
}
function writeReleaseManifest(qaVersion, commit, shortCommit, qaReport, qaRepeatReport) {
function writeReleaseManifest(qaVersion, commit, shortCommit, qaReport, qaRepeatReport, releaseDashboard) {
const manifest = {
app: 'heros_web',
qaVersion,
@@ -54,6 +69,7 @@ function writeReleaseManifest(qaVersion, commit, shortCommit, qaReport, qaRepeat
generatedAt: new Date().toISOString(),
qaReport,
qaRepeatReport,
releaseDashboard,
verification: ['verify:local-release', 'qa:early', ...(qaRepeatReport ? ['qa:early:repeat'] : []), 'verify:public-deploy']
};
@@ -62,6 +78,132 @@ function writeReleaseManifest(qaVersion, commit, shortCommit, qaReport, qaRepeat
console.log(`Wrote dist/release-manifest.json for ${manifest.shortCommit} (${qaVersion})`);
}
function writeReleaseDashboard({ qaVersion, commit, shortCommit, qaReport, qaRepeatReport }) {
const qa = readJsonFromDist(qaReport);
const repeat = qaRepeatReport ? readJsonFromDist(qaRepeatReport) : undefined;
const missedObjectives = repeat?.missedObjectives ?? qa?.missedObjectives ?? [];
const runRows = repeat?.runs ?? [];
const html = `<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Heros Web Release QA ${escapeHtml(shortCommit)}</title>
<style>
:root { color-scheme: dark; font-family: "Segoe UI", sans-serif; background: #131815; color: #f1eadb; }
body { margin: 0; padding: 28px; background: #131815; }
main { max-width: 1120px; margin: 0 auto; }
h1, h2 { margin: 0; font-weight: 700; }
h1 { font-size: 28px; }
h2 { font-size: 18px; margin-top: 28px; }
.meta, .grid, table { margin-top: 16px; }
.meta { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; }
.tile { border: 1px solid #665941; background: #221f18; border-radius: 8px; padding: 12px; }
.label { color: #b9ad91; font-size: 12px; }
.value { margin-top: 5px; font-size: 18px; font-weight: 700; word-break: break-word; }
table { width: 100%; border-collapse: collapse; border: 1px solid #665941; background: #1b1c17; }
th, td { padding: 10px 12px; border-bottom: 1px solid #3e382b; text-align: left; font-size: 14px; }
th { color: #e8d39a; background: #252116; }
tr:last-child td { border-bottom: 0; }
.ok { color: #a9db8f; }
.warn { color: #f3c96b; }
a { color: #9cc6ff; }
@media (max-width: 820px) { .meta { grid-template-columns: 1fr 1fr; } }
</style>
</head>
<body>
<main>
<h1>Heros Web Release QA</h1>
<section class="meta">
${metricTile('Version', qaVersion)}
${metricTile('Commit', shortCommit)}
${metricTile('Early QA', `${qa?.summary?.victories ?? 0}/${qa?.battleCount ?? 0} victories`)}
${metricTile('Repeat QA', repeat ? `${repeat.summary.runs} runs` : 'not run')}
</section>
<h2>Artifacts</h2>
<table>
<thead><tr><th>Type</th><th>File</th></tr></thead>
<tbody>
<tr><td>Manifest</td><td><a href="release-manifest.json">release-manifest.json</a></td></tr>
<tr><td>Early QA</td><td><a href="${escapeAttribute(qaReport)}">${escapeHtml(qaReport)}</a></td></tr>
${
qaRepeatReport
? `<tr><td>Repeat QA</td><td><a href="${escapeAttribute(qaRepeatReport)}">${escapeHtml(qaRepeatReport)}</a></td></tr>`
: ''
}
</tbody>
</table>
<h2>Repeated Objective Misses</h2>
<table>
<thead><tr><th>Battle</th><th>Objective</th><th>Misses</th><th>Rate</th><th>Latest Reason</th></tr></thead>
<tbody>
${
missedObjectives.length > 0
? missedObjectives
.map((miss) => {
const latest = miss.examples?.at(-1);
return `<tr><td>${escapeHtml(String(miss.battleNo))}</td><td>${escapeHtml(miss.objectiveId)}</td><td class="warn">${escapeHtml(String(miss.count ?? 1))}</td><td>${formatRate(miss.missRate)}</td><td>${escapeHtml(latest?.failureReason ?? miss.failureReason ?? '')}</td></tr>`;
})
.join('')
: '<tr><td colspan="5" class="ok">No missed objectives recorded.</td></tr>'
}
</tbody>
</table>
<h2>Repeat Runs</h2>
<table>
<thead><tr><th>Run</th><th>Victories</th><th>Missed Objectives</th><th>Battles With Misses</th></tr></thead>
<tbody>
${
runRows.length > 0
? runRows
.map(
(run) =>
`<tr><td>${escapeHtml(String(run.run))}</td><td>${escapeHtml(String(run.victories))}</td><td>${escapeHtml(String(run.missedObjectiveCount))}</td><td>${escapeHtml((run.battlesWithMissedObjectives ?? []).join(', ') || '-')}</td></tr>`
)
.join('')
: '<tr><td colspan="4">Repeat QA was not run for this release.</td></tr>'
}
</tbody>
</table>
<p class="label">Full commit: ${escapeHtml(commit)}</p>
</main>
</body>
</html>`;
writeFileSync(join('dist', releaseDashboardFile), html, 'utf8');
console.log(`Wrote dist/${releaseDashboardFile}`);
}
function readJsonFromDist(fileName) {
return JSON.parse(readFileSync(join('dist', fileName), 'utf8'));
}
function metricTile(label, value) {
return `<div class="tile"><div class="label">${escapeHtml(label)}</div><div class="value">${escapeHtml(String(value))}</div></div>`;
}
function formatRate(value) {
return typeof value === 'number' ? `${Math.round(value * 100)}%` : '-';
}
function escapeHtml(value) {
return String(value)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function escapeAttribute(value) {
return escapeHtml(value).replaceAll('`', '&#96;');
}
function gitValue(args, fallback) {
const result = spawnSync('git', args, { cwd: process.cwd(), encoding: 'utf8' });
if (result.status !== 0) {