From b763a2d5e3a6343d76990933c9d1a3106c5b3e6a Mon Sep 17 00:00:00 2001 From: Charlie Kolb Date: Tue, 11 Aug 2026 09:21:51 +0200 Subject: [PATCH] ci: Report the turbo critical path in build job summaries (#35092) Co-authored-by: Claude Fable 5 --- .github/workflows/ci-master.yml | 3 + .github/workflows/ci-pull-requests.yml | 4 + scripts/turbo-critical-path.mjs | 101 +++++++++++++++++++++++++ 3 files changed, 108 insertions(+) create mode 100644 scripts/turbo-critical-path.mjs diff --git a/.github/workflows/ci-master.yml b/.github/workflows/ci-master.yml index 1a6b7f66719..4965c97646e 100644 --- a/.github/workflows/ci-master.yml +++ b/.github/workflows/ci-master.yml @@ -24,6 +24,9 @@ jobs: # The default is build without typechecking, we want both build-command: pnpm build + - name: Report build critical path + run: node scripts/turbo-critical-path.mjs >> "$GITHUB_STEP_SUMMARY" + unit-test: name: Unit tests uses: ./.github/workflows/test-unit-reusable.yml diff --git a/.github/workflows/ci-pull-requests.yml b/.github/workflows/ci-pull-requests.yml index 38f49894608..282fbded6da 100644 --- a/.github/workflows/ci-pull-requests.yml +++ b/.github/workflows/ci-pull-requests.yml @@ -155,6 +155,10 @@ jobs: with: build-command: ${{ fromJSON(steps.ci-filter.outputs.results).ci && 'pnpm build' || 'pnpm turbo run build --filter=@n8n/playwright-janitor' }} + - name: Report build critical path + if: fromJSON(steps.ci-filter.outputs.results).ci + run: node scripts/turbo-critical-path.mjs >> "$GITHUB_STEP_SUMMARY" + - name: Run format check if: fromJSON(steps.ci-filter.outputs.results).ci run: pnpm format:check diff --git a/scripts/turbo-critical-path.mjs b/scripts/turbo-critical-path.mjs new file mode 100644 index 00000000000..2ca0c5d4412 --- /dev/null +++ b/scripts/turbo-critical-path.mjs @@ -0,0 +1,101 @@ +#!/usr/bin/env node +/** + * Reports the build-time-weighted critical path of a Turborepo run, from a + * `turbo run --summarize` summary file. The critical path is the + * dependency chain with the largest total duration — the lower bound on wall + * time no amount of parallelism can beat. Use it to spot tasks worth + * shrinking or edges worth removing. + * + * Usage: node scripts/turbo-critical-path.mjs [path-to-summary.json] + * Defaults to the newest file in .turbo/runs/. Prints markdown; never fails. + */ +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { join } from 'node:path'; + +const TOP_N = 10; + +function newestRunSummary() { + const dir = join(process.cwd(), '.turbo', 'runs'); + const files = readdirSync(dir) + .filter((f) => f.endsWith('.json')) + .map((f) => join(dir, f)) + .sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs); + if (files.length === 0) throw new Error(`no run summaries in ${dir}`); + return files[0]; +} + +function fmt(seconds) { + return seconds >= 100 ? `${Math.round(seconds)}s` : `${seconds.toFixed(1)}s`; +} + +try { + const file = process.argv[2] ?? newestRunSummary(); + const summary = JSON.parse(readFileSync(file, 'utf8')); + + const tasks = (summary.tasks ?? []) + .filter((t) => t.execution?.startTime && t.execution?.endTime) + .map((t) => ({ + id: t.taskId, + dur: (t.execution.endTime - t.execution.startTime) / 1000, + deps: t.dependencies ?? [], + cached: t.cache?.status === 'HIT', + })); + if (tasks.length === 0) throw new Error(`no executed tasks in ${file}`); + + const byId = new Map(tasks.map((t) => [t.id, t])); + const memo = new Map(); + function heaviestChain(id) { + if (memo.has(id)) return memo.get(id); + const task = byId.get(id); + if (!task) return { total: 0, path: [] }; + // turbo graphs are acyclic, so plain recursion terminates + let best = { total: 0, path: [] }; + for (const dep of task.deps) { + const chain = heaviestChain(dep); + if (chain.total > best.total) best = chain; + } + const result = { total: best.total + task.dur, path: [...best.path, id] }; + memo.set(id, result); + return result; + } + + let critical = { total: 0, path: [] }; + for (const t of tasks) { + const chain = heaviestChain(t.id); + if (chain.total > critical.total) critical = chain; + } + + const cpu = tasks.reduce((sum, t) => sum + t.dur, 0); + const wall = + (Math.max(...summary.tasks.map((t) => t.execution?.endTime ?? 0)) - + Math.min( + ...summary.tasks.filter((t) => t.execution?.startTime).map((t) => t.execution.startTime), + )) / + 1000; + const cachedCount = tasks.filter((t) => t.cached).length; + + const label = (t) => `\`${t.id}\`${t.cached ? ' (cache hit)' : ''}`; + + const lines = [ + `### Turbo critical path — \`${summary.execution?.command ?? 'run'}\``, + '', + `Wall ${fmt(wall)} · task CPU ${fmt(cpu)} · parallelism ${(cpu / wall).toFixed(1)}× · ${tasks.length} tasks (${cachedCount} cache hits)`, + '', + `**Critical path: ${fmt(critical.total)}** (${((critical.total / wall) * 100).toFixed(0)}% of wall time)`, + '', + ...critical.path.map((id) => `- ${fmt(byId.get(id).dur)} ${label(byId.get(id))}`), + '', + `**Slowest tasks**`, + '', + ...tasks + .slice() + .sort((a, b) => b.dur - a.dur) + .slice(0, TOP_N) + .map((t) => `- ${fmt(t.dur)} ${label(t)}`), + '', + ]; + console.log(lines.join('\n')); +} catch (error) { + // Reporting only — never break the build over it. + console.log(`turbo-critical-path: skipped (${error.message})`); +}