☰
Operit PR 技术预审重构:基于 GitHub merge candidate 的差异归责与分层构建门禁实战解析
2026/9/28 3:18:11 网站建设 项目流程
  • AI Agent
  • 人工智能
  • 大模型
  • AI 应用
  • 工具调用
  • 本地部署
  • MCP Clients
  • Agent 记忆

【免费下载链接】Operit

The most powerful AI agent and AI chat software on Android/Operit是一款Android上能力最为强大、发展最久的AI Agent

项目地址:https://gitcode.com/gh_mirrors/op/Operit
点击查看免费下载

导读

本文以 Operit 仓库的 PR 技术预审重构方案 为主体,完整讲解如何将 Pull Request 的自动化门禁从「多检查多红灯、职责混乱」重构为「单一聚合技术状态 + 基于 merge candidate 净差异的精确归责 + 按作用域分层的 Android 构建」。读者将掌握三类核心技术能力:一是如何用candidate^1..candidate净差异消除过时 fork 的路径误判;二是如何让翻译、WebChat、JVM、native、ToolPkg 与文档改动各自获得确定的作用域并精准归责;三是如何通过「候选契约 → 快速检查 → 专项 job → 聚合状态」的流水线设计,让一次改动只出现一个技术红灯,同时保住 secret 安全与构建可信度。

背景:旧 PR 门禁的四个核心缺陷

重构前的pull_request工作流同时使用三个不同的提交对象——目标分支 tip、贡献者 head 和 GitHub merge ref——却没有为它们定义各自的职责。由此产生一系列连锁问题:

  1. 路径误判:当贡献者分支落后于上游时,基于base tip..head tip的差异会把上游在 fork 之后新增的内容(如文档、翻译)算进贡献者的改动里,导致「过时 fork 把上游新增内容算入贡献者差异」。
  2. 未验证最终候选树:实际构建只检出 head,而不是 GitHub 为合并生成的候选树。head 本身不包含最终合并树中可能出现的修复与正确的构建入口,因此最终合并结果从未被真正验证过。
  3. 错误归责:旧问题(历史债务)被错误地记到当前 PR 头上,例如修改罗马尼亚语翻译时,英文或葡萄牙语的历史占位符错误也会被提升为本次错误。
  4. 门禁碎片化:技术检查、PR 模板策略、文档提示和完整 Android 构建被拆成多个阻断状态。一次改动可能同时得到多个重复红灯;更糟的是,任意检查skipped后聚合器仍可能把它视为成功。

重构目标因此非常明确:所有技术检查只运行在 GitHub 生成的 merge candidate 上,用「candidate 第一父提交到 candidate」的净差异做分类与归责,每个 PR 只保留一个聚合技术检查状态,同时让 PR 标题、正文、Issue 和 checklist 不再参与自动阻断。

步骤一:候选树与作用域契约

旧实现的差异污染

旧 PR workflow 检出贡献者 head,并比较事件中的 base tip 与 head tip。由于贡献者分支落后时上游后续提交会污染差异范围,head 也不包含最终合并树中的修复和构建入口,语义从一开始就是不稳定的。

新实现:只认 GitHub 生成的 merge candidate

新实现中,pull_request事件只检出github.sha(即 GitHub 自动生成的 merge candidate)。检查入口必须验证该提交恰好有两个父提交:第一父提交等于事件 base,第二父提交等于事件 head。

核心差异契约用三行伪代码即可概括:

base = candidate^1 diff = base..candidate workspace = candidate

也就是说:差异始终取「candidate 的第一父提交 → candidate」,工作区始终检出 candidate 本身。由此,过时 fork 不再把上游新增内容算入贡献者改动;最终合并树中实际会生效的内容才是被检查与构建的内容。

在源码层面,该契约由 ci/script/pr_check.py 的candidate_context()函数强制执行:

  • git rev-parse <sha>^{commit}归一化 base、head、candidate 三个 SHA;
  • git rev-list --parents -n 1 <candidate>取出候选提交的父列表;
  • 父数量不等于 2 直接抛错(candidate ... must have exactly two parents);
  • 第一父不等于事件 base 抛first parent错误,第二父不等于事件 head 抛second parent错误。

配套的单元测试在 ci/test/test_pr_check.py 中完整覆盖了这一契约:测试用临时 Git 仓库构造「feature 分支落后于 main」的场景——main 在 fork 之后新增了docs/upstream.md,随后--no-ff合并生成 candidate,断言changed_paths()只返回app/src/main/res/values-ro/strings.xml而不会包含上游文档;同时验证了第一父/第二父错配以及「非合并提交」均会被拒绝。

