Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-19-fork-ci-parallel-lanes.md
2026-08-19-fork-ci-parallel-lanes.md: 86a90db76a95e0e881581402dfd2ddafc6726dbb
2026-08-19-fork-ci-parallel-lanes.zh.md: 7e034e9f334f07071db81d686e12ab93dfff5d02
2026-08-19-fork-ci-parallel-lanes.md: 842436f3fabde9114168d97209da8a3d67f65149
2026-08-19-fork-ci-parallel-lanes.zh.md: 8a262a94dfef0a232109d482868b61056a076957
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ English | [中文](2026-08-19-fork-ci-parallel-lanes.zh.md)
Fork CI is now four parallel, keyless jobs behind one stable verdict, all on GitHub-hosted runners:

- `static` (one host build feeding lint and typecheck host+client, the doc-sync aggregate, the shared static gates — constraints, package invariants, Cordis config, runtime closure, optional-dependency imports, issue policy — the module graph check, and the desktop runtime closure), `unit` (the complete vitest inventory, including apps/desktop and apps/web specs, pinned to two forked workers so the timing-sensitive terminal/subprocess suites keep headroom on the 4-vCPU runner), `web` (built frontend + `DSH_SNAPSHOT=replay` browser replay), `coverage` (`check:ci:coverage`, per-file 100% on `packages/*/*/src`).
- `all-checks-passed` aggregates the three blocking lanes with `if: always()` so a failed dependency can never skip the required check into a green; branch protection requires only `Fork CI / all checks passed`.
- `all-checks-passed` aggregates the four blocking lanes with `if: always()` so a failed dependency can never skip the required check into a green; branch protection requires only `Fork CI / all checks passed`.
- Pull requests trigger the workflow; `cancel-in-progress` exempts only push (`${{ github.event_name != 'push' }}`), because a master push is the post-merge signal and the cache producer, while superseded pull-request and dispatch runs are disposable.
- Caches flow master → every lane: the `unit` lane alone saves the pnpm store on master pushes (five parallel saves of one key would race and waste compression), and the `web` lane alone saves the Playwright browser cache; every lane restores both families on every event.
- `coverage` runs with `DSH_COVERAGE_MAX_WORKERS=3` (two instrumented workers plus one exempt heavy-suite worker) and `DSH_GATE_CONCURRENCY=2`, so the two coverage gates overlap at 2 + 1 = 3 forks instead of serializing — sized for the 4-vCPU hosted runner. The first Ubuntu run of this lane reproduced a process-exit race: the scenario host crashed on a partial tree.json read and never published its ready file. The host fixture now retries the read+parse, the scenario reads `DSH_COVERAGE_TEST_TIMEOUT_MS` (set to 60000 on this lane) to widen its ready wait, and a ready-timeout failure surfaces the host's exit and stderr.
Expand All @@ -22,17 +22,17 @@ Fork CI is now four parallel, keyless jobs behind one stable verdict, all on Git
- The `static` lane sets `DSH_ARCHIVE_BASE_REF` to the PR base only on the pull-request-gated `doc-sync` step: an empty string would be read as a literal ref instead of the script's HEAD default.
- `knip` and `duplication` stay out of the lane until the fork's pre-existing debt is fixed: knip fails on an unused desktop file and dependencies plus 108 unlisted test imports, and jscpd on 14 plugin-installer clones. `check:ci:static` embeds knip, so the lane runs its green subset as explicit steps instead of the aggregate; fixing the debt means re-adopting `check:ci:static` plus one duplication step.

The `web` lane is diagnostic at first: it is deliberately absent from `all-checks-passed.needs`, and its job name carries a `(diagnostic)` suffix so its non-blocking status stays visible in the pull-request check list. Local runs cannot exercise the assembled app's confined bash tool (the host sandbox denies `posix_openpt` and nested `sandbox-exec`, cascading failures through the aria goldens), so only an Ubuntu run can prove the fork's web goldens current. `web` stays diagnostic until an Ubuntu run proves them current; promoting it is a one-line change to `needs`, and golden drift is first refreshed on CI with `DSH_SNAPSHOT=refresh`.
The `web` lane started diagnostic (absent from `all-checks-passed.needs`, `(diagnostic)` name suffix) because local runs cannot exercise the assembled app's confined bash tool (the host sandbox denies `posix_openpt` and nested `sandbox-exec`, cascading failures through the aria goldens), so only an Ubuntu run could prove the fork's web goldens current. Its first Ubuntu run failed on exactly one stale golden — the retry row now reports attempt 1 of the fork's five transient retries — which was refreshed, and the next run went green; `web` then joined `all-checks-passed.needs` under its plain name.

