diff --git a/.github/workflows/check-ios.yml b/.github/workflows/check-ios.yml index b2a33c758..50e065ac9 100644 --- a/.github/workflows/check-ios.yml +++ b/.github/workflows/check-ios.yml @@ -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 @@ -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' runs-on: xcode-27 timeout-minutes: 60 defaults: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1a5b847e1..1c0d7187d 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,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: @@ -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: @@ -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 @@ -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 diff --git a/docs/ci-trigger-and-cache-policy.md b/docs/ci-trigger-and-cache-policy.md index f27e47478..544447d98 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,22 @@ ### 改动范围判定 -`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)。 @@ -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---<环境哈希>-`:环境哈希含工具链与 `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`。 +- 原生胶囊(`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/e2e/switch-motion.spec.ts b/openless-all/app/e2e/switch-motion.spec.ts index 035899065..06aa65511 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,31 +77,54 @@ 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); + }, + }); + }); + 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 { + __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(); - 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 }; } diff --git a/openless-all/app/scripts/ci-cache-sharing.test.mjs b/openless-all/app/scripts/ci-cache-sharing.test.mjs index bd9d40766..1e21611db 100644 --- a/openless-all/app/scripts/ci-cache-sharing.test.mjs +++ b/openless-all/app/scripts/ci-cache-sharing.test.mjs @@ -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//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//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', diff --git a/openless-all/app/scripts/ci-changed-areas.sh b/openless-all/app/scripts/ci-changed-areas.sh index 928083d56..7503efef8 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 | 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 +# 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..176f16461 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,185 @@ const reset = () => { }; try { - // Documentation cannot reach either check. + 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)) { + 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(); - // ...but its manifest moves the shared workspace dependency graph. + // 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', { + tauri: 'true', + msrv: 'true', + linux: 'true', + harmony: 'true', + android: 'true', + // The device build compiles this shared Core for aarch64-apple-ios. + 'ios-device': 'true', + frontend: 'false', + }); + reset(); + + // ...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', + capsule: 'false', + 'ios-device': '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. + commit({ 'openless-all/app/native-capsule/src/lib.rs': 'pub fn x() {}\n' }, 'capsule source'); + 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 + // 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'); + // 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', every('true')); reset(); - // A dependency bump moves both the Tauri build and the MSRV promise. + // 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: 'true', + frontend: 'true', + android: 'true', + capsule: 'false', + 'ios-device': 'false', + }); + reset(); + + // 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 +261,7 @@ const reusable = readFileSync( ), 'utf8', ); -const areas = ['tauri', 'msrv', 'linux', 'harmony']; +const areas = ['tauri', 'msrv', 'linux', 'harmony', 'frontend', 'android', 'capsule']; check( 'workflow publishes every area', areas.every((area) => workflow.includes(area + ': ${{ steps.detect.outputs.' + area + ' }}')), @@ -160,7 +269,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 capsule; do/.test(workflow), true, ); check( @@ -206,7 +315,17 @@ 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( + '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), true, ); check( @@ -258,35 +377,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..8e7c373e3 --- /dev/null +++ b/openless-all/app/scripts/ci-impact.mjs @@ -0,0 +1,364 @@ +#!/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 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'; +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', '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']; + +/** + * 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'], + // The Linux reusable workflow also owns Harmony Rust unit tests. + linux: ['openless-linux-egui', 'openless-harmony-host'], + harmony: ['openless-harmony-host'], + 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', + 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 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) || + /^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 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 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)) { + // 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) { + if (CAPSULE_PACKAGES.has(owner)) tags.add('capsule'); + for (const [platform, packages] of context.closures) { + 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 + // 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 'capsule': + return reached.has('capsule'); + 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