diff --git a/.github/workflows/release-timings.yml b/.github/workflows/release-timings.yml new file mode 100644 index 00000000..1a09b7c7 --- /dev/null +++ b/.github/workflows/release-timings.yml @@ -0,0 +1,239 @@ +name: Report Release build timings + +on: + workflow_dispatch: + inputs: + release_tag: + description: "Formal vX.Y.Z Release" + required: true + type: string + release_sha: + description: "Release commit SHA" + required: true + type: string + issue_number: + description: "Related Issue receiving the timing report" + required: true + type: string + desktop_run_id: + description: "Draft desktop preparation Run ID" + required: true + type: string + desktop_mode: + description: "Desktop release plan" + required: true + type: choice + options: [rebuild, reuse] + mobile_run_id: + description: "Draft Android preparation Run ID" + required: true + type: string + mobile_mode: + description: "Android release plan" + required: true + type: choice + options: [rebuild, reuse] + docker_run_id: + description: "Draft GHCR preparation Run ID" + required: true + type: string + +permissions: + actions: read + contents: read + issues: write + +concurrency: + group: release-build-timings-${{ inputs.release_tag }} + cancel-in-progress: false + +jobs: + report: + if: github.repository == 'tianma-if/edgeever' + name: Collect all Release endpoint timings + runs-on: ubuntu-latest + timeout-minutes: 50 + env: + GH_TOKEN: ${{ github.token }} + TCR_IMAGE: ccr.ccs.tencentyun.com/edgeever/edgeever + + steps: + - name: Check out timing reporter + uses: actions/checkout@v5 + + - name: Resolve Release and post-publication workflow runs + id: release + env: + RELEASE_SHA: ${{ inputs.release_sha }} + RELEASE_TAG: ${{ inputs.release_tag }} + run: | + release_json="$(gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --json isDraft,isPrerelease,publishedAt,targetCommitish,url)" + test "$(jq -r '.isDraft' <<<"$release_json")" = "false" + test "$(jq -r '.isPrerelease' <<<"$release_json")" = "false" + test "$(jq -r '.targetCommitish' <<<"$release_json")" = "$RELEASE_SHA" + + find_release_run() { + local workflow="$1" + local deadline=$((SECONDS + 120)) + while ((SECONDS < deadline)); do + run_id="$( + gh run list \ + --repo "$GITHUB_REPOSITORY" \ + --workflow "$workflow" \ + --event release \ + --limit 20 \ + --json databaseId,displayTitle,headSha \ + --jq ".[] | select(.displayTitle == \"$RELEASE_TAG\" and .headSha == \"$RELEASE_SHA\") | .databaseId" | + head -n 1 + )" + if [[ -n "$run_id" ]]; then + printf '%s' "$run_id" + return 0 + fi + sleep 5 + done + return 1 + } + + demo_run_id="$(find_release_run deploy-demo.yml)" + tcr_run_id="$(find_release_run docker-tcr-mirror.yml)" + { + echo "published_at=$(jq -r '.publishedAt' <<<"$release_json")" + echo "release_url=$(jq -r '.url' <<<"$release_json")" + echo "demo_run_id=$demo_run_id" + echo "tcr_run_id=$tcr_run_id" + } >> "$GITHUB_OUTPUT" + + - name: Wait for Cloudflare deployment and TCR source sync + id: post_release + env: + DEMO_RUN_ID: ${{ steps.release.outputs.demo_run_id }} + TCR_RUN_ID: ${{ steps.release.outputs.tcr_run_id }} + run: | + wait_for_run() { + local run_id="$1" + while true; do + run_json="$(gh run view "$run_id" --repo "$GITHUB_REPOSITORY" --json status,conclusion,url)" + status="$(jq -r '.status' <<<"$run_json")" + if [[ "$status" = "completed" ]]; then + jq -r '.conclusion' <<<"$run_json" + return 0 + fi + sleep 10 + done + } + + echo "demo_conclusion=$(wait_for_run "$DEMO_RUN_ID")" >> "$GITHUB_OUTPUT" + echo "tcr_source_conclusion=$(wait_for_run "$TCR_RUN_ID")" >> "$GITHUB_OUTPUT" + + - name: Observe public TCR Release readiness + id: tcr + env: + RELEASE_SHA: ${{ inputs.release_sha }} + RELEASE_TAG: ${{ inputs.release_tag }} + SOURCE_CONCLUSION: ${{ steps.post_release.outputs.tcr_source_conclusion }} + run: | + status="failure" + ready_at="" + if [[ "$SOURCE_CONCLUSION" = "success" ]]; then + for attempt in $(seq 1 180); do + if raw_manifest="$(docker buildx imagetools inspect --raw "${TCR_IMAGE}:${RELEASE_TAG}" 2>/dev/null)" && + jq -e '([.manifests[].platform.architecture] | index("amd64") != null) and ([.manifests[].platform.architecture] | index("arm64") != null)' <<<"$raw_manifest" >/dev/null; then + metadata="$(docker buildx imagetools inspect "${TCR_IMAGE}:${RELEASE_TAG}" --format '{{json .Image}}' 2>/dev/null || true)" + if jq -e --arg revision "$RELEASE_SHA" 'length >= 2 and all(.[]; .config.Labels["org.opencontainers.image.revision"] == $revision)' <<<"$metadata" >/dev/null 2>&1; then + status="success" + ready_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + break + fi + fi + sleep 15 + done + if [[ "$status" != "success" ]]; then + status="timeout" + fi + fi + { + echo "status=$status" + echo "ready_at=$ready_at" + } >> "$GITHUB_OUTPUT" + + - name: Download exact Actions timing data + env: + DEMO_RUN_ID: ${{ steps.release.outputs.demo_run_id }} + DESKTOP_RUN_ID: ${{ inputs.desktop_run_id }} + DOCKER_RUN_ID: ${{ inputs.docker_run_id }} + MOBILE_RUN_ID: ${{ inputs.mobile_run_id }} + TCR_RUN_ID: ${{ steps.release.outputs.tcr_run_id }} + run: | + collect_run() { + local name="$1" + local run_id="$2" + gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}" > "$RUNNER_TEMP/${name}-run.json" + gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/jobs?filter=latest&per_page=100" > "$RUNNER_TEMP/${name}-jobs.json" + jq -s '{run: .[0], jobs: .[1].jobs}' \ + "$RUNNER_TEMP/${name}-run.json" \ + "$RUNNER_TEMP/${name}-jobs.json" \ + > "$RUNNER_TEMP/${name}.json" + } + + collect_run desktop "$DESKTOP_RUN_ID" + collect_run mobile "$MOBILE_RUN_ID" + collect_run docker "$DOCKER_RUN_ID" + collect_run cloudflare "$DEMO_RUN_ID" + collect_run tcr-source "$TCR_RUN_ID" + + - name: Generate unified Release timing report + env: + RELEASE_URL: ${{ steps.release.outputs.release_url }} + TCR_READY_AT: ${{ steps.tcr.outputs.ready_at }} + TCR_STATUS: ${{ steps.tcr.outputs.status }} + run: | + node scripts/report-release-timings.mjs \ + --tag "${{ inputs.release_tag }}" \ + --sha "${{ inputs.release_sha }}" \ + --published-at "${{ steps.release.outputs.published_at }}" \ + --release-url "$RELEASE_URL" \ + --desktop "$RUNNER_TEMP/desktop.json" \ + --desktop-mode "${{ inputs.desktop_mode }}" \ + --mobile "$RUNNER_TEMP/mobile.json" \ + --mobile-mode "${{ inputs.mobile_mode }}" \ + --docker "$RUNNER_TEMP/docker.json" \ + --cloudflare "$RUNNER_TEMP/cloudflare.json" \ + --tcr-source "$RUNNER_TEMP/tcr-source.json" \ + --tcr-ready-at "$TCR_READY_AT" \ + --tcr-status "$TCR_STATUS" \ + --json "$RUNNER_TEMP/release-build-timings.json" \ + --markdown "$RUNNER_TEMP/release-build-timings.md" + cat "$RUNNER_TEMP/release-build-timings.md" >> "$GITHUB_STEP_SUMMARY" + + - name: Return timing report to the related Issue + env: + ISSUE_NUMBER: ${{ inputs.issue_number }} + RELEASE_TAG: ${{ inputs.release_tag }} + run: | + marker="" + comment_file="$RUNNER_TEMP/release-build-timings-comment.md" + printf '%s\n\n' "$marker" > "$comment_file" + cat "$RUNNER_TEMP/release-build-timings.md" >> "$comment_file" + comment_id="$( + gh api "repos/${GITHUB_REPOSITORY}/issues/${ISSUE_NUMBER}/comments?per_page=100" \ + --paginate \ + --jq ".[] | select(.body | startswith(\"$marker\")) | .id" | + head -n 1 + )" + if [[ -n "$comment_id" ]]; then + jq -n --rawfile body "$comment_file" '{body: $body}' | + gh api --method PATCH "repos/${GITHUB_REPOSITORY}/issues/comments/${comment_id}" --input - >/dev/null + else + gh issue comment "$ISSUE_NUMBER" \ + --repo "$GITHUB_REPOSITORY" \ + --body-file "$comment_file" + fi + + - name: Upload machine-readable Release timing report + uses: actions/upload-artifact@v7 + with: + name: edgeever-${{ inputs.release_tag }}-build-timings + path: ${{ runner.temp }}/release-build-timings.json + if-no-files-found: error + retention-days: 90 diff --git a/scripts/release.mjs b/scripts/release.mjs index a7d39210..3bf2d4be 100644 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -27,6 +27,7 @@ export const RELEASE_WORKFLOWS = { androidPlaySignature: "android-play-signature-audit.yml", docker: "docker-image.yml", demo: "deploy-demo.yml", + timings: "release-timings.yml", }; export const RELEASE_VALIDATIONS = [ @@ -1178,6 +1179,38 @@ const releaseMain = async (options) => { "--body", `Released in [${tag}](${releaseUrl}).\n\nRequired local validations, Draft asset and image preparation, and post-publication audits passed.`, ]); + const timingDispatch = run("gh", [ + "workflow", + "run", + RELEASE_WORKFLOWS.timings, + "--repo", + options.repository, + "--ref", + "main", + "-f", + `release_tag=${tag}`, + "-f", + `release_sha=${releaseSha}`, + "-f", + `issue_number=${issueNumber}`, + "-f", + `desktop_run_id=${desktopRunId}`, + "-f", + `desktop_mode=${desktopPlan.rebuild ? "rebuild" : "reuse"}`, + "-f", + `mobile_run_id=${mobileRunId}`, + "-f", + `mobile_mode=${mobilePlan.rebuild ? "rebuild" : "reuse"}`, + "-f", + `docker_run_id=${dockerRunId}`, + ], { allowFailure: true }); + if (timingDispatch.status === 0) { + console.log( + `[release] endpoint timing report continues in background: https://github.com/${options.repository}/actions/workflows/${RELEASE_WORKFLOWS.timings}`, + ); + } else { + console.warn("[release] failed to dispatch the non-blocking endpoint timing report"); + } run("gh", [ "issue", "close", diff --git a/scripts/release.test.mjs b/scripts/release.test.mjs index c5685cda..d447934c 100644 --- a/scripts/release.test.mjs +++ b/scripts/release.test.mjs @@ -21,6 +21,15 @@ describe("release automation", () => { expect(RELEASE_WORKFLOWS.docker).toBe("docker-image.yml"); }); + test("dispatches a unified post-release endpoint timing report", () => { + expect(RELEASE_WORKFLOWS.timings).toBe("release-timings.yml"); + const releaseSource = readFileSync(new URL("./release.mjs", import.meta.url), "utf8"); + expect(releaseSource).toContain("desktop_run_id=\${desktopRunId}"); + expect(releaseSource).toContain("mobile_run_id=\${mobileRunId}"); + expect(releaseSource).toContain("docker_run_id=\${dockerRunId}"); + expect(releaseSource).toContain("endpoint timing report continues in background"); + }); + test("blocks publication until the Draft Android APK passes the Play signature gate", () => { const releaseSource = readFileSync(new URL("./release.mjs", import.meta.url), "utf8"); const signatureGate = releaseSource.indexOf('label: "Draft Android Play signature gate"'); diff --git a/scripts/report-release-timings.mjs b/scripts/report-release-timings.mjs new file mode 100644 index 00000000..70088842 --- /dev/null +++ b/scripts/report-release-timings.mjs @@ -0,0 +1,292 @@ +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})` : "—"); + +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 = ["macOS arm64", "macOS x64"] + .map((name) => findJob(payload, name)) + .filter(Boolean); + if (architectureJobs.length > 0) { + return architectureJobs.map((job) => { + const packageStep = findStep(job, "Package desktop installer"); + return componentRow({ + target: job.name, + mode: "rebuild", + candidate: job, + duration: jobDurationMs(job), + detail: `package + notarize ${formatDuration(stepDurationMs(packageStep))}`, + url: job.html_url ?? payload.run.html_url, + }); + }); + } + + const reuseJob = findJob(payload, "Reuse macOS release assets"); + if (reuseJob) { + return [ + componentRow({ + target: "macOS arm64 + 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", + 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, + 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 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), + ]; + + 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: 1, + release, + generatedAt: new Date().toISOString(), + 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.`, + "", + "| 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), + 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)); +} diff --git a/scripts/report-release-timings.test.mjs b/scripts/report-release-timings.test.mjs new file mode 100644 index 00000000..4dc6e2eb --- /dev/null +++ b/scripts/report-release-timings.test.mjs @@ -0,0 +1,165 @@ +import { describe, expect, test } from "bun:test"; +import { + createReleaseTimingReport, + renderReleaseTimingMarkdown, +} from "./report-release-timings.mjs"; + +const step = (name, start, seconds) => ({ + name, + conclusion: "success", + started_at: start, + completed_at: new Date(Date.parse(start) + seconds * 1000).toISOString(), +}); + +const job = (name, start, seconds, steps = []) => ({ + name, + conclusion: "success", + started_at: start, + completed_at: new Date(Date.parse(start) + seconds * 1000).toISOString(), + html_url: `https://example.test/jobs/${encodeURIComponent(name)}`, + steps, +}); + +const payload = (name, start, seconds, jobs) => ({ + run: { + name, + conclusion: "success", + created_at: start, + run_started_at: start, + updated_at: new Date(Date.parse(start) + seconds * 1000).toISOString(), + html_url: `https://example.test/runs/${encodeURIComponent(name)}`, + }, + jobs, +}); + +describe("release timing report", () => { + test("combines Cloudflare, both registries, and rebuilt clients", () => { + const start = "2026-08-25T00:00:00Z"; + const report = createReleaseTimingReport({ + release: { tag: "v1.41.0", sha: "abc", publishedAt: start }, + desktop: payload("Desktop", start, 600, [ + job("macOS arm64", start, 480, [ + step("Package desktop installer", start, 180), + ]), + job("macOS x64", start, 600, [ + step("Package desktop installer", start, 300), + ]), + ]), + desktopMode: "rebuild", + mobile: payload("Mobile", start, 540, [ + job("Build Android packages", start, 540, [ + step("Build signed release APK for GitHub Release", start, 420), + ]), + ]), + mobileMode: "rebuild", + docker: payload("Docker", start, 300, [ + job("Publish official multi-platform image", start, 300, [ + step("Build and publish image", start, 240), + ]), + ]), + cloudflare: payload("Cloudflare", start, 120, [ + job("Build and deploy Demo Worker", start, 120, [ + step("Deploy Demo Worker", start, 60), + ]), + ]), + tcrSource: payload("TCR", start, 20, [ + job("Trigger asynchronous Tencent-side image build", start, 20), + ]), + tcrReadyAt: "2026-08-25T00:10:00Z", + tcrStatus: "success", + }); + + expect(report.allEndpointsReady).toBe(true); + expect(report.allEndpointsReadyDurationMs).toBe(600_000); + expect(report.rows.map((row) => row.target)).toEqual([ + "Cloudflare Demo Worker", + "Docker GHCR (amd64 + arm64)", + "Docker TCR (amd64 + arm64)", + "macOS arm64", + "macOS x64", + "Android arm64", + ]); + const markdown = renderReleaseTimingMarkdown(report); + expect(markdown).toContain( + "All post-publication endpoints were observed after **10m 00s**", + ); + expect(markdown).toContain( + "| Docker TCR (amd64 + arm64) | Tencent-side build | success | 10m 00s |", + ); + expect(markdown).toContain( + "| macOS x64 | rebuild | success | 10m 00s | package + notarize 5m 00s |", + ); + }); + + test("reports reused native assets without pretending they were rebuilt", () => { + const start = "2026-08-25T00:00:00Z"; + const common = { + release: { tag: "v1.41.1", sha: "def", publishedAt: start }, + docker: payload("Docker", start, 60, [ + job("Publish official multi-platform image", start, 60), + ]), + cloudflare: payload("Cloudflare", start, 60, [ + job("Build and deploy Demo Worker", start, 60), + ]), + tcrSource: payload("TCR", start, 15, [ + job("Trigger asynchronous Tencent-side image build", start, 15), + ]), + tcrReadyAt: "2026-08-25T00:02:00Z", + tcrStatus: "success", + }; + const report = createReleaseTimingReport({ + ...common, + desktop: payload("Desktop", start, 40, [ + job("Reuse macOS release assets", start, 40), + ]), + desktopMode: "reuse", + mobile: payload("Mobile", start, 30, [ + job("Reuse Android release APK", start, 30), + ]), + mobileMode: "reuse", + }); + + expect(report.rows.at(-2)).toMatchObject({ + target: "macOS arm64 + x64", + mode: "reuse", + durationMs: 40_000, + }); + expect(report.rows.at(-1)).toMatchObject({ + target: "Android arm64", + mode: "reuse", + durationMs: 30_000, + }); + }); + + test("does not claim all endpoints are ready when TCR times out", () => { + const start = "2026-08-25T00:00:00Z"; + const report = createReleaseTimingReport({ + release: { tag: "v1.41.2", sha: "ghi", publishedAt: start }, + desktop: payload("Desktop", start, 40, [ + job("Reuse macOS release assets", start, 40), + ]), + desktopMode: "reuse", + mobile: payload("Mobile", start, 30, [ + job("Reuse Android release APK", start, 30), + ]), + mobileMode: "reuse", + docker: payload("Docker", start, 60, [ + job("Publish official multi-platform image", start, 60), + ]), + cloudflare: payload("Cloudflare", start, 60, [ + job("Build and deploy Demo Worker", start, 60), + ]), + tcrSource: payload("TCR", start, 15, [ + job("Trigger asynchronous Tencent-side image build", start, 15), + ]), + tcrReadyAt: null, + tcrStatus: "timeout", + }); + + expect(report.allEndpointsReady).toBe(false); + expect(report.allEndpointsReadyDurationMs).toBeNull(); + expect(renderReleaseTimingMarkdown(report)).toContain( + "At least one post-publication endpoint failed or was not observed", + ); + }); +});