The upstream workflows stay verbatim (`disabled_manually` in settings) rather than carrying fork guard patches: `scripts/ci-workflow.spec.ts` pins their exact `if` strings, and settings-level disabling keeps them conflict-free across upstream syncs. The fork-owned executed gate `scripts/fork-ci-workflow.spec.ts` pins the new contract: triggers, keyless-ness, hosted runners, cache direction, aggregator membership (including the diagnostic-web exclusion), and the continued absence of snapshot replay and real-API e2e.
The upstream workflows stay verbatim (`disabled_manually` in settings) rather than carrying fork guard patches: `scripts/ci-workflow.spec.ts` pins their exact `if` strings, and settings-level disabling keeps them conflict-free across upstream syncs. The fork-owned executed gate `scripts/fork-ci-workflow.spec.ts` pins the new contract: triggers, keyless-ness, hosted runners, cache direction, aggregator membership, and the continued absence of snapshot replay and real-API e2e.

## Alternatives considered

**In-file repository guards on ci.yml/e2e.yml** — the fork's copies would skip cleanly even if someone re-enables the workflows. Rejected: `scripts/ci-workflow.spec.ts` asserts those `if` strings exactly, so the patch forks a shared spec file and conflicts on every upstream sync; the workflows are already disabled in settings, which is the writer-visible control point.

**Snapshot replays re-enabled now** — re-run `test:snapshot` on every pull request. Rejected: the goldens were dropped in dd602d3668 while they drifted, and re-owning them needs a fork-side refresh verified on CI; that stays a separate step before the lane returns.

**Making `web` blocking immediately** — rejected: a red lane from unverified goldens would block every merge during the refresh window; the diagnostic start is one line to promote once green.
**Making `web` blocking immediately** — rejected at first: a red lane from unverified goldens would block every merge during the refresh window, so the lane started diagnostic and promoted once its Ubuntu run went green.

## Consequences

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ Status: implemented
Fork CI 现在是四个并行、无密钥的 job,汇聚到一个稳定判定,全部跑在 GitHub 托管 runner 上:

- `static`(一次 host 构建喂给 lint 与 host+client typecheck、doc-sync 聚合、共享静态门禁——constraints、package invariants、Cordis config、runtime closure、optional-dependency imports、issue policy——模块图检查与桌面运行时闭包)、`unit`(完整 vitest 清单,含 apps/desktop 与 apps/web 的 spec,固定为两个 fork worker,让时序敏感的终端/子进程套件在 4-vCPU runner 上保有裕量)、`web`(构建产物 + `DSH_SNAPSHOT=replay` 浏览器回放)、`coverage`(`check:ci:coverage`,`packages/*/*/src` 逐文件 100%)。
- `all-checks-passed` 以 `if: always()` 聚合三个阻塞车道,失败依赖永远不可能把必查项跳过成绿色;branch protection 只需勾选 `Fork CI / all checks passed`。
- `all-checks-passed` 以 `if: always()` 聚合四个阻塞车道,失败依赖永远不可能把必查项跳过成绿色;branch protection 只需勾选 `Fork CI / all checks passed`。
- pull request 触发工作流;`cancel-in-progress` 只豁免 push(`${{ github.event_name != 'push' }}`),因为 master push 既是合并后信号也是缓存生产者,而被取代的 PR 与手动运行可丢弃。
- 缓存由 master 流向所有车道:只有 `unit` 车道在 master push 保存 pnpm store(五个并行写同一 key 会竞争并浪费压缩),只有 `web` 车道保存 Playwright 浏览器缓存;每个车道在每个事件上都恢复这两个缓存族。
- `coverage` 以 `DSH_COVERAGE_MAX_WORKERS=3`(两个插桩 worker 加一个豁免重套件 worker)与 `DSH_GATE_CONCURRENCY=2` 运行,两个 coverage gate 以 2 + 1 = 3 个 fork 重叠而不是串行——按 4-vCPU 托管 runner 定尺寸。本车道的首次 Ubuntu 运行复现了 process-exit 竞态:场景宿主进程因读到写了一半的 tree.json 而崩溃,永远没有发布 ready 文件。宿主夹具现在对读取+解析做重试,场景读取 `DSH_COVERAGE_TEST_TIMEOUT_MS`(本车道设为 60000)放宽 ready 等待,且 ready 超时失败会带出宿主的退出码与 stderr。
Expand All @@ -22,17 +22,17 @@ Fork CI 现在是四个并行、无密钥的 job,汇聚到一个稳定判定
- `static` 车道只在 PR 专属的 `doc-sync` 步骤上把 `DSH_ARCHIVE_BASE_REF` 设为 PR base:空字符串会被脚本当作字面 ref 而非 HEAD 默认值。
- `knip` 与 `duplication` 在 fork 既有债务修复前不进车道:knip 因一个未使用的桌面文件与依赖、108 条未列明的测试导入而失败,jscpd 因 14 处 plugin-installer clone 而失败。`check:ci:static` 内嵌 knip,因此车道以显式步骤运行其绿色子集而非聚合;债务修复后应重新采用 `check:ci:static` 加一个 duplication 步骤。

