diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index b974ce08..add3585c 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -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: >- diff --git a/.github/workflows/mobile-build.yml b/.github/workflows/mobile-build.yml index 041bbb49..4128cb50 100644 --- a/.github/workflows/mobile-build.yml +++ b/.github/workflows/mobile-build.yml @@ -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 diff --git a/package.json b/package.json index 25ea6f89..b466d6e2 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/desktop-build-workflow.test.mjs b/scripts/desktop-build-workflow.test.mjs new file mode 100644 index 00000000..0afa06c7 --- /dev/null +++ b/scripts/desktop-build-workflow.test.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"); + }); +}); diff --git a/scripts/report-native-build-timings.mjs b/scripts/report-native-build-timings.mjs new file mode 100644 index 00000000..d4858028 --- /dev/null +++ b/scripts/report-native-build-timings.mjs @@ -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)); +} diff --git a/scripts/report-native-build-timings.test.mjs b/scripts/report-native-build-timings.test.mjs new file mode 100644 index 00000000..55893730 --- /dev/null +++ b/scripts/report-native-build-timings.test.mjs @@ -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"); + }); +});