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

+
+ ${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')} +
+ +

Artifacts

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

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) => + `` + ) + .join('') + : '' + } + +
RunVictoriesMissed ObjectivesBattles With Misses
${escapeHtml(String(run.run))}${escapeHtml(String(run.victories))}${escapeHtml(String(run.missedObjectiveCount))}${escapeHtml((run.battlesWithMissedObjectives ?? []).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);