import { readFileSync, writeFileSync } from "node:fs"; import { durationMs, formatDuration } from "./report-native-build-timings.mjs"; const readJson = (path) => JSON.parse(readFileSync(path, "utf8")); const timestamp = (value) => { if (!value) return null; const parsed = Date.parse(value); return Number.isFinite(parsed) ? parsed : null; }; const runDurationMs = (run) => durationMs(run.run_started_at ?? run.created_at, run.updated_at); const findJob = (payload, name) => payload.jobs.find((candidate) => candidate.name === name) ?? null; const findStep = (job, name) => job?.steps?.find((candidate) => candidate.name === name) ?? null; const jobDurationMs = (job) => job ? durationMs(job.started_at, job.completed_at) : null; const stepDurationMs = (step) => step ? durationMs(step.started_at, step.completed_at) : null; const resultOf = (candidate) => candidate?.conclusion ?? candidate?.status ?? "unknown"; const escapeCell = (value) => String(value ?? "—").replaceAll("|", "\\|"); const runLink = (url) => (url ? `[Run](${url})` : "—"); export function summarizeReleaseAttempts(attempts = []) { const unique = attempts.filter((payload, index, entries) => entries.findIndex((candidate) => candidate.run.id === payload.run.id) === index ); const starts = unique .map(({ run }) => timestamp(run.created_at ?? run.run_started_at)) .filter((value) => value !== null); const ends = unique .map(({ run }) => timestamp(run.updated_at)) .filter((value) => value !== null); const releaseTargets = new Set( unique.map(({ run }) => run.head_sha).filter(Boolean), ); return { workflowRunCount: unique.length, releaseTargetCount: releaseTargets.size, failedRunCount: unique.filter(({ run }) => ["failure", "cancelled", "timed_out"].includes(run.conclusion) ).length, endToEndDurationMs: starts.length > 0 && ends.length > 0 ? Math.max(...ends) - Math.min(...starts) : null, cumulativeWorkflowDurationMs: unique.reduce( (total, { run }) => total + (runDurationMs(run) ?? 0), 0, ), }; } function componentRow({ target, mode, candidate, duration, detail, url }) { return { target, mode, result: resultOf(candidate), durationMs: duration, detail, url, }; } function nativeRows(payload, platform, requestedMode) { if (platform === "desktop") { const architectureJobs = [ { name: "macOS arm64", packageStep: "Package desktop installer", detail: "package + notarize" }, { name: "macOS x64", packageStep: "Package desktop installer", detail: "package + notarize" }, { name: "Windows x64 unsigned Preview", packageStep: "Package unsigned Windows installer", detail: "package" }, { name: "Linux x64 AppImage Preview", packageStep: "Package Linux AppImage", detail: "package" }, ] .map((target) => ({ target, job: findJob(payload, target.name) })) .filter(({ job }) => Boolean(job)); if (architectureJobs.length > 0) { return architectureJobs.map(({ target, job }) => { const packageStep = findStep(job, target.packageStep); return componentRow({ target: target.name .replace("Windows x64 unsigned Preview", "Windows x64 Preview") .replace("Linux x64 AppImage Preview", "Linux x64 Preview"), mode: "rebuild", candidate: job, duration: jobDurationMs(job), detail: `${target.detail} ${formatDuration(stepDurationMs(packageStep))}`, url: job.html_url ?? payload.run.html_url, }); }); } const reuseJob = findJob(payload, "Reuse desktop release assets"); if (reuseJob) { return [ componentRow({ target: "macOS arm64 + x64 + Windows x64 + Linux x64", mode: "reuse", candidate: reuseJob, duration: jobDurationMs(reuseJob), detail: "copied previously verified assets", url: reuseJob.html_url ?? payload.run.html_url, }), ]; } const planJob = findJob(payload, "Plan desktop release asset"); return [ componentRow({ target: "macOS arm64 + x64 + Windows x64 + Linux x64", mode: "already prepared", candidate: planJob ?? payload.run, duration: jobDurationMs(planJob) ?? runDurationMs(payload.run), detail: `requested ${requestedMode}; Draft assets were already ready`, url: planJob?.html_url ?? payload.run.html_url, }), ]; } const buildJob = findJob(payload, "Build Android packages"); if (buildJob) { const packageStep = findStep( buildJob, "Build signed release APK for GitHub Release", ); return [ componentRow({ target: "Android arm64", mode: "rebuild", candidate: buildJob, duration: jobDurationMs(buildJob), detail: `APK build ${formatDuration(stepDurationMs(packageStep))}`, url: buildJob.html_url ?? payload.run.html_url, }), ]; } const reuseJob = findJob(payload, "Reuse Android release APK"); if (reuseJob) { return [ componentRow({ target: "Android arm64", mode: "reuse", candidate: reuseJob, duration: jobDurationMs(reuseJob), detail: "copied previously verified Play-signed APK", url: reuseJob.html_url ?? payload.run.html_url, }), ]; } const planJob = findJob(payload, "Plan Android release asset"); return [ componentRow({ target: "Android arm64", mode: "already prepared", candidate: planJob ?? payload.run, duration: jobDurationMs(planJob) ?? runDurationMs(payload.run), detail: `requested ${requestedMode}; Draft APK was already ready`, url: planJob?.html_url ?? payload.run.html_url, }), ]; } export function createReleaseTimingReport({ release, desktop, desktopMode, mobile, mobileMode, docker, store = null, storeMode = "", attempts = [], cloudflare, tcrSource, tcrReadyAt, tcrStatus, }) { const cloudflareJob = findJob(cloudflare, "Build and deploy Demo Worker"); const cloudflareStep = findStep(cloudflareJob, "Deploy Demo Worker"); const dockerJob = findJob(docker, "Publish official multi-platform image"); const dockerStep = findStep(dockerJob, "Build and publish image"); const storeJob = store ? findJob(store, "Deliver Google Play") : null; const storeBuildStep = findStep( storeJob, "Build and verify signed Play bundle", ); const storeUploadStep = findStep(storeJob, "Upload bundle to Google Play"); const storeDownloadStep = findStep(storeJob, "Download Play-signed universal APK"); const resolvedStoreMode = storeMode || ( storeJob && (!storeBuildStep || resultOf(storeBuildStep) === "skipped") && storeDownloadStep && resultOf(storeDownloadStep) === "success" ? "recover" : "build + deliver" ); const tcrSourceJob = findJob( tcrSource, "Trigger asynchronous Tencent-side image build", ); const tcrStart = tcrSource.run.run_started_at ?? tcrSource.run.created_at; const tcrDuration = durationMs(tcrStart, tcrReadyAt); const rows = [ componentRow({ target: "Cloudflare Demo Worker", mode: "deploy", candidate: cloudflareJob ?? cloudflare.run, duration: jobDurationMs(cloudflareJob) ?? runDurationMs(cloudflare.run), detail: `deploy step ${formatDuration(stepDurationMs(cloudflareStep))}`, url: cloudflareJob?.html_url ?? cloudflare.run.html_url, }), componentRow({ target: "Docker GHCR (amd64 + arm64)", mode: "build + push", candidate: dockerJob ?? docker.run, duration: jobDurationMs(dockerJob) ?? runDurationMs(docker.run), detail: `image build/push ${formatDuration(stepDurationMs(dockerStep))}`, url: dockerJob?.html_url ?? docker.run.html_url, }), { target: "Docker TCR (amd64 + arm64)", mode: "Tencent-side build", result: tcrStatus, durationMs: tcrDuration, detail: `release source sync ${formatDuration(jobDurationMs(tcrSourceJob) ?? runDurationMs(tcrSource.run))}; duration is end-to-end until the public TCR tag was observed`, url: tcrSourceJob?.html_url ?? tcrSource.run.html_url, }, ...nativeRows(desktop, "desktop", desktopMode), ...nativeRows(mobile, "android", mobileMode), ...(storeJob ? [ componentRow({ target: "Google Play signed APK", mode: resolvedStoreMode, candidate: storeJob, duration: jobDurationMs(storeJob), detail: resolvedStoreMode === "recover" ? `recovered existing Play-signed APK ${formatDuration(stepDurationMs(storeDownloadStep))}` : `AAB build ${formatDuration(stepDurationMs(storeBuildStep))}; Play upload ${formatDuration(stepDurationMs(storeUploadStep))}`, url: storeJob.html_url ?? store.run.html_url, }), ] : []), ]; const publishedAt = timestamp(release.publishedAt); const endpointReadyTimes = [cloudflare.run.updated_at, tcrReadyAt] .map(timestamp) .filter((value) => value !== null); const allEndpointsReady = resultOf(cloudflareJob ?? cloudflare.run) === "success" && tcrStatus === "success" && endpointReadyTimes.length === 2; const allEndpointsReadyAt = allEndpointsReady ? Math.max(...endpointReadyTimes) : null; return { schemaVersion: 2, release, generatedAt: new Date().toISOString(), preparation: summarizeReleaseAttempts(attempts), allEndpointsReady, allEndpointsReadyDurationMs: publishedAt !== null && allEndpointsReadyAt !== null && allEndpointsReadyAt >= publishedAt ? allEndpointsReadyAt - publishedAt : null, rows, }; } export function renderReleaseTimingMarkdown(report) { const readinessSummary = report.allEndpointsReady ? `All post-publication endpoints were observed after **${formatDuration(report.allEndpointsReadyDurationMs)}**.` : "At least one post-publication endpoint failed or was not observed before the reporting deadline."; const lines = [ `## Release build timings · ${report.release.tag}`, "", `${readinessSummary} Client and GHCR assets are prepared in the Draft phase and therefore report their actual preparation jobs rather than time after publication.`, "", ...(report.preparation.workflowRunCount > 0 ? [ `Draft preparation spanned **${formatDuration(report.preparation.endToEndDurationMs)}** across **${report.preparation.workflowRunCount} workflow runs** and **${report.preparation.releaseTargetCount} release target(s)**; ${report.preparation.failedRunCount} run(s) failed or were cancelled. Cumulative workflow duration was ${formatDuration(report.preparation.cumulativeWorkflowDurationMs)}.`, "", ] : []), "| Target | Mode | Result | Duration | Detail | Workflow |", "| --- | --- | --- | ---: | --- | --- |", ]; for (const row of report.rows) { lines.push( `| ${escapeCell(row.target)} | ${escapeCell(row.mode)} | ${escapeCell(row.result)} | ${formatDuration(row.durationMs)} | ${escapeCell(row.detail)} | ${runLink(row.url)} |`, ); } lines.push( "", "> Durations use GitHub Actions job/step timestamps. TCR is measured end-to-end from the release-triggered source sync until the matching public multi-architecture tag and Git revision become visible.", "", ); return lines.join("\n"); } function parseArguments(argv) { const options = {}; for (let index = 0; index < argv.length; index += 2) { const key = argv[index]; const value = argv[index + 1]; if (!key?.startsWith("--") || value === undefined) { throw new Error(`Invalid argument near ${key ?? "end"}`); } options[key.slice(2)] = value; } for (const required of [ "tag", "sha", "published-at", "desktop", "desktop-mode", "mobile", "mobile-mode", "docker", "cloudflare", "tcr-source", "tcr-status", "json", "markdown", ]) { if (!options[required]) throw new Error(`Missing --${required}`); } return options; } if (import.meta.main) { const options = parseArguments(process.argv.slice(2)); const report = createReleaseTimingReport({ release: { tag: options.tag, sha: options.sha, publishedAt: options["published-at"], url: options["release-url"] ?? null, }, desktop: readJson(options.desktop), desktopMode: options["desktop-mode"], mobile: readJson(options.mobile), mobileMode: options["mobile-mode"], docker: readJson(options.docker), store: options.store ? readJson(options.store) : null, storeMode: options["store-mode"] ?? "", attempts: options.attempts ? readJson(options.attempts) : [], cloudflare: readJson(options.cloudflare), tcrSource: readJson(options["tcr-source"]), tcrReadyAt: options["tcr-ready-at"] || null, tcrStatus: options["tcr-status"], }); writeFileSync(options.json, `${JSON.stringify(report, null, 2)}\n`); writeFileSync(options.markdown, renderReleaseTimingMarkdown(report)); }