From 6a30a9f73df135fcc37c8cd5c2f4fc604b3ca9c4 Mon Sep 17 00:00:00 2001 From: limityan Date: Sun, 2 Aug 2026 23:59:46 +0800 Subject: [PATCH] perf(ci): stabilize Rust validation critical path --- .github/workflows/ci.yml | 42 ++++------ .../rust-build-dependency-boundaries.md | 9 +++ scripts/check-github-config.test.mjs | 78 ++++++++++++++++++- 3 files changed, 100 insertions(+), 29 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6a7c6209b2..06c4238701 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -101,14 +101,13 @@ jobs: - uses: dtolnay/rust-toolchain@stable - - name: Generate dependency lockfile - run: cargo generate-lockfile - - uses: swatinem/rust-cache@v2 with: shared-key: "cli-ci-v2-${{ matrix.cache_key }}" cache-bin: false - save-if: ${{ github.event_name != 'pull_request' }} + # PRs restore trusted caches but never publish merge-ref artifacts. + save-if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} + cache-on-failure: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} - name: Run CLI and ACP tests on macOS if: runner.os == 'macOS' @@ -133,7 +132,6 @@ jobs: rust-build-check: name: Rust Build Check (${{ matrix.os }}) runs-on: ${{ matrix.os }} - needs: frontend-build env: # Keep the workspace check plus desktop test profiles within hosted-runner disk limits. CARGO_INCREMENTAL: "0" @@ -154,15 +152,11 @@ jobs: shell: pwsh run: ./scripts/ci/setup-openssl-windows.ps1 - - name: Download frontend build artifacts - uses: actions/download-artifact@v7 - with: - name: frontend-dist - path: dist - - - name: Create mobile-web dist directory (workaround for Tauri) + # Tauri code generation only requires its configured resource roots to + # exist during check/test; distributable assets remain frontend-build's owner. + - name: Create Tauri resource directories shell: bash - run: mkdir -p src/mobile-web/dist + run: mkdir -p dist src/mobile-web/dist - name: Install Linux system dependencies (Tauri) if: runner.os == 'Linux' @@ -197,15 +191,14 @@ jobs: - uses: dtolnay/rust-toolchain@stable - - name: Generate dependency lockfile - run: cargo generate-lockfile - - uses: swatinem/rust-cache@v2 with: shared-key: "ci-check-v3-${{ runner.os }}-no-cargo-bin-v1" cache-bin: false - # PR caches are scoped to merge refs; main pushes own shared cache refreshes. - save-if: ${{ github.event_name != 'pull_request' }} + # PR caches are scoped to merge refs; trusted main pushes own shared + # refreshes and retain completed dependency builds after late test failures. + save-if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} + cache-on-failure: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} - name: Check compilation run: cargo check --locked --workspace @@ -224,7 +217,7 @@ jobs: run: cargo test --locked -p bitfun-relay-service - name: Run subscription authentication tests - run: cargo test --locked -p bitfun-ai-adapters --features subscription-auth subscription_auth + run: cargo test --locked -p bitfun-ai-adapters --features subscription-auth --lib subscription_auth # File watching is backed by a different OS API on every platform # (ReadDirectoryChangesW / FSEvents / inotify), so watch registration @@ -237,14 +230,14 @@ jobs: # blocking unrelated work. - name: Run file watch contract tests if: runner.os != 'macOS' - run: cargo test --locked -p bitfun-services-integrations --features file-watch + run: cargo test --locked -p bitfun-services-integrations --no-default-features --features file-watch --test file_watch_contracts # Search tools resolve paths and symlinks directly, which also differs # across platforms. Scoped to the search module: the glob tests in this # crate fail on Windows independently of this branch (walk-root # derivation treats separators differently) and need their own fix. - name: Run search tool tests - run: "cargo test --locked -p tool-runtime search::" + run: "cargo test --locked -p tool-runtime --lib search::" # ── Frontend: build ──────────────────────────────────────────────── frontend-build: @@ -317,10 +310,3 @@ jobs: - name: Build mobile web run: pnpm run build:mobile-web - - - name: Upload frontend build artifacts - uses: actions/upload-artifact@v6 - with: - name: frontend-dist - path: dist - retention-days: 1 diff --git a/docs/architecture/rust-build-dependency-boundaries.md b/docs/architecture/rust-build-dependency-boundaries.md index 25a9d8942c..5506d5f1c0 100644 --- a/docs/architecture/rust-build-dependency-boundaries.md +++ b/docs/architecture/rust-build-dependency-boundaries.md @@ -143,6 +143,15 @@ Cargo target gate 语义见 CI 负责 workspace 级检查、真实产品 feature 组合、跨平台、完整测试和最终产品构建。本地未执行的宽泛验证必须标记为未执行或 CI 覆盖,不能表述为本地通过。CI 失败时再按失败路径复现对应重命令,不要求每次本地改动预跑全部构建。 +### 7.1 Hosted CI 关键路径与缓存 + +- 验证 job 只依赖自身的编译期前置条件。Tauri `check`/`test` 只要求配置中的前端和资源目录存在时,Rust job 自行创建空目录,不等待或传递可发布前端产物;真实静态资源仍由前端构建和产品打包 owner 负责。 +- Pull Request 可以恢复可信分支产生的 Cargo 缓存,但不得写入 merge-ref 缓存。只有可信 `main` push 可以保存共享缓存;若依赖编译已经完成而后段测试失败,允许该可信构建保存依赖缓存,避免下一次跨平台构建无谓冷启动。 +- 未先修改 Cargo manifest 的 PR/main 验证 job 以仓库提交的 `Cargo.lock` 为唯一解析结果并通过 `--locked` 验证,不在 cache restore 前重新生成 lockfile。依赖解析更新必须作为可评审的源码变更提交,不能让同一 commit 因上游兼容版本发布而自然产生新的 cache key;先改写版本号的发布 job 不属于该前提。 +- 缓存只承载可复用依赖产物,不为追求命中率启用 workspace crate 或 incremental artifact 缓存;缓存容量、失效粒度和可信边界优先于单次命中率。 +- focused test 同时选择最小 Cargo target(如 `--lib` 或 `--test `)和必要 feature;仅使用名称过滤不能阻止无关 test target 进入编译图。 +- 只有具备独立 owner、平台矩阵或失败归因价值的验证才拆成 job。顺序执行但共享同一依赖图的命令优先留在既有 job 中,避免用新增 job 重复结账工具链、checkout 和缓存恢复成本。 + ## 8. 评审证据 依赖治理 PR 按改动选择证据,不机械执行全部命令: diff --git a/scripts/check-github-config.test.mjs b/scripts/check-github-config.test.mjs index 6bc42cd7e9..15fc7cd0d1 100644 --- a/scripts/check-github-config.test.mjs +++ b/scripts/check-github-config.test.mjs @@ -1,5 +1,12 @@ import assert from 'node:assert/strict'; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { createRequire } from 'node:module'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { spawnSync } from 'node:child_process'; @@ -8,6 +15,10 @@ import { fileURLToPath } from 'node:url'; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const scriptPath = path.join(repoRoot, 'scripts/check-github-config.mjs'); +const requireFromWebUi = createRequire( + path.join(repoRoot, 'src/web-ui/package.json'), +); +const yaml = requireFromWebUi('yaml'); function createRepo({ workflow, nodeVersionFile }) { const root = mkdtempSync(path.join(tmpdir(), 'bitfun-github-config-')); @@ -191,3 +202,68 @@ jobs: assert.equal(result.status, 0, result.stderr); assert.match(result.stdout, /GitHub YAML config check passed/); }); + +test('keeps Rust CI independent, restore-only on PRs, and target-focused', () => { + const workflow = yaml.parse( + readFileSync(path.join(repoRoot, '.github/workflows/ci.yml'), 'utf8'), + ); + const rustJob = workflow.jobs['rust-build-check']; + const frontendJob = workflow.jobs['frontend-build']; + const trustedMain = + "${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}"; + + assert.equal( + rustJob.needs, + undefined, + 'Rust validation must not wait for the frontend build', + ); + assert.equal( + rustJob.steps.some((step) => step.uses?.startsWith('actions/download-artifact@')), + false, + 'Rust validation must not download frontend artifacts', + ); + assert.match( + rustJob.steps.find((step) => step.name === 'Create Tauri resource directories') + ?.run ?? '', + /mkdir -p dist src\/mobile-web\/dist/, + ); + assert.equal( + frontendJob.steps.some( + (step) => + step.uses?.startsWith('actions/upload-artifact@') && + step.with?.name === 'frontend-dist', + ), + false, + 'The frontend build must not upload an artifact with no consumer', + ); + + for (const jobName of ['cli-test', 'rust-build-check']) { + const job = workflow.jobs[jobName]; + const cache = job.steps.find((step) => + step.uses?.startsWith('swatinem/rust-cache@'), + ); + assert.equal( + job.steps.some((step) => step.run?.includes('cargo generate-lockfile')), + false, + `${jobName} must consume the committed Cargo.lock`, + ); + assert.equal(cache?.with?.['save-if'], trustedMain); + assert.equal(cache?.with?.['cache-on-failure'], trustedMain); + } + + const commandByStep = new Map( + rustJob.steps.map((step) => [step.name, step.run]), + ); + assert.equal( + commandByStep.get('Run subscription authentication tests'), + 'cargo test --locked -p bitfun-ai-adapters --features subscription-auth --lib subscription_auth', + ); + assert.equal( + commandByStep.get('Run file watch contract tests'), + 'cargo test --locked -p bitfun-services-integrations --no-default-features --features file-watch --test file_watch_contracts', + ); + assert.equal( + commandByStep.get('Run search tool tests'), + 'cargo test --locked -p tool-runtime --lib search::', + ); +});