`web` 车道起初是诊断性的:刻意不进入 `all-checks-passed.needs`,且 job 名带 `(diagnostic)` 后缀,让非阻塞状态在 pull request 检查列表中保持可见。本地无法执行组装应用的受限 bash 工具(宿主沙箱拒绝 `posix_openpt` 与嵌套 `sandbox-exec`,失败沿 aria golden 级联),只有 Ubuntu 上的运行才能证明 fork 的 web golden 是否最新。`web` 保持诊断性,直到 Ubuntu 运行证明 golden 当前为止;将其提升只需改动 `needs` 一行,golden 漂移则先在 CI 上用 `DSH_SNAPSHOT=refresh` 刷新
`web` 车道起初是诊断性的(不进入 `all-checks-passed.needs`job 名带 `(diagnostic)` 后缀),因为本地无法执行组装应用的受限 bash 工具(宿主沙箱拒绝 `posix_openpt` 与嵌套 `sandbox-exec`,失败沿 aria golden 级联),只有 Ubuntu 上的运行才能证明 fork 的 web golden 是否最新。其首次 Ubuntu 运行恰好挂在一个过期 golden 上——重试行现在报告 fork 五次瞬态重试的第 1 次——刷新后下一轮变绿;`web` 随后以普通名称加入 `all-checks-passed.needs`

上游工作流保持原样(设置里 `disabled_manually`),而不是打 fork 守卫补丁:`scripts/ci-workflow.spec.ts` 用精确字符串钉死了它们的 `if`,设置级禁用让上游同步零冲突。fork 专属的执行门禁 `scripts/fork-ci-workflow.spec.ts` 钉住新契约:触发条件、无密钥、托管 runner、缓存方向、聚合器成员(含诊断性 web 排除),以及快照回放与真实 API e2e 的持续缺席。
上游工作流保持原样(设置里 `disabled_manually`),而不是打 fork 守卫补丁:`scripts/ci-workflow.spec.ts` 用精确字符串钉死了它们的 `if`,设置级禁用让上游同步零冲突。fork 专属的执行门禁 `scripts/fork-ci-workflow.spec.ts` 钉住新契约:触发条件、无密钥、托管 runner、缓存方向、聚合器成员,以及快照回放与真实 API e2e 的持续缺席。

## Alternatives considered

**在 ci.yml/e2e.yml 里加文件级仓库守卫**——即使有人重新启用这些工作流,fork 的副本也会干净地跳过。否决:`scripts/ci-workflow.spec.ts` 精确断言这些 `if` 字符串,补丁会分叉共享 spec 文件并在每次上游同步时冲突;工作流本就已在设置中禁用,那才是写者可见的控制点。

**现在就恢复快照回放**——每个 PR 重跑 `test:snapshot`。否决:golden 在 dd602d3668 中因漂移被移除,重新认领需要先在 CI 上验证 fork 侧刷新;这仍是车道回归前的独立一步。

**立即把 `web` 设为阻塞**——否决:未经验证的 golden 造成的红灯会在刷新窗口期内阻塞所有合并;诊断性起步只需在变绿后改一行即可晋升
**立即把 `web` 设为阻塞**——起初否决:未经验证的 golden 造成的红灯会在刷新窗口期内阻塞所有合并,因此车道先以诊断性起步,待 Ubuntu 运行变绿后再晋升

## Consequences

Expand Down
19 changes: 6 additions & 13 deletions .github/workflows/fork-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,9 @@ name: Fork CI
# - unit: the complete keyless vitest unit inventory (thread-safe and
# process-bound projects; includes apps/desktop and apps/web specs)
# - web: built web frontend + keyless browser replay
# (DSH_SNAPSHOT=replay over apps/web/tests). DIAGNOSTIC lane for
# now: local runs cannot exercise the confined bash tool, so the
# fork verifies this lane green on Ubuntu before it joins
# all-checks-passed.needs (then promote it there).
# (DSH_SNAPSHOT=replay over apps/web/tests). Promoted into the
# verdict after its first Ubuntu run proved the fork's goldens
# current (one stale retry golden, refreshed in df281d24e2).
# - coverage: per-file 100% coverage gate on packages/*/*/src
# - all-checks-passed: single stable required check for branch protection
#
Expand Down Expand Up @@ -218,11 +217,7 @@ jobs:
run: pnpm exec vitest run --maxWorkers=2

