diff --git a/scripts/ship-release.mjs b/scripts/ship-release.mjs
index d632acb..3e911c4 100644
--- a/scripts/ship-release.mjs
+++ b/scripts/ship-release.mjs
@@ -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 = `
+
+
+
+
+ Heros Web Release QA ${escapeHtml(shortCommit)}
+
+
+
+
+ Heros Web Release QA
+
+
+ Artifacts
+
+
+ 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 | Victories | Missed Objectives | Battles With Misses |
+
+ ${
+ runRows.length > 0
+ ? runRows
+ .map(
+ (run) =>
+ `| ${escapeHtml(String(run.run))} | ${escapeHtml(String(run.victories))} | ${escapeHtml(String(run.missedObjectiveCount))} | ${escapeHtml((run.battlesWithMissedObjectives ?? []).join(', ') || '-')} |
`
+ )
+ .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 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 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) {
diff --git a/scripts/verify-public-deploy.mjs b/scripts/verify-public-deploy.mjs
index b2edc44..caaab76 100644
--- a/scripts/verify-public-deploy.mjs
+++ b/scripts/verify-public-deploy.mjs
@@ -134,6 +134,12 @@ try {
);
console.log(`Verified QA repeat report ${releaseManifest.qaRepeatReport} with ${qaRepeatReport.runs.length} runs`);
}
+ if (releaseManifest.releaseDashboard) {
+ const dashboard = await readTextAsset(targetUrl, releaseManifest.releaseDashboard);
+ assert(dashboard.includes('Heros Web Release QA'), `Expected deployed release dashboard title: ${dashboard.slice(0, 200)}`);
+ assert(dashboard.includes(releaseManifest.shortCommit ?? releaseManifest.commit), `Expected deployed release dashboard commit: ${dashboard.slice(0, 400)}`);
+ console.log(`Verified release dashboard ${releaseManifest.releaseDashboard}`);
+ }
const relevantLogs = consoleMessages.filter(
(message) => message.type === 'error' || (isWarning(message.type) && relevantLogPattern.test(message.text))
@@ -173,6 +179,14 @@ async function readJsonAsset(pageUrl, assetPath) {
return response.json();
}
+async function readTextAsset(pageUrl, assetPath) {
+ const assetUrl = new URL(assetPath, pageUrl);
+ assetUrl.searchParams.set('v', expectedQaVersion ?? String(Date.now()));
+ const response = await fetch(assetUrl, { signal: AbortSignal.timeout(90000) });
+ assert(response.ok, `Expected deployed text asset at ${assetUrl.href}: ${response.status} ${response.statusText}`);
+ return response.text();
+}
+
function assert(condition, message) {
if (!condition) {
throw new Error(message);