Skip to content

Commit d8cb659

Browse files
authored
ci: duration-balanced integration shards, warm desktop-live, CI on every PR (#8763)
* ci: duration-balanced integration shards, warm desktop-live, CI on every PR - integration: a custom Vitest sequencer packs `--shard` files by measured duration from a committed `vitest.integration-durations.json` (refreshed with scripts/update-integration-durations.ts) instead of by path hash. On the measured run the apps/sim shards go from 183-384s to 254s each, so the slowest job drops from ~7.4 to ~5.3 min at the same cost. Every shard computes the same partition, so files stay disjoint and covered. - desktop-live: Turbopack's dev cache is kept on a sticky disk keyed by event, fork and the installed Next version, so the spec's warm-up restores instead of cold-compiling. The app is stopped with SIGINT so the cache write completes, a cache Turbopack reports as corrupt is dropped and the app restarted from empty, and the apps boot while apt and the Electron bundle run. - ci.yml: no paths-ignore on pull_request. `checks / ci` is going to be the required check, and a pull request whose run never starts never reports it. * ci: clear the dev cache by absolute path, always run session cleanup, floor shard weights, log through @sim/logger * ci: a malformed durations file names its fix, and non-finite weights count as unknown
1 parent bd7085e commit d8cb659

5 files changed

Lines changed: 421 additions & 18 deletions

File tree

‎.github/workflows/checks.yml‎

Lines changed: 76 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,9 @@ jobs:
1515
#
1616
# The two paths build different schemas (migrations add triggers, checks and NOT VALID
1717
# constraints that `db:push` does not), so each runs the whole suite. Vitest splits each
18-
# suite's files across four shards; a shard runs its files one at a time against its own
19-
# database. Files run serially, so a shard barely uses more than one core: 4 vCPU is enough.
18+
# suite's files across four shards by measured duration (DurationBalancedSequencer in vitest.shared.ts); a
19+
# shard runs its files one at a time against its own database. Files run serially, so a shard
20+
# barely uses more than one core: 4 vCPU is enough.
2021
name: integration (${{ matrix.provision }}, ${{ matrix.shard }}/4)
2122
runs-on: &runner-4vcpu ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }}
2223
timeout-minutes: 20
@@ -272,6 +273,23 @@ jobs:
272273
working-directory: packages/db
273274
run: bun run db:migrate
274275

