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

${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')} ${metricTile('Battle Failures', battleFailureCount)} ${metricTile('Process Failures', processFailureCount)} ${metricTile('Objective Misses', totalMissedObjectives)}

Artifacts

${ qaRepeatReport ? `` : '' }
TypeFile
Manifestrelease-manifest.json
Early QA${escapeHtml(qaReport)}
Repeat QA${escapeHtml(qaRepeatReport)}

Repeated Battle Outcomes

${ battleOutcomes.length > 0 ? battleOutcomes .map((outcome) => { const cssClass = outcome.outcome === 'victory' ? 'ok' : 'bad'; return ``; }) .join('') : '' }
BattleIdOutcomeCount
${escapeHtml(String(outcome.battleNo))}${escapeHtml(outcome.battleId ?? '')}${escapeHtml(outcome.outcome)}${escapeHtml(String(outcome.count ?? 1))}
No battle outcome data recorded.

Repeated Objective Misses

${ missedObjectives.length > 0 ? missedObjectives .map((miss) => { const latest = miss.examples?.at(-1); return ``; }) .join('') : '' }
BattleObjectiveMissesRateLatest Reason
${escapeHtml(String(miss.battleNo))}${escapeHtml(miss.objectiveId)}${escapeHtml(String(miss.count ?? 1))}${formatRate(miss.missRate)}${escapeHtml(latest?.failureReason ?? miss.failureReason ?? '')}
No missed objectives recorded.

Repeat Runs

${ runRows.length > 0 ? runRows .map( (run) => { const reportLink = distAssetPath(run.path); const statusClass = run.exitCode === 0 && run.failures === 0 ? 'ok' : 'bad'; return ``; } ) .join('') : '' }
RunStatusExitVictoriesBattle FailuresMissed ObjectivesBattles With MissesReport
${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)}` : '-'}
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}`); } }