路径分类:NUL 分隔、rename 双面分类、规则唯一收口

路径分类只使用上述净差异,具体实现:

git diff --name-only --no-renames --diff-filter=ACMRDT -z base candidate
  • --no-renames:rename 被拆成「删除旧路径 + 新增新路径」两条记录,两个方向分别进入各自的作用域分类,保证 rename 或 delete 不会漏掉原路径对应的检查(例如旧路径是 Kotlin 代码,即便被重命名为文档路径,原路径的 JVM 检查仍然触发);
  • -z+ NUL 分隔:文件名中的空格、引号等特殊字符不会破坏解析;
  • 分类规则只保存在 ci/script/pr_check.py 一处,作为单一事实来源(SSOT),供 workflow 与测试共同引用。

classify_paths()会输出一个ScopePlan,包含localization、android_resources、android_jvm、android_instrumentation、android_full、web、toolpkg、docs、yaml、ci十个布尔作用域。其中值得注意的分类边界:

  • 翻译资源app/src/main/res/values-*/strings.xml与locales_config.xml命中android_resources(仅资源 lane);
  • 默认语言values/strings.xml因为内容会参与 JVM 编译,归入android_jvm;
  • ANDROID_FULL_PATTERNS命中(如 cmake/operit_git_source.cmake、.github/workflows/pr-check.yml、app/src/main/cpp/**、gradle/**)以及avator/dragonbones、avator/fbx、avator/mmd、llm/llama、llm/mnn、quickjs、showerclient、terminal等 native 模块根目录,都会升级为android_full;
  • 作用域之间存在优先级与互斥:android_full>android_jvm>android_resources,确保改动一旦涉及构建输入就提升到完整构建 lane。

作用域计划最终被写入 GitHub Output 与 step summary(append_step_summary()),在 PR 页面以表格形式展示每个作用域是否需要检查,并列出前 100 条候选路径(超出部分折叠并提示省略数量)。

步骤二:差异归责与检查器

旧实现的归责乱象

旧本地化检查从「新增 XML 行」提取 key,再把所有语言中的同名历史错误全部提升为本次错误——这正是「改一个语言、全语言红灯」的根源。仓库卫生和 Markdown 检查在缺少 base 参数时会退化为全仓扫描,导致 PR 与本地调用的语义不一致。

新实现:显式 base/candidate 双快照比较

所有 PR 检查现在显式接收--base与--candidate两个 Git object。以本地化检查 ci/script/check_localizations.py 为例,其归责模型是:

  1. 双快照:分别对 base 和 candidate 调用git ls-tree -r -z+git show <commit>:<path>读取app/src/main/res下所有资源文件,解析成结构化的ResourceEntry(name、tag、text、attributes、items);
  2. 语义化比较:比较维度包括资源类型(string/plurals/string-array/integer-array)、占位符结构(%s、%1$d等 printf 格式与{name}花括号占位符)、plurals 的 quantity 分支、array 的长度与逐项内容。多行 string、plurals、array 与删除资源均能正确识别;
  3. 归责过滤:select_blocking_issues()只保留两类阻断项——candidate 快照中新增的诊断,或 PR 实际触碰的资源实体((path, key)在 base 与 candidate 间发生变化)仍然存在的诊断。缺失翻译、与源文本相同的文本和历史问题一律只汇总为提示(notes),不阻断;
  4. 配置双向一致性:locales_config.xml的根元素、locale子项、android:defaultLocale、重复项与 locale tag 合法性均做结构校验,并保证「翻译目录必须注册」与「配置项必须存在对应 strings.xml」双向一致;
  5. 按路径隔离 locale:同一 locale 的组合 qualifier 资源(如values-b+zh+Hant)按文件路径独立比较,互不覆盖;
  6. 输出约束:诊断写入 Actions annotation 与 step summary,并限制重复输出数量,避免海量历史问题刷屏。

仓库卫生检查 ci/script/check_repo_hygiene.py 同样收敛到候选净差异:git diff --check检查空白错误(whitespace)、逐行扫描合并冲突标记(<<<<<<< / ======= / >>>>>>>,merge-conflict)、对变更的.json/.xml文件做语法解析(json/xml)、检查工作区 HEAD 是否等于 candidate(workspace-commit)、工作区是否被污染(workspace-dirty),以及新增符号链接(symlink)需显式人工审查。快速检查的设计目标是一次展示全部可修问题,不因首个错误提前终止。

步骤三:工作流与构建分层

旧实现的成本浪费

旧实现里一个翻译文件就会触发完整 Android 构建;PR 构建还继承 secret、初始化已经删除的 submodule、调用迁移前的 ToolPkg 路径,并在失败时上传构建产物——每一个都是安全或资源问题。

新实现:Candidate checks聚合 + 按作用域分层的专项 job

新的 .github/workflows/pr-check.yml 触发条件为pull_request(development分支,类型含opened/synchronize/reopen/edited/ready_for_review),工作流权限仅为contents: read,并设置concurrency按 PR 号取消进行中的旧运行。整体分三层:

第一层fast(快速检查,所有 PR 必跑)

  • 检出github.sha(fetch-depth: 0,不初始化 submodule,不持久化凭据);
  • pr_check.py plan输出候选契约与作用域计划;
  • 门禁单元测试(unittest discover -s ci/test)、仓库卫生、Markdown 链接检查无条件运行;本地化检查仅在localization == true时运行,YAML 检查仅在yaml == true时运行;
  • 所有快速检查均continue-on-error: true,最后统一由「Require successful fast checks」步骤汇总判定:required 为 true 且 outcome 非 success 即失败,同时向 step summary 输出| Stage | Required | Outcome |决策表。快速检查失败后,后续耗时阶段直接跳过(android_build/android_tests以needs.fast.result == 'success'为前提)。

YAML 与 Actions 检查值得一提:变更的.yml/.yaml先用 Ruby 的Psych.parse_file做AST 语法解析(合法标量不会被转成 Ruby 对象,避免对象反序列化误报),再对.github/workflows/*.yml运行 actionlint,且 actionlint 二进制通过官方 SHA-256 校验(8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8)后使用。

专项检查(仍在 fast job 内按需执行)

  • WebChat(web == true):web-chat下npm ci、typecheck、npm run build:webchat;
  • ToolPkg(toolpkg == true):npm run build:examples:github重建 GitHub TypeScript 示例,并用git diff --exit-code -- examples/github.js强制其与提交版本一致;examples/toolpkg_wasm_demo按独立 lockfile 执行pack:toolpkg(AssemblyScript + TypeScript + 归档构建),随后用test -s断言 manifest 声明的入口main.js、modules/core.wasm与归档dist/toolpkg_wasm_demo.toolpkg必须存在且非空;最后调用 tools/example_packages/sync_example_packages.py 以--mode test校验、再执行真实同步;
  • Android 资源(android_resources == true):安装build-tools;35.0.0后,用aapt2 compile --dir app/src/main/res做资源语法编译,输出 zip 并断言非空——翻译改动只走到这一层,绝不启动完整 assemble。

第二/三层android_build与android_tests(按作用域独立 job)

  • android_build仅在android_full == true时运行:初始化 terminal submodule(只初始化当前存在且实际需要的公共 submodule)、安装 JDK 21 / Node 22 / Android SDK(platforms;android-34、platforms;android-36、build-tools;35.0.0、ndk;25.1.8937393、cmake;3.22.1)、恢复 CMake 源码缓存与manual-deps缓存、执行 ci/script/download_android_dependencies.sh 与 ci/script/prepare_android_dependencies.py 准备依赖、用 Rust 1.88.0 交叉编译 tools/native_ripgrep 生成liboperit_ripgrep.so并断言非空、重建 WebChat 资产与同步 ToolPkg 包,最后./gradlew assembleDebug --stacktrace --no-daemon。
  • android_tests在android_jvm == true || android_full == true时运行:按不同 profile(jvm/full)恢复依赖缓存并准备依赖,运行:app:testDebugUnitTest;若android_instrumentation == true(改动涉及app/src/androidTest/**),额外追加:app:compileDebugAndroidTestKotlin与:app:compileDebugAndroidTestJavaWithJavac的编译验证。

聚合状态candidate(每个 PR 只保留一个技术红灯)

candidatejob 依赖fast、android_build、android_tests三者(if: always()),再次通过check_stage汇总:只有 required 为 true 且 outcome 非 success 的阶段才算失败。最终 PR 页面上只出现一个名为Candidate checks的聚合状态,而每个专项 job 仍单独展示各自的具体失败原因——既避免了「一次改动多个重复红灯」,又保留了诊断的可追溯性。

可信构建与 PR 构建彻底分离

  • PR workflow 零 secret:permissions: contents: read,不读取任何 secret,也不上传 APK/AAB;
  • 可信的 main 推送构建与手工 Android 构建由独立 workflow .github/workflows/android-build.yml 承担:支持workflow_dispatch(assembleDebug、:app:assembleNightly、:app:assembleClone、:app:bundleRelease四种任务选择)与 main 分支的路径过滤推送;按事件隔离并发(concurrency: android-build-${{ github.event_name }}-${{ github.ref }}),构建前强制校验 OAuth 输入;只有该 workflow 允许上传 APK/AAB 与 reports 产物(app/build/outputs/apk/**、app/build/outputs/bundle/**/*.aab,保留 14 天);
  • 构建输入完整性:ToolPkg 运行时文件、GitHub 示例生成结果、可信构建输入(ci/script/download_android_dependencies.sh等)均显式校验;Gradle Wrapper 下载内容必须匹配 Gradle 8.13 官方 SHA-256(gradle/wrapper/gradle-wrapper.properties 中distributionSha256Sum=20f1b1176237254a6fc204d8434196fa11a4cfb387567519c61556e8710aed78)。