web:
# Diagnostic lane (see the header job map): the name suffix keeps its
# non-blocking status visible in the pull-request check list. Absent from
# all-checks-passed.needs until its first Ubuntu run proves the fork's web
# goldens current. A red lane here never blocks a pull request.
name: web browser replay (diagnostic)
name: web browser replay
runs-on: ubuntu-latest
timeout-minutes: 90
steps:
Expand Down Expand Up @@ -356,16 +351,14 @@ jobs:

# Single stable required check for branch protection: require "Fork CI /
# all checks passed" instead of enumerating jobs whose names evolve as lanes
# change. Every blocking job above must stay listed in `needs`; `web` is
# deliberately absent while it is the diagnostic lane above (rejoin it once
# the fork's web replay is proven green on Ubuntu).
# change. Every blocking job above must stay listed in `needs`.
# `if: always()` is load-bearing: without it a failed dependency would SKIP
# this job, and GitHub counts a skipped required check as passing — so this
# job always runs on pull requests and fails on any non-success result,
# including 'cancelled' and 'skipped'.
all-checks-passed:
name: all checks passed
needs: [static, unit, coverage]
needs: [static, unit, web, coverage]
if: always() && github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
Expand Down
8 changes: 7 additions & 1 deletion packages/client/ui-trajectory/tests/table.client.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,16 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/re
import { TrajectoryTable } from '../src/client/TrajectoryTable.tsx'
import type { TrajectoryTurnModel } from '../src/client/layout.ts'

afterEach(() => {
afterEach(async () => {
cleanup()
vi.restoreAllMocks()
Reflect.deleteProperty(HTMLElement.prototype, 'scrollTo')
// The virtualizer's scrollend fallback debounces notify by
// isScrollingResetDelay on the jsdom window; a scroll in the last test can
// leave that timer pending past the environment teardown, where its
// callback reads a destroyed `window` (ReferenceError) and fails the whole
// run. Let it fire while jsdom is still alive.
await new Promise(resolve => setTimeout(resolve, 200))
})

const TURNS: readonly TrajectoryTurnModel[] = [{
Expand Down
16 changes: 7 additions & 9 deletions scripts/fork-ci-workflow.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@ import { describe, expect, it } from 'vitest'
// one stable verdict, and single-producer caches restored by every lane.
const root = resolve(import.meta.dirname, '..')
const workflowPath = '.github/workflows/fork-ci.yml'
// The lanes behind the required verdict. `web` is the diagnostic lane for
// now and must stay OUT of this list until its Ubuntu run proves the fork's
// web goldens current; promote it here together with all-checks-passed.needs.
const BLOCKING_JOBS = ['static', 'unit', 'coverage'] as const
// The lanes behind the required verdict. `web` joined after its Ubuntu run
// proved the fork's goldens current; a lane only leaves this list together
// with all-checks-passed.needs.
const BLOCKING_JOBS = ['static', 'unit', 'web', 'coverage'] as const
const CACHED_JOBS = ['static', 'unit', 'web', 'coverage'] as const

describe('Fork CI workflow', () => {
Expand Down Expand Up @@ -129,8 +129,6 @@ describe('Fork CI workflow', () => {
for (const jobName of BLOCKING_JOBS) {
expect(aggregate.needs, `${jobName} must gate the verdict`).toContain(jobName)
}
// The diagnostic web lane must not gate merges while unverified.
expect(aggregate.needs).not.toContain('web')
if (!Array.isArray(aggregate.steps)) throw new TypeError('Aggregate must define steps')
const failStep = aggregate.steps.filter(isRecord).find(step => step.name === 'Fail if any needed job did not succeed')
expect(failStep).toMatchObject({
Expand Down Expand Up @@ -253,9 +251,9 @@ describe('Fork CI workflow', () => {
})
expect(stepRuns(coverage)).toContain('pnpm run check:ci:coverage')

// The non-blocking status must stay visible in the pull-request check
// list; a rename would make a red diagnostic lane look like a blocking one.
expect(web).toMatchObject({ name: 'web browser replay (diagnostic)' })
// A renamed or resuffixed check changes the check name branch protection
// sees; the lane is blocking now, so keep the plain name.
expect(web).toMatchObject({ name: 'web browser replay' })
})

it('keeps snapshot replay and real-API e2e out until they are re-owned for the fork', () => {
Expand Down
Loading