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 battleOutcomes = repeat?.battleOutcomes ?? battleOutcomesFromQaReport(qa);
const runRows = repeat?.runs ?? [];
const processFailureCount = repeat?.summary?.runsWithProcessFailures ?? 0;
const battleFailureCount = battleOutcomes
.filter((outcome) => outcome.outcome !== 'victory')
.reduce((total, outcome) => total + (outcome.count ?? 1), 0);
const totalMissedObjectives = repeat?.summary?.totalMissedObjectives ?? qa?.summary?.missedObjectiveCount ?? missedObjectives.length;
const html = `
Heros Web Release QA ${escapeHtml(shortCommit)}
Heros Web Release QA
Artifacts
Repeated Battle Outcomes
| Battle | Id | Outcome | Count |
${
battleOutcomes.length > 0
? battleOutcomes
.map((outcome) => {
const cssClass = outcome.outcome === 'victory' ? 'ok' : 'bad';
return `| ${escapeHtml(String(outcome.battleNo))} | ${escapeHtml(outcome.battleId ?? '')} | ${escapeHtml(outcome.outcome)} | ${escapeHtml(String(outcome.count ?? 1))} |
`;
})
.join('')
: '| No battle outcome data recorded. |
'
}
Repeated Objective Misses
| Battle | Objective | Misses | Rate | Latest Reason |
${
missedObjectives.length > 0
? missedObjectives
.map((miss) => {
const latest = miss.examples?.at(-1);
return `| ${escapeHtml(String(miss.battleNo))} | ${escapeHtml(miss.objectiveId)} | ${escapeHtml(String(miss.count ?? 1))} | ${formatRate(miss.missRate)} | ${escapeHtml(latest?.failureReason ?? miss.failureReason ?? '')} |
`;
})
.join('')
: '| No missed objectives recorded. |
'
}
Repeat Runs
| Run | Status | Exit | Victories | Battle Failures | Missed Objectives | Battles With Misses | Report |
${
runRows.length > 0
? runRows
.map(
(run) => {
const reportLink = distAssetPath(run.path);
const statusClass = run.exitCode === 0 && run.failures === 0 ? 'ok' : 'bad';
return `| ${escapeHtml(String(run.run))} | ${escapeHtml(run.outcome ?? 'unknown')} | ${escapeHtml(String(run.exitCode ?? 0))} | ${escapeHtml(String(run.victories))} | ${escapeHtml(String(run.failures ?? 0))} | ${escapeHtml(String(run.missedObjectiveCount))} | ${escapeHtml((run.battlesWithMissedObjectives ?? []).join(', ') || '-')} | ${reportLink ? `${escapeHtml(reportLink)}` : '-'} |
`;
}
)
.join('')
: '| Repeat QA was not run for this release. |
'
}
Full commit: ${escapeHtml(commit)}
`;
writeFileSync(join('dist', releaseDashboardFile), html, 'utf8');
console.log(`Wrote dist/${releaseDashboardFile}`);
}
function battleOutcomesFromQaReport(report) {
return (report?.results ?? []).map((result) => ({
battleNo: result.no,
battleId: result.id,
outcome: result.outcome,
count: 1
}));
}
function readJsonFromDist(fileName) {
return JSON.parse(readFileSync(join('dist', fileName), 'utf8'));
}
function metricTile(label, value) {
return `${escapeHtml(label)}
${escapeHtml(String(value))}
`;
}
function formatRate(value) {
return typeof value === 'number' ? `${Math.round(value * 100)}%` : '-';
}
function distAssetPath(filePath) {
if (!filePath) {
return '';
}
return String(filePath).replaceAll('\\', '/').replace(/^dist\//, '');
}
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}`);
}
}