作用域边界与完成情况

本次重构的作用域严格限定在:

  • .github/workflows/pr-check.yml 与 .github/workflows/android-build.yml 两个工作流;
  • gradle/wrapper/gradle-wrapper.properties;
  • ci/script/ 与 ci/test/(含 ci/test/test_pr_check.py、ci/test/test_localizations.py、ci/test/test_repo_hygiene.py、ci/test/test_toolpkg_sync.py、ci/test/test_markdown_links.py);
  • examples/toolpkg_wasm_demo/package-lock.json(WASM ToolPkg 独立锁文件);
  • PR 模板、CI 文档与贡献指南。

Issue 自动整理、发布签名、业务代码与仓库 ruleset不在此次修改范围内。

按完成清单核对,重构后的关键事实如下:旧PR Requiredworkflow、模板策略 job 与聚合器已删除;新 workflow 保留Candidate checks聚合状态并按作用域展示专项 job;快速检查与 Android 分层均使用 candidate 第一父差异;PR 不读取 secret 也不上传 APK/AAB;已补充门禁、归责、Markdown 与 ZIP 安全测试;Gradle 8.13 distribution 使用官方 SHA-256 校验;WASM ToolPkg 使用独立锁文件执行真实编译与打包;官方 Actions 固定到 Node.js 24 运行时版本(actions/checkout@93cb6efe...、actions/setup-python@ece7cb06...等均以 SHA 固定);首次线上完整 lane 已通过 assemble 与 JVM 单测,并补齐 Android lint 检出的四个 WASM 模块数翻译。

