DSH / Atlas
2026-07-26implementedprocess

CI failover runbook — hosted pools → in-house pool

CI 故障切换手册 — 托管池 → 自有池

The three required Linux worker jobs in [CI](../../../../.github/workflows/ci.yml) (`node 24 / static`, `node 24 / coverage`, `node 24 / snapshots and artifacts`) run on the hosted enterprise 32-core pools; the required verdict job that aggregates them (`all checks passed`) runs on standard `ubuntu-latest`; the independent native Windows job (`windows node 24 / native complete`) runs on the hosted `dsh-windows-2025-1

English

Problem

The three required Linux worker jobs in CI (node 24 / static, node 24 / coverage, node 24 / snapshots and artifacts) run on the hosted enterprise 32-core pools; the required verdict job that aggregates them (all checks passed) runs on standard ubuntu-latest; the independent native Windows job (windows node 24 / native complete) runs on the hosted dsh-windows-2025-16core larger runner. When the enterprise pools degrade — jobs queue indefinitely or the enterprise labels vanish — every open pull request becomes unmergeable, and the ordinary recovery of merging a fix is itself deadlocked behind the very required checks that cannot run. Scope: two independent switches, one per platform. DSH_CI_FAILOVER_LINUX recovers an enterprise Linux-pool outage (the three required Linux workers plus the all checks passed verdict); DSH_CI_FAILOVER_WINDOWS recovers a hosted Windows-pool outage (the native Windows job). A Linux-pool outage need not retarget the native Windows job and vice versa. The verdict's other required dependencies (node-compat, python-sdk, windows) stay on standard hosted runners by design (the portable boundary); in a broader GitHub-hosted capacity failure that also takes out the standard pools, those dependencies still block all checks passed. An outage therefore needs a switch any responder with repository write access can throw without merging anything.

Decision

Each of the three required Linux worker jobs, the independent native Windows job, and the all checks passed verdict job — which would otherwise stay queued on the failed pool even after every worker passed — resolves its runner pool through a repository variable, and the switch is split by platform so an outage on one platform does not retarget the other. The three Linux workers and the all checks passed verdict (whose needs are the required Linux workers and which runs on the vm-backup pool) resolve through DSH_CI_FAILOVER_LINUX; the native Windows job resolves through DSH_CI_FAILOVER_WINDOWS. Unset (normal), they run on the hosted enterprise pools. Set to selfhosted by any repository writer, the corresponding jobs retarget onto the in-house self-hosted pool: under DSH_CI_FAILOVER_LINUX, the Linux jobs and verdict move onto the vm-backup pool, snapshot concurrency drops to the shared-VM bound, and the hosted-path pnpm cache restores are skipped; under DSH_CI_FAILOVER_WINDOWS, the native Windows job moves onto the dsh-win-ci pool. Each switch is writer-manageable repository state, not a merge, so it works while every check is red. The in-house pools' readiness is continuously re-proven by the serial / linux (self-hosted standby) and serial / windows (self-hosted standby) lanes, which run the complete unsharded aggregates on every master push.

ci-master.yml exempts exactly one event from cancel-in-progress (${{ github.event_name != 'push' }}), so one master push does not cancel the drill still running from the previous one. Each drill runs its complete unsharded aggregate with one gate worker, which takes longer than the interval between master merges; under unconditional cancellation a drill is superseded before reaching a verdict and the lane yields no readiness evidence for a responder to check.

The exemption is narrower than "a drill always finishes", in two ways. GitHub keeps a single pending entry per group, so a newer pending run displaces an older one and intermediate push runs still end as cancelled during busy periods. And the expression is evaluated against the newly triggered run, so a run whose own event is not push — a benchmark dispatched on master within ci-master.yml, sharing its group CI master-<ref> — evaluates to true and does cancel a drill that is mid-flight. That is a rare manual action and the next master push restores the evidence, so it does not warrant further mechanism. What the carve-out buys is that the lane periodically reaches a verdict at all, which is what makes it usable as evidence.

The decision belongs at workflow level because cancellation applies to the whole superseded run: a job-level concurrency group does not exempt its job. The negated form is load-bearing rather than cosmetic: naming pull_request alone would also stop cancelling workflow_dispatch, and each runner benchmark fans out to twelve larger runners for up to fifteen minutes inside this same group on master, so a re-dispatch would queue ahead of a drill instead of replacing a stale measurement. What bounds the cost is that a master push in ci-master.yml carries only wine-apt-cache and these two drills; the pull-request jobs live in the separate ci.yml (which does not see push), and the benchmarks are workflow_dispatch-gated within ci-master.yml. scripts/ci-workflow.spec.ts pins that push-reachable set — classifying by exact condition, since a negated event test mentions the event it excludes — so a new push-reachable job cannot quietly start accumulating uncancelled runs.

What the in-house pool is

vm-backup: one 64-core VM, six always-on systemd-managed runner instances. Its image must preinstall Playwright Chromium's Linux system packages; CI downloads the lockfile-selected browser but never runs apt on this persistent shared host. Check the latest serial / linux (self-hosted standby) run before switching: its aggregate includes browser replay, so a green standby verifies both ordinary capacity and this browser prerequisite.

Windows pool

dsh-win-ci: 32 always-on runner instances (scheduled tasks GH-Runner-01GH-Runner-32) on the in-house Windows CI server (one 96-core / 580 GB machine). Labels: [self-hosted, dsh-win-ci, windows]. The image must preinstall Node 24, pnpm, Git (with Git Bash on PATH, i.e. C:\Program Files\Git\bin — the bash tool spawns bash by name), PowerShell 7, and enable Developer Mode for symlink support. Check the latest serial / windows (self-hosted standby) run before switching: a green standby verifies the pool can execute check:ci:windows-complete end-to-end.

Switch (any repository writer, ~1 minute, no merge)

The two switches are independent: flip only the one whose platform is degraded.

  1. Repository Settings → Secrets and variables → Actions → Variables → New repository variable: name DSH_CI_FAILOVER_LINUX (Linux pool outage) or DSH_CI_FAILOVER_WINDOWS (Windows pool outage), value selfhosted.
  2. Retrigger the required jobs so they re-resolve their pool. Jobs already queued for the hosted labels do not retarget and cannot be re-run in place, so for the documented indefinite-queue outage, cancel the stuck run and re-run all jobs, or push a new commit; "Re-run failed jobs" only helps once a job has actually failed rather than queued.
  3. That is the entire switch. Under Linux failover the workflow also drops DSH_SNAPSHOT_MAX_CONCURRENCY to 12 for the shared VM and skips the hosted-path pnpm cache restores because the VM's persistent store serves warm installs. Coverage uses the same four single-worker instrumented partitions and two exempt workers on both Linux pools. The Windows switch has no concurrency or cache branches; it only retargets the native Windows job's pool.

#Dependabot exception. Both switches' selectors deliberately exclude dependabot[bot]: under failover, Dependabot PRs stay queued for the hosted pool rather than executing dependency-supplied code on the persistent VMs. A Dependabot PR that remains queued during an outage is expected behavior, not a failed switch; it completes when the hosted pool recovers.

Who can flip the variable. GitHub's API lets any collaborator with write access manage repository variables, so each switch is writer-level, not strictly admin-only. In this repository's trust model that is not an escalation: the runner groups admit all workflows of this private, fork-disabled repository (a deliberate trade to make PR-ref failover possible at all), so any writer could already reach the VMs by pushing a branch workflow. The boundary against untrusted code is repository membership; the variables only route work for members.

Capacity during failover

Six always-on instances absorb normal PR traffic (the pool's steady-state load is one serial standby job per master push, so failover capacity is effectively the full pool). If queues still build, register additional instances with an org registration token (org Settings → Actions → Runners → New runner). Clone an existing runner directory excluding its identity filesrsync -a --exclude '.runner*' --exclude '.credentials*' --exclude '_diag' --exclude '_work' <src>/ <dst>/ (the globs also catch .runner_migrated/.credentials_migrated, which GitHub writes on migrated runners and which equally trigger the already-configured refusal) — then run config.sh (copying .runner/.credentials verbatim makes it refuse with "already configured"), and start the listener: sudo ./svc.sh install ubuntu && sudo ./svc.sh start. Registration alone leaves the runner offline; only a started service adds capacity. About a minute per instance.

Switch back

Delete the DSH_CI_FAILOVER_LINUX or DSH_CI_FAILOVER_WINDOWS variable (or set it to anything other than selfhosted). New runs resolve back to the hosted enterprise pools. Remove any extra instances that were registered during the incident.

Trust boundary

The variables are writer-manageable repository state; a pull request event itself can neither set them nor read a different value into effect, and the selector expressions live in workflow definitions. Note that under failover, pull_request runs execute the PR merge ref's own workflow definition — the boundary against untrusted code is repository membership (private, forking disabled, Dependabot excluded by the selectors), not the variable. Note on runner-group policy: pinning the runner group to the master-ref workflow is incompatible with this failover — the five failover jobs are pull_request runs evaluated from PR merge refs, and a master-pinned group leaves them queued (observed live on 2026-07-27; the group was widened to all workflows of this repository to unblock the switch). A stricter runner-side policy therefore costs PR failover; the shipped posture accepts repository-scoped, all-workflow group access.

Alternatives considered

Merge a workflow change to switch pools. Rejected because the outage that motivates the switch is exactly the state in which no PR can merge: the required checks are the ones failing. A repository variable is writer-manageable state that takes effect on re-run without a merge.

Keep the self-hosted pool always in the required path. Rejected because it trades hosted-pool availability for the in-house VM's, moving a single point of failure rather than adding a fallback. The variables keep the hosted pools primary and the self-hosted pools proven, one-action standbys; splitting them by platform means an outage on one platform does not retarget the other.

Consequences

Recovering from a hosted-pool outage is flipping the affected platform's variable (any writer) plus a re-run, with no merge on the critical path. The cost is a second runner topology per platform to keep working: the standby lanes exercise them on every master push so the failover targets never go stale, and the snapshot-concurrency and cache-restore branches in ci.yml carry a selfhosted leg (Linux only) that must stay in step with the hosted leg. Splitting the switch by platform adds one more variable to manage but bounds the blast radius of each switch to the jobs of a single platform.

中文

问题

CI 中三个必需的 Linux 工作作业(node 24 / staticnode 24 / coveragenode 24 / snapshots and artifacts)运行在托管的企业级 32 核池上;聚合它们的必需判定作业(all checks passed)运行在标准 ubuntu-latest 上;独立的原生 Windows 作业(windows node 24 / native complete)运行在托管的 dsh-windows-2025-16core 大型运行器上。当企业池发生故障——作业无限排队或企业标签消失——所有开启的拉取请求都无法合并,而"合并一个修复"这一常规恢复手段本身正被那些无法运行的必需检查死锁。适用范围:两个独立开关,每个平台一个。DSH_CI_FAILOVER_LINUX 恢复企业级 Linux 池故障(三个必需的 Linux 工作作业加 all checks passed 判定作业);DSH_CI_FAILOVER_WINDOWS 恢复托管 Windows 池故障(原生 Windows 作业)。Linux 池故障无需重定向原生 Windows 作业,反之亦然。判定作业的其余必需依赖(node-compatpython-sdkwindows)按设计留在标准托管运行器上(可移植边界);若更大范围的 GitHub 托管容量故障连标准池一并击倒,这些依赖仍会阻塞 all checks passed。因此故障需要一个任何具备仓库写权限的响应者都能在不合并任何代码的情况下触发的开关。

决策

三个必需的 Linux 工作作业、独立的原生 Windows 作业,以及 all checks passed 判定作业(若不随切换,即使全部工作作业通过,它仍会滞留在故障池的队列中)——各自通过仓库变量解析运行器池,且开关按平台拆分,使一个平台的故障不会重定向另一个平台。三个 Linux 工作作业与 all checks passed 判定作业(其 needs 是必需的 Linux 工作作业,且运行在 vm-backup 池上)通过 DSH_CI_FAILOVER_LINUX 解析;原生 Windows 作业通过 DSH_CI_FAILOVER_WINDOWS 解析。变量不存在(正常)时它们运行在托管企业池上;由任何具备写权限的协作者设为 selfhosted 时,对应作业切换到公司自有的自托管池:DSH_CI_FAILOVER_LINUX 下,Linux 作业与判定作业切到 vm-backup 池,快照并发降到共享虚拟机上限,并跳过托管路径的 pnpm 缓存恢复;DSH_CI_FAILOVER_WINDOWS 下,原生 Windows 作业切到 dsh-win-ci 池。每个开关都是写者可管理的仓库状态而非一次合并,因此在所有检查都是红色时仍然有效。自有池的就绪状态由 serial / linux (self-hosted standby)serial / windows (self-hosted standby) 通道持续验证——每次 master 推送都在其上运行完整的未分片聚合流程。

ci-master.yml 只豁免一个事件不做取消(${{ github.event_name != 'push' }}),因此一次 master 推送不会取消上一次推送留下的、仍在运行的演练。每次演练以单门禁工作进程执行完整的未分片聚合流程,耗时长于 master 合并的间隔;在无条件取消下,演练会在得出结论前被后续运行取代,该通道无法产出供响应者查看的就绪证据。

这项豁免比「演练总能跑完」要窄,有两点限制。其一,GitHub 每个组只保留一个待运行条目,更新的待运行条目会顶掉更早的,繁忙时段中间的推送运行仍会以 cancelled 结束。其二,该表达式是针对新触发的运行求值的,因此自身事件不是 push 的运行——例如在 ci-master.yml 内的 master 上派发的基准测试,与其演练共用 CI master-<ref> 组——求值为 true,会取消正在运行中的演练。这属于罕见的手动操作,且下一次 master 推送即可恢复证据,因此不值得为它再加机制。这项豁免换来的是该通道周期性地得出结论,而这正是它能作为证据的前提。

这个决定必须放在工作流级:取消作用于被取代的整个运行,作业级 concurrency 组并不能豁免其所属作业。采用否定式写法而非仅指名 pull_request,是有实质作用的:后者会连 workflow_dispatch 一起停止取消,而每次运行器基准测试会在 master 上的同一并发组内同时占用 12 台大规格运行器、最长 15 分钟,届时重复派发会排在演练之前,而不是替换掉已过时的测量。成本之所以可控,是因为 ci-master.yml 中一次 master 推送只承载 wine-apt-cache 和这两条演练;拉取请求作业位于独立的 ci.yml(不监听 push),而基准测试在 ci-master.yml 内受 workflow_dispatch 门控。scripts/ci-workflow.spec.ts 会锁定这个推送可达集合——按条件精确匹配,因为否定式事件判断会包含它所排除的事件名——使新的推送可达作业无法悄悄开始累积未取消的运行。

自有池是什么

vm-backup:一台 64 核虚拟机,6 个常驻 systemd 管理的运行器实例。其镜像必须预装 Playwright Chromium 的 Linux 系统软件包;CI 会下载锁文件选定的浏览器,但绝不在这台持久化共享主机上运行 apt。切换前先看 serial / linux (self-hosted standby) 最近一次运行:其聚合流程包含浏览器回放,因此绿色热备同时验证常规容量和这项浏览器先决条件。

Windows 池

dsh-win-ci:公司内部 Windows CI 服务器(一台 96 核 / 580 GB 机器)上 32 个常驻运行器实例(计划任务 GH-Runner-01GH-Runner-32)。标签:[self-hosted, dsh-win-ci, windows]。镜像必须预装 Node 24、pnpm、Git(Git Bash 在 PATH 上,即 C:\Program Files\Git\bin——bash 工具按名称 spawn bash)、PowerShell 7,并为符号链接支持启用开发人员模式。切换前先看 serial / windows (self-hosted standby) 最近一次运行:绿色热备验证该池能端到端执行 check:ci:windows-complete

切换步骤(任何具备写权限的协作者,约 1 分钟,无需合并)

两个开关相互独立:只切换发生故障的那个平台。

  1. 仓库 Settings → Secrets and variables → Actions → Variables → New repository variable:名称 DSH_CI_FAILOVER_LINUX(Linux 池故障)或 DSH_CI_FAILOVER_WINDOWS(Windows 池故障),值 selfhosted
  2. 重新触发必需作业,使其重新解析运行器池。已经为托管标签排队的作业不会重定向,也无法原地 re-run,因此对于本手册所述的无限排队故障,应取消卡住的运行并 re-run all jobs,或推送一个新提交;“Re-run failed jobs”只有在作业真正失败(而非仍在排队)时才有用。
  3. 切换到此完成。Linux 故障切换状态下,工作流还会把 DSH_SNAPSHOT_MAX_CONCURRENCY 降为 12,以限制共享虚拟机上的争抢,并跳过托管路径的 pnpm 缓存恢复,因为虚拟机的持久 store 会直接提供热安装。覆盖率在两个 Linux 池上都使用 4 个单 worker 插桩分区与 2 个豁免 worker。Windows 开关没有并发或缓存分支;它只重定向原生 Windows 作业的运行器池。

#**Dependabot 例外。**两个开关的选择器都刻意排除了 dependabot[bot]:故障切换期间,Dependabot 拉取请求继续在托管池排队,而不是把依赖项提供的代码放到持久化虚拟机上执行。故障期间 Dependabot PR 持续排队是预期行为而非切换失败;托管池恢复后它会自行完成。

**谁能扳动这个变量。**GitHub 的 API 允许任何具有写权限的协作者管理仓库变量,因此每个开关实际是写者级而非严格的管理员级。在本仓库的信任模型下这并不构成升权:runner group 接纳本私有、禁 fork 仓库的全部工作流(这是让 PR 引用的故障切换得以成立的刻意取舍),因此任何写者本就可以通过推送分支工作流触达这台虚拟机。抵御不可信代码的边界是仓库成员资格;变量只是为成员路由工作。

切换期间的容量

6 个常驻实例可承接正常 PR 流量(该池平时唯一的稳态负载是每次 master 推送一个串行热备作业,故障切换时几乎全池可用)。若仍出现排队,用组织级注册 token(组织 Settings → Actions → Runners → New runner)追加注册实例。复制现有 runner 目录时必须排除身份文件——rsync -a --exclude '.runner*' --exclude '.credentials*' --exclude '_diag' --exclude '_work' <src>/ <dst>/(通配同时排除 .runner_migrated/.credentials_migrated——GitHub 会在迁移过的运行器上写入这些文件,它们同样会触发 already-configured 拒绝)——再跑 config.sh(原样拷贝 .runner/.credentials 会使其以 "already configured" 拒绝),然后启动监听器sudo ./svc.sh install ubuntu && sudo ./svc.sh start。仅注册不会上线;只有启动了服务的 runner 才会增加容量。每个约一分钟。

切回

删除 DSH_CI_FAILOVER_LINUXDSH_CI_FAILOVER_WINDOWS 变量(或改为 selfhosted 以外的任何值),新的运行即解析回托管企业池。若故障期间追加注册过实例,将其移除。

信任边界

这些变量是写者可管理的仓库状态;pull_request 事件本身既不能设置它们,也不能让不同的值生效,选择器表达式存在于工作流定义中。需要注意:故障切换期间,pull_request 运行执行的是 PR merge 引用自带的工作流定义——抵御不可信代码的边界是仓库成员资格(私有、禁 fork、选择器排除 Dependabot),而非该变量。关于 runner group 策略的说明:把 runner group 绑定到 master 引用的工作流与本故障切换机制不兼容——五个故障切换作业是从 PR merge 引用求值的 pull_request 运行,master 绑定的组会让它们持续排队(2026-07-27 实际故障中亲历;当时将组放宽为本仓库全部工作流才疏通了切换)。更严格的运行器侧策略以牺牲 PR 故障切换为代价;当前采用的形态是仓库范围、全工作流的组访问。

曾考虑的替代方案

通过合并一次工作流改动来切换池。 否决,因为触发切换的故障状态恰恰是任何 PR 都无法合并的状态:必需检查正是失败的那些。仓库变量是写者可管理的状态,重跑即生效,无需合并。

让自托管池长期处于必需路径中。 否决,因为这是拿托管池的可用性去换自有虚拟机的可用性,只是搬移了单点故障而非增加回退。这些变量让托管池保持主路径,自托管池作为一个经过验证、一步即可启用的热备;按平台拆分意味着一个平台的故障不会重定向另一个平台。

后果

从托管池故障中恢复只需切换受影响平台的变量(任何写者可设)加一次重跑,关键路径上没有合并。代价是每个平台都要维护第二套运行器拓扑:热备通道在每次 master 推送时都运行它们,避免故障切换目标变得陈旧;而 ci.yml 中的快照并发与缓存恢复分支带有一条 selfhosted 支路(仅 Linux),必须与托管支路保持同步。按平台拆分开关多了一个需要管理的变量,但把每个开关的影响范围限定在单个平台的作业上。