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
100 changes: 58 additions & 42 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,29 +1,9 @@
name: CI

on:
push:
branches: [main]
# 在触发前过滤不影响当前构建的文件;与 tools/ci-changes.py 保持一致。
paths:
- '**'
- '!*.md'
- '!LICENSE'
- '!.gitignore'
- '!docs/**'
- '!.github/ISSUE_TEMPLATE/**'
- '!.github/release-notes.md'
- '!.github/PULL_REQUEST_TEMPLATE.md'
- '!postman/mocks/**'
- '!postman/simulations/**'
- '!src-tauri/icons-v2/android/**'
- '!src-tauri/icons-v2/ios/**'
- 'CHANGELOG.md'
- 'docs/faq.md'
- 'docs/laboratory.md'
- 'docs/request-policy.md'
pull_request:
branches: [main]
# 保留必需检查结果,避免纯文档 PR 因缺少状态而无法合并。
workflow_dispatch:

permissions:
contents: read
Expand All @@ -37,21 +17,24 @@ jobs:
name: Changed paths
runs-on: ubuntu-latest
outputs:
build: ${{ steps.filter.outputs.build }}
frontend: ${{ steps.filter.outputs.frontend }}
rust: ${{ steps.filter.outputs.rust }}
native: ${{ steps.filter.outputs.native }}
ci: ${{ steps.filter.outputs.ci }}
steps:
- uses: actions/checkout@v7.0.1
with:
fetch-depth: 0
- name: 核验路径过滤规则
run: python3 -m unittest discover -s tools -p 'test_ci_changes.py'
- name: 判断是否需要构建
run: python3 -B -m unittest discover -s tools -p 'test_ci*.py'
- name: 选择相关检查
id: filter
run: python3 tools/ci-changes.py

rust:
name: Rust (windows-latest)
needs: changes
if: needs.changes.outputs.build == 'true'
if: needs.changes.outputs.rust == 'true'
runs-on: windows-latest
steps:
- uses: actions/checkout@v7.0.1
Expand All @@ -67,22 +50,11 @@ jobs:
env:
CARGO_TARGET_X86_64_PC_WINDOWS_MSVC_RUNNER: powershell -NoProfile -ExecutionPolicy Bypass -File ${{ github.workspace }}/tools/run-windows-test.ps1
run: cargo test --locked -p mpider-core -p article-md -p mpider-tauri
- uses: actions/setup-python@v7.1.0
if: runner.os == 'Windows'
with:
python-version: "3.12"
- name: Windows 原生协议离线测试
shell: pwsh
if: runner.os == 'Windows'
run: |
$PSNativeCommandUseErrorActionPreference = $true
python -m pip install -r native/requirements.txt
python -B -m unittest discover -s native -p 'test_*.py'

frontend:
name: Frontend
needs: changes
if: needs.changes.outputs.build == 'true'
if: needs.changes.outputs.frontend == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7.0.1
Expand All @@ -96,12 +68,56 @@ jobs:
- run: pnpm install --frozen-lockfile
- run: pnpm exec tsc --noEmit -p frontend/tsconfig.json
- run: pnpm build
- name: 界面契约与更新发行回归
run: pnpm test:frontend

native:
name: Native (${{ matrix.os }})
needs: changes
if: needs.changes.outputs.native == 'true'
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v7.0.1
- uses: actions/setup-python@v7.1.0
with:
python-version: "3.12"
- uses: pnpm/action-setup@v6.1.0
with:
version: 9
- uses: actions/setup-node@v7.1.0
with:
node-version: 24
- name: 安装原生测试依赖
run: python -m pip install -r native/requirements.txt
- name: 原生桥协议离线测试
run: |
python -m pip install -r native/requirements.txt
python -m unittest discover -s native -p 'test_*.py'
- name: 界面契约与原生桥回归
run: pnpm test
run: python -B -m unittest discover -s native -p 'test_*.py'
- name: 原生观察与列表过滤回归
run: pnpm test:native

ci:
name: CI configuration
needs: changes
if: needs.changes.outputs.ci == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7.0.1
- name: 路径判断与汇总检查回归
run: python3 -B -m unittest discover -s tools -p 'test_ci*.py'
- name: 核验 workflow 语法与任务依赖
uses: docker://rhysd/actionlint:1.7.12

result:
name: CI
if: always()
needs: [changes, frontend, rust, native, ci]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7.0.1
- name: 确认所需检查均已通过
env:
CI_NEEDS: ${{ toJSON(needs) }}
run: python3 -B tools/check-ci-results.py
16 changes: 16 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,22 @@ pnpm test

按改动范围补充有意义的回归。说明实际运行的平台、命令与结果,区分离线测试、桌面启动、真实请求和长期观察;没有验证的部分明确写出。

日常 CI 仅由面向 main 的 PR 或手动操作触发,合并后的普通 main 推送不重复运行。PR 按路径选择任务:

| 改动范围 | 执行的检查 |
| --- | --- |
| 公开说明、截图、协作模板、`.gitignore`、本地 Postman 模拟、未使用的移动端图标 | 轻量路径判断与汇总,不运行应用构建 |
| 前端源码、广告默认配置、编入界面的 Markdown(含 `CHANGELOG.md`) | 前端类型、构建及界面回归 |
| Rust 实现、Windows 图标、Rust 测试样本 | Windows Rust 格式、Clippy 与测试 |
| 原生工作进程及指纹 | Rust 检查,以及 Linux/Windows Python 与原生观察脚本回归 |
| 前后端共享类型、IPC 或 Rust 原生协议 | 联动前端、Rust 与原生桥检查 |
| CI 路径判断、汇总脚本及其测试 | 路径与汇总回归、workflow 静态检查 |
| workflow 自身、共享依赖或未知构建输入 | 保守运行全部检查,验证配置实际可用 |

混合改动取相关检查的并集。`pnpm test:frontend` 与 `pnpm test:native` 可分别运行 Node 回归;`pnpm test` 仍执行两组完整回归。手动运行 CI 会执行全部任务;版本标签的完整发行验证保持独立。

合并保护应要求固定的 `CI` 汇总检查,并要求 PR 基于最新 main 完成验证。汇总检查始终执行:所选任务失败、取消、意外跳过或路径判断失败都会阻止通过;无关任务可以跳过。这样纯文档 PR 不会因缺少构建状态而无法合并。

## 代码与数据约定

