Files
edgeever/scripts/report-release-timings.mjs
T

372 lines
13 KiB
JavaScript

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));
}