diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7c91b725..5bacf077 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,74 +14,172 @@ concurrency: cancel-in-progress: true jobs: - windows-script-validation: - runs-on: windows-latest - timeout-minutes: 30 + repository-checks: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@v5 + - name: Run repository checks + run: python3 scripts/run_local_ci.py --profile portable --group repository --require-clean --summary-dir target/local-ci/portable-repository + - name: Upload local CI summary + if: always() + uses: actions/upload-artifact@v6 + with: + name: local-ci-summary-portable-repository + path: target/local-ci/portable-repository + if-no-files-found: warn + script-tests: + runs-on: ubuntu-latest + timeout-minutes: 20 steps: - name: Checkout uses: actions/checkout@v5 + - name: Run portable script tests + run: python3 scripts/run_local_ci.py --profile portable --group scripts --require-clean --summary-dir target/local-ci/portable-scripts + - name: Upload local CI summary + if: always() + uses: actions/upload-artifact@v6 + with: + name: local-ci-summary-portable-scripts + path: target/local-ci/portable-scripts + if-no-files-found: warn + windows-script-tests: + runs-on: windows-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v5 - name: Set up Python uses: actions/setup-python@v6 with: python-version: '3.x' + - name: Run Windows repository checks + shell: pwsh + run: python scripts/run_local_ci.py --profile windows --group repository --require-clean --summary-dir target/local-ci/windows-repository + - name: Run Windows script tests + shell: pwsh + run: python scripts/run_local_ci.py --profile windows --group scripts --require-clean --summary-dir target/local-ci/windows-scripts + - name: Upload repository summary + if: always() + uses: actions/upload-artifact@v6 + with: + name: local-ci-summary-windows-repository + path: target/local-ci/windows-repository + if-no-files-found: warn + - name: Upload script summary + if: always() + uses: actions/upload-artifact@v6 + with: + name: local-ci-summary-windows-scripts + path: target/local-ci/windows-scripts + if-no-files-found: warn + java-linux: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@v5 - name: Set up Java 21 uses: actions/setup-java@v5 with: distribution: temurin java-version: '21' cache: maven - - - name: Run Windows CI preflight - shell: pwsh - run: python scripts/run_local_ci.py --profile windows --require-clean --summary-dir target/local-ci/windows - + - name: Run Linux Java verification + run: python3 scripts/run_local_ci.py --profile portable --group java --require-clean --summary-dir target/local-ci/portable-java - name: Upload local CI summary if: always() uses: actions/upload-artifact@v6 with: - name: local-ci-summary-windows - path: target/local-ci/windows + name: local-ci-summary-portable-java + path: target/local-ci/portable-java if-no-files-found: warn - - name: Upload JaCoCo coverage report uses: actions/upload-artifact@v6 with: - name: jacoco-coverage-report-windows + name: jacoco-coverage-report-linux path: target/site/jacoco if-no-files-found: error - build: - runs-on: ubuntu-latest - timeout-minutes: 20 - + java-windows: + runs-on: windows-latest + timeout-minutes: 30 steps: - name: Checkout uses: actions/checkout@v5 - + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.x' - name: Set up Java 21 uses: actions/setup-java@v5 with: distribution: temurin java-version: '21' cache: maven - - - name: Run portable CI preflight - run: python3 scripts/run_local_ci.py --profile portable --require-clean --summary-dir target/local-ci/portable - + - name: Run Windows Java verification + shell: pwsh + run: python scripts/run_local_ci.py --profile windows --group java --require-clean --summary-dir target/local-ci/windows-java - name: Upload local CI summary if: always() uses: actions/upload-artifact@v6 with: - name: local-ci-summary-linux - path: target/local-ci/portable + name: local-ci-summary-windows-java + path: target/local-ci/windows-java if-no-files-found: warn - - name: Upload JaCoCo coverage report uses: actions/upload-artifact@v6 with: - name: jacoco-coverage-report-linux + name: jacoco-coverage-report-windows path: target/site/jacoco if-no-files-found: error + + ci-required: + if: ${{ always() }} + needs: [repository-checks, script-tests, windows-script-tests, java-linux, java-windows] + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Require all execution jobs + env: + REPOSITORY: ${{ needs.repository-checks.result }} + SCRIPTS: ${{ needs.script-tests.result }} + WINDOWS_SCRIPTS: ${{ needs.windows-script-tests.result }} + JAVA_LINUX: ${{ needs.java-linux.result }} + JAVA_WINDOWS: ${{ needs.java-windows.result }} + run: | + printf '%s\n' "repository-checks=$REPOSITORY" "script-tests=$SCRIPTS" "windows-script-tests=$WINDOWS_SCRIPTS" "java-linux=$JAVA_LINUX" "java-windows=$JAVA_WINDOWS" + [[ "$REPOSITORY" == success && "$SCRIPTS" == success && "$WINDOWS_SCRIPTS" == success && "$JAVA_LINUX" == success && "$JAVA_WINDOWS" == success ]] + + build: + if: ${{ always() }} + needs: [repository-checks, script-tests, java-linux] + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Require Linux execution jobs + env: + REPOSITORY: ${{ needs.repository-checks.result }} + SCRIPTS: ${{ needs.script-tests.result }} + JAVA_LINUX: ${{ needs.java-linux.result }} + run: | + printf '%s\n' "repository-checks=$REPOSITORY" "script-tests=$SCRIPTS" "java-linux=$JAVA_LINUX" + [[ "$REPOSITORY" == success && "$SCRIPTS" == success && "$JAVA_LINUX" == success ]] + + windows-script-validation: + if: ${{ always() }} + needs: [windows-script-tests, java-windows] + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Require Windows execution jobs + env: + WINDOWS_SCRIPTS: ${{ needs.windows-script-tests.result }} + JAVA_WINDOWS: ${{ needs.java-windows.result }} + run: | + printf '%s\n' "windows-script-tests=$WINDOWS_SCRIPTS" "java-windows=$JAVA_WINDOWS" + [[ "$WINDOWS_SCRIPTS" == success && "$JAVA_WINDOWS" == success ]] diff --git a/CHANGELOG.md b/CHANGELOG.md index 092e9ddb..4edb5c77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable maintenance updates to this fork are documented here. ## Unreleased +- Split CI into independent repository, script, and Java jobs with grouped local entrypoints and a unified `ci-required` gate; retain the existing required-check names during migration. - Save KataGo rule preferences on confirmation and close the settings dialog after successful application. - Keep SGF rule-failure navigation local until explicit confirmed restore, preserve trial-return rules and positions, and resume both comparison engines after synchronization (#448). - Keep thread-source controls visible and locked to CFG for empty or unrecognized local targets, without explanatory warnings (#437). diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index b892b762..bf94ddba 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -68,6 +68,49 @@ powershell -ExecutionPolicy Bypass -File scripts/run_local_ci.ps1 -Profile All - 可只查看计划执行的步骤。`LIZZIE_PYTHON`、`LIZZIE_MAVEN`、`LIZZIE_BASH` 和 `LIZZIE_POWERSHELL` 可用于指定工具路径。 +按职责选择 `--group all|repository|scripts|java`(PowerShell 为 `-Group`),默认 +`all` 保持原完整调用。所有组都需要 Python 和 Git,并执行 `git diff --check`; +`--require-clean` / `-RequireClean` 保留运行前后的干净工作树检查。 + +| Group | Portable | Windows | 额外工具 | +| --- | --- | --- | --- | +| `repository` | 换行自测、换行、Markdown 链接 | 换行 | 无 | +| `scripts` | Python 辅助脚本、KataGo shell、Bash 语法 | JCEF、NVIDIA、RTX50 PowerShell 语法 | Portable 需 Bash;Windows 需 PowerShell | +| `java` | 原完整 Maven verify | 凭据专项,再执行原完整 Maven verify | Maven、JDK 21;不查找 Bash/PowerShell | + +例如仅运行无 Java 的仓库检查: + +```bash +bash scripts/run_local_ci.sh --profile portable --group repository --summary-dir target/local-ci/portable-repository +``` + +```powershell +pwsh -File scripts/run_local_ci.ps1 -Profile Windows -Group Scripts -SummaryDir target/local-ci/windows-scripts +``` + +`profile=all` 合并并去重两套检查,只执行一次 Windows 全量 verify 和凭据专项, +不表示跨 OS 验收。不要在同一 checkout 并行运行两个写入 `target` 的 Maven 调用; +需要并行时使用独立 worktree。 + +多次分组调用请使用不同 summary 目录;默认仍为 `target/local-ci/`。 +JSON/Markdown 摘要增加 `group`;非 Java 组不清理或读取既有 JUnit 报告, +Java/JUnit 状态为未执行。真实 Java 调用先清除旧 Surefire/Failsafe 报告,再收集本次结果。 +Dry-run 仅生成计划,不代表检查通过。 + +Actions 的 `ci.yml` 对所有 PR(包括仅文档改动)和 main push 执行五个独立 job: +`repository-checks`、`script-tests`、`windows-script-tests`、`java-linux`、`java-windows`。 +Windows 脚本 job 依次运行 `windows/repository` 与 `windows/scripts`,摘要独立上传。 +两平台 Java job 保留全量测试、`LoggingProviderSmokeIT`、shaded JAR 和 JaCoCo; +验证成功但 coverage artifact 缺失仍失败。各组失败时仍尝试上传本组摘要。 + +`ci-required` 直接要求五项全部成功。迁移期间 `build` 汇总两个 Portable 非 Java job +和 `java-linux`;`windows-script-validation` 汇总两个 Windows job。三个汇总器均拒绝 +失败、取消、跳过、缺失或未知结果。发布仍仅接受目标 SHA 的 `ci.yml` push 成功运行。 + +首个 PR 合并且对应 main push CI 成功后,请 wimi321 将 main required checks 替换为 +GitHub Actions 来源的 `ci-required`,其他保护设置不变。确认设置生效后,后续 PR +才删除两个旧汇总门禁;当前 PR 不需要管理员预先修改保护。 + 本地预检用于在推送前尽早发现问题,不能代替受保护分支上的干净 Windows 和 Ubuntu runner,也不能代替 macOS 签名、公证与多平台发布资产审计。 diff --git a/docs/SPECIALIZED_ACCEPTANCE.md b/docs/SPECIALIZED_ACCEPTANCE.md new file mode 100644 index 00000000..79ff8f60 --- /dev/null +++ b/docs/SPECIALIZED_ACCEPTANCE.md @@ -0,0 +1,99 @@ +# 专项验收契约 + +常规 CI、Windows 原生桌面、真实 GPU、完整打包分别给出结论。一次运行只证明其实际执行的场景、提交和环境;本页定义如何选择验收及记录证据,不是所有平台已经通过的声明。 + +## 按改动风险选择验收 + +| 层级 | 触发条件 | 运行条件与入口 | 通过含义 | +| --- | --- | --- | --- | +| 确定性 UI / 状态回归 | 分组、窄宽度重排、长文案、修复/启用资格与副作用变更 | JDK 21、Maven、可写的隔离工作目录;下述现有 JUnit 随两平台 Java gate 执行 | 被执行的布局与状态契约通过;fixture 仅证明受控输入下的行为 | +| Windows 原生桌面 | 窗口尺寸、主题、字体、DPI、滚动、焦点、按钮可达性等可见行为变更 | Windows 原生 JVM、交互桌面、实际产品主题、100% / 150% / 200% 显示缩放;启动指定 SHA 的 shaded JAR | 指定主题、缩放、窗口尺寸下实际观察的场景通过 | +| 真实 GPU | GPU 检测、安装、修复、显式启用、后端切换变更 | 对应 GPU/驱动、真实 KataGo/backend、权重与配置、必要的下载网络/代理、隔离可修复目录 | 实际组件操作、前台引擎身份与启动分析结果满足场景;纯布局变更不自动要求整套 GPU 流程 | +| 完整打包 / 发布 | 打包脚本、原生资源、运行时、启动器、安装/升级、签名、资产身份或上传变更 | 目标 OS/架构及平台 workflow 所需工具、真实资源、网络;签名/发布需要对应凭据和授权 | 选定真实资产的组装、审计、平台 smoke,以及实际执行的签名/发布步骤通过 | + +先列触发的层级和场景,再检查工具、平台、服务与权限。缺少前提时记录具体原因并完成独立可执行项。凭据只记录“可用/不可用/未检查”,不记录值。普通 PR 不新增 GPU runner;已有 headless 测试留在常规 CI,不另跑同一清单制造独立 UI 绿勾。 + +## 确定性覆盖与执行边界 + +下表按可观察契约引用已有测试;方法名用于定位,不是额外的 CI 测试清单。 + +| 目标契约 | 已有证据 | 边界 | +| --- | --- | --- | +| 分组内按钮可见、提示与动作不重叠 | [KataGoAccelerationLayoutTest](../src/test/java/featurecat/lizzie/gui/KataGoAccelerationLayoutTest.java):`maintenanceActionsWrapWithoutLeavingTheirGroup`、`hintAndActionsDoNotOverlapInNarrowBlock`、`experimentalSelectorAndButtonAreStacked` | 使用生产布局在 EDT 排列轻量 Swing 组件,检查边界和相对位置;不证明完整原生对话框的主题绘制 | +| 窄宽度重排及再次放大/缩小 | 同类:`narrowStatusRowUsesTheFullWidthInsteadOfA24PixelValueColumn`、`statusRowReturnsToColumnsAfterGrowing`、`growingAndShrinkingReflowsBothDirections`、`viewportWidthIsRespected` | 检查实际组件尺寸与重排,不是截图或 DPI 验收 | +| 文字完整、最后一行可见 | 同类:`allEightResourceBundlesKeepNarrowStatusAndHintsVisible`、`wrappedHeightIncludesSwingsCaretMarginAtLineBreakBoundaries`;[KataGoAutoSetupDialogLayoutTest](../src/test/java/featurecat/lizzie/gui/KataGoAutoSetupDialogLayoutTest.java):`localizedButtonWidthIncludesTheEntireThaiLabel`、`longLocalizedWeightActionsWrapWithoutClipping`、`wrappedStatusCanShrinkAgainAfterBackendSwitch` | 文本布局测试含 `modelToView2D` 的末行边界;放大字体是受控输入,不是 Windows 150%/200% 原生证据 | +| 窗口工作区适配、权重动作重排 | `KataGoAutoSetupDialogLayoutTest`:`dialogShrinksBelowItsDesktopMinimumAtHighDisplayScaling`、`dialogPlacementStaysInsidePositiveAndNegativeMonitorCoordinates`、`weightActionsStayInlineAtTheDefaultDialogWidth` | 几何计算与生产行布局;真实显示器工作区、系统缩放及窗口装饰另验 | +| 修复与启用资格分离 | [TensorRtAccelerationViewTest](../src/test/java/featurecat/lizzie/gui/TensorRtAccelerationViewTest.java):`readyComponentsWithInactiveProfileStayDistinguishable`、`incompleteWeightAndGtpDoNotBlockRepairAndAreListedForEnable`、`missingActivationItemsAreExposedOnTheAccessibleEnableDescription` | 验证组件就绪、profile 未启用和缺失项目的可观察状态;不以字符串键断言代替动作执行 | +| 修复不改 profile,只有显式启用写入 | [TensorRtComponentRepairTest](../src/test/java/featurecat/lizzie/util/TensorRtComponentRepairTest.java):`missingRuntimeEngineAndCompanionRepairToReadyWithoutChangingProfiles`、`onlyEnableTensorRtWritesTheProfileAndMissingItemsBlockActivation` | 真实修复/启用方法使用临时配置与受控资源 fixture,检查文件和 profile 前后状态;模拟 Windows/GPU 输入,不启动真实 DirectML/TensorRT 引擎 | + +上述四类均可在 headless 下运行。两平台 [ci.yml](../.github/workflows/ci.yml) 的 `java-linux` / `java-windows` 通过 [run_local_ci.py](../scripts/run_local_ci.py) 执行完整 Maven `verify`,均显式指定 `-Djava.awt.headless=true`、`-DskipTests=false`,没有为这些类另设排除。它们使用 Surefire 默认识别的 `*Test` 命名,留在 [pom.xml](../pom.xml) 的常规测试生命周期;shaded JAR、`LoggingProviderSmokeIT` 和 JaCoCo 仍由原 Java gate 负责。 + +需要显示器的反例:[WholeGameAnalysisDialogLayoutTest](../src/test/java/featurecat/lizzie/gui/WholeGameAnalysisDialogLayoutTest.java) 的窗口/缩放/输入动作测试先调用 `assumeFalse(GraphicsEnvironment.isHeadless())`。因此两平台全量 headless gate 不执行这些窗口断言。其他带 assumptions 的测试也以该次 JUnit XML 中的实际 skip 为准;不得固定全仓库 skips 数量,或把 skipped 算作通过。 + +### 聚焦复核 + +在仓库根目录运行(WSL 先 `unset DISPLAY`;PowerShell 不执行这一行): + +```bash +mvn -B -Dfmt.skip=true -Djava.awt.headless=true -Dtest=KataGoAccelerationLayoutTest,KataGoAutoSetupDialogLayoutTest,TensorRtAccelerationViewTest,TensorRtComponentRepairTest,WholeGameAnalysisDialogLayoutTest test +``` + +若需要隔离应用工作目录,增加 `-Dlizzie.work.dir=<独立目录>`。同一 checkout 不并行运行写入 `target` 的 Maven 调用。完整平台入口和分组使用方式见 [开发指南](DEVELOPMENT.md),不要用上述聚焦命令替换完整 Java gate。 + +记录:目标 SHA、源码是否有未提交修改、OS/JDK/Maven、headless 值、命令、开始/结束时间、退出码、逐类 tests/failures/errors/skipped、实际跳过的测试及 assumption、Surefire XML/日志路径;hosted 运行另附 run URL、event、head SHA、job 与 artifact。dry-run 只记录命令规划成功。 + +覆盖盘点只有两种完成结果:目标确定性契约已有覆盖且无确认缺口;或所有确认缺口已补成行为回归、进入两平台 Java gate 并取得测试/hosted 证据。若缺口需要修改业务实现,记录可达行为与阻塞,先确认业务修复范围,不能仅列候选便关闭补缺任务。 + +## Windows 原生桌面 + +先固定完整 SHA,在独立 checkout 中按[开发指南的本地构建步骤](DEVELOPMENT.md#本地构建)生成候选,保存源码 SHA、构建命令、工具版本与构建日志。记录 shaded JAR 路径、启动时间、PID、JVM 命令、窗口标题和隔离配置目录,使截图能对应到实际构建与进程。维护机器的 candidate 工具可用时,保留其 `candidate.json`、`run.json`;其他机器记录同等证据即可。启动 shaded JAR;普通 JAR 没有 `Main-Class`。 + +每个实际产品主题分别列 100% / 150% / 200% 档位,记录主题名称、浅/深模式和版本。按改动风险选择默认宽度、窄宽度、放大再缩小、长文案、分组及滚动场景;同一场景逐档观察: + +1. 打开受影响页面,确认 TensorRT 主动作、维护动作、实验后端组可见且彼此分开。 +2. 缩窄再放大,检查动作重排、状态/提示不重叠、末行及按钮文字完整;必要时滚动到各组并记录位置。 +3. 用鼠标与键盘触发受影响动作,观察禁用态、焦点和动作语义。涉及真实安装/切换时同时执行下一节 GPU 场景。 + +桌面记录每行必须填写以下字段: + +| 场景 | 构建 SHA / artifact / run.json | Windows / JVM / 主题 / 语言 | 缩放与 DPI | 窗口尺寸 | 用户动作 | 预期 | 实际 | 截图 | 状态 / 原因 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 一个场景及一个缩放档位 | 完整 SHA、shaded JAR 路径及进程身份 | 具体版本与实际主题 | 100%/150%/200% 及实际 DPI | 外框和内容区宽×高,注明逻辑/物理像素 | 按顺序记录 | 可判定的结果 | 实际观察,不复制预期 | 可访问的图片路径或链接 | PASS / FAIL / BLOCKED / NOT RUN | + +未执行档位逐项写 `NOT RUN`;缺少桌面/设备写 `BLOCKED`;决定放弃某档位仍写 `NOT RUN` 并附决定者、日期与原因,不计 PASS。WSLg、Xvfb、headless、手工放大 Swing 字体均不替代 Windows 原生实际主题与缩放证据。 + +## 真实 GPU + +运行前记录:应用完整 SHA/候选身份、Windows 版本、GPU 型号及设备、驱动版本、KataGo 与 backend/runtime 版本、模型/配置、初始前台引擎、网络/代理可用状态和隔离目录。硬件不匹配则对应场景保持 `BLOCKED`;不操作日常用户的引擎文件或凭据。 + +对于 TensorRT 修复/启用边界,至少保留以下完整序列: + +1. 在真实 DirectML 前台开始分析,保存实际命令、进程/引擎身份和一段分析输出作为基线。 +2. 在隔离目录制造场景所需的 TensorRT 缺失组件,记录缺失项;从应用点击修复 TensorRT,保存开始、阶段、结束或错误日志。 +3. 修复完成后确认组件状态正确,前台仍是原 DirectML 引擎,profile 未被暗中切换,并再次取得 DirectML 分析输出。修复成功不是启用成功。 +4. 用户显式点击启用 TensorRT;记录此次动作和实际切换结果,保存真实 TensorRT 启动日志(可辨认 backend/GPU/模型)以及对应分析结果。只有写入 profile 或出现成功提示不能证明启用完成。 +5. 其他受影响检测、安装、取消/失败、后端切换场景按改动风险单列;保留失败现场后再恢复隔离环境。 + +GPU 记录每行包含:场景 ID、SHA/构建身份、硬件/驱动/KataGo/backend/runtime、初始状态、带时间的动作序列、预期、实际、前后引擎身份、真实启动与分析日志路径及时间段、截图、PASS/FAIL/BLOCKED/NOT RUN 和原因。日志发布前脱敏,保留判断引擎身份与分析结果所需信息。PowerShell parser、资源下载 fixture、GPU 检测 fixture 均只证明其受控范围,不证明硬件链路。 + +## 完整打包与发布 + +真实入口与参数以目标提交的 workflow 为准: + +| 平台 | Workflow | 组装与后续检查 | +| --- | --- | --- | +| Windows | [build-windows-release.yml](../.github/workflows/build-windows-release.yml) | [package_windows_exe.sh](../scripts/package_windows_exe.sh)、[validate_release_assets.sh](../scripts/validate_release_assets.sh);目标安装/启动风险另用 [windows_smoke_test.ps1](../scripts/windows_smoke_test.ps1)、[windows_upgrade_smoke.ps1](../scripts/windows_upgrade_smoke.ps1) | +| Linux | [build-linux-release.yml](../.github/workflows/build-linux-release.yml) | [package_release.sh](../scripts/package_release.sh)、资产内容审计与目标桌面启动 | +| macOS | [arm64](../.github/workflows/build-macos-arm64-release.yml) / [amd64](../.github/workflows/build-macos-amd64-release.yml) | [package_macos_dmg.sh](../scripts/package_macos_dmg.sh)、[sign_macos_release_with_retry.sh](../scripts/sign_macos_release_with_retry.sh)、资产审计和原生安装/启动 | + +先检查目标平台工具和 workflow 声明的下载/签名/发布条件。平台 smoke 使用可丢弃的配置;执行前检查其配置清理选项。签名是否执行、为何跳过、验证结果按 [macOS 签名说明](MACOS_SIGNING.md)单独记录,不用打包成功推断签名成功。 + +记录:源码 SHA、tag/版本/渠道、OS/架构、workflow run URL/event/job、构建工具、真实资源来源及版本、资产文件名及身份校验、内容审计日志、安装/portable/DMG 启动与升级场景、签名/公证状态、上传目标与结果、预期/实际、证据路径、逐项状态。组装、签名、上传和实机启动分别给结论,未执行步骤明确标注。 + +脚本 fixture 单测通过不代表真实安装包、资源闭包、签名、发布身份或上传通过。源码 shaded JAR 验收也不证明 portable 启动器/安装器/升级路径。需要不发布的 packaging smoke 时先明确资产、平台与不写 Release 的范围;不能为文档或常规 CI 验收暗中触发具有发布写权限的 workflow。 + +## 历史证据与结论 + +[已验证平台](TESTED_PLATFORMS.md)记录历史环境;#453 等既有验收只适用于原记录的提交、构建和环境。缺少完整 SHA 或环境信息的旧记录保留其原始事实并注明不足,不补猜身份。新提交不自动继承历史 PASS;复用时必须指出原证据身份、覆盖边界和为何仍适用,未覆盖的新风险另验。 + +每次结论分别列:确定性测试、原生桌面、GPU、打包/发布的实际状态与证据。审查通过与验收完成是两道门;仅勾选有证据的验收项。专项契约/覆盖盘点完成,不等于全部硬件和发布资产已经验收。 diff --git a/docs/TESTED_PLATFORMS.md b/docs/TESTED_PLATFORMS.md index 0130cce3..07b3c22f 100644 --- a/docs/TESTED_PLATFORMS.md +++ b/docs/TESTED_PLATFORMS.md @@ -2,6 +2,8 @@ 这份文档记录当前主发布资产的已知验证状态。 +专项变更的触发条件、运行要求和证据字段见[专项验收契约](SPECIALIZED_ACCEPTANCE.md)。本页历史记录只适用于其已记录的提交和环境,新提交需单独判断覆盖范围。 + 目的不是假装“所有平台都完全测过”,而是明确告诉用户: - 哪些已经实机验证过 diff --git a/scripts/run_local_ci.ps1 b/scripts/run_local_ci.ps1 index 0b29feec..43bc82ba 100644 --- a/scripts/run_local_ci.ps1 +++ b/scripts/run_local_ci.ps1 @@ -1,6 +1,8 @@ param( [ValidateSet('Windows', 'Portable', 'All')] [string]$Profile = 'All', + [ValidateSet('All', 'Repository', 'Scripts', 'Java')] + [string]$Group = 'All', [switch]$DryRun, [switch]$RequireClean, [string]$SummaryDir = 'target/local-ci' @@ -25,7 +27,7 @@ function Test-Java21([string]$JavaHome) { return $version -match 'version "21(?:\.|\")' } -if (-not (Test-Java21 $env:JAVA_HOME)) { +if ($Group -in @('All', 'Java') -and -not $DryRun -and -not (Test-Java21 $env:JAVA_HOME)) { $jdkCandidates = @( Get-ChildItem -Path (Join-Path $repoRoot '.tools\jdk-21*') -Directory -ErrorAction SilentlyContinue Get-ChildItem -Path (Join-Path $env:SystemDrive 'jdk21\jdk-21*') -Directory -ErrorAction SilentlyContinue @@ -50,31 +52,10 @@ if (-not $python) { throw 'Python 3 was not found. Set LIZZIE_PYTHON or add python to PATH.' } -if (-not $env:LIZZIE_MAVEN) { - $maven = Get-Command mvn.cmd, mvn -ErrorAction SilentlyContinue | Select-Object -First 1 - if (-not $maven) { - $maven = Get-ChildItem -Path (Join-Path $repoRoot '.tools\apache-maven-*\bin\mvn.cmd'), 'C:\tools\apache-maven-*\bin\mvn.cmd' -File -ErrorAction SilentlyContinue | Sort-Object FullName | Select-Object -Last 1 - } - if ($maven) { $env:LIZZIE_MAVEN = $maven.FullName } -} - -if (-not $env:LIZZIE_BASH) { - $gitBash = Join-Path $env:ProgramFiles 'Git\bin\bash.exe' - if (Test-Path -LiteralPath $gitBash -PathType Leaf) { - $env:LIZZIE_BASH = $gitBash - } else { - $bash = Get-Command bash.exe, bash -ErrorAction SilentlyContinue | - Where-Object { $_.Source -notmatch '\\Windows\\(?:System32|Sysnative)\\bash\.exe$' } | - Select-Object -First 1 - } - if (-not $env:LIZZIE_BASH -and $bash) { - $env:LIZZIE_BASH = $bash.Source - } -} - $arguments = @( (Join-Path $PSScriptRoot 'run_local_ci.py'), '--profile', $Profile.ToLowerInvariant(), + '--group', $Group.ToLowerInvariant(), '--summary-dir', $SummaryDir ) if ($DryRun) { $arguments += '--dry-run' } diff --git a/scripts/run_local_ci.py b/scripts/run_local_ci.py index 3d7c69bf..6d68488e 100644 --- a/scripts/run_local_ci.py +++ b/scripts/run_local_ci.py @@ -104,6 +104,7 @@ class Step: name: str command: tuple[str, ...] env: dict[str, str] | None = None + group: str = "scripts" @dataclass @@ -241,7 +242,7 @@ def windows_steps(maven: str, powershell: str) -> list[Step]: "if($errors.Count -gt 0){$errors|Format-List|Out-String|Write-Error; exit 1}" ) return [ - Step("Verify repository line endings", (python, "scripts/check_line_endings.py")), + Step("Verify repository line endings", (python, "scripts/check_line_endings.py"), group="repository"), Step("Verify bundled JCEF logic", (python, "scripts/test_prepare_bundled_jcef.py")), Step( "Verify bundled NVIDIA runtime packaging", @@ -261,6 +262,7 @@ def windows_steps(maven: str, powershell: str) -> list[Step]: "-Dtest=PlatformCredentialStoreTest,RemoteComputeConfigTest,MigratingCredentialStoreTest", "test", ), + group="java", ), Step( "Run full Windows verification gate", @@ -276,6 +278,7 @@ def windows_steps(maven: str, powershell: str) -> list[Step]: "-Dfailsafe.failIfNoSpecifiedTests=true", "verify", ), + group="java", ), ] @@ -283,9 +286,9 @@ def windows_steps(maven: str, powershell: str) -> list[Step]: def portable_steps(maven: str, bash: str) -> list[Step]: python = sys.executable steps = [ - Step("Test line-ending checker", (python, "scripts/test_check_line_endings.py")), - Step("Verify repository line endings", (python, "scripts/check_line_endings.py")), - Step("Verify local Markdown links", (python, "scripts/check_markdown_links.py")), + Step("Test line-ending checker", (python, "scripts/test_check_line_endings.py"), group="repository"), + Step("Verify repository line endings", (python, "scripts/check_line_endings.py"), group="repository"), + Step("Verify local Markdown links", (python, "scripts/check_markdown_links.py"), group="repository"), Step("Compile release helper Python", (python, "-m", "py_compile", *PY_COMPILE_FILES)), ] steps.extend( @@ -326,6 +329,7 @@ def portable_steps(maven: str, bash: str) -> list[Step]: "-Dfailsafe.failIfNoSpecifiedTests=true", "verify", ), + group="java", ) ) return steps @@ -348,26 +352,25 @@ def deduplicate_steps(steps: Iterable[Step]) -> list[Step]: return result -def build_steps(profile: str, maven: str, bash: str | None, powershell: str | None) -> list[Step]: +def build_steps( + profile: str, maven: str, bash: str | None, powershell: str | None, + group: str = "all", +) -> list[Step]: + if group in {"all", "scripts"}: + if profile in {"portable", "all"} and bash is None: + raise RuntimeError("The selected scripts require bash.") + if profile in {"windows", "all"} and powershell is None: + raise RuntimeError("The selected scripts require PowerShell.") if profile == "windows": - if powershell is None: - raise RuntimeError("The Windows profile requires PowerShell.") - return windows_steps(maven, powershell) - if profile == "portable": - if bash is None: - raise RuntimeError("The portable profile requires bash.") - return portable_steps(maven, bash) - if bash is None or powershell is None: - raise RuntimeError("The all profile requires both PowerShell and bash.") - combined = windows_steps(maven, powershell) + portable_steps(maven, bash) - # A local Windows run cannot become an Ubuntu run by invoking Maven twice. - # Keep the Windows verification gate and run every portable helper around it. - combined = [ - step - for step in combined - if step.name != "Run full portable verification gate" - ] - return deduplicate_steps(combined) + steps = windows_steps(maven, powershell or "pwsh") + elif profile == "portable": + steps = portable_steps(maven, bash or "bash") + else: + steps = windows_steps(maven, powershell or "pwsh") + portable_steps(maven, bash or "bash") + # A local Windows run cannot become an Ubuntu run by invoking Maven twice. + steps = [step for step in steps if step.name != "Run full portable verification gate"] + steps = deduplicate_steps(steps) + return [step for step in steps if group == "all" or step.group == group] def git_output(*args: str) -> str: @@ -442,6 +445,8 @@ def write_summary( results: list[StepResult], junit: JunitSummary, success: bool, + group: str, + junit_executed: bool, ) -> None: output_dir.mkdir(parents=True, exist_ok=True) status = overall_result(success, results) @@ -449,6 +454,7 @@ def write_summary( "schema_version": 1, "result": status, "profile": profile, + "group": group, "dry_run": dry_run, "started_at": started_at, "duration_seconds": round(duration_seconds, 3), @@ -457,6 +463,7 @@ def write_summary( "java": java_details, "git_sha": git_output("rev-parse", "HEAD"), "junit": asdict(junit), + "junit_status": "collected" if junit_executed else "not executed", "steps": [asdict(result) for result in results], } (output_dir / "local-ci-summary.json").write_text( @@ -467,12 +474,15 @@ def write_summary( "", f"- Result: **{status}**", f"- Profile: `{profile}`", + f"- Group: `{group}`", + f"- Java: {java_details}", f"- Git SHA: `{payload['git_sha']}`", f"- Duration: `{duration_seconds:.1f}s`", ( "- JUnit: " f"{junit.tests} tests, {junit.failures} failures, " f"{junit.errors} errors, {junit.skipped} skipped" + if junit_executed else "- JUnit: not executed" ), "", "| Step | Result | Seconds |", @@ -490,30 +500,34 @@ def run(args: argparse.Namespace) -> int: started_at = datetime.now(timezone.utc).isoformat() output_dir = (REPO_ROOT / args.summary_dir).resolve() results: list[StepResult] = [] - java_details = "not checked (dry run)" + java_selected = args.group in {"all", "java"} + scripts_selected = args.group in {"all", "scripts"} + java_details = "not executed" + junit_executed = False try: if args.require_clean: require_clean_checkout() - if not args.dry_run: + if java_selected and not args.dry_run: reset_junit_reports() - maven = args.maven or ("mvn" if args.dry_run else resolve_maven()) + junit_executed = True + maven = (args.maven or ("mvn" if args.dry_run else resolve_maven())) if java_selected else "mvn" bash = None powershell = None - if args.profile in {"portable", "all"}: + if scripts_selected and args.profile in {"portable", "all"}: bash = args.bash or ("bash" if args.dry_run else resolve_bash()) - if args.profile in {"windows", "all"}: + if scripts_selected and args.profile in {"windows", "all"}: powershell = args.powershell or ( "pwsh" if args.dry_run else resolve_powershell() ) - if not args.dry_run: + if java_selected and not args.dry_run: major, java_details = java_major_version(maven) if major != 21: raise RuntimeError( f"Local CI requires JDK 21, but Maven is using Java {major}. " "Set JAVA_HOME to a JDK 21 installation." ) - steps = build_steps(args.profile, maven, bash, powershell) + steps = build_steps(args.profile, maven, bash, powershell, args.group) steps.append(Step("Verify working-tree diff", ("git", "diff", "--check"))) for index, step in enumerate(steps, start=1): @@ -559,7 +573,7 @@ def run(args: argparse.Namespace) -> int: return_code = 1 try: - junit = JunitSummary() if args.dry_run else collect_junit_summary() + junit = collect_junit_summary() if junit_executed else JunitSummary() write_summary( output_dir, args.profile, @@ -570,12 +584,14 @@ def run(args: argparse.Namespace) -> int: results, junit, return_code == 0, + args.group, + junit_executed, ) print(f"Local CI report: {output_dir}", flush=True) print( "JUnit: " f"{junit.tests} tests, {junit.failures} failures, " - f"{junit.errors} errors, {junit.skipped} skipped", + f"{junit.errors} errors, {junit.skipped} skipped" if junit_executed else "JUnit: not executed", flush=True, ) except (OSError, RuntimeError) as error: @@ -589,6 +605,9 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: parser.add_argument( "--profile", choices=("windows", "portable", "all"), default="all" ) + parser.add_argument( + "--group", choices=("all", "repository", "scripts", "java"), default="all" + ) parser.add_argument("--dry-run", action="store_true") parser.add_argument("--require-clean", action="store_true") parser.add_argument("--summary-dir", default="target/local-ci") diff --git a/scripts/run_local_ci.sh b/scripts/run_local_ci.sh index 2219a5b3..07dc6acf 100644 --- a/scripts/run_local_ci.sh +++ b/scripts/run_local_ci.sh @@ -11,6 +11,10 @@ while [[ $# -gt 0 ]]; do profile="${2:?missing profile}" shift 2 ;; + --group) + extra_args+=("$1" "${2:?missing group}") + shift 2 + ;; --dry-run|--require-clean) extra_args+=("$1") shift @@ -35,15 +39,6 @@ if [[ -z "$python_bin" ]]; then exit 1 fi -if [[ -z "${LIZZIE_MAVEN:-}" ]]; then - if command -v mvn >/dev/null 2>&1; then - export LIZZIE_MAVEN="$(command -v mvn)" - else - candidate="$(find "$repo_root/.tools" -path '*/apache-maven-*/bin/mvn' -type f 2>/dev/null | sort | tail -n 1)" - [[ -n "$candidate" ]] && export LIZZIE_MAVEN="$candidate" - fi -fi - cd "$repo_root" if [[ ${#extra_args[@]} -gt 0 ]]; then exec "$python_bin" scripts/run_local_ci.py --profile "$profile" "${extra_args[@]}" diff --git a/scripts/test_publish_release_request.py b/scripts/test_publish_release_request.py index 385fa0ac..d3c738f7 100644 --- a/scripts/test_publish_release_request.py +++ b/scripts/test_publish_release_request.py @@ -933,17 +933,6 @@ def test_every_platform_serializes_by_workflow_and_release_tag(self) -> None: ) self.assertIn("cancel-in-progress: true", workflow) - def test_ci_runs_publisher_tests_as_an_importable_module(self) -> None: - workflow = ( - SCRIPT_PATH.parents[1] / ".github" / "workflows" / "ci.yml" - ).read_text(encoding="utf-8") - local_ci = SCRIPT_PATH.with_name("run_local_ci.py").read_text(encoding="utf-8") - - self.assertIn("scripts/run_local_ci.py --profile portable", workflow) - self.assertIn('"scripts.test_publish_release_request"', local_ci) - self.assertIn('"-m", "unittest", module', local_ci) - self.assertNotIn('Step(f"Run {module}", (python, module))', local_ci) - @unittest.skipIf(os.name == "nt", "behavior test runs with native bash in CI") def test_macos_signing_retries_transient_failures(self) -> None: bash = shutil.which("bash") diff --git a/scripts/test_run_local_ci.py b/scripts/test_run_local_ci.py index 8ff3430a..98ab614e 100644 --- a/scripts/test_run_local_ci.py +++ b/scripts/test_run_local_ci.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 from pathlib import Path +import json import os import shutil import subprocess @@ -11,31 +12,19 @@ class RunLocalCiTest(unittest.TestCase): - def test_shell_wrapper_accepts_profile_without_optional_arguments(self): + def test_shell_wrapper_default_group_keeps_complete_all_profile_plan(self): repository = Path(__file__).resolve().parents[1] bash = os.environ.get("LIZZIE_BASH") or shutil.which("bash") if not bash: self.skipTest("bash is required to exercise the POSIX local-CI wrapper") with tempfile.TemporaryDirectory() as temporary: temporary_path = Path(temporary) - fake_python = temporary_path / "python" - captured_arguments = temporary_path / "arguments.txt" - fake_python.write_text( - '#!/usr/bin/env bash\nprintf "%s\\n" "$@" > "$LIZZIE_WRAPPER_ARGS"\n', - encoding="utf-8", - ) - fake_python.chmod(0o755) environment = os.environ.copy() - environment.update( - { - "LIZZIE_PYTHON": str(fake_python), - "LIZZIE_MAVEN": "/usr/bin/true", - "LIZZIE_WRAPPER_ARGS": str(captured_arguments), - } - ) + environment["LIZZIE_PYTHON"] = run_local_ci.sys.executable completed = subprocess.run( - [bash, "scripts/run_local_ci.sh", "--profile", "all"], + [bash, "scripts/run_local_ci.sh", "--profile", "all", "--dry-run", + "--summary-dir", str(temporary_path)], cwd=repository, env=environment, capture_output=True, @@ -45,10 +34,56 @@ def test_shell_wrapper_accepts_profile_without_optional_arguments(self): ) self.assertEqual(0, completed.returncode, completed.stderr) - self.assertEqual( - ["scripts/run_local_ci.py", "--profile", "all"], - captured_arguments.read_text(encoding="utf-8").splitlines(), + summary = json.loads((temporary_path / "local-ci-summary.json").read_text(encoding="utf-8")) + commands = [step["command"] for step in summary["steps"]] + self.assertEqual(1, sum(command[-1] == "verify" for command in commands)) + self.assertTrue(any(command[-1] == "test" for command in commands)) + self.assertEqual("all", summary["group"]) + self.assertEqual("PASS", summary["result"]) + + def test_repository_group_preserves_stale_junit_without_java_or_shells(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + scripts = root / "scripts" + scripts.mkdir() + for name in ("run_local_ci.py", "check_line_endings.py"): + shutil.copy2(run_local_ci.REPO_ROOT / "scripts" / name, scripts / name) + subprocess.run(["git", "init", "-q", str(root)], check=True) + subprocess.run( + ["git", "-C", str(root), "-c", "user.name=CI Test", "-c", + "user.email=ci@example.invalid", "commit", "--allow-empty", "-qm", "fixture"], + check=True, + ) + stale = root / "target" / "surefire-reports" / "TEST-stale.xml" + stale.parent.mkdir(parents=True) + stale.write_bytes(b"deliberately invalid old report") + environment = os.environ.copy() + for name in ("LIZZIE_MAVEN", "LIZZIE_BASH", "LIZZIE_POWERSHELL", "JAVA_HOME"): + environment[name] = str(root / "unavailable") + completed = subprocess.run( + [run_local_ci.sys.executable, str(scripts / "run_local_ci.py"), + "--profile", "windows", "--group", "repository"], + cwd=root, env=environment, capture_output=True, text=True, timeout=30, + ) + self.assertEqual(0, completed.returncode, completed.stdout + completed.stderr) + self.assertEqual(b"deliberately invalid old report", stale.read_bytes()) + summary = json.loads((root / "target/local-ci/local-ci-summary.json").read_text(encoding="utf-8")) + self.assertEqual("PASS", summary["result"]) + self.assertEqual("not executed", summary["junit_status"]) + self.assertEqual(0, summary["junit"]["tests"]) + self.assertEqual("not executed", summary["java"]) + + # A real Java selection must discard the old report even if Maven setup fails. + completed = subprocess.run( + [run_local_ci.sys.executable, str(scripts / "run_local_ci.py"), + "--profile", "windows", "--group", "java"], + cwd=root, env=environment, capture_output=True, text=True, timeout=30, ) + self.assertNotEqual(0, completed.returncode) + self.assertFalse(stale.exists()) + summary = json.loads((root / "target/local-ci/local-ci-summary.json").read_text(encoding="utf-8")) + self.assertEqual("FAIL", summary["result"]) + self.assertEqual(0, summary["junit"]["tests"]) def test_all_profile_keeps_one_maven_verification(self): steps = run_local_ci.build_steps("all", "mvn", "bash", "pwsh") diff --git a/scripts/test_validate_windows_release_assets.py b/scripts/test_validate_windows_release_assets.py index 3f833e09..f57fccde 100644 --- a/scripts/test_validate_windows_release_assets.py +++ b/scripts/test_validate_windows_release_assets.py @@ -291,16 +291,6 @@ def test_package_and_publisher_require_exact_two_tensorrt_volumes(self) -> None: self.assertIn(".7z.001", package_script) self.assertIn(".7z.002", package_script) - def test_windows_ci_runs_jcef_tests_with_isolated_work_directories(self) -> None: - workflow = (self.root / ".github/workflows/ci.yml").read_text(encoding="utf-8") - local_ci = (self.root / "scripts/run_local_ci.py").read_text(encoding="utf-8") - self.assertIn("scripts/run_local_ci.py --profile windows", workflow) - self.assertIn('"scripts/test_prepare_bundled_jcef.py"', local_ci) - self.assertIn('"scripts.test_validate_windows_release_assets"', local_ci) - self.assertIn("tempfile.gettempdir()", local_ci) - self.assertIn("temp / 'credential-tests'", local_ci) - self.assertIn("temp / 'full-tests'", local_ci) - if __name__ == "__main__": unittest.main()