效果验证:一次改动只看到一个技术状态

以类似 PR #770 的纯翻译改动为例:候选摘要只报告该 PR 在当前main上实际引入的问题——不再报告其他语言的历史债务、上游文档变化或 PR 模板格式错误。fastjob 中android_resources == true只执行 aapt2 资源编译,android_build与android_tests均因作用域为 false 而不启动;candidate聚合 job 汇总后给出唯一的Candidate checks红灯(若有),并精确指向专项 job 的具体失败原因。整个流程同时满足:纯翻译改动不启动完整 assemble、fork PR 日志与产物中不存在仓库 secret、Android 构建只初始化当前存在且实际需要的公共 submodule、YAML 使用 AST 语法解析与 actionlint 双重校验、Gradle Wrapper 下载内容必须匹配官方 SHA-256。

这套「候选契约 → 净差异归责 → 作用域分层 → 聚合状态」的 PR 门禁架构,可以作为多模块、多语言、多原生依赖的 Android 仓库 CI 重构的通用范本:只要保证检查与构建永远作用在 GitHub 真实生成的 merge candidate 上,并把「历史债务」与「本次改动」严格区分开,单状态门禁与高信噪比诊断就能同时成立。

  • AI Agent
  • 人工智能
  • 大模型
  • AI 应用
  • 工具调用
  • 本地部署
  • MCP Clients
  • Agent 记忆

【免费下载链接】Operit

The most powerful AI agent and AI chat software on Android/Operit是一款Android上能力最为强大、发展最久的AI Agent

项目地址:https://gitcode.com/gh_mirrors/op/Operit
点击查看免费下载

相关推荐

上一篇:终极兼容方案:D3D8to9让经典老游戏在现代Windows上重生
下一篇:Koin Android ViewModel 完整指南:生命周期感知注入、声明式 DSL 与作用域实战

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询