ci: optimize native release builds and report timings

This commit is contained in:
tianma-if committed 2026-08-14 01:06:03 +08:00
1 parent 37b1631ed5
commit d8fd772a78
6 files changed
+391 -4

No files matched your search

+54 -3
View File
@@ -11,6 +11,7 @@ on:
types: [published]
permissions:
actions: read
contents: write
jobs:
@@ -154,8 +155,10 @@ jobs:
include:
- arch: arm64
runner: macos-15
shared_validation: true
- arch: x64
runner: macos-15-intel
shared_validation: false
steps:
- name: Check out source
@@ -169,7 +172,11 @@ jobs:
- name: Set up Rust
uses: dtolnay/rust-toolchain@stable
- name: Cache Bun dependencies
# Restoring this large cache is consistently slower than a cold install on
# the Intel runner. The arm64 job owns the shared validation and keeps the
# cache because its restore path is substantially faster.
- name: Cache Bun dependencies for shared validation
if: matrix.shared_validation
uses: actions/cache@v5
with:
path: ~/.bun/install/cache
@@ -248,28 +255,36 @@ jobs:
run: bun run build:web
- name: Verify Web precache budget
if: matrix.shared_validation
run: bun run verify:web-performance
- name: Run project type checks
if: matrix.shared_validation
run: |
bun run typecheck
bun run typecheck:mobile
- name: Build debug sidecar for integration tests
if: matrix.shared_validation
run: cargo build --manifest-path crates/desktop-sidecar/Cargo.toml
- name: Run desktop regression tests
if: matrix.shared_validation
run: bun run test:desktop
- name: Verify packaged renderer startup
if: matrix.shared_validation
run: bun run verify:desktop-renderer
- name: Build Rust sidecar
- name: Validate Rust sidecar
if: matrix.shared_validation
run: |
cargo fmt --manifest-path crates/desktop-sidecar/Cargo.toml -- --check
cargo clippy --manifest-path crates/desktop-sidecar/Cargo.toml --all-targets -- -D warnings
cargo test --manifest-path crates/desktop-sidecar/Cargo.toml
cargo build --manifest-path crates/desktop-sidecar/Cargo.toml --release
- name: Build architecture-specific Rust sidecar
run: cargo build --manifest-path crates/desktop-sidecar/Cargo.toml --release
- name: Prepare Apple notarization API key
env:
@@ -312,6 +327,42 @@ jobs:
if-no-files-found: error
retention-days: 14
native-build-timings:
needs: desktop
if: >-
${{
always() &&
github.repository == 'tianma-if/edgeever' &&
needs.desktop.result != 'skipped'
}}
name: Report macOS build timings
runs-on: ubuntu-latest
steps:
- name: Check out timing reporter
uses: actions/checkout@v5
- name: Collect native build timings
env:
GH_TOKEN: ${{ github.token }}
run: |
gh api \
"repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/jobs?filter=latest&per_page=100" \
> "$RUNNER_TEMP/actions-jobs.json"
node scripts/report-native-build-timings.mjs \
--input "$RUNNER_TEMP/actions-jobs.json" \
--platform desktop \
--json "$RUNNER_TEMP/native-build-timings.json" \
--markdown "$RUNNER_TEMP/native-build-timings.md"
cat "$RUNNER_TEMP/native-build-timings.md" >> "$GITHUB_STEP_SUMMARY"
- name: Upload machine-readable timing report
uses: actions/upload-artifact@v6
with:
name: edgeever-desktop-build-timings-attempt-${{ github.run_attempt }}
path: ${{ runner.temp }}/native-build-timings.json
if-no-files-found: error
retention-days: 90
desktop-assets:
needs: [release-plan, desktop]
if: >-
+37
View File
@@ -22,6 +22,7 @@ on:
- "scripts/verify-android-apk-signature.mjs"
permissions:
actions: read
contents: write
concurrency:
@@ -266,3 +267,39 @@ jobs:
- name: Remove temporary upload keystore
if: always()
run: rm -f "$RUNNER_TEMP/edgeever-upload.p12"
native-build-timings:
needs: android
if: >-
${{
always() &&
github.repository == 'tianma-if/edgeever' &&
needs.android.result != 'skipped'
}}
name: Report Android build timings
runs-on: ubuntu-latest
steps:
- name: Checkout timing reporter
uses: actions/checkout@v5
- name: Collect native build timings
env:
GH_TOKEN: ${{ github.token }}
run: |
gh api \
"repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/jobs?filter=latest&per_page=100" \
> "$RUNNER_TEMP/actions-jobs.json"
node scripts/report-native-build-timings.mjs \
--input "$RUNNER_TEMP/actions-jobs.json" \
--platform android \
--json "$RUNNER_TEMP/native-build-timings.json" \
--markdown "$RUNNER_TEMP/native-build-timings.md"
cat "$RUNNER_TEMP/native-build-timings.md" >> "$GITHUB_STEP_SUMMARY"
- name: Upload machine-readable timing report
uses: actions/upload-artifact@v7
with:
name: edgeever-android-build-timings-attempt-${{ github.run_attempt }}
path: ${{ runner.temp }}/native-build-timings.json
if-no-files-found: error
retention-days: 90
+1 -1
View File
@@ -52,7 +52,7 @@
"build:desktop": "EDGE_EVER_DESKTOP_BUILD=1 bun run prepare:desktop:icons && bun run build:web && bun run build:desktop:sidecar && bun run --cwd apps/desktop dist",
"benchmark:desktop:sidecar": "bun scripts/benchmark-desktop-sidecar.mjs",
"verify:web-performance": "bun scripts/verify-web-performance.mjs",
"test:release-planning": "bun test scripts/plan-native-release.test.mjs scripts/check-native-release-assets.test.mjs scripts/create-mac-update-metadata.test.mjs scripts/release.test.mjs scripts/validate-store-delivery.test.mjs scripts/store-delivery.test.mjs scripts/verify-android-apk-signature.test.mjs scripts/desktop-icns.test.mjs scripts/preview-macos-dock-icon.test.mjs apps/web/src/lib/version-check.test.mjs apps/mobile/src/lib/mobile-release.test.ts",
"test:release-planning": "bun test scripts/plan-native-release.test.mjs scripts/check-native-release-assets.test.mjs scripts/create-mac-update-metadata.test.mjs scripts/desktop-build-workflow.test.mjs scripts/report-native-build-timings.test.mjs scripts/release.test.mjs scripts/validate-store-delivery.test.mjs scripts/store-delivery.test.mjs scripts/verify-android-apk-signature.test.mjs scripts/desktop-icns.test.mjs scripts/preview-macos-dock-icon.test.mjs apps/web/src/lib/version-check.test.mjs apps/mobile/src/lib/mobile-release.test.ts",
"release": "bun scripts/release.mjs",
"publish:stores": "bun scripts/store-delivery.mjs",
"test:desktop:sidecar": "bun scripts/test-desktop-sidecar.mjs",
+58
View File
@@ -0,0 +1,58 @@
import { readFileSync } from "node:fs";
import { describe, expect, test } from "bun:test";
const workflow = readFileSync(new URL("../.github/workflows/desktop-build.yml", import.meta.url), "utf8");
const mobileWorkflow = readFileSync(new URL("../.github/workflows/mobile-build.yml", import.meta.url), "utf8");
function step(name) {
const start = workflow.indexOf(` - name: ${name}\n`);
expect(start).toBeGreaterThanOrEqual(0);
const next = workflow.indexOf("\n - name: ", start + 1);
return workflow.slice(start, next === -1 ? workflow.length : next);
}
describe("desktop release workflow", () => {
test("assigns shared validation only to the arm64 matrix job", () => {
expect(workflow).toContain([
" - arch: arm64",
" runner: macos-15",
" shared_validation: true",
].join("\n"));
expect(workflow).toContain([
" - arch: x64",
" runner: macos-15-intel",
" shared_validation: false",
].join("\n"));
});
test("runs architecture-independent checks once", () => {
for (const name of [
"Cache Bun dependencies for shared validation",
"Verify Web precache budget",
"Run project type checks",
"Build debug sidecar for integration tests",
"Run desktop regression tests",
"Verify packaged renderer startup",
"Validate Rust sidecar",
]) {
expect(step(name)).toContain(" if: matrix.shared_validation\n");
}
});
test("still builds and verifies each architecture-specific installer", () => {
expect(step("Build architecture-specific Rust sidecar")).not.toContain("if: matrix.shared_validation");
expect(step("Package desktop installer")).not.toContain("if: matrix.shared_validation");
expect(step("Package desktop installer")).toContain("EDGE_EVER_DESKTOP_ARCH: ${{ matrix.arch }}");
expect(step("Verify desktop installer")).not.toContain("if: matrix.shared_validation");
});
test("reports timings after builds without instrumenting native build steps", () => {
expect(workflow).toContain("name: Report macOS build timings");
expect(workflow).toContain("--platform desktop");
expect(workflow).toContain("name: edgeever-desktop-build-timings");
expect(mobileWorkflow).toContain("name: Report Android build timings");
expect(mobileWorkflow).toContain("--platform android");
expect(mobileWorkflow).toContain("name: edgeever-android-build-timings");
expect(mobileWorkflow).not.toContain("Build signed release APK for GitHub Release\n run: time");
});
});
+163
View File
@@ -0,0 +1,163 @@
import { readFileSync, writeFileSync } from "node:fs";
const TARGETS = {
desktop: [
{
name: "macOS arm64",
jobName: "macOS arm64",
packageSteps: ["Package desktop installer"],
},
{
name: "macOS x64",
jobName: "macOS x64",
packageSteps: ["Package desktop installer"],
},
],
android: [
{
name: "Android arm64",
jobName: "Build Android packages",
packageSteps: [
"Build signed release APK for GitHub Release",
"Build fast main-branch APK",
],
},
],
};
function timestamp(value) {
if (!value) return null;
const result = Date.parse(value);
return Number.isFinite(result) ? result : null;
}
export function durationMs(startedAt, completedAt) {
const start = timestamp(startedAt);
const end = timestamp(completedAt);
return start === null || end === null || end < start ? null : end - start;
}
export function formatDuration(milliseconds) {
if (milliseconds === null) return "—";
const seconds = Math.round(milliseconds / 1000);
const minutes = Math.floor(seconds / 60);
const remainder = seconds % 60;
return minutes > 0 ? `${minutes}m ${String(remainder).padStart(2, "0")}s` : `${remainder}s`;
}
function normalizeStep(step) {
return {
name: step.name,
conclusion: step.conclusion ?? null,
startedAt: step.started_at ?? null,
completedAt: step.completed_at ?? null,
durationMs: durationMs(step.started_at, step.completed_at),
};
}
export function createTimingReport(payload, platform, metadata = {}) {
const targets = TARGETS[platform];
if (!targets) throw new Error(`Unsupported timing platform: ${platform}`);
if (!Array.isArray(payload?.jobs)) throw new Error("Actions jobs response must contain a jobs array");
const results = targets.map((target) => {
const job = payload.jobs.find((candidate) => candidate.name === target.jobName);
if (!job) {
const available = payload.jobs.map((candidate) => candidate.name).join(", ");
throw new Error(`Missing Actions job “${target.jobName}”. Available jobs: ${available}`);
}
const steps = (job.steps ?? []).map(normalizeStep);
const packageCandidates = target.packageSteps
.map((name) => steps.find((candidate) => candidate.name === name))
.filter(Boolean);
const packageStep = packageCandidates.find((candidate) => candidate.durationMs !== null)
?? packageCandidates[0]
?? null;
const jobStart = timestamp(job.started_at);
const jobEnd = timestamp(job.completed_at);
const packageStart = timestamp(packageStep?.startedAt);
const packageEnd = timestamp(packageStep?.completedAt);
return {
name: target.name,
jobName: target.jobName,
conclusion: job.conclusion ?? null,
startedAt: job.started_at ?? null,
completedAt: job.completed_at ?? null,
totalDurationMs: durationMs(job.started_at, job.completed_at),
preparationDurationMs: jobStart !== null && packageStart !== null ? packageStart - jobStart : null,
packageDurationMs: packageStep?.durationMs ?? null,
finalizationDurationMs: packageEnd !== null && jobEnd !== null ? jobEnd - packageEnd : null,
packageStep: packageStep?.name ?? null,
steps,
};
});
return {
schemaVersion: 1,
platform,
repository: metadata.repository ?? null,
runId: metadata.runId ?? null,
runAttempt: metadata.runAttempt ?? null,
commit: metadata.commit ?? null,
targets: results,
};
}
export function renderTimingMarkdown(report) {
const lines = [
"## Native build timings",
"",
"The values below come from GitHub Actions job and step timestamps; the native build processes are not instrumented.",
"",
"| Target | Result | Total job | Before package | Package / notarize | After package |",
"| --- | --- | ---: | ---: | ---: | ---: |",
];
for (const target of report.targets) {
lines.push(
`| ${target.name} | ${target.conclusion ?? "unknown"} | ${formatDuration(target.totalDurationMs)} | ${formatDuration(target.preparationDurationMs)} | ${formatDuration(target.packageDurationMs)} | ${formatDuration(target.finalizationDurationMs)} |`,
);
}
lines.push("", "### Slowest recorded steps", "", "| Target | Step | Duration |", "| --- | --- | ---: |");
for (const target of report.targets) {
const slowest = target.steps
.filter((step) => step.durationMs !== null && !step.name.startsWith("Post "))
.sort((left, right) => right.durationMs - left.durationMs)
.slice(0, 5);
for (const step of slowest) {
lines.push(`| ${target.name} | ${step.name.replaceAll("|", "\\|")} | ${formatDuration(step.durationMs)} |`);
}
}
lines.push("");
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 ["input", "platform", "json", "markdown"]) {
if (!options[required]) throw new Error(`Missing --${required}`);
}
return options;
}
if (import.meta.main) {
const options = parseArguments(process.argv.slice(2));
const payload = JSON.parse(readFileSync(options.input, "utf8"));
const report = createTimingReport(payload, options.platform, {
repository: process.env.GITHUB_REPOSITORY,
runId: process.env.GITHUB_RUN_ID,
runAttempt: process.env.GITHUB_RUN_ATTEMPT,
commit: process.env.GITHUB_SHA,
});
writeFileSync(options.json, `${JSON.stringify(report, null, 2)}\n`);
writeFileSync(options.markdown, renderTimingMarkdown(report));
}
@@ -0,0 +1,78 @@
import { describe, expect, test } from "bun:test";
import {
createTimingReport,
durationMs,
formatDuration,
renderTimingMarkdown,
} from "./report-native-build-timings.mjs";
function job(name, startedAt, completedAt, packageDurationSeconds) {
const packageStart = new Date(Date.parse(startedAt) + 60_000).toISOString();
const packageEnd = new Date(Date.parse(packageStart) + packageDurationSeconds * 1000).toISOString();
return {
name,
conclusion: "success",
started_at: startedAt,
completed_at: completedAt,
steps: [
{
name: "Install dependencies",
conclusion: "success",
started_at: startedAt,
completed_at: packageStart,
},
{
name: name.startsWith("macOS") ? "Package desktop installer" : "Build signed release APK for GitHub Release",
conclusion: "success",
started_at: packageStart,
completed_at: packageEnd,
},
],
};
}
describe("native build timing reports", () => {
test("calculates stable durations", () => {
expect(durationMs("2026-08-13T00:00:00Z", "2026-08-13T00:02:03Z")).toBe(123_000);
expect(durationMs(null, "2026-08-13T00:02:03Z")).toBeNull();
expect(formatDuration(123_000)).toBe("2m 03s");
});
test("reports both desktop architectures from Actions timestamps", () => {
const report = createTimingReport({
jobs: [
job("macOS arm64", "2026-08-13T00:00:00Z", "2026-08-13T00:06:00Z", 180),
job("macOS x64", "2026-08-13T00:00:00Z", "2026-08-13T00:09:00Z", 360),
],
}, "desktop", { runId: "123" });
expect(report.runId).toBe("123");
expect(report.targets.map((target) => target.name)).toEqual(["macOS arm64", "macOS x64"]);
expect(report.targets[1].packageDurationMs).toBe(360_000);
expect(renderTimingMarkdown(report)).toContain("| macOS x64 | success | 9m 00s | 1m 00s | 6m 00s | 2m 00s |");
});
test("selects the release Android package step", () => {
const report = createTimingReport({
jobs: [job("Build Android packages", "2026-08-13T00:00:00Z", "2026-08-13T00:10:00Z", 480)],
}, "android");
expect(report.targets[0]).toMatchObject({
name: "Android arm64",
packageStep: "Build signed release APK for GitHub Release",
packageDurationMs: 480_000,
});
});
test("selects the completed Android package variant", () => {
const androidJob = job("Build Android packages", "2026-08-13T00:00:00Z", "2026-08-13T00:10:00Z", 480);
androidJob.steps.unshift({
name: "Build signed release APK for GitHub Release",
conclusion: "skipped",
started_at: null,
completed_at: null,
});
androidJob.steps[2].name = "Build fast main-branch APK";
const report = createTimingReport({ jobs: [androidJob] }, "android");
expect(report.targets[0].packageStep).toBe("Build fast main-branch APK");
});
});