feat(release): report endpoint build timings
This commit is contained in:
1 parent
5ab291cbcd
commit
b7e7e6e986
5 files changed
+738
No files matched your search
@@ -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="<!-- edgeever-release-build-timings:${RELEASE_TAG} -->"
|
||||
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
|
||||
@@ -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",
|
||||
|
||||
@@ -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"');
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user