Skip to content

Commit 1a7931c

Browse files
committed
improvement(ci): halve the mobile job, narrow the desktop gate, skip proven checks on main, fix the top flake
- e2e (mobile): Chromium and WebKit run as two processes against one app instead of back to back (each seeds its own fixtures and writes its own report), and every e2e group restores its own Turbopack dev cache. The job was the PR critical path at ~19.5 min: Chromium's pass (718 s median) carried the cold compile and WebKit waited it out (310 s). `suite setup` failures are now logged instead of only reaching the uploaded report. - dev-cache action: one mount shared by desktop-live and the e2e groups, keyed by app, event, fork, Next version and the hash of the file that sets the app's environment, so a cache is never read under NEXT_PUBLIC_* values it was not compiled with. http-e2e.sh stops apps with SIGINT so the cache write completes, drops a cache Turbopack reports as corrupt, and retries a startup it aborted once from an empty cache. - desktop-live-changes.sh: run the live desktop suite when a pull request touches a path it exercises instead of skipping only docs-like paths. Every genuine desktop-live failure since the layout change was on a PR the new rule still runs; pushes always run it. - ci.yml: a `proof` job lets a main push skip the checks when it is a merge whose tree is identical to its staging parent and a staging push run of that parent passed `checks / ci`; migrate gates on that proof instead. Any other shape or any error runs the full checks. - codeql, helm, desktop-e2e skip the staging -> main release PR (main's push and schedule still run them); helm never cancels a push mid-publish; trigger-promote on 2 vCPU; attestations via actions/attest with artifact-metadata: write. - stripe-sync-convergence: start each contender only after the parked transaction holds its locks, wait on a deadline rather than 200 polls, and release the transaction before failing, so a lost race no longer hangs the suite's teardown. It was the most frequent flake (9 red runs). - Integration shard weights refreshed from a recent run; /ship runs the affected workspaces' suites instead of the full suite locally.
1 parent 024c31b commit 1a7931c

14 files changed

Lines changed: 454 additions & 203 deletions

File tree

‎.agents/skills/ship/SKILL.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ When the user runs `/ship`:
4242
- If the diff modifies UI code (any non-test `.tsx` file, or anything under `apps/sim/components/`, `apps/sim/hooks/`, or `apps/sim/stores/`), run `/cleanup`. It fans out the React/UI passes (effects, memo, callbacks, state, React Query, emcn, url-state), the comment pass, and the test-audit pass, and applies fixes so they land in this commit.
4343
- Otherwise, if the diff adds or changes tests (`*.test.ts(x)`, `*.integration.ts`, `**/e2e/**`, `apps/sim/scripts/test-*-e2e.ts`), run `/test-audit audit <changed test files>` on its own. Every new or changed test must pass the authoring gate; delete the ones that don't rather than shipping them.
4444
- Then run the test files the diff adds or changes, plus the existing tests beside changed source files, with `bun run --cwd <workspace> test <paths>` (`bun run --cwd apps/sim test <paths>` for the app; `*.integration.ts` needs the setup in `.claude/rules/sim-testing.md`). A failing test aborts ship.
45-
- Then run root `bun run test` from the repo root. It chains `test:scripts` (the `scripts/*.test.ts` suite CI runs) before every workspace suite; workspace-scoped runs skip it, which is how a `scripts/check-*.test.ts` failure has reached CI. A failing test aborts ship.
45+
- Then run `bun run test:scripts` (the `scripts/*.test.ts` suite CI runs; workspace-scoped runs skip it, which is how a `scripts/check-*.test.ts` failure has reached CI) and the suites of the workspaces the change reaches: `bunx turbo run test --filter='...[origin/staging]'` (each changed workspace plus every workspace that depends on it). CI runs every suite, sharded, on every push; the full local suite needs more memory than a laptop reliably has and catches nothing CI does not. When the diff touches root test or build config (`vitest.shared.ts`, `turbo.json`, the root `package.json`, `bun.lock`), the filter selects nothing that config reaches, so run the full root `bun run test` instead. A failing test aborts ship.
4646
5. **Run migration safety** — only if the diff touches `packages/db/migrations/**` or `packages/db/schema.ts`:
4747
- Run `/db-migrate` to review the migration for zero-downtime safety (expand/contract phasing, backward-compatibility with the deployed app version).
4848
- `(cd packages/db && bunx drizzle-kit generate && git status --porcelain ./migrations)` must print nothing (CI's schema/migration sync step).
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
name: dev-cache
2+
description: Mount Turbopack's dev cache at apps/sim/.next/dev for one `next dev` app, keyed by app, event, fork, installed Next version and the app's environment.
3+
4+
inputs:
5+
provider:
6+
description: The CI_PROVIDER repo variable, forwarded to the cache action.
7+
required: false
8+
default: ''
9+
name:
10+
description: The app the cache belongs to. Each app runs `next dev` under its own fixed NEXT_PUBLIC_* values, so each gets its own cache.
11+
required: true
12+
env-hash:
13+
description: Hash of the file that sets the app's environment. Next inlines NEXT_PUBLIC_* values at compile time, so a changed environment must never restore a cache compiled under the old one.
14+
required: true
15+
16+
# The cache turns a route's cold compile into a restore. It is content-addressed, so changed modules
17+
# still recompile. The Next version in the key starts an upgrade from an empty cache, and the event
18+
# and fork segments keep untrusted runs off the cache trusted runs read. Needs dependencies installed
19+
# (it reads node_modules/next), and the app must be stopped with SIGINT so the last write completes.
20+
runs:
21+
using: composite
22+
steps:
23+
- name: Resolve Turbopack dev cache key
24+
id: key
25+
shell: bash
26+
env:
27+
APP: ${{ inputs.name }}
28+
FORK_SUFFIX: ${{ github.event.pull_request.head.repo.fork && '-fork' || '' }}
29+
ENV_HASH: ${{ inputs.env-hash }}
30+
run: echo "value=${GITHUB_REPOSITORY}-next-dev-${APP}-${GITHUB_EVENT_NAME}${FORK_SUFFIX}-$(jq -r .version node_modules/next/package.json)-${ENV_HASH:0:12}" >> "$GITHUB_OUTPUT"
31+
32+
- name: Mount Turbopack dev cache
33+
uses: ./.github/actions/cache
34+
with:
35+
provider: ${{ inputs.provider }}
36+
key: ${{ steps.key.outputs.value }}
37+
path: ./apps/sim/.next/dev
Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,30 @@
11
#!/usr/bin/env bash
2-
# Prints `changed=false` only when every file a pull request changes is clearly unrelated to the
3-
# live desktop suite, and `changed=true` otherwise, including when the diff cannot be worked out.
2+
# Prints `changed=true` when a pull request touches a path the live desktop suite exercises, or when
3+
# the diff cannot be worked out, and `changed=false` otherwise.
4+
#
5+
# The suite (apps/desktop/e2e/desktop-tools-live-sim.spec.ts) drives the Electron app against a
6+
# local Sim, realtime and Redis through the desktop, mothership, copilot, upload and auth routes and
7+
# the chat page. A change that only breaks Sim boot or an unrelated route fails the build and e2e
8+
# jobs, and every staging and main push runs this suite regardless, so a path missing here can
9+
# delay a failure to the staging push but never lets it deploy.
410
#
511
# Usage: desktop-live-changes.sh <base-sha>
612
set -u
713

8-
unrelated='^(apps/docs/|apps/pii/|apps/sim/content/|packages/(python-sdk|ts-sdk)/)|\.mdx?$|(^|/)LICENSE$'
14+
relevant='^(apps/desktop/|apps/realtime/'\
15+
'|packages/(desktop-bridge|browser-protocol|terminal-protocol|realtime-protocol|db|auth|emcn|utils|logger|security|platform-authz|runtime-secrets)/'\
16+
'|apps/sim/lib/(desktop|mothership|uploads|auth|terminal|browser-agent|api/(client|server))/'\
17+
'|apps/sim/lib/api/contracts/(chats|copilot|desktop-|mothership-|upload-sessions|workspace-file)'\
18+
'|apps/sim/app/api/(desktop|mothership|copilot|files|v2/uploads|auth|users/me)/'\
19+
'|apps/sim/app/api/workspaces/\[id\]/files/'\
20+
'|apps/sim/app/workspace/\[workspaceId\]/(home/|components/|layout\.tsx)'\
21+
'|apps/sim/app/(layout\.tsx|desktop/)'\
22+
'|apps/sim/stores/(chat|chat-panel|panel|mothership-[a-z-]+|tool-permission|terminal|copilot-terminal|browser-session)/'\
23+
'|apps/sim/hooks/(use-mothership|use-desktop|use-chat|queries/(mothership|desktop|copilot|chats))'\
24+
'|apps/sim/types/sim-desktop'\
25+
'|apps/sim/(proxy\.ts|next\.config\.ts|package\.json|instrumentation)'\
26+
'|bun\.lock$|package\.json$'\
27+
'|\.github/(workflows/(checks|ci)\.yml|scripts/desktop-live-changes\.sh|actions/))'
928

1029
base=${1:-}
1130
run() {
@@ -17,8 +36,7 @@ run() {
1736
git fetch --quiet --depth=1 origin "$base" || run "could not fetch $base"
1837
names=$(git diff --no-renames --name-only "$base" HEAD) || run "could not diff against $base"
1938
[ -n "$names" ] || run 'no changed files listed'
20-
if printf '%s\n' "$names" | grep -qvE "$unrelated"; then
21-
run 'a change may affect it'
22-
fi
39+
printf 'Changed files:\n%s\n' "$names" >&2
40+
match=$(printf '%s\n' "$names" | grep -E -m 1 "$relevant") && run "a change touches a path it exercises: $match"
2341
echo "changed=false"
24-
echo 'Skipping the live desktop suite: every change is unrelated to it' >&2
42+
echo 'Skipping the live desktop suite: no change touches a path it exercises' >&2

‎.github/scripts/http-e2e.sh‎

Lines changed: 71 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,13 @@
1010
# the readiness deadline only has to catch a hung boot: an exited server fails immediately, and
1111
# either way the server log tail lands in the job log.
1212
#
13-
# Each app starts from an empty Turbopack dev cache: a cache written under other NEXT_PUBLIC_*
14-
# values, by a server that `next dev` SIGKILLs 100ms after SIGTERM, can panic Turbopack or wedge a
15-
# route compile on restore. It runs in its own session under an E2E_APP tag, and stop-session.sh
16-
# returns only once every process in that session or carrying that tag has exited (Next's
17-
# telemetry flush runs detached and still writes .next/dev).
13+
# Each group restores its own Turbopack dev cache (mounted by the dev-cache action, keyed by group
14+
# and by this file's hash, so a cache is only ever read under the NEXT_PUBLIC_* values it was
15+
# compiled with). The app is stopped with SIGINT so its last cache write completes: `next dev`
16+
# SIGKILLs its server 100ms after SIGTERM. A cache Turbopack reports as corrupt is dropped, and a
17+
# startup it aborted is retried once from an empty cache. The app runs in its own session under an
18+
# E2E_APP tag, and stop-session.sh returns only once every process in that session or carrying
19+
# that tag has exited (Next's telemetry flush runs detached and still writes .next/dev).
1820
set -euo pipefail
1921

2022
group=${1:?usage: http-e2e.sh <scim|cli|stop-after|desktop-inbox|mobile>}
@@ -27,11 +29,35 @@ app_tag=''
2729
server_log=''
2830
status_log=''
2931

32+
cache_broken() {
33+
grep -qiE 'cache corruption|turbopack.*panic|panicked' "$server_log" 2>/dev/null
34+
}
35+
36+
# The cache directory is a mount point: empty it rather than remove it.
37+
clear_cache() {
38+
local cache="$GITHUB_WORKSPACE/apps/sim/.next/dev"
39+
[ -d "$cache" ] || return 0
40+
find "$cache" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
41+
}
42+
43+
# SIGINT is best-effort; the cleanup always runs, since workers and the detached telemetry flush
44+
# can outlive a server that has already exited.
45+
stop_app() {
46+
if kill -INT "$server_pid" 2>/dev/null; then
47+
for _ in $(seq 1 30); do kill -0 "$server_pid" 2>/dev/null || break; sleep 1; done
48+
fi
49+
bash "$GITHUB_WORKSPACE/.github/scripts/stop-session.sh" "$server_pid" "$app_tag"
50+
}
51+
3052
finish() {
3153
local status=$?
3254
if [ -n "$server_pid" ]; then
33-
bash "$GITHUB_WORKSPACE/.github/scripts/stop-session.sh" "$server_pid" "$app_tag" || status=1
55+
stop_app || status=1
3456
wait "$server_pid" 2>/dev/null || true
57+
if cache_broken; then
58+
echo "::warning::Turbopack reported a broken dev cache; clearing it for the next run."
59+
clear_cache
60+
fi
3561
if [ -n "$status_log" ]; then
3662
awk '/^ (GET|POST|PUT|PATCH|DELETE|HEAD) \/api\// { print }' "$server_log" > "$status_log"
3763
fi
@@ -43,6 +69,13 @@ finish() {
4369
}
4470
trap finish EXIT
4571

72+
# launch_app <port>
73+
launch_app() {
74+
E2E_APP="$app_tag" setsid node ../../node_modules/next/dist/bin/next dev --hostname 127.0.0.1 \
75+
--port "$1" > "$server_log" 2>&1 &
76+
server_pid=$!
77+
}
78+
4679
# start_app <name> <port> <label> [record-http-status]
4780
start_app() {
4881
local name=$1 port=$2 label=$3
@@ -54,14 +87,20 @@ start_app() {
5487
export NEXT_PUBLIC_APP_URL="http://127.0.0.1:$port"
5588
export BETTER_AUTH_URL="$NEXT_PUBLIC_APP_URL"
5689
export DISABLE_TELEMETRY=true NEXT_TELEMETRY_DISABLED=1
57-
rm -rf .next/dev
58-
E2E_APP="$app_tag" setsid node ../../node_modules/next/dist/bin/next dev --hostname 127.0.0.1 \
59-
--port "$port" > "$server_log" 2>&1 &
60-
server_pid=$!
90+
launch_app "$port"
6191

62-
local started=$SECONDS
92+
local started=$SECONDS retried=false
6393
until curl --fail --silent --max-time 10 "$NEXT_PUBLIC_APP_URL/api/health" > /dev/null; do
6494
if ! kill -0 "$server_pid" 2>/dev/null; then
95+
if [ "$retried" = false ] && cache_broken; then
96+
echo "::warning::Turbopack rejected the restored dev cache; restarting from an empty cache."
97+
tail -n 50 "$server_log"
98+
stop_app
99+
clear_cache
100+
retried=true
101+
launch_app "$port"
102+
continue
103+
fi
65104
echo "::error::Local $label app exited during startup."
66105
exit 1
67106
fi
@@ -161,11 +200,27 @@ case "$group" in
161200
export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--max-old-space-size=12288"
162201
bunx --no-install playwright install --with-deps chromium webkit
163202
start_app mobile 3024 'mobile browser' record-http-status
164-
MOBILE_E2E_BASE_URL="$NEXT_PUBLIC_APP_URL" \
165-
MOBILE_E2E_DATABASE_URL="$DATABASE_URL" \
166-
MOBILE_E2E_AUTH_SECRET="$BETTER_AUTH_SECRET" \
167-
MOBILE_E2E_REPORT_PATH="$report_dir/mobile-e2e-report.json" \
168-
bun run test:mobile:e2e
203+
# Chromium and WebKit run as two processes against the one app, so WebKit no longer waits out
204+
# Chromium's pass. Each process seeds and removes its own fixtures under fresh ids and writes
205+
# its own report, so they share nothing but the server.
206+
browser_pids=()
207+
for browser in chromium webkit; do
208+
MOBILE_E2E_BROWSER="$browser" \
209+
MOBILE_E2E_BASE_URL="$NEXT_PUBLIC_APP_URL" \
210+
MOBILE_E2E_DATABASE_URL="$DATABASE_URL" \
211+
MOBILE_E2E_AUTH_SECRET="$BETTER_AUTH_SECRET" \
212+
MOBILE_E2E_REPORT_PATH="$report_dir/mobile-e2e-$browser-report.json" \
213+
bun run test:mobile:e2e > "$report_dir/mobile-$browser.log" 2>&1 &
214+
browser_pids+=("$!")
215+
done
216+
browsers_failed=0
217+
for pid in "${browser_pids[@]}"; do wait "$pid" || browsers_failed=1; done
218+
for browser in chromium webkit; do
219+
echo "::group::Mobile checks: $browser"
220+
cat "$report_dir/mobile-$browser.log"
221+
echo "::endgroup::"
222+
done
223+
[ "$browsers_failed" = 0 ]
169224
;;
170225

171226
*)

‎.github/scripts/staging-proof.sh‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
#!/usr/bin/env bash
2+
# Prints `proven=true` when the checked-out main commit is a merge whose tree is byte-identical to
3+
# its staging parent and a staging push run of that parent passed `checks / ci`; `proven=false`
4+
# otherwise, including on any error, so the caller falls back to running the full checks.
5+
#
6+
# A release lands on main as a merge of the staging head, and the merge's tree equals that head's
7+
# tree, so the staging push run already checked exactly this code with the same checks.yml.
8+
# Waits for a staging run that is still in progress, up to PROOF_WAIT_SECONDS.
9+
#
10+
# Usage: GH_TOKEN=... GITHUB_REPOSITORY=owner/repo staging-proof.sh (needs fetch-depth: 2)
11+
set -u
12+
13+
prove() {
14+
echo "proven=$1"
15+
echo "$2" >&2
16+
exit 0
17+
}
18+
19+
staging=$(git rev-parse -q --verify 'HEAD^2') || prove false 'Not a merge commit; running the checks.'
20+
[ "$(git rev-parse 'HEAD^{tree}')" = "$(git rev-parse "$staging^{tree}")" ] ||
21+
prove false "The tree differs from staging parent $staging; running the checks."
22+
23+
deadline=$((SECONDS + ${PROOF_WAIT_SECONDS:-2400}))
24+
while :; do
25+
runs=$(gh api "repos/$GITHUB_REPOSITORY/actions/workflows/ci.yml/runs?head_sha=$staging&event=push&branch=staging&per_page=20" \
26+
--jq '.workflow_runs[] | "\(.id) \(.status)"') || prove false 'Could not list staging runs; running the checks.'
27+
[ -n "$runs" ] || prove false "No staging push run for $staging; running the checks."
28+
pending=false
29+
while read -r id status; do
30+
gate=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$id/jobs?per_page=100" \
31+
--jq '[.jobs[] | select(.name == "checks / ci") | .conclusion] | first // ""') || gate=''
32+
[ "$gate" = success ] && prove true "Staging run $id passed checks / ci on this exact tree ($staging); skipping the checks."
33+
[ "$status" = completed ] || pending=true
34+
done <<< "$runs"
35+
[ "$pending" = true ] || prove false "No staging run for $staging passed checks / ci; running the checks."
36+
[ "$SECONDS" -lt "$deadline" ] || prove false "Timed out waiting for the staging run of $staging; running the checks."
37+
sleep 30
38+
done

‎.github/workflows/checks.yml‎

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,14 @@ jobs:
201201
working-directory: packages/db
202202
run: bun run db:migrate
203203

204+
# One cache per group: each group's app runs under its own fixed NEXT_PUBLIC_* values.
205+
- name: Mount Turbopack dev cache
206+
uses: ./.github/actions/dev-cache
207+
with:
208+
provider: ${{ vars.CI_PROVIDER }}
209+
name: e2e-${{ matrix.group }}
210+
env-hash: ${{ hashFiles('.github/scripts/http-e2e.sh') }}
211+
204212
# No step timeout: the job's bound covers a hang without cutting a slow but healthy suite
205213
# short of writing its report.
206214
- name: Run end-to-end suites
@@ -216,9 +224,9 @@ jobs:
216224
if-no-files-found: ignore
217225
retention-days: 7
218226

219-
# Pull requests skip the live desktop suite only when every change is clearly unrelated to the
220-
# app it drives (docs, other apps, published content). Anything else, and any failure to work
221-
# out the diff, runs it: a pull request that skipped it wrongly would first fail on staging.
227+
# Pull requests run the live desktop suite only when they touch a path it exercises (listed in
228+
# desktop-live-changes.sh), or when the diff cannot be worked out. Pushes always run it, so a
229+
# pull request that skipped it wrongly would first fail on staging, before anything deploys.
222230
desktop-changes:
223231
name: desktop-changes
224232
runs-on: &runner-2vcpu ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }}
@@ -293,21 +301,13 @@ jobs:
293301
run: bun run db:migrate
294302

295303
# Turbopack's dev cache turns the spec's route warm-up from a cold compile (~4 min) into a
296-
# restore. It is content-addressed, so a pull request's changed modules still recompile; the
297-
# key carries the installed Next version so an upgrade starts from an empty cache, and the
298-
# event and fork segments keep untrusted runs off the cache trusted runs read.
299-
- name: Resolve Turbopack dev cache key
300-
id: next-cache
301-
run: echo "key=${GITHUB_REPOSITORY}-next-dev-desktop-live-${GITHUB_EVENT_NAME}${FORK_SUFFIX}-$(jq -r .version node_modules/next/package.json)" >> "$GITHUB_OUTPUT"
302-
env:
303-
FORK_SUFFIX: ${{ github.event.pull_request.head.repo.fork && '-fork' || '' }}
304-
304+
# restore.
305305
- name: Mount Turbopack dev cache
306-
uses: ./.github/actions/cache
306+
uses: ./.github/actions/dev-cache
307307
with:
308308
provider: ${{ vars.CI_PROVIDER }}
309-
key: ${{ steps.next-cache.outputs.key }}
310-
path: ./apps/sim/.next/dev
309+
name: desktop-live
310+
env-hash: ${{ hashFiles('.github/workflows/checks.yml') }}
311311

312312
# Chat switches, Stop, sign-out, approval, the dormant-executor round trip, and background
313313
# runs across a chat switch and a network cut. The spec runs the recording proxy (the app's

0 commit comments

Comments
 (0)