Files
heros_web/scripts/ship-release.mjs

236 lines
8.5 KiB
JavaScript

import { spawnSync } from 'node:child_process';
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
const qaVersion = process.env.PUBLIC_QA_VERSION ?? makeQaVersion();
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();
runPnpmScript('verify:local-release');
runPnpmScript('qa:early', { QA_REPORT_PATH: join('dist', qaReportFile) });
if (qaRepeatRuns > 1) {
runPnpmScript('qa:early:repeat', {
QA_REPEAT_RUNS: String(qaRepeatRuns),
QA_REPEAT_REPORT_PATH: join('dist', qaRepeatReportFile)
});
}
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 });
console.log(`Shipped and verified public deploy with PUBLIC_QA_VERSION=${qaVersion} at ${releaseShortCommit}`);
function makeQaVersion() {
const stamp = new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14);
return `ship-${stamp}`;
}
function assertNoTrackedChanges() {
const result = spawnSync('git', ['status', '--porcelain', '--untracked-files=no'], {
cwd: process.cwd(),
encoding: 'utf8'
});
if (result.status !== 0) {
throw new Error(`Could not inspect tracked git status before shipping:\n${result.stderr.trim()}`);
}
const dirty = result.stdout.trim();
if (dirty) {
throw new Error(`Refusing to ship release with uncommitted tracked changes:\n${dirty}`);
}
}
function writeReleaseManifest(qaVersion, commit, shortCommit, qaReport, qaRepeatReport, releaseDashboard) {
const manifest = {
app: 'heros_web',
qaVersion,
commit,
shortCommit,
branch: gitValue(['branch', '--show-current'], 'unknown'),
generatedAt: new Date().toISOString(),
qaReport,
qaRepeatReport,
releaseDashboard,
verification: ['verify:local-release', 'qa:early', ...(qaRepeatReport ? ['qa:early:repeat'] : []), 'verify:public-deploy']
};
mkdirSync('dist', { recursive: true });
writeFileSync(join('dist', 'release-manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
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) {
return fallback;
}
return result.stdout.trim() || fallback;
}
function runPnpmScript(scriptName, extraEnv = {}) {
const label = `pnpm run ${scriptName}`;
console.log(`\n$ ${label}`);
const result =
process.platform === 'win32'
? spawnSync(process.env.ComSpec ?? 'cmd.exe', ['/d', '/s', '/c', `pnpm.cmd run ${scriptName}`], {
cwd: process.cwd(),
env: { ...process.env, ...extraEnv },
stdio: 'inherit'
})
: spawnSync('pnpm', ['run', scriptName], {
cwd: process.cwd(),
env: { ...process.env, ...extraEnv },
stdio: 'inherit'
});
if (result.status !== 0) {
throw new Error(`Command failed: ${label}`);
}
}