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
32 changes: 31 additions & 1 deletion .github/workflows/check-ios.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,35 @@ 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.
decision=$( [ "$verdict" = 'false' ] && echo false || echo true )
echo "ios-device=$decision"
echo "device=$decision" >> "$GITHUB_OUTPUT"

test:
runs-on: xcode-27
timeout-minutes: 30
Expand Down Expand Up @@ -54,7 +83,8 @@ jobs:
run: git diff --exit-code -- openless-all/app

device-build:
needs: test
needs: [changes, test]
if: needs.changes.outputs.device != 'false'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve iOS-target compilation for shared Rust changes

For a PR changing only shared Core sources, the new gate skips the only job that compiles them for an iOS target. The test job's Cargo commands at lines 70–72 have no --target, so they compile for the macOS runner; --all-targets selects Cargo targets, not iOS architectures. This leaves existing iOS-only branches, such as the capability implementation in openless-core/src/shared_types.rs, unchecked. Previously, device-build invoked scripts/build-core.sh device, which builds for aarch64-apple-ios. Keep shared Rust changes in this gate, or add an iOS-target compile check to test before excluding them.

Useful? React with 👍 / 👎.

runs-on: xcode-27
timeout-minutes: 60
defaults:
Expand Down
38 changes: 25 additions & 13 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -43,6 +47,9 @@ 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 }}
capsule: ${{ steps.detect.outputs.capsule }}
# Validate the pinned minimum compiler whenever native sources can change.
msrv_needed: ${{ steps.detect.outputs.msrv }}
steps:
Expand All @@ -58,16 +65,21 @@ 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 capsule; 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:
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:
Expand Down Expand Up @@ -115,7 +127,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
Expand Down Expand Up @@ -554,15 +567,14 @@ jobs:
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.
# 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.tauri != 'false'
if: needs.changes.outputs.capsule != 'false'
name: Native capsule (${{ matrix.os }})
strategy:
fail-fast: false
Expand Down
31 changes: 17 additions & 14 deletions docs/ci-trigger-and-cache-policy.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,29 +7,31 @@
| 事件 | 行为 |
|---|---|
| `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 验证入口) |

同一 PR 连续推送会取消旧运行(`concurrency.cancel-in-progress`)。

### 改动范围判定

`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 闭包,或影响 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/**`、`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 与真实 device Core 编译) | 改动能到达 iOS Core 闭包,或动了 `openless-all/ios/**`;共享 Rust 代码也要保留 device target 证据 |

跳过只允许是“改动可达性可证明”的结果,不允许是“读不到差异”或“接线漏了”的兜底。
契约测试 `scripts/ci-changed-areas.test.mjs` 用真实 git diff 驱动这些判定,双向断言:够不到的必须跳过,其余的必须运行。改动前它抓到的真实故障(`linux` 输出漏写导致 Linux job 被误跳过)就是这条规则存在的理由。

`linux-egui-package` 通过可复用工作流的 `scope` 输入接收结果(`full` / `none`,**默认 `full`**):纯散文改动跳过整个 Linux 构建与打包链。独立 Linux 手动构建沿用完整检查,只上传 Actions 产物;管理员在产品验收后,针对已接受的现有 tag 构建并单独附加包,见 [发布规范](../RELEASING.md)。

Expand Down Expand Up @@ -83,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-<job>-<os>-<环境哈希>-<lockfile哈希>`:环境哈希含工具链与 `CARGO_PROFILE_*`,所以 stable / MSRV / release 天然分开,不要用跨平台 `shared-key` 混合。
- 原生胶囊(`native-capsule` job)原本是一个独立的 `pull_request`-only workflow:缓存写在 `refs/pull/<n>/merge`,这个作用域此后再不会被恢复;而 beta 上没有任何 job 产出同 key 的树,所以 PR 永远冷编译。现在它已并入 `ci.yml`(同一个 `Changed areas` 门禁、同一次运行),用 `shared-key: native-capsule` + beta 门控的 `save-if`。
- 原生胶囊(`native-capsule` job)原本是一个 `pull_request`-only workflow:缓存写在 `refs/pull/<n>/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. 维护
Expand Down
50 changes: 39 additions & 11 deletions openless-all/app/e2e/switch-motion.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,15 @@ async function flip(page: Page, control: ElementHandle<HTMLElement>): Promise<Sa
const knob = element.querySelector<HTMLElement>('.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<HTMLElement>('.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
Expand All @@ -72,31 +77,54 @@ async function flip(page: Page, control: ElementHandle<HTMLElement>): Promise<Sa
const currentTime = transition.currentTime;
for (const fraction of [0.25, 0.5, 0.75]) {
transition.currentTime = duration * fraction;
controlledXs.push(knob.getBoundingClientRect().left);
state.controlledXs.push(knob.getBoundingClientRect().left);
}
if (startTime !== null) transition.startTime = startTime;
else transition.currentTime = currentTime;
};
const onEnd = (event: TransitionEvent) => {
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<Element, { state: typeof state; stop: () => void }>;
};
store.__switchMotion ??= new WeakMap();
store.__switchMotion.set(element, {
state,
stop: () => {
knob.removeEventListener('transitionrun', onRun);
knob.removeEventListener('transitionend', onEnd);
},
});
});
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<HTMLElement>('.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<number>(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();
const { xs, controlledXs, finished } = await sampling;
const after = (await control.getAttribute('aria-checked')) ?? '';
return { name, checked: [before, after], start, end: xs.at(-1)!, xs, controlledXs, finished };
}
Expand Down
14 changes: 10 additions & 4 deletions openless-all/app/scripts/ci-cache-sharing.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -94,15 +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/<n>/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.
// The capsule crates used to run in a pull-request-only workflow that wrote a
// dependency tree under `refs/pull/<n>/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 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(
capsuleJob,
/if: needs\.changes\.outputs\.capsule != 'false'/,
'the capsule job must be gated on its own area, not run for every desktop change',
);
assert.ok(
!existsSync(join(root, '.github/workflows/native-capsule.yml')),
'native-capsule.yml must stay merged into ci.yml',
Expand Down
Loading
Loading