276+
# Turbopack's dev cache turns the spec's route warm-up from a cold compile (~4 min) into a
277+
# restore. It is content-addressed, so a pull request's changed modules still recompile; the
278+
# key carries the installed Next version so an upgrade starts from an empty cache, and the
279+
# event and fork segments keep untrusted runs off the cache trusted runs read.
280+
- name: Resolve Turbopack dev cache key
281+
id: next-cache
282+
run: echo "key=${GITHUB_REPOSITORY}-next-dev-desktop-live-${GITHUB_EVENT_NAME}${FORK_SUFFIX}-$(jq -r .version node_modules/next/package.json)" >> "$GITHUB_OUTPUT"
283+
env:
284+
FORK_SUFFIX: ${{ github.event.pull_request.head.repo.fork && '-fork' || '' }}
285+
286+
- name: Mount Turbopack dev cache
287+
uses: ./.github/actions/cache
288+
with:
289+
provider: ${{ vars.CI_PROVIDER }}
290+
key: ${{ steps.next-cache.outputs.key }}
291+
path: ./apps/sim/.next/dev
292+
275293
# Chat switches, Stop, sign-out, approval and the flag-off foreground round trip. The spec
276294
# runs the recording proxy (the app's public origin) and the stand-in worker.
277295
- name: Verify desktop tools in the Electron app against a local app
@@ -292,39 +310,84 @@ jobs:
292310
READY_TIMEOUT_SECONDS: 300
293311
run: |
294312
report_dir="$RUNNER_TEMP/e2e"
313+
next_log="$report_dir/desktop-tools-next.log"
295314
mkdir -p "$report_dir"
296-
sudo apt-get update -q
297-
sudo apt-get install -yq xvfb libgtk-3-0t64 libnss3 libasound2t64 libgbm1 libxss1 \
298-
libxtst6 libatk-bridge2.0-0t64 libxkbcommon0 > /dev/null
299-
# Bundle only: `bun run build` also fetches the macOS node-pty prebuilds for packaging,
300-
# which a Linux run does not use.
301-
(cd apps/desktop && bun run scripts/build.ts)
302315
# Each app runs in its own session under an E2E_APP tag, and stop-session.sh returns once
303316
# every process it started has exited.
304317
realtime_tag="desktop-realtime-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT-$$"
305318
server_tag="desktop-tools-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT-$$"
319+
# Keeps the restored cache under the cap the dev scripts apply locally.
320+
(cd apps/sim && bun run dev:cache:cap)
321+
start_sim() {
322+
(cd apps/sim && E2E_APP="$server_tag" exec setsid node ../../node_modules/next/dist/bin/next dev --hostname 127.0.0.1 \
323+
--port 3021 >> "$next_log" 2>&1) &
324+
server_pid=$!
325+
}
326+
# A corrupted cache aborts Turbopack instead of falling back. Only then is the shared cache
327+
# dropped: a failing test must not cost every later run its warm cache.
328+
cache_broken() {
329+
grep -qiE 'cache corruption|turbopack.*panic|panicked' "$next_log" 2>/dev/null
330+
}
331+
# The cache directory is a mount point: empty it rather than remove it. Absolute, because
332+
# the EXIT trap runs after the step has moved into apps/desktop.
333+
clear_cache() {
334+
find "$GITHUB_WORKSPACE/apps/sim/.next/dev" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
335+
}
336+
# SIGINT first: `next dev` SIGKILLs its server 100ms after SIGTERM, which discards a cache
337+
# write in flight. stop-session.sh then removes anything still running.
338+
# SIGINT is best-effort; the cleanup always runs, since workers and the detached telemetry
339+
# flush can outlive a server that has already exited.
340+
stop_sim() {
341+
if kill -INT "$server_pid" 2>/dev/null; then
342+
for _ in $(seq 1 30); do kill -0 "$server_pid" 2>/dev/null || break; sleep 1; done
343+
fi
344+
bash "$GITHUB_WORKSPACE/.github/scripts/stop-session.sh" "$server_pid" "$server_tag"
345+
}
306346
(cd apps/realtime && PORT=3023 SIM_DB_ROLE=realtime ALLOWED_ORIGINS="$NEXT_PUBLIC_APP_URL" \
307347
E2E_APP="$realtime_tag" exec setsid bun src/index.ts > "$report_dir/desktop-tools-realtime.log" 2>&1) &
308348
realtime_pid=$!
309-
(cd apps/sim && E2E_APP="$server_tag" exec setsid node ../../node_modules/next/dist/bin/next dev --hostname 127.0.0.1 \
310-
--port 3021 > "$report_dir/desktop-tools-next.log" 2>&1) &
311-
server_pid=$!
349+
start_sim
312350
finish() {
313351
status=$?
314-
bash "$GITHUB_WORKSPACE/.github/scripts/stop-session.sh" "$server_pid" "$server_tag" || status=1
352+
stop_sim || status=1
315353
bash "$GITHUB_WORKSPACE/.github/scripts/stop-session.sh" "$realtime_pid" "$realtime_tag" || status=1
316354
wait "$server_pid" "$realtime_pid" 2>/dev/null || true
355+
if cache_broken; then
356+
echo "::warning::Turbopack reported a broken dev cache; clearing it for the next run."
357+
clear_cache
358+
fi
317359
exit "$status"
318360
}
319361
trap finish EXIT
362+
# The apps boot while the runner installs Electron's libraries and bundles the shell.
363+
sudo apt-get update -q
364+
sudo apt-get install -yq xvfb libgtk-3-0t64 libnss3 libasound2t64 libgbm1 libxss1 \
365+
libxtst6 libatk-bridge2.0-0t64 libxkbcommon0 > /dev/null
366+
# Bundle only: `bun run build` also fetches the macOS node-pty prebuilds for packaging,
367+
# which a Linux run does not use.
368+
(cd apps/desktop && bun run scripts/build.ts)
320369
started=$SECONDS
370+
retried=0
321371
until curl --fail --silent --max-time 10 http://127.0.0.1:3021/api/health > /dev/null &&
322372
curl --fail --silent --max-time 10 http://127.0.0.1:3023/health > /dev/null; do
323-
kill -0 "$server_pid" 2>/dev/null || { tail -n 200 "$report_dir/desktop-tools-next.log"; exit 1; }
373+
if ! kill -0 "$server_pid" 2>/dev/null; then
374+
tail -n 200 "$next_log"
375+
if [ "$retried" = 0 ] && cache_broken; then
376+
echo "::warning::Turbopack rejected the restored dev cache; restarting from an empty cache."
377+
bash "$GITHUB_WORKSPACE/.github/scripts/stop-session.sh" "$server_pid" "$server_tag"
378+
clear_cache
379+
: > "$next_log"
380+
retried=1
381+
start_sim
382+
continue
383+
fi
384+
exit 1
385+
fi
324386
kill -0 "$realtime_pid" 2>/dev/null || { tail -n 200 "$report_dir/desktop-tools-realtime.log"; exit 1; }
325387
[ $((SECONDS - started)) -lt "$READY_TIMEOUT_SECONDS" ] || { echo '::error::Local app did not become ready'; exit 1; }
326388
sleep 2
327389
done
390+
echo "Local apps ready $((SECONDS - started))s after the Electron bundle"
328391
cd apps/desktop
329392
SIM_DESKTOP_E2E_SIM_URL=http://127.0.0.1:3021 \
330393
SIM_DESKTOP_E2E_PROXY_PORT=3020 \

