236 lines
8.5 KiB
JavaScript
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('&', '&')
|
|
.replaceAll('<', '<')
|
|
.replaceAll('>', '>')
|
|
.replaceAll('"', '"')
|
|
.replaceAll("'", ''');
|
|
}
|
|
|
|
function escapeAttribute(value) {
|
|
return escapeHtml(value).replaceAll('`', '`');
|
|
}
|
|
|
|
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}`);
|
|
}
|
|
}
|