ci: optimize native release builds and report timings
This commit is contained in:
1 parent
37b1631ed5
commit
d8fd772a78
6 files changed
+391
-4
No files matched your search
@@ -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: >-
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user