- 文档、注释、日志和 UI 文案使用中文;模块顶部说明职责。
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ cargo test --locked -p mpider-core -p article-md -p mpider-tauri
pnpm tauri dev
```

Windows 测试 runner、Python 离线测试和完整客户端构建见[Windows 构建说明](docs/windows-standalone.md)。CI 按改动路径检查前端与 Windows Rust:main 上仅修改公开资料、协作模板、`.gitignore`、本地 Postman 模拟或未使用的 Android/iOS 图标时不触发 CI,PR 保留路径判断以满足必需检查、跳过构建;编入界面的更新记录、FAQ、实验室与请求策略文档仍触发检查。客户端发行仅面向 Windows x64,标签必须与项目版本一致,完成构建、离线测试、便携包解压自检与签名清单生成后创建 Release 草稿。
Windows 测试 runner、Python 离线测试和完整客户端构建见[Windows 构建说明](docs/windows-standalone.md)。日常 CI 在 PR 中按改动路径选择前端、Windows Rust、原生桥或 CI 配置检查,合并到 main 后不重复执行;纯公开资料只运行轻量路径判断与汇总,编入界面的更新记录、FAQ、实验室与请求策略文档仅触发前端检查。共享契约改动联动验证,手动触发可运行全部检查,详细规则见[贡献指南](CONTRIBUTING.md)。客户端发行仅面向 Windows x64,标签必须与项目版本一致,完成构建、离线测试、便携包解压自检与签名清单生成后创建 Release 草稿。

[贡献指南](CONTRIBUTING.md) · [实现原理](docs/how-it-works.md) · [更新记录](CHANGELOG.md) · [第三方声明](THIRD-PARTY-NOTICES.md)

Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@
"build": "vite build",
"preview": "vite preview",
"tauri": "tauri",
"test": "node --test tools/test_native_bridge_filter.mjs tools/test_session_agent.mjs tools/test_cursor_lab.ts tools/test_home_sync.ts tools/test_pagination.ts tools/test_relative_time.ts tools/test_advertisements.ts tools/test_action_visibility.ts tools/test_release_updater.mjs"
"test": "pnpm test:frontend && pnpm test:native",
"test:frontend": "node --test tools/test_cursor_lab.ts tools/test_home_sync.ts tools/test_pagination.ts tools/test_relative_time.ts tools/test_advertisements.ts tools/test_action_visibility.ts tools/test_release_updater.mjs",
"test:native": "node --test tools/test_native_bridge_filter.mjs tools/test_session_agent.mjs"
},
"dependencies": {
"@fontsource-variable/inter": "^5.3.0",
Expand Down
30 changes: 30 additions & 0 deletions tools/check-ci-results.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"""汇总 CI:应运行的任务必须成功,跳过、失败和取消不能冒充成功。"""

import json
import os
import sys


def result_errors(needs):
changes = needs.get("changes", {})
if changes.get("result") != "success":
return ["路径判断未成功,不能确认检查范围"]
errors = []
for name in ("frontend", "rust", "native", "ci"):
enabled = changes.get("outputs", {}).get(name)
result = needs.get(name, {}).get("result")
if enabled not in ("true", "false"):
errors.append(f"{name}: 缺少有效的路径判断结果")
elif enabled == "true" and result != "success":
errors.append(f"{name}: 应执行的检查未通过({result})")
elif enabled == "false" and result not in ("success", "skipped"):
errors.append(f"{name}: 检查异常({result})")
return errors


if __name__ == "__main__":
errors = result_errors(json.loads(os.environ["CI_NEEDS"]))
if errors:
print("\n".join(errors), file=sys.stderr)
sys.exit(1)
print("所需检查均已通过,无关任务已跳过")
83 changes: 64 additions & 19 deletions tools/ci-changes.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
"""仅让影响应用或构建的改动运行完整 CI;与 workflow 的 push 路径过滤同步。"""
"""按 PR 改动选择检查;共享契约联动验证,未知输入保守运行全部检查。"""

import json
import os
from pathlib import Path
import subprocess

CHECKS = ("frontend", "rust", "native", "ci")

EMBEDDED_GUIDES = {
"CHANGELOG.md",
"docs/faq.md",
Expand All @@ -28,20 +30,59 @@
"src-tauri/icons-v2/ios/",
)

CI_FILES = {
"tools/ci-changes.py", "tools/test_ci_changes.py",
"tools/check-ci-results.py", "tools/test_ci_results.py",
}

SHARED_CONTRACT_FILES = {
"frontend/src/lib/tauri.ts", "frontend/src/lib/workbench.ts",
"frontend/src/views/types.ts", "crates/core/src/model.rs",
"crates/core/src/workbench/mod.rs", "crates/core/src/store/workbench.rs",
"crates/core/src/store/native.rs",
}

NATIVE_TESTS = {"tools/test_native_bridge_filter.mjs", "tools/test_session_agent.mjs"}

FRONTEND_FILES = {
"vite.config.ts", "tailwind.config.cjs", "config/advertisements.json",
"tools/release-updater.mjs", "tools/test_release_updater.mjs",
}


def requires_build(paths):
def checks_for_paths(paths):
selected = set()
for path in paths:
if path in EMBEDDED_GUIDES:
return True
if path in NON_BUILD_FILES or path.startswith(NON_BUILD_PREFIXES):
selected.add("frontend")
elif path in NON_BUILD_FILES or path.startswith(NON_BUILD_PREFIXES):
continue
if "/" not in path and (path.endswith(".md") or path == "LICENSE"):
elif "/" not in path and (path.endswith(".md") or path == "LICENSE"):
continue
return True
return False
elif path in CI_FILES:
selected.add("ci")
elif path in SHARED_CONTRACT_FILES or path.startswith(("src-tauri/src/", "crates/core/src/native/")):
selected.update(("frontend", "rust", "native"))
elif path in NATIVE_TESTS or (path.startswith("native/") and Path(path).name.startswith("test_")):
selected.add("native")
elif path.startswith("native/") or path == "tools/native_bridge_probe.js":
# 工作进程及指纹被 Rust include_str! 编入,需验证两边。
selected.update(("rust", "native"))
elif path in {"Cargo.toml", "src-tauri/tauri.conf.json"}:
# 前端回归同时核对三处项目版本。
selected.update(("frontend", "rust"))
elif path.startswith(("crates/", "src-tauri/")) or path in {"Cargo.lock", "tools/run-windows-test.ps1"}:
selected.add("rust")
elif (path.startswith("frontend/") or path in FRONTEND_FILES
or (path.startswith("tools/test_") and path.endswith((".ts", ".mjs")))):
selected.add("frontend")
else:
# workflow 自身、依赖清单、共享构建配置和未知文件实际运行检查。
selected.update(CHECKS)
return {check: check in selected for check in CHECKS}


def changed_paths(event):
def changed_paths(event, *, cwd=None):
if "pull_request" in event:
pr = event["pull_request"]
revision = pr["base"]["sha"] + "..." + pr["head"]["sha"]
Expand All @@ -52,20 +93,24 @@ def changed_paths(event):
command = ["git", "ls-files", "-z"]
else:
command = ["git", "diff", "--name-only", "--no-renames", "-z", before, event["after"]]
result = subprocess.check_output(command)
result = subprocess.check_output(command, cwd=cwd)
return [os.fsdecode(path) for path in result.split(b"\0") if path]


if __name__ == "__main__":
event = json.loads(Path(os.environ["GITHUB_EVENT_PATH"]).read_text())
def checks_for_event(event, event_name):
if event_name == "workflow_dispatch":
return dict.fromkeys(CHECKS, True)
try:
paths = changed_paths(event)
build = requires_build(paths)
return checks_for_paths(changed_paths(event))
except subprocess.CalledProcessError:
# 强制更新等情况下旧对象可能不在本地,保守执行检查。
paths = []
build = True
value = str(build).lower()
# 旧对象缺失或 Git 比较失败时保守执行,禁止当成无改动跳过。
return dict.fromkeys(CHECKS, True)


if __name__ == "__main__":
event = json.loads(Path(os.environ["GITHUB_EVENT_PATH"]).read_text())
checks = checks_for_event(event, os.environ["GITHUB_EVENT_NAME"])
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output:
output.write("build=" + value + "\n")
print(json.dumps({"build": build, "changed_files": len(paths)}))
for name, enabled in checks.items():
output.write(f"{name}={str(enabled).lower()}\n")
print(json.dumps(checks))
Loading
Loading