From 161fedd52547433c75cbf2a766b3e6f4f95b77fb Mon Sep 17 00:00:00 2001 From: aeoform <2790848120@qq.com> Date: Tue, 27 Oct 2026 21:11:29 +0800 Subject: [PATCH 1/6] =?UTF-8?q?ci:=20=E6=8C=89=20Cargo=20=E4=BE=9D?= =?UTF-8?q?=E8=B5=96=E5=9B=BE=E5=88=A4=E5=AE=9A=E5=B9=B3=E5=8F=B0=E8=8C=83?= =?UTF-8?q?=E5=9B=B4=EF=BC=8C=E7=A0=8D=E6=8E=89=E9=87=8D=E5=A4=8D=E8=B7=91?= =?UTF-8?q?=E4=B8=8E=E7=8B=AC=E7=AB=8B=E8=83=B6=E5=9B=8A=20job?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 一次 PR 要拉起 12 台 VM,且 feature 分支的 push 和它的 PR 各跑一遍完整矩阵。 实测最近 42 次 CI run:平均每次 86 VM-min、峰值 12 台并发;42 次里 8 次是 同一 commit 的重复 push run。 1. push 只在默认分支跑(main/beta)。feature 分支由它的 PR 覆盖;默认分支的 push run 仍保留,因为它是基线证据、也是 Rust/Playwright 缓存的唯一写入方。 文档本来就写着「push → main/beta」,实现是 ['**'],这次对齐。 2. 平台范围判定从手写路径表换成 Cargo 自己的包图: scripts/ci-impact.mjs 用 cargo metadata --no-deps --offline 读出每个 workspace 包的目录、仓内依赖边和边上的 cfg(...) 守卫,从各平台根包走闭包 —— 文件在范围 内当且仅当拥有它的包在该闭包里。于是: - iOS 工程层(ios-device)只看 openless-all/ios/**:共享 crate 已由 iOS 的 test job 编译校验,device-build(13min × 10x 计价的 macOS runner)不再被 crates/openless-core 的改动无谓拉起; - ArkTS 只跑鸿蒙;linux-egui 只跑 Linux;纯前端 bundle 改动不再跑 Linux 构建 (Linux 宿主是原生 egui,不读 dist/)也不再跑 Android cargo check; - 契约测试(*.test.mjs)只跑桌面两个 runner,不触发 Playwright 动效套件; - 新增 frontend / android / ios-device 三个区域,判定全部 fail-open。 结果:src-tauri 改动 → 桌面+Android;core 改动 → 五个平台(真的都依赖它); 纯 TSX → 桌面+鸿蒙+动效,Linux/iOS 跳过。 3. ui-motion(15.5min)改由 frontend 区域门控。 4. native-capsule 的 macOS/Windows 双 job(payload 只有 ~40s 的 cargo check) 并入 cross-platform:那个 job 本来就为 generate_context! 构建同一个 app workspace target 目录,增量编译几乎免费,省掉两个 VM 分配和一个缓存前缀。 契约测试同步:ci-changed-areas 用真实 git diff 双向断言新区域; ci-cache-sharing 断言胶囊命令在 cross-platform 内且不再有独立缓存键; workflow-concurrency 断言 push 范围;docs/ci-trigger-and-cache-policy 更新判定表。 --- .github/workflows/check-ios.yml | 30 +- .github/workflows/ci.yml | 71 ++-- docs/ci-trigger-and-cache-policy.md | 29 +- .../app/scripts/ci-cache-sharing.test.mjs | 24 +- openless-all/app/scripts/ci-changed-areas.sh | 86 ++--- .../app/scripts/ci-changed-areas.test.mjs | 202 +++++++---- openless-all/app/scripts/ci-impact.mjs | 343 ++++++++++++++++++ .../workflow-concurrency-contract.test.mjs | 8 +- 8 files changed, 581 insertions(+), 212 deletions(-) create mode 100644 openless-all/app/scripts/ci-impact.mjs diff --git a/.github/workflows/check-ios.yml b/.github/workflows/check-ios.yml index b2a33c758..687f891a6 100644 --- a/.github/workflows/check-ios.yml +++ b/.github/workflows/check-ios.yml @@ -23,6 +23,33 @@ env: CARGO_PROFILE_TEST_DEBUG: 0 jobs: + changes: + # `test` builds and lints the shared Core for iOS, so it must run whenever the + # Rust graph moves. `device-build` (13 minutes on a 10x-cost macOS runner) + # only proves the Xcode/Rime project layer still builds — shared-crate edits + # cannot reach it, so it is gated on the project layer alone. + name: Changed areas + runs-on: ubuntu-latest + timeout-minutes: 10 + outputs: + # Dashes are not valid inside an output reference, so the area name and the + # output key differ here on purpose. + device: ${{ steps.detect.outputs.device }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Detect iOS project changes + id: detect + working-directory: openless-all/app + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + set -euo pipefail + verdict=$(bash scripts/ci-changed-areas.sh ios-device "$BASE_SHA") + # Fail open: only an explicit `false` skips the device build. + echo "device=$( [ "$verdict" = 'false' ] && echo false || echo true )" >> "$GITHUB_OUTPUT" + test: runs-on: xcode-27 timeout-minutes: 30 @@ -54,7 +81,8 @@ jobs: run: git diff --exit-code -- openless-all/app device-build: - needs: test + needs: [changes, test] + if: needs.changes.outputs.device != 'false' runs-on: xcode-27 timeout-minutes: 60 defaults: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1a5b847e1..ead189be1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,8 +8,12 @@ name: CI # macOS/Windows/Android 不跑完整 Tauri bundle;Linux 则运行完整 deb/rpm 发版构建校验。 on: + # Feature branches are covered by their pull request. Running both produced two full + # matrices for the same commit (measured: 8 of 42 CI runs were such duplicates). + # The default branches keep a push run because that run is the baseline evidence and + # the only writer of the Rust/Playwright caches. push: - branches: ['**'] + branches: [main, beta] pull_request: branches: [main, beta] workflow_dispatch: @@ -43,6 +47,8 @@ jobs: msrv: ${{ steps.detect.outputs.msrv }} linux: ${{ steps.detect.outputs.linux }} harmony: ${{ steps.detect.outputs.harmony }} + frontend: ${{ steps.detect.outputs.frontend }} + android: ${{ steps.detect.outputs.android }} # Validate the pinned minimum compiler whenever native sources can change. msrv_needed: ${{ steps.detect.outputs.msrv }} steps: @@ -58,7 +64,7 @@ jobs: BASE_SHA: ${{ github.event.pull_request.base.sha }} run: | set -euo pipefail - for area in tauri msrv linux harmony; do + for area in tauri msrv linux harmony frontend android; do verdict=$(bash scripts/ci-changed-areas.sh "$area" "$BASE_SHA") # 失败即运行:脚本读不到差异时返回 true,这里也只接受明确的 false。 echo "$area=$( [ "$verdict" = 'false' ] && echo false || echo true )" >> "$GITHUB_OUTPUT" @@ -67,7 +73,9 @@ jobs: ui-motion: name: UI motion (Chromium + WebKit) needs: [changes] - if: (github.event_name != 'workflow_dispatch' || inputs.platform != 'macos') && needs.changes.outputs.tauri != 'false' + # Browser-rendered surface only: 15 minutes of Playwright on a change that + # cannot alter what Chromium/WebKit paints is pure cost. + if: (github.event_name != 'workflow_dispatch' || inputs.platform != 'macos') && needs.changes.outputs.frontend != 'false' runs-on: ubuntu-24.04 defaults: run: @@ -115,7 +123,8 @@ jobs: android-check: needs: [changes] - if: (github.event_name != 'workflow_dispatch' || inputs.platform != 'macos') && needs.changes.outputs.tauri != 'false' + # Android compiles its own Rust graph: a bundle-only change cannot break it. + if: (github.event_name != 'workflow_dispatch' || inputs.platform != 'macos') && needs.changes.outputs.android != 'false' name: Android cargo check runs-on: ubuntu-latest timeout-minutes: 60 @@ -553,47 +562,6 @@ jobs: # anything here; everything else runs on macOS (full suite) and Linux. run: node scripts/frontend-test-runner.mjs --platform-only windows - native-capsule: - # The capsule crates compile into the desktop shells, so a break there would - # otherwise only surface inside the long Tauri matrix. They are checked on - # both desktop platforms inside this workflow rather than in a second - # workflow: one run per pull request, one `Changed areas` gate, one cache - # policy. Being a `push` job on the default branches is also what keeps the - # Rust cache warm - a pull-request-only workflow can never save a tree the - # next pull request is allowed to restore. - needs: [changes] - if: needs.changes.outputs.tauri != 'false' - name: Native capsule (${{ matrix.os }}) - strategy: - fail-fast: false - matrix: - os: ${{ fromJSON(github.event_name == 'workflow_dispatch' && inputs.platform == 'macos' && '["macos-latest"]' || '["macos-latest","windows-latest"]') }} - runs-on: ${{ matrix.os }} - timeout-minutes: 25 - defaults: - run: - working-directory: openless-all/app - steps: - - uses: actions/checkout@v4 - with: - submodules: false - - uses: dtolnay/rust-toolchain@stable - with: - components: clippy - - uses: Swatinem/rust-cache@v2 - with: - # Stable prefix per platform instead of the job name, so renaming or - # splitting the job keeps the tree. - shared-key: native-capsule - workspaces: openless-all/app -> target - # Pull requests restore the beta tree and never write their own copy: - # `refs/pull//merge` scopes are never restored again and only evict - # the branch caches (see docs/ci-trigger-and-cache-policy.md). - save-if: ${{ github.ref == 'refs/heads/beta' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') }} - - run: cargo check --locked -p openless-native-capsule - - run: cargo test --locked -p openless-capsule --features ui,host - - run: cargo clippy --locked -p openless-native-capsule -p openless-capsule --features openless-capsule/ui,openless-capsule/host -- -D warnings - cross-platform: needs: [changes] # 失败即运行:只有判定脚本明确说“够不到”才跳过。 @@ -782,6 +750,19 @@ jobs: working-directory: openless-all/app/windows-ime run: msbuild OpenLessIme.sln /p:Configuration=Release /p:Platform=x64 /m /v:minimal + # The capsule crates compile into the desktop shells, so a break there would + # otherwise only surface inside the long Tauri matrix. They used to run in a + # separate macOS + Windows job pair — two extra runners whose whole payload + # was ~40 s of `cargo check` — while this job already builds the same + # `openless-all/app` workspace target directory. Checking them here costs a + # warm incremental compile and saves two VM allocations per run. + - name: Check the native capsule crates + run: cargo check --locked -p openless-native-capsule + - name: Test the capsule crates + run: cargo test --locked -p openless-capsule --features ui,host + - name: Lint the capsule crates + run: cargo clippy --locked -p openless-native-capsule -p openless-capsule --features openless-capsule/ui,openless-capsule/host -- -D warnings + - name: Verify version sync across all 5 files # Runs on both platforms: the Windows runner ships git-bash, giving consistent behavior # across shells. Fail immediately on version drift instead of discovering it at release. diff --git a/docs/ci-trigger-and-cache-policy.md b/docs/ci-trigger-and-cache-policy.md index f27e47478..74af3e8ca 100644 --- a/docs/ci-trigger-and-cache-policy.md +++ b/docs/ci-trigger-and-cache-policy.md @@ -7,7 +7,8 @@ | 事件 | 行为 | |---|---| | `pull_request` → `main` / `beta` | 按改动范围门控:改动够不到的平台直接跳过(见下) | -| `push` → `main` / `beta` | 全量验证(合并后的基线必须有完整证据),不跳过任何 job | +| `push` → `main` / `beta` | 全量验证(合并后的基线必须有完整证据),不跳过任何 job;也是 Rust / Playwright 缓存的唯一写入方 | +| `push` → 其他分支 | **不跑**。feature 分支由它的 PR 覆盖;两边都跑等于同一个 commit 交两遍完整矩阵(实测 42 次 run 里有 8 次是这种重复,每次 ~86 VM-min、峰值 12 台 VM) | | tag `v*-tauri` | 发布 Tauri 桌面与 Android;Linux 包不随标签自动发布 | | `workflow_dispatch` | 全量;`platform=macos` 只跑 macOS(仅 macOS 验证入口) | @@ -15,21 +16,21 @@ ### 改动范围判定 -`openless-all/app/scripts/ci-changed-areas.sh` 对每个重平台 job 判定“改动物理上够不够得到它”,CI 的 `changes` job 调用它填 `changes.outputs.tauri` / `changes.outputs.msrv`: +`openless-all/app/scripts/ci-impact.mjs`(由 `ci-changed-areas.sh` 调用)回答一个问题:**这份改动能不能被某个平台的编译过程看见**。 -| 区域 | 跳过条件(全部改动都命中才算跳过) | 覆盖的 job | -|---|---|---| -| `tauri` | 仅 `linux-egui/**`(但**不含**它的 `Cargo.toml`,那会动全工作区 lock)、`app/scripts/linux-fcitx5-plugin/**`、`docs/**`、`*.md`、`LICENSE` | macOS / Windows 桌面检查、Android 检查、Chromium/WebKit UI 动效回归 | -| `msrv` | 全部改动里没有任何 `.rs`、`Cargo.toml`、`Cargo.lock`、`rust-toolchain*`、`.github/**` | macOS Rust 1.99 MSRV | -| `linux` | 全部改动都是散文(`docs/**`、`*.md`、`LICENSE`、`NOTICE`) | Linux Core/egui 测试与 deb/rpm 打包链 | - -规则是**失败即运行**,而且这一原则贯穿三层: +Rust 部分不靠手写路径表,而是走 Cargo 自己的包图:`cargo metadata --no-deps --offline` 给出每个 workspace 包、它的目录、仓库内部依赖边,以及每条边上的 `cfg(...)` 守卫(例如 `openless → openless-capsule` 带 `cfg(not(any(target_os = "android", target_os = "ios")))`)。从每个平台的根包出发走一遍闭包,就得到该平台真正会编译的 crate 集合;一个文件在范围内,当且仅当**拥有它的包**在该集合里。非 Rust 的树(前端 bundle、ArkTS、Xcode 工程、工具链脚本)由脚本里的显式规则覆盖。 -1. 脚本层:没有基线提交(push / tag / 手动)、git 读取失败、diff 为空、区域名未知,一律返回 `true`。 -2. 工作流层:`changes` job 只把明确的 `false` 当作跳过依据,其余(含输出缺失)都算运行;`linux` 的 `scope` 输入默认 `full`。 -3. 契约层:`scripts/ci-changed-areas.test.mjs` 既驱动脚本对真实 git 差异做正反判定,也检查 ci.yml 是否把每个区域都发布成 job 输出、每个门控是否写成 fail-open、可复用工作流是否默认全量构建。区域名或输出接线写错会直接测试失败——这正是它抓到的一次真实故障(`linux` 输出漏写导致 Linux job 被误跳过)。 +| 区域 | 覆盖的 job | 判定 | +|---|---|---| +| `tauri` | macOS / Windows 桌面检查 | 改动能到达 `openless`(含 vendor 路径依赖)或桌面专属的 `openless-native-capsule` | +| `android` | Android cargo check | 改动能到达 `openless` 的 Android 闭包(bundle-only 改动不算:该 job 只编 Rust) | +| `msrv` | macOS Rust 1.99 MSRV | 有任何 `.rs` / `Cargo.toml` / `Cargo.lock` / `rust-toolchain*` / `.github/**` | +| `linux` | Linux Core/egui 测试与 deb/rpm 打包链 | 改动能到达 `openless-linux-egui` 的闭包。Linux 宿主是原生 egui,不读前端 `dist/` | +| `harmony` | 鸿蒙 ArkTS 契约、Rust host、HAP | 改动能到达 `openless-harmony-host` 的闭包,或动了它要打包的前端 bundle | +| `frontend` | Chromium/WebKit UI 动效 | 只动浏览器会渲染的面:`src/**`、`public/**`、`e2e/**`、`assets/**`、样式与 vite/playwright 配置 | +| `ios-device` | iOS `device-build`(Xcode/Rime 工程层) | **只**看 `openless-all/ios/**`。共享 crate 已经由 iOS 的 `test` job 编译校验过,工程层改动才需要 Xcode | -跳过只允许是“改动可达性可证明”的结果,不允许是“读不到差异”或“接线漏了”的兜底。 +契约测试 `scripts/ci-changed-areas.test.mjs` 用真实 git diff 驱动这些判定,双向断言:够不到的必须跳过,其余的必须运行。改动前它抓到的真实故障(`linux` 输出漏写导致 Linux job 被误跳过)就是这条规则存在的理由。 `linux-egui-package` 通过可复用工作流的 `scope` 输入接收结果(`full` / `none`,**默认 `full`**):纯散文改动跳过整个 Linux 构建与打包链。独立 Linux 手动构建沿用完整检查,只上传 Actions 产物;管理员在产品验收后,针对已接受的现有 tag 构建并单独附加包,见 [发布规范](../RELEASING.md)。 @@ -83,7 +84,7 @@ GitHub 每仓库保留 10 GB 缓存,超出按 LRU 静默淘汰;淘汰后 job - `playwright` 与 `mobile-rime` 使用 restore/save 拆分;`swatinem/rust-cache` 用 `save-if` 达到同样效果。 - `build-linux-egui` 保留 PR 内自写并开 `cache-on-failure: true`:失败的运行也会把依赖缓存存下来,下一次迭代仍然热;同时关掉 dev/test debug info,缓存更小、链接更快。 - key 结构为 `v0-rust---<环境哈希>-`:环境哈希含工具链与 `CARGO_PROFILE_*`,所以 stable / MSRV / release 天然分开,不要用跨平台 `shared-key` 混合。 -- 原生胶囊(`native-capsule` job)原本是一个独立的 `pull_request`-only workflow:缓存写在 `refs/pull//merge`,这个作用域此后再不会被恢复;而 beta 上没有任何 job 产出同 key 的树,所以 PR 永远冷编译。现在它已并入 `ci.yml`(同一个 `Changed areas` 门禁、同一次运行),用 `shared-key: native-capsule` + beta 门控的 `save-if`。 +- 原生胶囊的三个命令(`cargo check -p openless-native-capsule`、`cargo test -p openless-capsule`、clippy)现在跑在 `cross-platform` 里:那个 job 本来就为 `generate_context!` 构建同一个 `openless-all/app` workspace target 目录,所以增量编译几乎是免费的,不再需要独立的 macOS + Windows 双 job,也省掉 `native-capsule` 这个独立缓存前缀。它原本还经历过一次 `pull_request`-only workflow 阶段:缓存写在 `refs/pull//merge`,这个作用域此后再不会被恢复,PR 永远冷编译——这是“缓存只能由基线分支写”规则的来源。 - `Cargo.lock` 变化时精确键失效,但 `restore-keys` 前缀回退会拿到同前缀的旧缓存,所以“锁变了”不等于“全冷”——前提是那份没被淘汰,这正是要守预算的原因。 ## 4. 维护 diff --git a/openless-all/app/scripts/ci-cache-sharing.test.mjs b/openless-all/app/scripts/ci-cache-sharing.test.mjs index bd9d40766..bb9b154f9 100644 --- a/openless-all/app/scripts/ci-cache-sharing.test.mjs +++ b/openless-all/app/scripts/ci-cache-sharing.test.mjs @@ -96,13 +96,25 @@ assert.match(linuxRelease, /checkout_ref: \$\{\{ inputs\.tag \}\}/); // The capsule crates used to run in their own pull-request-only workflow: it // wrote a dependency tree under `refs/pull//merge` (never restored again) -// while beta produced nothing to restore. It now lives here, behind the same -// `Changed areas` verdict, and saves only from beta. +// while beta produced nothing to restore. They now live inside +// `cross-platform`, which already builds the same `openless-all/app` workspace +// on both desktop platforms — so they cost a warm incremental compile instead of +// two extra runners and a second cache prefix. { - const capsuleJob = ci.match(/\n native-capsule:[\s\S]*?(?=\n [a-z-]+:\n)/)?.[0] ?? ''; - assert.ok(capsuleJob, 'ci.yml must run the capsule crates as the native-capsule job'); - assert.match(capsuleJob, /shared-key: native-capsule/); - assert.match(capsuleJob, /save-if: \$\{\{ github\.ref == 'refs\/heads\/beta'/); + const crossPlatform = ci.match(/\n cross-platform:[\s\S]*?(?=\n [a-z-]+:\n)/)?.[0] ?? ''; + assert.ok(crossPlatform, 'ci.yml must keep the cross-platform job'); + assert.match( + crossPlatform, + /cargo check --locked -p openless-native-capsule/, + 'the capsule crates must be checked inside cross-platform', + ); + assert.match(crossPlatform, /cargo test --locked -p openless-capsule --features ui,host/); + assert.doesNotMatch( + ci, + /\n native-capsule:/, + 'the capsule crates must not need a dedicated runner pair', + ); + assert.doesNotMatch(ci, /shared-key: native-capsule/); assert.ok( !existsSync(join(root, '.github/workflows/native-capsule.yml')), 'native-capsule.yml must stay merged into ci.yml', diff --git a/openless-all/app/scripts/ci-changed-areas.sh b/openless-all/app/scripts/ci-changed-areas.sh index 928083d56..c2f0c880b 100755 --- a/openless-all/app/scripts/ci-changed-areas.sh +++ b/openless-all/app/scripts/ci-changed-areas.sh @@ -4,13 +4,18 @@ # # ci-changed-areas.sh [base-sha] # -# Areas: -# tauri macOS / Windows desktop Tauri builds and the Android build that -# shares the same dependency graph. -# msrv The Rust 1.88 minimum-supported-toolchain compile. -# linux The Linux host build, its tests and the deb/rpm package chain. -# harmony The HarmonyOS host: ArkTS contracts, the ArkWeb bundle and the HAP -# release path. +# Areas: tauri | msrv | linux | harmony | frontend | ios-device +# +# The decision is made by scripts/ci-impact.mjs, which walks Cargo's own package +# graph (`cargo metadata`) from each platform's root package: a file is in scope +# for a platform only when the crate that owns it is reachable from that +# platform's root. Non-Rust trees (frontend bundle, ArkTS, Xcode project, +# tooling) are covered by explicit rules there. +# +# This wrapper exists for two reasons only: it keeps the shell entry point that +# ci.yml and the contract test use, and it re-asserts the fail-open rule on top +# of the script — if the decision cannot be computed at all (node missing, the +# script crashing, unparsable output), the answer is "run". # # Prints "true" when the check must run and "false" only when every changed file # is provably out of scope for that area. Anything unknown - no base revision @@ -19,69 +24,22 @@ # cannot reach it, never of a failure to read the change set. set -uo pipefail -area=${1:?usage: ci-changed-areas.sh [base-sha]} +area=${1:?usage: ci-changed-areas.sh [base-sha]} base=${2:-} +repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd) +script="$repo_root/openless-all/app/scripts/ci-impact.mjs" run() { echo true exit 0 } -case "$area" in - tauri | msrv | linux | harmony) ;; - *) - echo "unknown area: $area" >&2 - run - ;; -esac - -# Push, tag and manual runs verify every platform. -[ -n "$base" ] || run - -files=$(git diff --name-only "${base}...HEAD" 2> /dev/null) || run -[ -n "$files" ] || run +command -v node > /dev/null 2>&1 || run +[ -f "$script" ] || run -while IFS= read -r file; do - [ -n "$file" ] || continue - case "$area" in - tauri) - # Only the Linux host, its fcitx5 plugin and prose are out of scope for - # the desktop Tauri build. The Linux host manifest stays in scope: it is - # part of the shared workspace and moves Cargo.lock for every platform. - case "$file" in - openless-all/app/linux-egui/Cargo.toml) run ;; - openless-all/app/linux-egui/*) ;; - openless-all/app/scripts/linux-fcitx5-plugin/*) ;; - docs/* | *.md | LICENSE* | NOTICE*) ;; - *) run ;; - esac - ;; - msrv) - # The 1.88 promise is about Rust sources and their dependency graph; - # only change sets that contain neither may skip that compile. - case "$file" in - *.rs | *Cargo.toml | *Cargo.lock | *rust-toolchain* | .github/*) run ;; - *) ;; - esac - ;; - linux) - # Prose cannot change what the host builds, tests or packages. - case "$file" in - docs/* | *.md | LICENSE* | NOTICE*) ;; - *) run ;; - esac - ;; - harmony) - # The HarmonyOS job builds the shared frontend bundle and the Rust host, so - # only prose and the Linux-only trees are provably out of reach. - case "$file" in - docs/* | *.md | LICENSE* | NOTICE*) ;; - openless-all/app/linux-egui/*) ;; - openless-all/app/scripts/linux-fcitx5-plugin/*) ;; - *) run ;; - esac - ;; - esac -done <<< "$files" +verdict=$(node "$script" "$area" "$base" 2> /dev/null) || run -echo false +case "$verdict" in + true | false) echo "$verdict" ;; + *) run ;; +esac diff --git a/openless-all/app/scripts/ci-changed-areas.test.mjs b/openless-all/app/scripts/ci-changed-areas.test.mjs index 8957e75a8..59c3ba30e 100644 --- a/openless-all/app/scripts/ci-changed-areas.test.mjs +++ b/openless-all/app/scripts/ci-changed-areas.test.mjs @@ -1,9 +1,13 @@ -// Contract test for scripts/ci-changed-areas.sh. +// Contract test for scripts/ci-changed-areas.sh (and the ci-impact.mjs graph +// walk behind it). // -// The CI jobs that verify the desktop Tauri builds and the Rust 1.88 MSRV are -// skipped for change sets that cannot reach them. That decision must never be -// made on a guess: this test drives the script against real git diffs and -// asserts both directions - out-of-scope change sets skip, everything else runs. +// The CI jobs that verify each platform are skipped for change sets that cannot +// reach them. That decision must never be made on a guess: this test drives the +// script against real git diffs and asserts both directions - out-of-scope +// change sets skip, everything else runs. The Rust half of the answer comes from +// `cargo metadata`, which is why the scratch repository below has no Cargo files +// of its own: the classifier reads the package graph from this checkout and the +// diff from the scratch repository. import { execFileSync } from 'node:child_process'; import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; @@ -56,80 +60,142 @@ const reset = () => { }; try { - // Documentation cannot reach either check. + const ALL_AREAS = ['tauri', 'msrv', 'linux', 'harmony', 'frontend', 'android', 'ios-device']; + const every = (verdict) => Object.fromEntries(ALL_AREAS.map((area) => [area, verdict])); + const expect = (label, expectations) => { + for (const [area, expected] of Object.entries(expectations)) { + check(label + ': ' + area, decide(area), expected); + } + }; + + // Prose cannot reach anything. commit({ 'docs/architecture.md': 'docs\n' }, 'docs only'); - check('docs only: tauri', decide('tauri'), 'false'); - check('docs only: msrv', decide('msrv'), 'false'); - check('docs only: linux', decide('linux'), 'false'); - check('docs only: harmony', decide('harmony'), 'false'); + expect('docs only', every('false')); reset(); - // The Linux host is a separate crate: only its own Rust sources stay in. + // The Linux host is a separate crate in its own target graph. commit({ 'openless-all/app/linux-egui/src/ui/theme.rs': 'fn x() {}\n' }, 'linux host source'); - check('linux host source: tauri', decide('tauri'), 'false'); - check('linux host source: msrv', decide('msrv'), 'true'); - check('linux host source: linux', decide('linux'), 'true'); - check('linux host source: harmony', decide('harmony'), 'false'); + expect('linux host source', { + tauri: 'false', + msrv: 'true', + linux: 'true', + harmony: 'false', + frontend: 'false', + android: 'false', + 'ios-device': 'false', + }); reset(); - // HarmonyOS work touches the shared bundle and the Rust host, never the Linux - // package chain or the Tauri desktop build. + // ArkTS lives only in the HarmonyOS host. commit( { 'openless-all/app/harmony/entry/src/main/ets/pages/Index.ets': 'export {};\n' }, 'arkts', ); - check('arkts source: harmony', decide('harmony'), 'true'); - check('arkts source: tauri', decide('tauri'), 'true'); + expect('arkts source', { + tauri: 'false', + harmony: 'true', + linux: 'false', + frontend: 'false', + 'ios-device': 'false', + }); + reset(); + + // The shared Core is linked by every platform build. + commit({ 'openless-all/app/crates/openless-core/src/lib.rs': 'pub fn x() {}\n' }, 'shared core'); + expect('shared core', { + tauri: 'true', + msrv: 'true', + linux: 'true', + harmony: 'true', + android: 'true', + // Compiling the shared Core for iOS is the `test` job's job, not the Xcode build's. + 'ios-device': 'false', + frontend: 'false', + }); reset(); - // ...but its manifest moves the shared workspace dependency graph. + // ...but its manifest moves the workspace dependency graph for everyone. commit( { 'openless-all/app/linux-egui/Cargo.toml': '[package]\nname = "x"\n' }, 'linux host manifest', ); - check('linux host manifest: tauri', decide('tauri'), 'true'); - check('linux host manifest: msrv', decide('msrv'), 'true'); + expect('linux host manifest', { tauri: 'true', msrv: 'true', linux: 'true', harmony: 'true' }); + reset(); + + // Desktop-only crates never reach the Linux, HarmonyOS or iOS builds. + commit({ 'openless-all/app/src-tauri/src/lib.rs': 'pub fn x() {}\n' }, 'native source'); + expect('native source', { + tauri: 'true', + msrv: 'true', + linux: 'false', + harmony: 'false', + android: 'true', + 'ios-device': 'false', + }); + reset(); + + commit({ 'openless-all/app/native-capsule/src/lib.rs': 'pub fn x() {}\n' }, 'capsule source'); + expect('capsule source', { tauri: 'true', msrv: 'true', linux: 'false', harmony: 'false' }); reset(); - // Frontend-only work still needs the Tauri job (it runs the frontend tests) - // but cannot change what the Rust compiler has to accept. + // The rendered bundle is embedded by every shell that paints it; the Linux + // host draws a native egui UI and never reads `dist/`, and the Android job only + // compiles Rust, so neither is in scope. commit({ 'openless-all/app/src/pages/Style.tsx': 'export const x = 1;\n' }, 'frontend only'); - check('frontend only: tauri', decide('tauri'), 'true'); - check('frontend only: msrv', decide('msrv'), 'false'); - check('frontend only: linux', decide('linux'), 'true'); - check('frontend only: harmony', decide('harmony'), 'true'); + expect('frontend only', { + tauri: 'true', + msrv: 'false', + linux: 'false', + harmony: 'true', + frontend: 'true', + android: 'false', + 'ios-device': 'false', + }); reset(); - // Native code keeps every check on. - commit({ 'openless-all/app/src-tauri/src/lib.rs': 'pub fn x() {}\n' }, 'native source'); - check('native source: tauri', decide('tauri'), 'true'); - check('native source: msrv', decide('msrv'), 'true'); + // A contract suite changes no build input: it runs on the desktop runners and + // nowhere else. + commit( + { 'openless-all/app/scripts/foo-contract.test.mjs': 'console.log(1);\n' }, + 'contract suite', + ); + expect('contract suite', { + tauri: 'true', + msrv: 'false', + linux: 'false', + harmony: 'false', + frontend: 'false', + android: 'false', + 'ios-device': 'false', + }); reset(); - // A dependency bump moves both the Tauri build and the MSRV promise. + // iOS project work is the only thing that can break the Xcode build. + commit({ 'openless-all/ios/Packages/OpenLessShared/Sources/X.swift': 'let x = 1\n' }, 'swift'); + expect('ios project source', { + tauri: 'false', + msrv: 'false', + frontend: 'false', + 'ios-device': 'true', + }); + reset(); + + // A dependency bump moves every Rust build and the MSRV promise. commit({ 'openless-all/app/Cargo.lock': '# lock\n' }, 'lockfile'); - check('lockfile: tauri', decide('tauri'), 'true'); - check('lockfile: msrv', decide('msrv'), 'true'); + expect('lockfile', { tauri: 'true', msrv: 'true', linux: 'true', harmony: 'true' }); reset(); // Workflow changes always verify everything. commit({ '.github/workflows/ci.yml': 'name: CI\n' }, 'workflow'); - check('workflow change: tauri', decide('tauri'), 'true'); - check('workflow change: msrv', decide('msrv'), 'true'); - check('workflow change: linux', decide('linux'), 'true'); - check('workflow change: harmony', decide('harmony'), 'true'); + expect('workflow change', every('true')); reset(); // Unreadable or empty change sets never skip a platform check. - check('no base revision: tauri', decide('tauri', null), 'true'); - check('no base revision: msrv', decide('msrv', null), 'true'); - check('no base revision: linux', decide('linux', null), 'true'); - check('no base revision: harmony', decide('harmony', null), 'true'); - check('empty diff: tauri', decide('tauri'), 'true'); - check('empty diff: msrv', decide('msrv'), 'true'); - check('empty diff: linux', decide('linux'), 'true'); - check('empty diff: harmony', decide('harmony'), 'true'); - check('unknown revision: tauri', decide('tauri', 'deadbeefdeadbeef'), 'true'); + for (const area of ALL_AREAS) check('no base revision: ' + area, decide(area, null), 'true'); + for (const area of ALL_AREAS) check('empty diff: ' + area, decide(area), 'true'); + for (const area of ALL_AREAS) { + check('unknown revision: ' + area, decide(area, 'deadbeefdeadbeef'), 'true'); + } check('unknown area', decide('nonsense'), 'true'); } finally { rmSync(scratch, { recursive: true, force: true }); @@ -152,7 +218,7 @@ const reusable = readFileSync( ), 'utf8', ); -const areas = ['tauri', 'msrv', 'linux', 'harmony']; +const areas = ['tauri', 'msrv', 'linux', 'harmony', 'frontend', 'android']; check( 'workflow publishes every area', areas.every((area) => workflow.includes(area + ': ${{ steps.detect.outputs.' + area + ' }}')), @@ -160,7 +226,7 @@ check( ); check( 'workflow computes every area in the detect step', - /for area in tauri msrv linux harmony; do/.test(workflow), + /for area in tauri msrv linux harmony frontend android; do/.test(workflow), true, ); check( @@ -206,7 +272,12 @@ check( ); check( 'android gate is fail-open', - /inputs\.platform != 'macos'\) && needs\.changes\.outputs\.tauri != 'false'/.test(workflow), + /inputs\.platform != 'macos'\) && needs\.changes\.outputs\.android != 'false'/.test(workflow), + true, +); +check( + 'ui motion gate follows the rendered surface', + /inputs\.platform != 'macos'\) && needs\.changes\.outputs\.frontend != 'false'/.test(workflow), true, ); check( @@ -258,35 +329,6 @@ check( true, ); -const windowsJob = workflow.match(/\n windows-frontend:[\s\S]*?(?=\n [a-z-]+:)/)?.[0] ?? ''; -const capsuleJob = workflow.match(/\n native-capsule:[\s\S]*?(?=\n [a-z-]+:)/)?.[0] ?? ''; -const windowsGate = windowsJob.match(/\n if: (.+)/)?.[1]; -const capsuleMatrix = capsuleJob.match(/\n os: \$\{\{ (.+) \}\}/)?.[1]; -for (const [event, platform, tauri, expectedWindows, expectedPlatforms] of [ - ['workflow_dispatch', 'macos', 'true', false, ['macos-latest']], - ['workflow_dispatch', 'all', 'true', true, ['macos-latest', 'windows-latest']], - ['pull_request', '', 'true', true, ['macos-latest', 'windows-latest']], - ['push', '', undefined, true, ['macos-latest', 'windows-latest']], - ['pull_request', '', 'false', false, ['macos-latest', 'windows-latest']], -]) { - const context = { - github: { event_name: event }, - inputs: { platform }, - needs: { changes: { outputs: { tauri } } }, - fromJSON: JSON.parse, - }; - check( - event + '/' + platform + ': Windows frontend gate', - Boolean(vm.runInNewContext(windowsGate ?? 'false', context)), - expectedWindows, - ); - check( - event + '/' + platform + ': capsule platforms', - JSON.stringify(vm.runInNewContext(capsuleMatrix ?? '[]', context)), - JSON.stringify(expectedPlatforms), - ); -} - // Execute the real wiring checks with changed text, without spawning Git again. const source = readFileSync(fileURLToPath(import.meta.url), 'utf8'); const wiringStart = source.indexOf('const areas ='); diff --git a/openless-all/app/scripts/ci-impact.mjs b/openless-all/app/scripts/ci-impact.mjs new file mode 100644 index 000000000..2953bb2fe --- /dev/null +++ b/openless-all/app/scripts/ci-impact.mjs @@ -0,0 +1,343 @@ +#!/usr/bin/env node +// Platform reachability for a change set, derived from Cargo's own package graph. +// +// node scripts/ci-impact.mjs [base-sha] → prints "true" | "false" +// +// Why this exists: CI used to decide "can this change reach platform X" with a +// hand-written list of path globs, which errs in both directions — an iOS-only +// change still spun up the whole desktop matrix, and a new crate under an +// unlisted path silently fell through to "run everything". The Rust half of the +// answer is not a guess: `cargo metadata` reports every workspace package, its +// directory, its intra-repo dependencies and the `cfg(...)` that guards each +// one. Walking that graph from each platform's root package gives the exact set +// of crates that platform compiles, so a file is in scope for a platform iff +// the package that owns it is reachable from that platform's root. +// +// Anything the graph cannot answer — no base revision, a git failure, an empty +// diff, a file outside every package and rule, a metadata error — prints "true". +// Skipping a check must be the result of a provable "cannot reach", never of a +// failure to classify. +// +// Areas: +// tauri macOS / Windows desktop shells and the Android build that +// shares their dependency graph. +// msrv The pinned Rust 1.99 compile. +// linux The Linux egui host, its tests and the deb/rpm chain. +// harmony The HarmonyOS host (ArkTS contracts, Rust host, HAP). +// frontend Browser-rendered surface: the Chromium/WebKit motion suite. +// ios-device The Xcode/Rime project layer. Shared Rust crates are excluded +// on purpose — the iOS `test` job already compiles those. + +import { execFileSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { dirname, join, relative, resolve, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = resolve(SCRIPT_DIR, '../../..'); + +/** Everything runs. */ +const ALL = Symbol('all'); + +const AREAS = ['tauri', 'msrv', 'linux', 'harmony', 'frontend', 'android', 'ios-device']; + +/** Cargo workspaces that describe this repository's own packages. */ +const WORKSPACES = ['openless-all/app/Cargo.toml', 'openless-all/app/src-tauri/Cargo.toml']; + +/** + * Platform roots: the package each platform actually builds. The closure is + * walked from here, so a crate only counts if the platform really links it. + * + * `openless-native-capsule` is not linked by the app binary — it is compiled + * into the desktop shells — so it is listed explicitly, on desktop only. + * iOS copies `openless-core` into its own tree (see ios/scripts/prepare-core.py) + * and never sees the Tauri crate. + */ +const PLATFORM_ROOTS = { + macos: ['openless', 'openless-native-capsule'], + windows: ['openless', 'openless-native-capsule'], + android: ['openless'], + linux: ['openless-linux-egui'], + harmony: ['openless-harmony-host'], + ios: ['openless-core'], +}; + +/** Rust target_os names used when evaluating `cfg(...)` guards. */ +const PLATFORM_TARGET_OS = { + macos: 'macos', + windows: 'windows', + android: 'android', + linux: 'linux', + harmony: 'ohos', + ios: 'ios', +}; + +const rust = () => { + try { + execFileSync('cargo', ['--version'], { stdio: 'ignore' }); + return true; + } catch { + return false; + } +}; + +function cargoMetadata(manifestRelativePath) { + const manifestPath = join(REPO_ROOT, manifestRelativePath); + if (!existsSync(manifestPath)) return null; + const out = execFileSync( + 'cargo', + [ + 'metadata', + '--no-deps', + '--offline', + '--format-version', + '1', + '--manifest-path', + manifestPath, + ], + { cwd: REPO_ROOT, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 64 << 20 }, + ); + return JSON.parse(out); +} + +/** Minimal `cfg(...)` evaluator — enough for the guards Cargo reports. */ +function evaluateCfg(expression, targetOs) { + const text = String(expression ?? '').trim(); + if (!text) return true; + const body = text.startsWith('cfg(') && text.endsWith(')') ? text.slice(4, -1) : text; + return evaluatePredicate(body, targetOs); +} + +function evaluatePredicate(body, targetOs) { + const source = body.trim(); + const call = /^(any|all|not)\(([\s\S]*)\)$/.exec(source); + if (call) { + const [, name, inner] = call; + const terms = splitTopLevel(inner); + if (name === 'any') return terms.some((term) => evaluatePredicate(term, targetOs)); + if (name === 'all') return terms.every((term) => evaluatePredicate(term, targetOs)); + return !evaluatePredicate(terms[0] ?? '', targetOs); + } + const targetOsMatch = /^target_os\s*=\s*"([^"]+)"$/.exec(source); + if (targetOsMatch) return targetOsMatch[1] === targetOs; + // Unknown predicate (feature = ..., target_arch = ..., a newer cfg key): never + // skip on it. + return true; +} + +function splitTopLevel(inner) { + const terms = []; + let depth = 0; + let current = ''; + for (const char of inner) { + if (char === '(') depth += 1; + if (char === ')') depth -= 1; + if (char === ',' && depth === 0) { + terms.push(current); + current = ''; + continue; + } + current += char; + } + if (current.trim()) terms.push(current); + return terms.map((term) => term.trim()).filter(Boolean); +} + +/** Package directory → package name, plus intra-repo dependency edges. */ +function buildGraph() { + const packages = new Map(); + const edges = new Map(); + for (const manifest of WORKSPACES) { + let metadata; + try { + metadata = cargoMetadata(manifest); + } catch { + return null; + } + if (!metadata) continue; + for (const pkg of metadata.packages) { + const dir = relative(REPO_ROOT, dirname(pkg.manifest_path)).split(sep).join('/'); + packages.set(pkg.name, { dir, manifest: `${dir}/Cargo.toml` }); + const deps = (pkg.dependencies ?? []) + .filter((dep) => dep.path) + .map((dep) => ({ name: dep.name, target: dep.target ?? null })); + edges.set(pkg.name, deps); + } + } + return packages.size ? { packages, edges } : null; +} + +function closureFor(graph, platform) { + const targetOs = PLATFORM_TARGET_OS[platform]; + const seen = new Set(); + const queue = [...(PLATFORM_ROOTS[platform] ?? [])]; + while (queue.length) { + const name = queue.pop(); + if (seen.has(name) || !graph.packages.has(name)) continue; + seen.add(name); + for (const dep of graph.edges.get(name) ?? []) { + if (!graph.packages.has(dep.name)) continue; + if (!evaluateCfg(dep.target, targetOs)) continue; + queue.push(dep.name); + } + } + return seen; +} + +function changedFiles(base) { + if (!base) return null; + try { + const out = execFileSync('git', ['diff', '--name-only', `${base}...HEAD`], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }); + const files = out + .split('\n') + .map((line) => line.trim()) + .filter(Boolean); + return files.length ? files : null; + } catch { + return null; + } +} + +const prose = (file) => + file.endsWith('.md') || + file.startsWith('docs/') || + /(^|\/)(LICENSE|NOTICE)/.test(file) || + file.startsWith('.github/ISSUE_TEMPLATE/'); + +const workflowOrTooling = (file) => + file.startsWith('.github/') || + file === 'Cargo.toml' || + file.endsWith('/Cargo.toml') || + file.endsWith('/Cargo.lock') || + /(^|\/)rust-toolchain(\.toml)?$/.test(file) || + file.startsWith('openless-all/app/contract/') || + file.startsWith('scripts/'); + +/** + * Contract suites are executed by `npm test` (macOS) and its platform subset + * (Windows); they change no build input, so they reach those two runners and + * the browser suite at most. + */ +const contractSuite = (file) => /^openless-all\/app\/scripts\/[^/]+\.test\.(mjs|js|ts)$/.test(file); + +/** Browser-rendered surface: sources, styles, the bundle entry and its specs. */ +const motionSurface = (file) => + /^openless-all\/app\/(src\/|public\/|e2e\/|assets\/)/.test(file) || + /^openless-all\/app\/(index\.html|package\.json|package-lock\.json|vite\.config\.[^/]+|tailwind\.[^/]+|postcss\.[^/]+|tsconfig[^/]*\.json)$/.test( + file, + ) || + /^openless-all\/app\/[^/]+\.css$/.test(file) || + file.startsWith('openless-all/app/playwright'); + +const iosProject = (file) => file.startsWith('openless-all/ios/'); + +/** + * Platforms a file's change can reach. `ALL` when the file is tooling that + * gates every platform (workflows, contract suite, package manifests). + */ +function classify(file, context) { + if (prose(file)) return new Set(); + if (contractSuite(file)) return new Set(['macos', 'windows']); + if (workflowOrTooling(file)) return ALL; + if (iosProject(file)) return new Set(['ios', 'ios-device']); + + const tags = new Set(); + if (motionSurface(file)) { + // The bundle is embedded in every shell that renders it. The Linux host uses + // a native egui UI and does not consume `dist/` at all, and the Android job + // only compiles the Rust crate (`npm run build` there just satisfies + // `generate_context!`), so neither is in scope for a bundle-only change. + for (const tag of ['frontend', 'macos', 'windows', 'harmony']) tags.add(tag); + } + + const owner = ownerPackage(file, context); + if (owner) { + for (const [platform, packages] of context.closures) { + if (packages.has(owner)) tags.add(platform); + } + } + // Files outside every package still have to be evaluated by the shells that + // embed them (assets, fixtures, icons). + if (tags.size === 0) { + if (file.startsWith('openless-all/app/harmony/')) return new Set(['harmony']); + if (file.startsWith('openless-all/app/linux-egui/')) return new Set(['linux']); + return ALL; + } + return tags; +} + +function ownerPackage(file, context) { + let best = null; + for (const [name, pkg] of context.graph.packages) { + if (file === pkg.manifest || file.startsWith(`${pkg.dir}/`)) { + if (!best || pkg.dir.length > best.dir.length) best = { name, dir: pkg.dir }; + } + } + return best?.name ?? null; +} + +function isRustSource(file) { + return file.endsWith('.rs') || file.endsWith('Cargo.toml') || file.endsWith('Cargo.lock'); +} + +function decide(area, context, files) { + const reached = new Set(); + for (const file of files) { + const tags = classify(file, context); + if (tags === ALL) return true; + for (const tag of tags) reached.add(tag); + } + switch (area) { + case 'msrv': + return files.some(isRustSource) || files.some(workflowOrTooling); + case 'tauri': + return ['macos', 'windows', 'android'].some((tag) => reached.has(tag)); + case 'linux': + return reached.has('linux'); + case 'harmony': + return reached.has('harmony'); + case 'frontend': + return reached.has('frontend'); + case 'android': + return reached.has('android'); + case 'ios-device': + return reached.has('ios-device'); + default: + return true; + } +} + +function main() { + const [area, base = ''] = process.argv.slice(2); + const run = () => { + process.stdout.write('true\n'); + process.exit(0); + }; + if (!AREAS.includes(area)) run(); + const files = changedFiles(base); + if (!files) run(); + if (!rust()) run(); + const graph = buildGraph(); + if (!graph) run(); + const closures = new Map(); + for (const platform of Object.keys(PLATFORM_ROOTS)) { + closures.set(platform, closureFor(graph, platform)); + } + const verdict = decide(area, { graph, closures }, files); + process.stdout.write(`${verdict ? 'true' : 'false'}\n`); +} + +const invokedDirectly = + Boolean(process.argv[1]) && resolve(process.argv[1]) === fileURLToPath(import.meta.url); +if (invokedDirectly) { + try { + main(); + } catch { + process.stdout.write('true\n'); + } +} + +export { AREAS, classify, decide, buildGraph, closureFor, evaluateCfg }; diff --git a/openless-all/app/scripts/workflow-concurrency-contract.test.mjs b/openless-all/app/scripts/workflow-concurrency-contract.test.mjs index 680886384..a1581f8f8 100644 --- a/openless-all/app/scripts/workflow-concurrency-contract.test.mjs +++ b/openless-all/app/scripts/workflow-concurrency-contract.test.mjs @@ -41,8 +41,12 @@ assert.ok( ); assert.ok( - workflows.ci.includes("branches: ['**']"), - 'CI must run on every branch push so branch changes receive the quality gate', + workflows.ci.includes('branches: [main, beta]'), + 'CI must run on default-branch pushes (baseline evidence and the only cache writer)', +); +assert.ok( + !workflows.ci.includes("branches: ['**']"), + 'CI must not spend a second full matrix on feature-branch pushes that their pull request already covers', ); // Release workflows are tag-only: a per-branch push rebuilt a 1-2 GB cache entry // on every commit and evicted the beta scope, while `ci.yml` already builds the From 67a111ea841dacac2d4021363bb33a3d5295b2d3 Mon Sep 17 00:00:00 2001 From: aeoform <2790848120@qq.com> Date: Tue, 27 Oct 2026 21:14:21 +0800 Subject: [PATCH 2/6] =?UTF-8?q?ci:=20=E6=8A=8A=E5=8C=BA=E5=9F=9F=E5=88=A4?= =?UTF-8?q?=E5=AE=9A=E7=BB=93=E6=9E=9C=E6=89=93=E8=BF=9B=20job=20=E6=97=A5?= =?UTF-8?q?=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 跳过某平台时,事后要能一眼分清「改动够不到」和「读不到差异」。两处 detect 步 骤现在都把 `=` 打到日志,同时仍写进 GITHUB_OUTPUT。 --- .github/workflows/check-ios.yml | 4 +++- .github/workflows/ci.yml | 5 ++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/check-ios.yml b/.github/workflows/check-ios.yml index 687f891a6..50e065ac9 100644 --- a/.github/workflows/check-ios.yml +++ b/.github/workflows/check-ios.yml @@ -48,7 +48,9 @@ jobs: set -euo pipefail verdict=$(bash scripts/ci-changed-areas.sh ios-device "$BASE_SHA") # Fail open: only an explicit `false` skips the device build. - echo "device=$( [ "$verdict" = 'false' ] && echo false || echo true )" >> "$GITHUB_OUTPUT" + decision=$( [ "$verdict" = 'false' ] && echo false || echo true ) + echo "ios-device=$decision" + echo "device=$decision" >> "$GITHUB_OUTPUT" test: runs-on: xcode-27 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ead189be1..0f393f891 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,7 +67,10 @@ jobs: for area in tauri msrv linux harmony frontend android; do verdict=$(bash scripts/ci-changed-areas.sh "$area" "$BASE_SHA") # 失败即运行:脚本读不到差异时返回 true,这里也只接受明确的 false。 - echo "$area=$( [ "$verdict" = 'false' ] && echo false || echo true )" >> "$GITHUB_OUTPUT" + decision=$( [ "$verdict" = 'false' ] && echo false || echo true ) + # 打出来才能事后判断某次跳过是「够不到」还是「读不到差异」。 + echo "$area=$decision" + echo "$area=$decision" >> "$GITHUB_OUTPUT" done ui-motion: From 56763b82aa858f64ec023b7d8a67c57ede265840 Mon Sep 17 00:00:00 2001 From: aeoform <2790848120@qq.com> Date: Tue, 27 Oct 2026 21:47:55 +0800 Subject: [PATCH 3/6] =?UTF-8?q?ci:=20=E8=83=B6=E5=9B=8A=20job=20=E4=BF=9D?= =?UTF-8?q?=E7=95=99=E4=BD=86=E6=8C=89=E5=8C=BA=E5=9F=9F=E9=97=A8=E6=8E=A7?= =?UTF-8?q?=EF=BC=88=E5=AE=9E=E6=B5=8B=E5=90=A6=E5=86=B3=E5=90=88=E5=B9=B6?= =?UTF-8?q?=E6=96=B9=E6=A1=88=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit run 40510306688 的真实 step 计时: Windows checks 里三步 capsule = 43 + 53 + 21 = 117 s(关键路径 job,直接 +2 分钟墙钟) macOS checks 里三步 capsule = 40 + 41 + 6 = 87 s 独立 job 原本平均 1.4 / 0.7 分钟——它有专属 warm cache,且只编 capsule 自己。 合并省了 2 台 VM,却把 2 分钟加到最慢的 job 上,加权分钟反而更贵。 改成:native-capsule job 保持原样,新增 `capsule` 区域,只有拥有改动文件的包是 openless-capsule / openless-native-capsule 时才跑。常见 PR 因此少 2 台 VM 且不增加 任何墙钟时间;胶囊相关 PR 与今天一致。 契约测试同步:胶囊 job 必须带自己的区域门控;ci-changed-areas 场景新增 capsule 列。 --- .github/workflows/ci.yml | 56 ++++++++++++++----- docs/ci-trigger-and-cache-policy.md | 4 +- .../app/scripts/ci-cache-sharing.test.mjs | 30 ++++------ .../app/scripts/ci-changed-areas.test.mjs | 29 ++++++++-- openless-all/app/scripts/ci-impact.mjs | 11 +++- 5 files changed, 92 insertions(+), 38 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0f393f891..1c0d7187d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,6 +49,7 @@ jobs: harmony: ${{ steps.detect.outputs.harmony }} frontend: ${{ steps.detect.outputs.frontend }} android: ${{ steps.detect.outputs.android }} + capsule: ${{ steps.detect.outputs.capsule }} # Validate the pinned minimum compiler whenever native sources can change. msrv_needed: ${{ steps.detect.outputs.msrv }} steps: @@ -64,7 +65,7 @@ jobs: BASE_SHA: ${{ github.event.pull_request.base.sha }} run: | set -euo pipefail - for area in tauri msrv linux harmony frontend android; do + for area in tauri msrv linux harmony frontend android capsule; do verdict=$(bash scripts/ci-changed-areas.sh "$area" "$BASE_SHA") # 失败即运行:脚本读不到差异时返回 true,这里也只接受明确的 false。 decision=$( [ "$verdict" = 'false' ] && echo false || echo true ) @@ -565,6 +566,46 @@ jobs: # anything here; everything else runs on macOS (full suite) and Linux. run: node scripts/frontend-test-runner.mjs --platform-only windows + native-capsule: + # The capsule crates compile into the desktop shells. They get their own macOS + Windows + # runners only because their `ui,host` feature set is not built anywhere else (the shells + # use `build:native-capsule:dev`), and that warm cache makes the job ~40 s. It is gated on + # its own area, so the common PR pays zero runners for it: measured on run 40510306688, + # moving these three commands into `cross-platform` cost 117 s on the critical-path Windows + # job, which is more than the two runners it saved. + needs: [changes] + if: needs.changes.outputs.capsule != 'false' + name: Native capsule (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: ${{ fromJSON(github.event_name == 'workflow_dispatch' && inputs.platform == 'macos' && '["macos-latest"]' || '["macos-latest","windows-latest"]') }} + runs-on: ${{ matrix.os }} + timeout-minutes: 25 + defaults: + run: + working-directory: openless-all/app + steps: + - uses: actions/checkout@v4 + with: + submodules: false + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + - uses: Swatinem/rust-cache@v2 + with: + # Stable prefix per platform instead of the job name, so renaming or + # splitting the job keeps the tree. + shared-key: native-capsule + workspaces: openless-all/app -> target + # Pull requests restore the beta tree and never write their own copy: + # `refs/pull//merge` scopes are never restored again and only evict + # the branch caches (see docs/ci-trigger-and-cache-policy.md). + save-if: ${{ github.ref == 'refs/heads/beta' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') }} + - run: cargo check --locked -p openless-native-capsule + - run: cargo test --locked -p openless-capsule --features ui,host + - run: cargo clippy --locked -p openless-native-capsule -p openless-capsule --features openless-capsule/ui,openless-capsule/host -- -D warnings + cross-platform: needs: [changes] # 失败即运行:只有判定脚本明确说“够不到”才跳过。 @@ -753,19 +794,6 @@ jobs: working-directory: openless-all/app/windows-ime run: msbuild OpenLessIme.sln /p:Configuration=Release /p:Platform=x64 /m /v:minimal - # The capsule crates compile into the desktop shells, so a break there would - # otherwise only surface inside the long Tauri matrix. They used to run in a - # separate macOS + Windows job pair — two extra runners whose whole payload - # was ~40 s of `cargo check` — while this job already builds the same - # `openless-all/app` workspace target directory. Checking them here costs a - # warm incremental compile and saves two VM allocations per run. - - name: Check the native capsule crates - run: cargo check --locked -p openless-native-capsule - - name: Test the capsule crates - run: cargo test --locked -p openless-capsule --features ui,host - - name: Lint the capsule crates - run: cargo clippy --locked -p openless-native-capsule -p openless-capsule --features openless-capsule/ui,openless-capsule/host -- -D warnings - - name: Verify version sync across all 5 files # Runs on both platforms: the Windows runner ships git-bash, giving consistent behavior # across shells. Fail immediately on version drift instead of discovering it at release. diff --git a/docs/ci-trigger-and-cache-policy.md b/docs/ci-trigger-and-cache-policy.md index 74af3e8ca..061baf2c3 100644 --- a/docs/ci-trigger-and-cache-policy.md +++ b/docs/ci-trigger-and-cache-policy.md @@ -28,6 +28,7 @@ Rust 部分不靠手写路径表,而是走 Cargo 自己的包图:`cargo meta | `linux` | Linux Core/egui 测试与 deb/rpm 打包链 | 改动能到达 `openless-linux-egui` 的闭包。Linux 宿主是原生 egui,不读前端 `dist/` | | `harmony` | 鸿蒙 ArkTS 契约、Rust host、HAP | 改动能到达 `openless-harmony-host` 的闭包,或动了它要打包的前端 bundle | | `frontend` | Chromium/WebKit UI 动效 | 只动浏览器会渲染的面:`src/**`、`public/**`、`e2e/**`、`assets/**`、样式与 vite/playwright 配置 | +| `capsule` | 胶囊 crates(macOS + Windows 双 runner) | 拥有改动文件的包是 `openless-capsule` / `openless-native-capsule`。它们的 `ui,host` feature 组合不在别处构建,所以保留独立 job,但常见 PR 不再为它付出两台 runner | | `ios-device` | iOS `device-build`(Xcode/Rime 工程层) | **只**看 `openless-all/ios/**`。共享 crate 已经由 iOS 的 `test` job 编译校验过,工程层改动才需要 Xcode | 契约测试 `scripts/ci-changed-areas.test.mjs` 用真实 git diff 驱动这些判定,双向断言:够不到的必须跳过,其余的必须运行。改动前它抓到的真实故障(`linux` 输出漏写导致 Linux job 被误跳过)就是这条规则存在的理由。 @@ -84,7 +85,8 @@ GitHub 每仓库保留 10 GB 缓存,超出按 LRU 静默淘汰;淘汰后 job - `playwright` 与 `mobile-rime` 使用 restore/save 拆分;`swatinem/rust-cache` 用 `save-if` 达到同样效果。 - `build-linux-egui` 保留 PR 内自写并开 `cache-on-failure: true`:失败的运行也会把依赖缓存存下来,下一次迭代仍然热;同时关掉 dev/test debug info,缓存更小、链接更快。 - key 结构为 `v0-rust---<环境哈希>-`:环境哈希含工具链与 `CARGO_PROFILE_*`,所以 stable / MSRV / release 天然分开,不要用跨平台 `shared-key` 混合。 -- 原生胶囊的三个命令(`cargo check -p openless-native-capsule`、`cargo test -p openless-capsule`、clippy)现在跑在 `cross-platform` 里:那个 job 本来就为 `generate_context!` 构建同一个 `openless-all/app` workspace target 目录,所以增量编译几乎是免费的,不再需要独立的 macOS + Windows 双 job,也省掉 `native-capsule` 这个独立缓存前缀。它原本还经历过一次 `pull_request`-only workflow 阶段:缓存写在 `refs/pull//merge`,这个作用域此后再不会被恢复,PR 永远冷编译——这是“缓存只能由基线分支写”规则的来源。 +- 原生胶囊(`native-capsule` job)原本是一个 `pull_request`-only workflow:缓存写在 `refs/pull//merge`,这个作用域此后再不会被恢复,而 beta 上没有 job 产出同 key 的树,所以 PR 永远冷编译。现在它在 `ci.yml` 里,用 `shared-key: native-capsule` + beta 门控的 `save-if`,并且由 `capsule` 区域门控——常见 PR 完全不会为它拉起 runner。 + 曾试过把它并进 `cross-platform`(省两台 VM),实测否决:run 40510306688 里那三个命令在 Windows 关键路径上花了 43+53+21 s,比它省下的两台 runner 更贵。要合并先要确认那套 `ui,host` feature 在桌面 job 的缓存里是热的。 - `Cargo.lock` 变化时精确键失效,但 `restore-keys` 前缀回退会拿到同前缀的旧缓存,所以“锁变了”不等于“全冷”——前提是那份没被淘汰,这正是要守预算的原因。 ## 4. 维护 diff --git a/openless-all/app/scripts/ci-cache-sharing.test.mjs b/openless-all/app/scripts/ci-cache-sharing.test.mjs index bb9b154f9..1e21611db 100644 --- a/openless-all/app/scripts/ci-cache-sharing.test.mjs +++ b/openless-all/app/scripts/ci-cache-sharing.test.mjs @@ -94,27 +94,21 @@ assert.match(linux, /cargo build --locked --release -p openless-linux-egui/); assert.match(linuxRelease, /release_tag: \$\{\{ inputs\.tag \}\}/); assert.match(linuxRelease, /checkout_ref: \$\{\{ inputs\.tag \}\}/); -// The capsule crates used to run in their own pull-request-only workflow: it -// wrote a dependency tree under `refs/pull//merge` (never restored again) -// while beta produced nothing to restore. They now live inside -// `cross-platform`, which already builds the same `openless-all/app` workspace -// on both desktop platforms — so they cost a warm incremental compile instead of -// two extra runners and a second cache prefix. +// The capsule crates used to run in a pull-request-only workflow that wrote a +// dependency tree under `refs/pull//merge` (never restored again) while beta +// produced nothing to restore. They now live in `ci.yml` behind their own area +// gate and save only from beta, so a run costs ~40 s and the common PR pays no +// runner for them at all. { - const crossPlatform = ci.match(/\n cross-platform:[\s\S]*?(?=\n [a-z-]+:\n)/)?.[0] ?? ''; - assert.ok(crossPlatform, 'ci.yml must keep the cross-platform job'); + const capsuleJob = ci.match(/\n native-capsule:[\s\S]*?(?=\n [a-z-]+:\n)/)?.[0] ?? ''; + assert.ok(capsuleJob, 'ci.yml must run the capsule crates as the native-capsule job'); + assert.match(capsuleJob, /shared-key: native-capsule/); + assert.match(capsuleJob, /save-if: \$\{\{ github\.ref == 'refs\/heads\/beta'/); assert.match( - crossPlatform, - /cargo check --locked -p openless-native-capsule/, - 'the capsule crates must be checked inside cross-platform', + capsuleJob, + /if: needs\.changes\.outputs\.capsule != 'false'/, + 'the capsule job must be gated on its own area, not run for every desktop change', ); - assert.match(crossPlatform, /cargo test --locked -p openless-capsule --features ui,host/); - assert.doesNotMatch( - ci, - /\n native-capsule:/, - 'the capsule crates must not need a dedicated runner pair', - ); - assert.doesNotMatch(ci, /shared-key: native-capsule/); assert.ok( !existsSync(join(root, '.github/workflows/native-capsule.yml')), 'native-capsule.yml must stay merged into ci.yml', diff --git a/openless-all/app/scripts/ci-changed-areas.test.mjs b/openless-all/app/scripts/ci-changed-areas.test.mjs index 59c3ba30e..469ba1204 100644 --- a/openless-all/app/scripts/ci-changed-areas.test.mjs +++ b/openless-all/app/scripts/ci-changed-areas.test.mjs @@ -60,7 +60,16 @@ const reset = () => { }; try { - const ALL_AREAS = ['tauri', 'msrv', 'linux', 'harmony', 'frontend', 'android', 'ios-device']; + const ALL_AREAS = [ + 'tauri', + 'msrv', + 'linux', + 'harmony', + 'frontend', + 'android', + 'capsule', + 'ios-device', + ]; const every = (verdict) => Object.fromEntries(ALL_AREAS.map((area) => [area, verdict])); const expect = (label, expectations) => { for (const [area, expected] of Object.entries(expectations)) { @@ -130,12 +139,19 @@ try { linux: 'false', harmony: 'false', android: 'true', + capsule: 'false', 'ios-device': 'false', }); reset(); commit({ 'openless-all/app/native-capsule/src/lib.rs': 'pub fn x() {}\n' }, 'capsule source'); - expect('capsule source', { tauri: 'true', msrv: 'true', linux: 'false', harmony: 'false' }); + expect('capsule source', { + tauri: 'true', + msrv: 'true', + capsule: 'true', + linux: 'false', + harmony: 'false', + }); reset(); // The rendered bundle is embedded by every shell that paints it; the Linux @@ -218,7 +234,7 @@ const reusable = readFileSync( ), 'utf8', ); -const areas = ['tauri', 'msrv', 'linux', 'harmony', 'frontend', 'android']; +const areas = ['tauri', 'msrv', 'linux', 'harmony', 'frontend', 'android', 'capsule']; check( 'workflow publishes every area', areas.every((area) => workflow.includes(area + ': ${{ steps.detect.outputs.' + area + ' }}')), @@ -226,7 +242,7 @@ check( ); check( 'workflow computes every area in the detect step', - /for area in tauri msrv linux harmony frontend android; do/.test(workflow), + /for area in tauri msrv linux harmony frontend android capsule; do/.test(workflow), true, ); check( @@ -275,6 +291,11 @@ check( /inputs\.platform != 'macos'\) && needs\.changes\.outputs\.android != 'false'/.test(workflow), true, ); +check( + 'capsule gate is fail-open', + workflow.includes("needs.changes.outputs.capsule != 'false'"), + true, +); check( 'ui motion gate follows the rendered surface', /inputs\.platform != 'macos'\) && needs\.changes\.outputs\.frontend != 'false'/.test(workflow), diff --git a/openless-all/app/scripts/ci-impact.mjs b/openless-all/app/scripts/ci-impact.mjs index 2953bb2fe..3bc029393 100644 --- a/openless-all/app/scripts/ci-impact.mjs +++ b/openless-all/app/scripts/ci-impact.mjs @@ -39,7 +39,7 @@ const REPO_ROOT = resolve(SCRIPT_DIR, '../../..'); /** Everything runs. */ const ALL = Symbol('all'); -const AREAS = ['tauri', 'msrv', 'linux', 'harmony', 'frontend', 'android', 'ios-device']; +const AREAS = ['tauri', 'msrv', 'linux', 'harmony', 'frontend', 'android', 'capsule', 'ios-device']; /** Cargo workspaces that describe this repository's own packages. */ const WORKSPACES = ['openless-all/app/Cargo.toml', 'openless-all/app/src-tauri/Cargo.toml']; @@ -62,6 +62,12 @@ const PLATFORM_ROOTS = { ios: ['openless-core'], }; +/** + * Capsule crates: compiled into the desktop shells and covered by their own + * macOS + Windows runners, which only earn their keep when these change. + */ +const CAPSULE_PACKAGES = new Set(['openless-capsule', 'openless-native-capsule']); + /** Rust target_os names used when evaluating `cfg(...)` guards. */ const PLATFORM_TARGET_OS = { macos: 'macos', @@ -255,6 +261,7 @@ function classify(file, context) { const owner = ownerPackage(file, context); if (owner) { + if (CAPSULE_PACKAGES.has(owner)) tags.add('capsule'); for (const [platform, packages] of context.closures) { if (packages.has(owner)) tags.add(platform); } @@ -303,6 +310,8 @@ function decide(area, context, files) { return reached.has('frontend'); case 'android': return reached.has('android'); + case 'capsule': + return reached.has('capsule'); case 'ios-device': return reached.has('ios-device'); default: From e1984ab7034f0dfbb357f3e87c3fc471fc739617 Mon Sep 17 00:00:00 2001 From: aeoform <2790848120@qq.com> Date: Tue, 27 Oct 2026 22:29:26 +0800 Subject: [PATCH 4/6] =?UTF-8?q?test(e2e):=20=E4=BF=AE=E6=8E=89=20switch-mo?= =?UTF-8?q?tion=20=E7=9A=84=E9=87=87=E6=A0=B7=E7=AB=9E=E6=80=81=EF=BC=88CI?= =?UTF-8?q?=2014%=20=E8=AF=AF=E6=8A=A5=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 失败快照给出了确定答案:controlledXs 是空数组(Expected length 3 / Received length 0 / Received array []),而同一 case 前面几条断言都过了——开关确实动了, 只是采样钩子没挂上。 原因:flip() 用 control.evaluate() 派发采样函数(在里面 addEventListener 'transitionrun'),但没有 await 就发 mouse.down()/up()。evaluate 和 click 是两条 独立的协议消息,CI 上负载高时 click 先到页面,监听还没挂上 → 抓不到 3 个受控 采样点。这就是最近 43 次 CI run 里失败 6 次(14%)、落在 5 个互不相关分支上 (含 beta 自身)的那条 `语音编辑 exposed its transform transition`。 改法(不放宽断言): - 把「挂监听」拆成一次 await 的 evaluate,采样结果存进页面侧 WeakMap(按控件 元素键),确认挂好后才点击; - 采样循环仍在点击时启动、750ms 后收尾,所以 xs.at(-1) 依然是静止位置; - 三条断言(状态翻转、滑块位移、transform 过渡跑完、3 个受控中间位置)全部 保持原样,重挂载仍会因为 xs 为 NaN 而失败。 本地验证:webkit project 全文件 --repeat-each=6 → 30/30 通过(8.7 分钟), 即那条 flaky case 连跑 6 次全绿。tsc --noEmit 干净。 --- openless-all/app/e2e/switch-motion.spec.ts | 44 ++++++++++++++++++---- 1 file changed, 36 insertions(+), 8 deletions(-) diff --git a/openless-all/app/e2e/switch-motion.spec.ts b/openless-all/app/e2e/switch-motion.spec.ts index 035899065..c5b833e76 100644 --- a/openless-all/app/e2e/switch-motion.spec.ts +++ b/openless-all/app/e2e/switch-motion.spec.ts @@ -49,10 +49,15 @@ async function flip(page: Page, control: ElementHandle): Promise('.ol-switch-thumb')!; return knob.getBoundingClientRect().left; }); - const sampling = control.evaluate(async (element) => { + // Arm the sampling listeners and *wait* for them before clicking. `evaluate` only + // dispatches its function; the click is a separate protocol message and on a loaded CI + // runner it can reach the page first. When that happened the `transitionrun` listener was + // not attached yet, so `controlledXs` came back empty — "语音编辑 exposed its transform + // transition", which failed 6 of 43 CI runs across unrelated branches, beta included. The + // switch animated every one of those times; only the hook missed it. + await control.evaluate((element) => { const knob = element.querySelector('.ol-switch-thumb')!; - const finished: number[] = []; - const controlledXs: number[] = []; + const state = { finished: [] as number[], controlledXs: [] as number[] }; const onRun = (event: TransitionEvent) => { if (event.target !== knob || event.propertyName !== 'transform') return; const transition = knob @@ -72,27 +77,50 @@ async function flip(page: Page, control: ElementHandle): Promise { if (event.target === knob && event.propertyName === 'transform') { - finished.push(event.elapsedTime); + state.finished.push(event.elapsedTime); } }; knob.addEventListener('transitionrun', onRun); knob.addEventListener('transitionend', onEnd); + const store = window as unknown as { + __switchMotion?: WeakMap void }>; + }; + store.__switchMotion ??= new WeakMap(); + store.__switchMotion.set(element, { + state, + stop: () => { + knob.removeEventListener('transitionrun', onRun); + knob.removeEventListener('transitionend', onEnd); + }, + }); + }); + // Sampling now starts with the click and settles within ~750ms, so `xs.at(-1)` is still the + // position the knob came to rest at. + const sampling = control.evaluate(async (element) => { + const knob = element.querySelector('.ol-switch-thumb')!; + const armed = ( + window as unknown as { + __switchMotion: WeakMap< + Element, + { state: { finished: number[]; controlledXs: number[] }; stop: () => void } + >; + } + ).__switchMotion.get(element)!; const xs: number[] = []; for (let frame = 0; frame < 45; frame++) { await new Promise(requestAnimationFrame); // A remount would leave this node detached and frozen: count it as a jump. xs.push(element.isConnected ? knob.getBoundingClientRect().left : Number.NaN); } - knob.removeEventListener('transitionrun', onRun); - knob.removeEventListener('transitionend', onEnd); - return { xs, controlledXs, finished }; + armed.stop(); + return { xs, controlledXs: armed.state.controlledXs, finished: armed.state.finished }; }); await page.mouse.down(); await page.mouse.up(); From 2d02fa4073af56b76478b84682e1c94c931ccb9b Mon Sep 17 00:00:00 2001 From: aeoform <2790848120@qq.com> Date: Tue, 27 Oct 2026 23:10:43 +0800 Subject: [PATCH 5/6] =?UTF-8?q?test(e2e):=20=E9=87=87=E6=A0=B7=E5=BE=AA?= =?UTF-8?q?=E7=8E=AF=E6=94=B9=E5=88=B0=E7=82=B9=E5=87=BB=E4=B9=8B=E5=90=8E?= =?UTF-8?q?=E5=90=AF=E5=8A=A8=EF=BC=8C=E5=8E=BB=E6=8E=89=E5=AF=B9=E7=82=B9?= =?UTF-8?q?=E5=87=BB=E5=BB=B6=E8=BF=9F=E7=9A=84=E4=BE=9D=E8=B5=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一版修掉了监听竞态,但采样循环仍在点击前就开始了 45 帧(~750ms); 如果 CI 把 click 延迟到 800ms,end 会取到动画前的位置。改成 挂监听(await)→ 点击 → 采样循环,45 帧仍覆盖 180ms 的滑动并收尾, xs.at(-1) 恒为静止位置。 本地 webkit:全文件 5/5 通过;含 --repeat-each=4 的 20/20。 --- openless-all/app/e2e/switch-motion.spec.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/openless-all/app/e2e/switch-motion.spec.ts b/openless-all/app/e2e/switch-motion.spec.ts index c5b833e76..06aa65511 100644 --- a/openless-all/app/e2e/switch-motion.spec.ts +++ b/openless-all/app/e2e/switch-motion.spec.ts @@ -101,9 +101,12 @@ async function flip(page: Page, control: ElementHandle): Promise { + await page.mouse.down(); + await page.mouse.up(); + // The frame loop starts *after* the click: 45 frames (~750ms) still covers the 180ms slide + // and settles, so `xs.at(-1)` is the resting position regardless of how long the click takes + // to arrive. Listening already started above, so a late click cannot lose the transition. + const { xs, controlledXs, finished } = await control.evaluate(async (element) => { const knob = element.querySelector('.ol-switch-thumb')!; const armed = ( window as unknown as { @@ -122,9 +125,6 @@ async function flip(page: Page, control: ElementHandle): Promise Date: Wed, 28 Oct 2026 14:33:06 +0800 Subject: [PATCH 6/6] fix(ci): keep platform gates for native consumers --- docs/ci-trigger-and-cache-policy.md | 6 +-- openless-all/app/scripts/ci-changed-areas.sh | 4 +- .../app/scripts/ci-changed-areas.test.mjs | 43 +++++++++++++++---- openless-all/app/scripts/ci-impact.mjs | 32 +++++++++----- 4 files changed, 62 insertions(+), 23 deletions(-) diff --git a/docs/ci-trigger-and-cache-policy.md b/docs/ci-trigger-and-cache-policy.md index 061baf2c3..544447d98 100644 --- a/docs/ci-trigger-and-cache-policy.md +++ b/docs/ci-trigger-and-cache-policy.md @@ -23,13 +23,13 @@ Rust 部分不靠手写路径表,而是走 Cargo 自己的包图:`cargo meta | 区域 | 覆盖的 job | 判定 | |---|---|---| | `tauri` | macOS / Windows 桌面检查 | 改动能到达 `openless`(含 vendor 路径依赖)或桌面专属的 `openless-native-capsule` | -| `android` | Android cargo check | 改动能到达 `openless` 的 Android 闭包(bundle-only 改动不算:该 job 只编 Rust) | +| `android` | Android cargo check | 改动能到达 `openless` 的 Android 闭包,或影响 Android 项目生成/构建的 npm manifest;纯 bundle-only 改动不单独触发 | | `msrv` | macOS Rust 1.99 MSRV | 有任何 `.rs` / `Cargo.toml` / `Cargo.lock` / `rust-toolchain*` / `.github/**` | | `linux` | Linux Core/egui 测试与 deb/rpm 打包链 | 改动能到达 `openless-linux-egui` 的闭包。Linux 宿主是原生 egui,不读前端 `dist/` | | `harmony` | 鸿蒙 ArkTS 契约、Rust host、HAP | 改动能到达 `openless-harmony-host` 的闭包,或动了它要打包的前端 bundle | -| `frontend` | Chromium/WebKit UI 动效 | 只动浏览器会渲染的面:`src/**`、`public/**`、`e2e/**`、`assets/**`、样式与 vite/playwright 配置 | +| `frontend` | Chromium/WebKit UI 动效 | 只动浏览器会渲染的面:`src/**`、`e2e/**`、样式与 vite/playwright 配置;`app/assets/**` / `app/public/**` 按 native consumer 处理 | | `capsule` | 胶囊 crates(macOS + Windows 双 runner) | 拥有改动文件的包是 `openless-capsule` / `openless-native-capsule`。它们的 `ui,host` feature 组合不在别处构建,所以保留独立 job,但常见 PR 不再为它付出两台 runner | -| `ios-device` | iOS `device-build`(Xcode/Rime 工程层) | **只**看 `openless-all/ios/**`。共享 crate 已经由 iOS 的 `test` job 编译校验过,工程层改动才需要 Xcode | +| `ios-device` | iOS `device-build`(Xcode/Rime 与真实 device Core 编译) | 改动能到达 iOS Core 闭包,或动了 `openless-all/ios/**`;共享 Rust 代码也要保留 device target 证据 | 契约测试 `scripts/ci-changed-areas.test.mjs` 用真实 git diff 驱动这些判定,双向断言:够不到的必须跳过,其余的必须运行。改动前它抓到的真实故障(`linux` 输出漏写导致 Linux job 被误跳过)就是这条规则存在的理由。 diff --git a/openless-all/app/scripts/ci-changed-areas.sh b/openless-all/app/scripts/ci-changed-areas.sh index c2f0c880b..7503efef8 100755 --- a/openless-all/app/scripts/ci-changed-areas.sh +++ b/openless-all/app/scripts/ci-changed-areas.sh @@ -4,7 +4,7 @@ # # ci-changed-areas.sh [base-sha] # -# Areas: tauri | msrv | linux | harmony | frontend | ios-device +# Areas: tauri | msrv | linux | harmony | frontend | android | capsule | ios-device # # The decision is made by scripts/ci-impact.mjs, which walks Cargo's own package # graph (`cargo metadata`) from each platform's root package: a file is in scope @@ -24,7 +24,7 @@ # cannot reach it, never of a failure to read the change set. set -uo pipefail -area=${1:?usage: ci-changed-areas.sh [base-sha]} +area=${1:?usage: ci-changed-areas.sh [base-sha]} base=${2:-} repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd) script="$repo_root/openless-all/app/scripts/ci-impact.mjs" diff --git a/openless-all/app/scripts/ci-changed-areas.test.mjs b/openless-all/app/scripts/ci-changed-areas.test.mjs index 469ba1204..176f16461 100644 --- a/openless-all/app/scripts/ci-changed-areas.test.mjs +++ b/openless-all/app/scripts/ci-changed-areas.test.mjs @@ -109,6 +109,17 @@ try { }); reset(); + // The Linux workflow owns Harmony Rust unit tests as well as the egui host. + commit({ 'openless-all/app/harmony/rust/src/lib.rs': 'pub fn x() {}\n' }, 'harmony rust'); + expect('harmony rust', { + tauri: 'false', + harmony: 'true', + linux: 'true', + frontend: 'false', + 'ios-device': 'false', + }); + reset(); + // The shared Core is linked by every platform build. commit({ 'openless-all/app/crates/openless-core/src/lib.rs': 'pub fn x() {}\n' }, 'shared core'); expect('shared core', { @@ -117,8 +128,8 @@ try { linux: 'true', harmony: 'true', android: 'true', - // Compiling the shared Core for iOS is the `test` job's job, not the Xcode build's. - 'ios-device': 'false', + // The device build compiles this shared Core for aarch64-apple-ios. + 'ios-device': 'true', frontend: 'false', }); reset(); @@ -169,19 +180,35 @@ try { }); reset(); - // A contract suite changes no build input: it runs on the desktop runners and - // nowhere else. + // Contract tests are consumed by Linux, Harmony and Android as well as desktop. commit( { 'openless-all/app/scripts/foo-contract.test.mjs': 'console.log(1);\n' }, 'contract suite', ); - expect('contract suite', { + expect('contract suite', every('true')); + reset(); + + // Native resources have both bundle and direct native consumers. + commit({ 'openless-all/app/assets/vocab-presets.json': '{}\n' }, 'native asset'); + expect('native asset', every('true')); + reset(); + + // npm manifests also control Android project generation, not only the bundle. + commit( + { + 'openless-all/app/package.json': '{}\n', + 'openless-all/app/package-lock.json': '{}\n', + }, + 'android tooling', + ); + expect('android tooling', { tauri: 'true', msrv: 'false', linux: 'false', - harmony: 'false', - frontend: 'false', - android: 'false', + harmony: 'true', + frontend: 'true', + android: 'true', + capsule: 'false', 'ios-device': 'false', }); reset(); diff --git a/openless-all/app/scripts/ci-impact.mjs b/openless-all/app/scripts/ci-impact.mjs index 3bc029393..8e7c373e3 100644 --- a/openless-all/app/scripts/ci-impact.mjs +++ b/openless-all/app/scripts/ci-impact.mjs @@ -25,8 +25,8 @@ // linux The Linux egui host, its tests and the deb/rpm chain. // harmony The HarmonyOS host (ArkTS contracts, Rust host, HAP). // frontend Browser-rendered surface: the Chromium/WebKit motion suite. -// ios-device The Xcode/Rime project layer. Shared Rust crates are excluded -// on purpose — the iOS `test` job already compiles those. +// ios-device The Xcode/Rime project layer and the shared Rust crates that +// are compiled for the real iOS device target. import { execFileSync } from 'node:child_process'; import { existsSync } from 'node:fs'; @@ -57,7 +57,8 @@ const PLATFORM_ROOTS = { macos: ['openless', 'openless-native-capsule'], windows: ['openless', 'openless-native-capsule'], android: ['openless'], - linux: ['openless-linux-egui'], + // The Linux reusable workflow also owns Harmony Rust unit tests. + linux: ['openless-linux-egui', 'openless-harmony-host'], harmony: ['openless-harmony-host'], ios: ['openless-core'], }; @@ -223,12 +224,18 @@ const workflowOrTooling = (file) => file.startsWith('scripts/'); /** - * Contract suites are executed by `npm test` (macOS) and its platform subset - * (Windows); they change no build input, so they reach those two runners and - * the browser suite at most. + * Contract tests are consumed by multiple platform jobs (and some run during + * Android/Harmony prebuilds). Changing a gate test must not skip the gate it + * describes, so classify every app script test as fail-open. */ const contractSuite = (file) => /^openless-all\/app\/scripts\/[^/]+\.test\.(mjs|js|ts)$/.test(file); +const nativeResource = (file) => + file.startsWith('openless-all/app/assets/') || file.startsWith('openless-all/app/public/'); + +const androidTooling = (file) => + /^openless-all\/app\/(package\.json|package-lock\.json)$/.test(file); + /** Browser-rendered surface: sources, styles, the bundle entry and its specs. */ const motionSurface = (file) => /^openless-all\/app\/(src\/|public\/|e2e\/|assets\/)/.test(file) || @@ -241,14 +248,16 @@ const motionSurface = (file) => const iosProject = (file) => file.startsWith('openless-all/ios/'); /** - * Platforms a file's change can reach. `ALL` when the file is tooling that - * gates every platform (workflows, contract suite, package manifests). + * Platforms a file's change can reach. `ALL` when the file is tooling or a + * shared resource whose native consumers cannot be proven by one package. */ function classify(file, context) { if (prose(file)) return new Set(); - if (contractSuite(file)) return new Set(['macos', 'windows']); + if (contractSuite(file)) return ALL; if (workflowOrTooling(file)) return ALL; if (iosProject(file)) return new Set(['ios', 'ios-device']); + if (nativeResource(file)) return ALL; + if (androidTooling(file)) return new Set(['frontend', 'macos', 'windows', 'harmony', 'android']); const tags = new Set(); if (motionSurface(file)) { @@ -263,7 +272,10 @@ function classify(file, context) { if (owner) { if (CAPSULE_PACKAGES.has(owner)) tags.add('capsule'); for (const [platform, packages] of context.closures) { - if (packages.has(owner)) tags.add(platform); + if (packages.has(owner)) { + tags.add(platform); + if (platform === 'ios') tags.add('ios-device'); + } } } // Files outside every package still have to be evaluated by the shells that