‎.github/workflows/ci.yml‎

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,11 @@ name: ci
1818
on:
1919
push:
2020
branches: [main, staging, dev]
21+
# No path filter: `checks / ci` is the required status check, and a pull request whose run
22+
# never starts never reports it, so it could not merge. Docs-only pull requests are rare enough
23+
# that running CI on them costs nothing measurable.
2124
pull_request:
2225
branches: [main, staging, dev]
23-
# Docs content and markdown don't affect the app build or images; push
24-
# runs stay unfiltered because they feed the deploy pipeline.
25-
paths-ignore:
26-
- 'apps/docs/content/**'
27-
- '**/*.md'
2826

2927
concurrency:
3028
group: ci-${{ github.ref }}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
#!/usr/bin/env bun
2+
/**
3+
* Rewrites `vitest.integration-durations.json`, the per-file weights the integration shards are
4+
* balanced by (`DurationBalancedSequencer` in `vitest.shared.ts`), from a CI run's integration reports.
5+
*
6+
* gh run download <run-id> -p 'integration-reports-*' -D /tmp/integration-reports
7+
* bun run scripts/update-integration-durations.ts /tmp/integration-reports
8+
*
9+
* A file's weight is its wall time including the import and collection that precede its first
10+
* test (the gap since the previous file in the same shard ended), averaged across the push and
11+
* migrate provisioning runs. apps/sim runs its files one at a time, so those gaps are real shard
12+
* time; where files overlap, a file counts only its own time. Weights only steer balance; a stale
13+
* file never changes what runs.
14+
*/
15+
import { readdir, readFile, writeFile } from 'node:fs/promises'
16+
import path from 'node:path'
17+
import { createLogger } from '@sim/logger'
18+
19+
interface JsonReport {
20+
testResults: { name: string; startTime: number; endTime: number }[]
21+
}
22+
23+
const logger = createLogger('UpdateIntegrationDurations')
24+
const REPORT_NAME = 'integration.json'
25+
const ROOT = path.resolve(import.meta.dir, '..')
26+
const OUTPUT = path.join(ROOT, 'vitest.integration-durations.json')
27+
28+
async function findReports(dir: string): Promise<string[]> {
29+
const entries = await readdir(dir, { withFileTypes: true, recursive: true })
30+
return entries
31+
.filter((entry) => entry.isFile() && entry.name === REPORT_NAME)
32+
.map((entry) => path.join(entry.parentPath, entry.name))
33+
}
34+
35+
/** `/home/runner/_work/sim/sim/apps/sim/lib/x.integration.ts` → `apps/sim/lib/x.integration.ts` */
36+
function repoPath(absolute: string): string | null {
37+
const match = absolute.match(/\/((?:apps|packages)\/.+)$/)
38+
return match ? match[1] : null
39+
}
40+
41+
const source = process.argv[2]
42+
if (!source) {
43+
logger.error('Usage: bun run scripts/update-integration-durations.ts <downloaded-reports-dir>')
44+
process.exit(1)
45+
}
46+
47+
const samples = new Map<string, number[]>()
48+
const reports = await findReports(path.resolve(source))
49+
for (const reportPath of reports) {
50+
const report = JSON.parse(await readFile(reportPath, 'utf8')) as JsonReport
51+
const files = [...report.testResults].sort((a, b) => a.startTime - b.startTime)
52+
let previousEnd = Number.NEGATIVE_INFINITY
53+
for (const file of files) {
54+
const key = repoPath(file.name)
55+
// The gap before a file is its import time only when nothing else was running: in a run
56+
// with file parallelism (packages/db) files overlap, and a file then counts its own time.
57+
const start =
58+
previousEnd > Number.NEGATIVE_INFINITY && previousEnd <= file.startTime
59+
? previousEnd
60+
: file.startTime
61+
if (key) samples.set(key, [...(samples.get(key) ?? []), (file.endTime - start) / 1000])
62+
previousEnd = Math.max(previousEnd, file.endTime)
63+
}
64+
}
65+
66+
if (samples.size === 0) {
67+
logger.error(`No ${REPORT_NAME} reports with test results under ${source}`)
68+
process.exit(1)
69+
}
70+
71+
const durations = Object.fromEntries(
72+
[...samples.keys()].sort().map((key) => {
73+
const values = samples.get(key) ?? []
74+
const mean = values.reduce((sum, value) => sum + value, 0) / values.length
75+
return [key, Math.round(mean * 10) / 10]
76+
})
77+
)
78+
await writeFile(OUTPUT, `${JSON.stringify(durations, null, 2)}\n`)
79+
logger.info(`Wrote ${samples.size} file weights from ${reports.length} reports to ${OUTPUT}`)

0 commit comments

Comments
 (0)