From 13261fdc843523b150032770d9e0c8be4b4ba057 Mon Sep 17 00:00:00 2001 From: Alfred Date: Sun, 11 Oct 2026 00:04:38 +0800 Subject: [PATCH 1/4] ci: gate current regressions and validate built Web artifacts --- .github/workflows/build.yml | 199 ++++++---- ...346\237\245\344\272\244\346\216\245-v2.md" | 340 ++++++++++++++++++ docs/ci/RESULT.md | 45 +++ tests/certificate/requirements.txt | 1 + tests/certificate/run_host.sh | 18 +- tests/certificate/test_service_stop.sh | 46 ++- tests/ci/test_policy.py | 129 +++++++ tests/ci/test_web_resource_failure.cjs | 17 + tests/prompts/xterm-offline.test.cjs | 16 +- tests/release/test_release.py | 49 ++- tools/ci/check_gate.py | 41 +++ tools/ci/check_profile.py | 61 ++++ tools/ci/check_release_state.py | 76 ++++ tools/ci/check_web_artifacts.py | 57 +++ 14 files changed, 975 insertions(+), 120 deletions(-) create mode 100644 "docs/ci/CI\346\224\271\350\277\233\350\256\276\350\256\241\344\270\216\345\256\241\346\237\245\344\272\244\346\216\245-v2.md" create mode 100644 docs/ci/RESULT.md create mode 100644 tests/certificate/requirements.txt create mode 100644 tests/ci/test_policy.py create mode 100644 tests/ci/test_web_resource_failure.cjs create mode 100644 tools/ci/check_gate.py create mode 100644 tools/ci/check_profile.py create mode 100644 tools/ci/check_release_state.py create mode 100644 tools/ci/check_web_artifacts.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0374bc7..73ae214 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -5,15 +5,8 @@ on: branches: [main, develop] tags: - 'v*' # 版本标签触发 - paths-ignore: - - '**.md' - - 'docs/**' - - '.github/copilot-instructions.md' pull_request: branches: [main, develop] - paths-ignore: - - '**.md' - - 'docs/**' release: types: [published] # 手动发布 Release 时也自动构建并上传 bin workflow_dispatch: # 允许手动触发 @@ -30,7 +23,28 @@ env: TARGET: esp32s3 jobs: + changes: + name: Check scope + runs-on: ubuntu-latest + outputs: + run_full: ${{ steps.scope.outputs.run_full }} + publish_release: ${{ steps.scope.outputs.publish_release }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-node@v4 + with: + node-version: '22' + - run: python3 -m unittest discover -s tests/ci -p 'test_*.py' + - run: python3 tests/release/test_release.py + - name: Select checks and release eligibility + id: scope + run: python3 tools/ci/check_profile.py + web-tests: + needs: changes + if: needs.changes.outputs.run_full == 'true' name: WebUI regression tests runs-on: ubuntu-latest steps: @@ -42,18 +56,27 @@ jobs: cache-dependency-path: tests/prompts/package-lock.json - run: npm ci --prefix tests/prompts - run: npm test --prefix tests/prompts - - run: python3 tests/release/test_release.py - run: python3 tests/release/test_web_minify.py - name: Install Chrome for actual-page regressions working-directory: tests/prompts run: npx playwright install --with-deps chrome - run: npm run test:browser --prefix tests/prompts + - name: Offline xterm and vendor regressions + run: npm run test:xterm --prefix tests/prompts + env: + OFFLINE_RESULTS: ${{ runner.temp }}/xterm-source runtime-tests: + needs: changes + if: needs.changes.outputs.run_full == 'true' name: Runtime and fan regression tests runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + - run: python3 -m pip install -r tests/certificate/requirements.txt - name: Fetch matching ESP-IDF cJSON and MbedTLS sources for host tests run: | export IDF_PATH="$RUNNER_TEMP/runtime-idf" @@ -66,6 +89,11 @@ jobs: node-version: '22' - run: bash tests/runtime/run_all.sh - run: bash tests/ws_subscriptions/run_operation_adapter.sh + - name: Current WebSocket and lifecycle regressions + run: | + bash tests/ws_subscriptions/run_host.sh + bash tests/ws_subscriptions/run_reviewer.sh + bash tests/ws_subscriptions/run_f1_f2.sh - run: bash tests/fan_monitor/run_host.sh - name: Fan output and decay regressions run: | @@ -73,22 +101,20 @@ jobs: node tests/fan_review/test_ui.cjs "$RUNNER_TEMP/fan-review.jsonl" python3 tests/fan_decay/run_host.py --csv "$RUNNER_TEMP/fan-decay.csv" node tests/fan_decay/test_ui.cjs - - name: CSR subject signing and parsing regression + - name: Certificate and current service-stop regressions run: | cmake -S "$IDF_PATH/components/mbedtls/mbedtls" -B "$RUNNER_TEMP/mbedtls" -DENABLE_TESTING=OFF -DENABLE_PROGRAMS=OFF cmake --build "$RUNNER_TEMP/mbedtls" --parallel 2 - cc -std=c11 -g -fsanitize=address,undefined -Itests/certificate/stubs -Icomponents/ts_cert/include \ - -I"$IDF_PATH/components/mbedtls/mbedtls/include" \ - tests/certificate/test_subject.c components/ts_cert/src/ts_cert_subject.c \ - "$RUNNER_TEMP/mbedtls/library/libmbedx509.a" "$RUNNER_TEMP/mbedtls/library/libmbedcrypto.a" \ - -lm -o "$RUNNER_TEMP/csr-subject" - "$RUNNER_TEMP/csr-subject" + CERT_TEST_PYTHON=python3 CERT_TEST_BUILD="$RUNNER_TEMP/certificate-tests" CERT_MBEDTLS_BUILD="$RUNNER_TEMP/mbedtls" bash tests/certificate/run_host.sh build: + needs: changes + if: needs.changes.outputs.run_full == 'true' name: Build ESP32-S3 Firmware runs-on: ubuntu-latest outputs: version: ${{ steps.version.outputs.version }} + commit: ${{ steps.version.outputs.commit }} steps: - name: Checkout repository @@ -159,17 +185,34 @@ jobs: - name: Get version info id: version run: | - if [ -f build/project_description.json ]; then - VERSION=$(grep -o '"project_version":[^,]*' build/project_description.json | cut -d'"' -f4) - fi - echo "version=${VERSION:-0.0.0}" >> $GITHUB_OUTPUT - echo "📦 Build version: ${VERSION:-0.0.0}" + python3 - <<'PYTHON' + import json, os, subprocess + from pathlib import Path + version = json.loads(Path('build/project_description.json').read_text())['project_version'] + if not version: + raise ValueError('Build version is empty') + commit = subprocess.check_output(['git', 'rev-parse', 'HEAD'], text=True).strip() + with open(os.environ['GITHUB_OUTPUT'], 'a') as output: + output.write(f'version={version}\ncommit={commit}\n') + print('Build version:', version, 'commit:', commit) + PYTHON - name: Print build size run: | source ~/esp/esp-idf/export.sh idf.py size + - name: Verify this build flashes its Web image + run: python3 tools/ci/check_web_artifacts.py --build-dir build + + - name: Upload built Web resources + uses: actions/upload-artifact@v4 + with: + name: web-assets-${{ github.sha }} + path: build/esp-idf/ts_webui/web_optimized/ + if-no-files-found: error + retention-days: 7 + - name: Upload firmware artifacts uses: actions/upload-artifact@v4 with: @@ -192,73 +235,89 @@ jobs: build/config/sdkconfig.h retention-days: 7 - # 编译成功后自动创建或更新 Release:tag 推送、main 推送或手动发布 Release - release: - name: Create or Update Release - needs: [build, web-tests, runtime-tests] - if: github.repository == 'RMinte-AI/TianshanOS' && (github.event_name == 'release' || startsWith(github.ref, 'refs/tags/v') || (github.ref == 'refs/heads/main' && github.event_name == 'push')) + web-artifacts: + name: Built WebUI smoke test + needs: [changes, build] + if: needs.changes.outputs.run_full == 'true' runs-on: ubuntu-latest - permissions: - contents: write # 创建 Release 需要写权限 - steps: - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 with: - ref: ${{ github.sha }} - - name: Download artifacts - uses: actions/download-artifact@v4 + node-version: '22' + cache: npm + cache-dependency-path: tests/prompts/package-lock.json + - run: npm ci --prefix tests/prompts + - name: Set emitted Web directory + run: echo "PROJECT_WEB_ROOT=$RUNNER_TEMP/built-web" >> "$GITHUB_ENV" + - uses: actions/download-artifact@v4 with: - pattern: tianshanos-firmware-* - path: firmware - - - name: Resolve release tag - id: release_tag - run: | - if [ "${{ github.event_name }}" = "release" ]; then - TAG="${{ github.event.release.tag_name }}" - elif [[ "${GITHUB_REF}" == refs/tags/* ]]; then - TAG="${GITHUB_REF_NAME}" - else - VERSION="${{ needs.build.outputs.version }}" - TAG="v${VERSION%%+*}" - fi - echo "tag=$TAG" >> "$GITHUB_OUTPUT" - echo "Release tag: $TAG" + name: web-assets-${{ github.sha }} + path: ${{ runner.temp }}/built-web + - run: python3 tools/ci/check_web_artifacts.py --web-root "$PROJECT_WEB_ROOT" + - name: Install Chrome + working-directory: tests/prompts + run: npx playwright install --with-deps chrome + - name: Load the emitted WebUI and verify resource failures block it + run: node --test tests/prompts/xterm-offline.test.cjs tests/ci/test_web_resource_failure.cjs + env: + OFFLINE_RESULTS: ${{ runner.temp }}/xterm-built - - name: Validate Chinese release notes and build version + ci-gate: + name: CI Gate + needs: [changes, web-tests, runtime-tests, build, web-artifacts] + if: always() + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Decide using the selected results + if: ${{ always() && !cancelled() }} + run: python3 tools/ci/check_gate.py env: - RELEASE_TAG: ${{ steps.release_tag.outputs.tag }} - BUILD_VERSION: ${{ needs.build.outputs.version }} + NEEDS_JSON: ${{ toJSON(needs) }} + RUN_CANCELLED: 'false' + - name: Reject cancellation at the final decision + if: ${{ always() && cancelled() }} run: | - python3 tools/check_release.py "$RELEASE_TAG" "$BUILD_VERSION" + echo "CI Gate failed: workflow cancelled at the final decision" >&2 + exit 1 - - name: Check if release already exists (main branch) - if: github.ref == 'refs/heads/main' - id: check + # main keeps automatic version-based publication; PR/dispatch/fork never publish. + release: + name: Create or Update Release + needs: [changes, build, ci-gate] + if: ${{ !cancelled() && needs.changes.outputs.publish_release == 'true' && needs.build.result == 'success' && needs['ci-gate'].result == 'success' }} + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.build.outputs.commit }} + fetch-depth: 0 + - uses: actions/download-artifact@v4 + with: + name: tianshanos-firmware-${{ needs.build.outputs.version }} + path: firmware + - name: Validate version, release state and this build's tag + id: preflight + run: python3 tools/ci/check_release_state.py env: GH_TOKEN: ${{ github.token }} - GH_REPO: ${{ github.repository }} - run: | - TAG="${{ steps.release_tag.outputs.tag }}" - if gh release view "$TAG" 2>/dev/null; then - echo "skip=true" >> $GITHUB_OUTPUT - echo "⏭️ Release $TAG already exists, skipping" - else - echo "skip=false" >> $GITHUB_OUTPUT - fi - - - name: Create Release - if: github.event_name == 'release' || startsWith(github.ref, 'refs/tags/') || (github.ref == 'refs/heads/main' && steps.check.outputs.skip != 'true') + BUILD_VERSION: ${{ needs.build.outputs.version }} + BUILD_COMMIT: ${{ needs.build.outputs.commit }} + - name: Create or upload the release assets + if: steps.preflight.outputs.skip_release == 'false' uses: softprops/action-gh-release@v2 with: - tag_name: ${{ steps.release_tag.outputs.tag }} - name: ${{ steps.release_tag.outputs.tag }} + tag_name: ${{ steps.preflight.outputs.tag }} + name: ${{ steps.preflight.outputs.tag }} files: | firmware/**/*.bin firmware/**/flasher_args.json fail_on_unmatched_files: true - target_commitish: ${{ github.sha }} - body_path: docs/releases/${{ steps.release_tag.outputs.tag }}.md + target_commitish: ${{ needs.build.outputs.commit }} + body_path: docs/releases/${{ steps.preflight.outputs.tag }}.md generate_release_notes: false env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git "a/docs/ci/CI\346\224\271\350\277\233\350\256\276\350\256\241\344\270\216\345\256\241\346\237\245\344\272\244\346\216\245-v2.md" "b/docs/ci/CI\346\224\271\350\277\233\350\256\276\350\256\241\344\270\216\345\256\241\346\237\245\344\272\244\346\216\245-v2.md" new file mode 100644 index 0000000..75472ee --- /dev/null +++ "b/docs/ci/CI\346\224\271\350\277\233\350\256\276\350\256\241\344\270\216\345\256\241\346\237\245\344\272\244\346\216\245-v2.md" @@ -0,0 +1,340 @@ +# GitHub Actions 改进设计与审查交接 v2 + +日期:2026-10-10(Asia/Shanghai) +状态:完整实施设计,按用户明确要求修订;本轮仅修改设计,不执行代码、工作流或 GitHub 设置变更。 + +## 修订记录与本版效力 + +- v1及上一轮审查对发布方向的判断有误,现明确撤回。本版以用户已经确定的“同意合并到上游main后,按项目版本自动发布”为准,不再把它作为待选择的新方向。 +- main push一律完整检查;只有明确的非发布纯文档PR可轻量检查。changes统一提供范围与发布资格,release直接依赖changes/build/ci-gate。 +- main已有Release正常跳过;需要新发布才检查标签与构建提交是否一致。保留版本标签和release:published上传入口,手动运行维持只构建。 +- Gate纳入最终判定时可观察到的运行取消状态,不承诺追溯修改已完成的成功结论。 +- 同步已有发布测试的main=True预期与新依赖接线;补gzip缺失、资源失败阻断和current-only共用准备细节。 +- 接受不强制PR分支更新的初版取舍,区分合并关卡和main发布前的完整检查。 + +v1保留为历史记录。本文件为完整全文,未变部分已带入;后续审查和实施只以v2为准,不需拼接v1。 + +## 1. 目标、位置与授权 + +目标是把已有测试变成可靠的合并关卡,补上构建后Web成品的验证,并让main自动发布使用本次完整CI成功的工件。保留项目现有发布用途,不重设计发布流程,也不通过增加测试数量来宣称覆盖所有硬件风险。 + +- 分支:`massif/ci-gates-plan`。 +- 独立目录:`/Users/massif/.codex/worktrees/ci-gates-plan/TianshanOS`。 +- 起点:上游 `RMinte-AI/TianshanOS` 的 `main`,`50093e1720b2e76878ed9a9f989125f35e5e0629`。 +- 用户本轮授权:在原规划分支修订完整设计。没有授权现在改代码、工作流、测试或GitHub设置。 +- 本轮只新增此文档;不提交、推送、创建 PR、修改主目录源码或其他工作目录。 +- 审查通过并由用户确认实施后,先完成代码和CI改进PR;请求用户合并前说明当时版本、对应Release状态及预计发布/跳过结果。用户批准合并到上游main后,符合条件的自动发布就是预期行为。合并后完整CI通过,再经用户授权配置GitHub合并规则;不擅自替用户合并,也不为验收人为创建真实发布。 + +上游当时仍有 PR #47 未合并,最新头为 `bdc987d93d6050de2602528f2750ede89225c276`,标题已扩展为“修复自动化删除保护并实现规则配置包导入”。此方案不合入、修改或重审那条分支。实施前须重新核查上游 main、PR #47 的最终状态和本地差异;如果它已合并,将其新增测试入口保留,再更新本方案对应清单。不能用当前旧版 package.json/run_all.sh 覆盖届时的新版本。 + +## 2. 原始问题与证据边界 + +v1已只读核查工作流、测试入口、Web打包流程及上游设置;本轮复核了发布条件、现有发布测试、gzip生成约定、xterm失败处理、服务停止脚本和最新main/PR #47状态。未运行测试,未宣称新入口已在Ubuntu通过。表内主分支规则是v1核查快照,设置前须重新读取。 + +| 当前事实 | 含义 | +|---|---| +| 有 ESP32-S3 完整构建、SPIFFS 打包、真实 Chrome 页面回归、ASan/UBSan 主机测试 | 现有 CI 已有实质防护,保留这些能力 | +| 主分支 API 返回 `protected=false`,适用于 main 的 rules 列表为空 | 检查目前没有成为强制合并条件 | +| npm 有 `test:xterm`,工作流未调用 | 真实离线终端及 vendor 完整性回归尚未形成 CI 门槛 | +| CSR 仅编译 test_subject;证书材料/生命周期/协调器等已有套件未全部接入 | 复用现有测试,而非新写一套模拟算法 | +| WebSocket 只接入 run_operation_adapter.sh | 订阅管理、事件注销、排空停止等现有回归未全部接入 | +| 浏览器主要服务源码;test_web_minify.py 仅验证两个 CSS 片段 | 源码通过不能直接证明压缩后页面能加载 | +| 工作流对 docs/** 和 Markdown 在入口做 paths-ignore | 如果直接把它设为必需检查,纯文档 PR 可能没有检查结果而无法合并 | +| Release job包含upstream main push、v标签、release:published入口 | 这是用户要求保留的项目行为;要衔接本次完整CI/Gate,并区分main同版本跳过与published入口上传 | + +本设计不把主机模拟等同 ESP32 调度、真实 SSH、Flash 断电恢复或实际热控验收。正式设备发布仍需针对修改内容做真机验收。 + +## 3. 实施范围和明确不做的事 + +包含: + +1. 始终返回状态的 CI 汇总门槛,解决文档 PR 与失败/跳过的语义。 +2. 接入现有 xterm、证书和 WebSocket 当前生产行为回归。 +3. 验证真实构建出的 Web 目录,复用已有浏览器用例,不重新运行全部浏览器矩阵。 +4. 保留上游main按项目版本自动发布、版本标签与release:published上传入口;普通PR、develop及fork无上游发布资格,workflow_dispatch仍只构建。 +5. 流程稳定后配置 main 的 PR/CI 规则及可撤销的上线步骤。 + +不包含:固件业务修改、新架构、所有历史反例重放、多平台构建矩阵、整仓格式改造、全局 -Werror、任意性能/内存阈值、生产凭据或真机操作、SDK升级、为验收升级版本/打发布标签/调用真实发布、擅自合并现有PR。用户批准合并后符合条件的自动发布属于预期流程,不是被禁止的验收实验。不给“CI 通过等于产品没有缺陷”的承诺。 + +## 4. 工作流结构与合并判定 + +保留现有一个 build.yml,初版不拆多套互相调用的工作流。新增少量有明确职责的任务,不增加跨仓库服务。 + +| Job ID / 显示名 | 职责 | +|---|---| +| changes / Check scope | 唯一范围/发布资格来源,输出run_full、publish_release;main、标签、published、手动运行强制完整,只有非发布纯文档PR可轻量;运行轻量策略和现有release校验测试 | +| web-tests / WebUI regression tests | 原静态/交互/浏览器回归,增加源码 xterm 检查 | +| runtime-tests / Runtime and fan regression tests | 原运行时/风扇/适配器测试,接入当前证书与 WebSocket 回归 | +| build / Build ESP32-S3 Firmware | 维持当前目标与SDK,构建app/www;上传前核对www.bin生成及同次flasher_args映射,再上传固件及真实Web成品目录 | +| web-artifacts / Built WebUI smoke test | 下载本次build生成目录,核对JS/CSS/HTML应有gzip及内容,复用离线终端场景,非预期资源失败必须阻断 | +| ci-gate / CI Gate | 始终评估前述结果及最终判定的运行取消状态;唯一建议设为必需检查的名称 | +| release / Create or Update Release | 直接needs changes、build、ci-gate;读取changes唯一资格并要求本次Gate/build成功;按现有main/标签/published用途使用同次工件 | + +```mermaid +flowchart TD + S[Check scope] --> W[WebUI regression tests] + S --> R[Runtime and fan regression tests] + S --> B[Build ESP32-S3 Firmware] + B --> A[Built WebUI smoke test] + S --> G[CI Gate] + W --> G + R --> G + B --> G + A --> G + S --> P[Release: main / v标签 / published资格] + G --> P + B --> P +``` + +### 4.1 事件与改动范围 + +删除工作流入口的 paths-ignore,让 PR 总能获得 CI Gate。将“是否跑重任务”放在任务内部,不用整个工作流缺席来表达通过。 + +普通文档白名单初版仅为:根目录 README.md/README_EN.md,及 docs/ 下的 Markdown。只要改动包含其他路径就跑完整检查。重命名同时考虑旧路径和新路径,不能把代码搬进 docs 后误判为文档。删文件也要被识别。 + +- docs 下的 vendor.json、归档、脚本、HTML等不属于普通文档,按代码变更处理;它们可能是测试输入。 +- 顶层品牌图片初版仍按完整检查处理;不增加一套图片用途推断规则。 +- release 文案变更即使属于普通文档,仍执行现有 test_release.py;它确实校验当前 version.txt 与当前说明。 +- PR 混合改代码和文档,跑完整检查。 +- 上游main push一律完整检查,包括纯文档;标签、release:published、手动运行也强制完整。初版所有已支持非PR运行均完整,develop/fork仍没有上游发布资格。 +- 只有PR需要按白名单判定轻量路径;无法确认纯文档的PR(如无可判定改动)跑完整检查。main不按diff或远程Release状态优化,不复用其他运行的工件。 +- 读取事件、取 diff、解析结果等真正失败,应使 changes 失败;不能把错误当作“没有改文件”。 + +changes使用仓库内小脚本处理GITHUB_EVENT_PATH与PR的git diff机器输出;不请求额外GitHub写权限,不引入新的过滤action。相关checkout获取所需历史,PR以事件base/head SHA判定;GitHub构建检验其提供的PR合并候选。非PR由事件直接确定完整检查,无须另一套push diff分类。 + +publish_release是“允许进入发布路径”的资格,不是“已经发布成功”:只有上游main push、合法v标签push、release:published有资格。PR、develop、fork和workflow_dispatch均无资格。changes不远程查询Release来决定是否构建;即使main版本已经发布,run_full和publish_release仍为true,后面的main预检再记录正常跳过。 + +release必须直接声明 `needs: [changes, build, ci-gate]`,读取 `needs.changes.outputs.publish_release`,并要求build和Gate结果均为success;不能通过间接依赖读取changes输出,也不能另复制一套事件分类条件。下游main/标签/published的差异只是已获资格后的资产处理方式,不重新判断发布资格。 + +### 4.2 CI Gate 真值规则 + +CI Gate 使用 `if: always()`,needs 包含 changes 和全部四个重任务;不依赖 shell 最后一个命令偶然返回0。 + +- changes 失败、取消、输出缺失或值不合法:失败。 +- run_full=true:web-tests、runtime-tests、build、web-artifacts 必须全部 success;任一 failure/cancelled/skipped 都失败。 +- run_full=false:仅允许上述四项为设计预期的 skipped;轻量检查必须成功,且 publish_release 必须为 false。 +- build 失败导致 web-artifacts 自动 skipped,整体仍失败。 +- 最终判定须读取平台的运行取消状态(如将最终步骤的cancelled()作为实际Gate输入/条件);即使依赖都成功但当前运行已取消,也不能给出通过。不能只看needs里的cancelled结果。 + +取消边界:Gate最终判定能观察到的取消必须拒绝;已经完成的成功检查不会因后来的取消被本方案追溯撤销。不增加轮询、API状态回写或撤销系统。正常取消导致任务被终止时,不能靠另一个默认成功步骤补出绿灯。 + +明确选择跳过和任务异常未执行是两种状态。GitHub设置只要求CI Gate,但此汇总不能弱化它对各重任务的判断。`[skip ci]` 不作为文档优化办法,因它可能使必需状态不出现。 + +## 5. 现有回归的接入方式 + +### 5.1 WebUI 与 xterm + +保留 npm test 和 test:browser,增加现有 `npm run test:xterm --prefix tests/prompts`。Chrome 已安装,复用现有依赖和锁文件。 + +只新增执行入口,不新增账号/语言/视口矩阵,不重复完整 WebUI 套件。xterm-offline 的输出改由 OFFLINE_RESULTS 指向 RUNNER_TEMP,避免测试改写受版本管理的修复证据。 + +### 5.2 证书当前回归 + +复用 runtime-tests 已准备的匹配 SDK cJSON/MbedTLS。MbedTLS只构建一次,随后以明确环境参数调用现有 tests/certificate/run_host.sh;现有独立 CSR subject 编译步骤由该入口覆盖,避免重复执行。 + +接入 subject、time_retry、material、UI、lifecycle、coordinator、API、time_cancel,以及服务停止的当前实现检查;不改已有断言,不删除测试用例。 + +必要适配: + +- 使用 workflow 的 Python,显式 CERT_TEST_PYTHON=python3、CERT_TEST_BUILD/CERT_MBEDTLS_BUILD 指向 RUNNER_TEMP;不依赖 /Users/massif 的默认路径。 +- 声明生成临时证书所需 cryptography 依赖。当前已验证本机版本为44.0.3,建议作为初版固定版本;Ubuntu实际通过后才承认其兼容,不升级生产依赖。 +- 保留合成证书和模拟时间;不访问生产密钥或真实设备,不把测试时间改成系统实时时间。 +- 主机代码确有 strdup/pthread/usleep 等需求;按已有 run_ssh.sh 的兼容方式启用所需声明。不能照搬只开放POSIX 2008的参数而再次隐藏 usleep,也不把这些参数加到固件编译。 +- test_service_stop.sh增加窄的--current-only模式,默认历史证据行为保留,常规run_host用current-only。必须先做共用准备:临时目录、当前源码读取及当前 `service_restart.inc` 生成,均不依赖历史基线分支。历史git show、旧stop文件生成、旧二进制编译及预期失败,仅在历史模式执行。随后两种模式都生成当前 `service_stop.inc`、编译当前二进制并执行停止和重启断言。不能因为跳过基线而漏掉restart输入文件;不删除当前断言。 + +若现有断言暴露真实业务缺陷,不降低断言、不吞错误;作为独立阻断项交用户判断是否另开修复。不能借 CI 适配修改证书业务。 + +### 5.3 WebSocket 当前回归 + +继续执行 run_operation_adapter.sh,并接入已有: + +- tests/ws_subscriptions/run_host.sh:manager、event、HTTP/WebUI生命周期、WS初始化。 +- tests/ws_subscriptions/run_reviewer.sh:当前排空、会话/代次、停止失败传播及核心回滚等回归。 +- tests/ws_subscriptions/run_f1_f2.sh:默认当前实现模式,入队/发送、迟到结算和竞争等已有断言。 + +不运行 run_baseline.sh、run_reviewer_baseline.sh 或 --baseline;不再重放旧漏洞来充当常规生产通过条件。检查这些入口是否重复启动同一子套件,重复者只执行一次;不同不变量不能按文件名相近而删掉。 + +仅适配当前脚本的 SDK/临时目录/编译器接口和真实 Linux 编译问题,保留 ASan/UBSan。不能先宣称这些尚未接入的脚本已经在 Ubuntu 跑通。 + +## 6. 成品 Web 检查 + +检验对象是本次idf.py build真正生成的 `build/esp-idf/ts_webui/web_optimized`,不能从源码另压缩一个目录替代。 + +build在优化及SPIFFS成功后、上传任何工件之前,直接核对:本次 `www.bin` 已生成、在同次 `flasher_args.json` 的flash_files映射中、映射指向实际本次文件。将失败归到build,不把这些文件交给只下载Web目录的任务后猜测。 + +随后上传真实Web目录为短期CI工件,名称含本次github.sha;保持现有固件工件上传。同次run的Web目录与固件共同进入Gate,不能下载上次运行的目录。CI artifact不是Release。 + +web-artifacts下载本次目录,做三件事: + +1. 用Node22解析生成的完整JS,包括app/api/router/terminal/语言包及vendor;不是只查原始源码。 +2. 按现有minify_web.py约定,从原始资源清单枚举所有JS、CSS、HTML,为每个资源要求对应 `.gz` 存在,再解压逐字比较。缺一个或全部没生成都失败;gzip异常或孤立的.gz缺原文件也失败。不能只遍历已经存在的.gz。确认index、语言包及终端vendor这些现有场景输入存在。 +3. 设置PROJECT_WEB_ROOT指向该目录,复用现有xterm-offline.test.cjs的中英文场景。保留真实终端交互、离线限制、退出重进与现有视口变化,不新增浏览器、账号、视口或语言矩阵。 + +资源失败必须成为结果:路由读取本地静态文件失败、履行/请求失败均记录具体URL与原因;非预期失败最终断言为零,必要的缺失也可立即使场景失败。不能catch后只打印日志或abort,然后仍因终端主体初始化成功而通过。维持外部请求限制;已有明确设计的mock请求/关闭清理须区分,不能放宽成吞掉任意失败。既有pageerror和交互断言保留,输出写RUNNER_TEMP。 + +新增负例限定为改变放行结果的样本:删除一个应有gzip后必须失败、内容不匹配/生成JS损坏仍失败、缺一个本地资源时失败确实进入判定。使用隔离目录,正常实际构建目录应通过,不扩压缩率、性能阈值或文件名组合测试。 + +此检查证明生成目录中的页面可加载,不宣称验证设备HTTP服务器、实际SPIFFS挂载或真实网络。不新增SPIFFS解包重建或硬件刷机。 + +## 7. 发布策略:保留main自动发布及既有入口 + +发布策略已由用户确定。本轮仅将它接到同次完整CI/Gate并修正必要预检,不重设计发布用途。 + +“带版本号”指项目现有version.txt、构建版本及发布说明机制:不检查提交消息有没有版本号,不要求本次diff修改version.txt。上游main当前版本合法且未发布、其他条件满足,就具备自动发布资格。用户批准合并后无需再手工打标签或点击发布。 + +### 7.1 最终事件—检查—发布行为表 + +| 事件/仓库 | run_full与检查 | 发布资格及实际行为 | +|---|---|---| +| 上游main push,包括纯文档 | true,全部重任务和CI Gate | 有资格;本次完整CI/Gate成功后,按项目版本自动创建尚未存在的Release;已有则记录正常跳过,不上传新资产 | +| 上游纯文档PR,全部路径在白名单 | false,轻量检查和CI Gate,重任务按设计skipped | 无资格,不发布 | +| 上游源码/混合PR | true,全部检查和Gate | 无资格,不发布 | +| 上游develop push | true,全部检查和Gate | 无资格,不发布 | +| 上游v*版本标签push | true,全部检查和Gate | 保留现有按该标签发布/上传入口,满足版本与提交一致性后使用本次工件 | +| 上游release:published | true,全部检查和Gate | 保留为已经发布的Release构建上传工件的用途,不因该Release存在而跳过上传 | +| workflow_dispatch | true,全部检查和Gate | 仅构建,无发布资格;不新增手动发布模式或发布开关 | +| fork运行 | PR可按同一白名单选择,其余已支持事件完整检查 | 始终无上游发布资格 | + +Gate/build任一失败、取消或未执行时,即使事件有资格也不得发布。main完整检查不受文件类型、远程Release查询结果或跨run缓存工件驱动的跳过逻辑影响。 + +### 7.2 统一接线和同次工件 + +changes是唯一发布资格来源;release直接needs changes、build、ci-gate,读取它的资格输出,要求本次build/Gate均success。普通PR不出现可运行的写权限发布任务,其余任务默认contents:read;只有发布任务授予contents:write,资格限定上游RMinte-AI/TianshanOS。 + +main目标标签沿用 `v{项目语义版本}`;v标签push取事件标签;published取github.event.release.tag_name。保持tools/check_release.py对version.txt、构建版本、标签和详细中文发布说明的校验。不改变项目版本命名,构建哈希/时间后缀不是新语义版本。 + +下载本次build的固件工件,记录实际构建提交;不能把main当前HEAD或其他运行的工件替代本次提交。版本标签/published入口核对标签解析后的commit和实际构建commit一致,保持现有上传用途。 + +### 7.3 main自动发布的顺序 + +在同次完整CI成功后: + +1. 校验当前项目版本、实际构建版本及对应发布说明,得到目标版本标签。 +2. 查询目标Release,明确区分存在、确实404不存在与权限/网络/服务异常。异常明确失败,不把所有非零结果当作“可以新建”。 +3. 如果该版本Release已存在,直接正常跳过创建及上传,日志必须写“该版本已存在,本次未发布新资产”。不能写成本次提交已经发布。此处不再用旧标签与新main提交不同来阻止正常跳过。 +4. 仅确认需要新发布后,检查目标标签。标签不存在时,允许沿用现有机制,为本次实际构建提交创建对应标签与Release,不要求标签事先存在。 +5. 标签已存在且解析后的commit等于本次构建commit时,可发布;若不同则明确失败。不得移动标签,不得向不匹配标签上传本次资产。标签查询/解析的权限、网络或服务错误同样不是“不存在”。 +6. 使用同次工件执行发布。失败据实报告,不把失败或跳过表述为已发布。 + +### 7.4 标签与published路径的区别 + +main“已有Release则跳过”不能作为所有入口的通用规则。v标签push保留现有创建/上传语义;release:published本来就是在已有Release上上传本次构建工件,不能因查询200而失去作用。两条入口仍先要求版本、提交、Gate与工件一致,遇到查询异常失败,不把鉴权失败当成不存在。 + +既有Release上传入口保持现有资产用途,不因本轮审核扩出新的覆盖/迁移策略。资格判定只在changes中定义;获资格后的跳过/创建/上传预检是处理不同现有用途,不另起第二套发布分类。 + +本轮规划以及将来的实施验收,不人为升级version、不打发布标签、不调用真实发布来做实验。但用户批准CI改进PR合并到main后,条件满足的自动发布是预期行为,不能再禁用它。 + +## 8. GitHub合并规则:代码稳定后再配置 + +这部分属于仓库设置,不进入git diff。推荐在CI改进PR合并、合并后普通push检查成功后执行,且操作前由用户确认具体设置及管理员约束。 + +建议值: + +- main要求通过PR合并。 +- 必需检查仅为精确名称 CI Gate,来源绑定 GitHub Actions;内部保证四项所选检查通过。 +- main禁止强推、禁止删除,建议管理员也遵守同一规则。 +- 不引入强制人工审批人数、签名、线性历史、merge queue或部署要求;它们不是本次CI目标。 +- 不将Release设为必需检查,因为普通PR按设计不运行它。 + +暂不强制分支更新,不增加merge queue或多平台矩阵。PR检查只证明它当时检查的合并候选;较早通过的PR不一定验证了后来合入main的其他改动。接受这个初版取舍,不能称为完全消除了合并风险。 + +上游main随后执行完整检查,用于阻止失败版本自动发布;它不能阻止所有问题提交先进入main,也不自动回滚已经合入的提交。未来引入队列才需要另评估merge_group,本轮不扩大治理。 + +操作前读取并保存适用的组织/仓库规则与保护设置。只修改本次获批字段,避免覆盖当时其他人新加的规则。操作后用API回读、做非破坏性门槛验收;不为了测试阻断而实际向main推坏提交。规则入口若不允许精准配置或权限发生变化,报告实际限制,不绕过。 + +## 9. 拟改文件 + +| 文件 | 改动性质 | +|---|---| +| .github/workflows/build.yml | always Gate、main强制完整、已有套件和成品任务;保留main/v标签/published入口,release直接needs changes/build/ci-gate并读取唯一资格 | +| tools/ci/check_profile.py | 小型事件/改动判定,输出run_full/publish_release;错误不能输出通过 | +| tools/ci/check_gate.py | 实际汇总决策,校验scope输出、必需任务及最终可观察到的运行取消状态 | +| tools/ci/check_web_artifacts.py | 应有gzip存在及内容、生成JS;www与同次flasher映射由build上传前调用相应核对,浏览器复用已有用例 | +| 发布预检代码(可为tools/ci/check_release_state.py) | 仅获资格后执行实际Release查询/状态和标签提交检查;区分main跳过与published上传,不重复事件资格分类 | +| tests/release/test_release.py | 保留main=True和现有版本/说明校验,改旧依赖硬编码以验证Gate及唯一资格的实际接线;不开发通用表达式解释器 | +| tests/ci/test_policy.py | 直接调用真实scope/Gate/发布预检代码;隔离响应验证main创建/跳过、fork/PR拒绝、Gate失败和新发布标签不匹配等必要场景 | +| tests/certificate/run_host.sh | Ubuntu路径、依赖/声明、临时目录和current-only调用 | +| tests/certificate/test_service_stop.sh | 共用准备与历史验证正确分离,current-only仍生成service_restart.inc和当前stop文件并执行有效断言 | +| tests/certificate/requirements.txt | 主机临时证书生成依赖固定版本 | +| tests/prompts/package.json | 若需统一入口,显式保留现有及届时PR新增测试;不改锁文件除非依赖确有变化 | +| 现有WS脚本及tests/prompts/xterm-offline.test.cjs | 必要Linux/目录适配;xterm明确记录静态读取/请求失败并使非预期失败阻断,保留现有交互和离线断言 | +| docs/ci/下的方案和实施记录、相关测试README | 写明入口、规则、命令、已验证边界及回退方法 | + +不会新增固件C文件,不改业务实现、设备SDK配置或主目录源码。上述路径是设计边界,不要求制造无用文件;能在现有入口清楚表达时可省去冗余包装,但所有放行判定须可审查、可测试。 + +## 10. 验收、顺序与停止条件 + +实施顺序:重新核对最新main和PR #47并保留新增入口 → 现有回归及必要Ubuntu适配 → 实际成品检查 → scope/Gate/保留现有发布入口的接线 → 定向验证与CI改进PR → 告知当前版本和合并发布预期 → 用户决定合并 → main完整检查 → 再经用户授权配置GitHub规则。 + +只做能改变判定的验收: + +| 必须覆盖的情况 | 预期 | +|---|---| +| 源码/混合PR | 原回归、新接入套件与实际成品检查执行,全部成功Gate才通过,无发布资格 | +| 纯README/docs Markdown PR | 轻量检查和Gate有结果,重项预期skipped,publish_release=false | +| docs中vendor JSON/脚本、工作流改动、代码删除/重命名到docs | 不漏判为普通文档,完整检查 | +| 上游main源码或纯文档push | run_full=true,具备发布资格;不要求版本出现在提交消息或diff里 | +| 应执行任务failure/cancelled/意外skipped,scope失败/无输出/非法输出 | Gate拒绝 | +| 依赖都成功,但最终判定的运行取消状态为true | Gate拒绝;不测试追溯撤销已成功结论的不存在能力 | +| build失败使artifact跳过 | Gate失败,不能发布 | +| main版本合法、Release不存在、标签不存在 | 隔离响应中允许为本次构建提交自动创建标签/Release | +| main版本合法、Release已存在、旧标签指向较早提交 | 正常跳过且日志为“该版本已存在,本次未发布新资产”,不得误报发布或标签冲突 | +| 确认需要main新发布后,标签已存在且commit不同 | 拒绝;不移动、不挂错工件 | +| PR、develop、fork、workflow_dispatch | 没有上游发布资格,手动运行只构建 | +| 发布资格true但Gate/build不成功 | release不执行;不是把main资格预期改成false | +| 上游v标签或release:published、版本/提交一致 | 保留本次工件上传用途;published不会因已有Release而通用skip | +| Release/标签查询为真正不存在与权限/网络/服务异常 | 正确区分;查询异常失败 | +| 生成JS损坏、gzip内容不匹配、删除一个应有gzip | 成品检查失败;正常同次目录通过 | +| 本地静态读取失败或非预期请求失败 | 测试结果失败,不只是打印/abort;原交互、离线、退出重进断言继续执行 | +| 证书current-only | 共用准备产出restart和当前stop文件;当前停止/重启断言执行,不依赖历史坏版本 | + +发布测试继续保留main允许进入路径的True预期。tests/release/test_release.py改掉旧 `needs: [build, web-tests, runtime-tests]` 精确字符串要求,结构性核对release直接依赖changes/build/ci-gate、读changes唯一资格、要求Gate/build成功,以及同次tag/说明/工件引用。scope和预检策略通过真实判定函数测试;不再为复杂接线开发通用GitHub表达式解释器,也不另写一套预期算法。 + +现有version、build版本、tag、中文说明的真实校验保留。工作流语法及实际连线须核对,不能只凭YAML可读取宣称Actions表达式有效。GitHub真实PR验证正常完整路径及Gate;新套件以Ubuntu结果验收,本机通过不替代。 + +成品负例只用隔离目录。发布状态与标签预检用模拟响应验证,不创建真实Release。不人为升级version、打发布标签、修改生产凭据、时钟、卡或固件;合并后按当前版本自动发布的资格不会因此被关闭。 + +如需真实验证纯文档Gate,可在用户授权后用fork临时分支/PR;不向上游main推坏样本。既有无关测试不为更保险重复扩跑;不扩浏览器、账号、视口、语言或OS矩阵。 + +停止条件:规定场景充分验证、最新PR头与结果一致、无新失败原因后停止。不增加取消轮询、状态回写、跨run工件复用、远程Release驱动的构建跳过或新手动发布功能。 + +记录既有约3分钟级作业及新增成本,检查重复/异常开销;不设未经验证的硬性性能阈值,不为凑时长删除有效断言。 + +## 11. 合并上线与回退 + +CI改进PR先保持未合并,保存最终SHA和验证结果。请求用户合并前,重新读取:当前version.txt、实际构建版本、对应Release是否存在,以及相关tag的状态,明确预计结果。 + +- 版本已发布:预计合并后完整检查成功,main发布路径正常跳过,不新增/覆盖资产。 +- 版本尚未发布且其他条件满足:预计合并后完整CI/Gate成功自动发布该版本;不存在标签可按既有机制创建,已有不匹配标签会在确认需要新发布后阻断。 +- 读取失败:说明实际错误,不能猜测“已发布”或“不存在”。不为避开发布偷偷修改版本或开关。 + +本轮复核基线version为0.6.1,v0.6.1 Release存在;若合并时仍相同,预计自动发布路径跳过新资产。这只是当前快照,必须在请求合并前重新核查。 + +用户批准合并到main后,main一律完整检查;符合条件的自动发布就是预期行为。若CI失败,阻止发布并报告,但不声称因此撤销了main上的提交。不会为了做验收实验额外创建Release。 + +GitHub规则仍后置:main检查成功、Gate名称与文档路径验收有效后,保存当时旧设置,经用户授权只改变获批字段。不要先配置一个会卡住修复PR的门槛。 + +回退:代码通过单独revert PR恢复,GitHub仅恢复本次改变字段,不删别人规则;Gate故障时是否临时移除required check由用户决定,修复验证后恢复。保留main自动发布是已经确定的发布要求,不能以本轮回退为由禁用它;回退前同样说明当时版本及预计发布/跳过结果,不恢复v1的错误发布方向。 + +不擅自合并、强推、删分支或人为打发布标签。合并授权与验收实验有明确区别;用户批准的正常main自动发布无需额外手动操作。 + +## 12. 给 GPT6 Pro 的审查要求 + +请只读审查本完整v2与所列源码,回复通过或具体阻断项;不实施、不提交、不更改GitHub设置。发布方向已由用户确定,v1及上一轮审查的相反判断撤回,不再把禁止main自动发布作为改进目标。 + +重点核对: + +1. 是否只补有证据的CI缺口,保留现有有效断言,不把历史坏版本重放塞入常规CI。 +2. main、标签、published、手动运行是否完整;纯文档PR能否获得Gate,代码/测试输入/重命名不被漏判。 +3. changes是否是唯一资格来源;release是否直接needs changes/build/ci-gate,读唯一输出,并要求同次Gate/build成功。 +4. Gate是否拒绝分类错误、非法/缺失输出、失败、取消和意外跳过;是否纳入最终可观察的运行取消,并明确没有追溯撤销承诺。 +5. main自动发布是否使用现有项目版本,而非提交消息或version diff;已有Release先正常跳过,新发布才做tag提交检查;允许main为同次构建创建缺失标签,不移动冲突标签。 +6. v标签与release:published是否保留既有上传用途,published不被通用“已有则跳过”破坏;workflow_dispatch只构建,fork/PR/develop无上游发布资格。 +7. test_release.py是否保留main=True及版本/说明规则,改旧依赖接线要求;策略是否执行真实判定代码而非第二套算法/通用表达式解释器。 +8. 成品是否来自同次真实构建;JS/CSS/HTML应有gzip缺失能否失败,资源失败是否确实阻断,build上传前是否核对www与flasher映射。 +9. current-only共用准备是否仍生成service_restart.inc与当前stop文件,现有停止/重启断言及ASan/UBSan是否保留;Ubuntu适配是否只处理必要接口问题。 +10. 是否承认较早PR检查和后续main改动的边界,GitHub设置仍后置且不加额外治理要求;合并前版本/Release预期与回退沟通是否明确。 +11. 实施前是否重新核对main/PR #47等并行工作,增量保留后来新增的package.json/run_all.sh测试入口。 + +本版发布规则已按用户决定确定,技术实现仍待审查及用户授权。本轮仅新增设计v2,未改代码、工作流或仓库规则,未执行任何测试、发布或分支合并。 + diff --git a/docs/ci/RESULT.md b/docs/ci/RESULT.md new file mode 100644 index 0000000..64e9be5 --- /dev/null +++ b/docs/ci/RESULT.md @@ -0,0 +1,45 @@ +# CI v2 实施回执 + +## 授权与范围 + +用户已批准 v2 并授权实施、提交个人仓库、创建专用上游 PR,持续处理到最新提交 CI 全部通过。设计文档保留审批时的原文,实施状态以本回执为准。 + +基线:上游 main `50093e1720b2e76878ed9a9f989125f35e5e0629`;独立分支 `massif/ci-gates-plan`。实施前再次确认 main 未变、PR #47 仍未合并。没有覆盖 package.json、package-lock.json 或 runtime/run_all.sh。 + +## 实际变更 + +- `build.yml` 接入唯一范围/发布资格来源、完整回归、真实构建 Web 工件和 `CI Gate`;Release 直接依赖 changes/build/Gate。 +- `tools/ci/check_profile.py`:只有白名单文档 PR 轻量;main、标签、published、手动运行完整检查。重命名同时考虑旧/新路径;diff 真正失败会失败。 +- `tools/ci/check_gate.py`:所选重任务全部成功才通过;分类失败、缺失/非法输出、取消及意外跳过均拒绝。工作流最终步骤通过合法的 cancelled() 条件拒绝当时可观察到的取消;不回写已经完成的状态。 +- `tools/ci/check_release_state.py`:复用原版本/说明校验;main 已有 Release 先正常跳过,再考虑新发布标签。标签不匹配拒绝;published 保留上传。查询错误不能当作不存在。该脚本只读,不创建 Release。 +- `tools/ci/check_web_artifacts.py`:上传前核对 www.bin 与本次 flasher 映射;成品检查应有 gzip、解压内容及 JS 语法。 +- `tests/prompts/xterm-offline.test.cjs`:资源读取/请求失败进入断言;保留真实终端交互、离线和退出重进。 +- `tests/ci/test_web_resource_failure.cjs`:唯一浏览器负例,在隔离成品副本删除样式资源,要求原场景实际失败。子 Node 测试运行器清除继承的 NODE_TEST_CONTEXT,避免场景未执行的假通过。 +- 证书入口支持 CI Python/临时路径/依赖;current-only 仍准备 restart 输入并执行当前停止、重启。仅主机编译启用 _DEFAULT_SOURCE;保留 ASan/UBSan。 +- `tests/release/test_release.py` 保留 main 可发布预期,改为验证真实策略及新 Gate 接线,没有另写表达式解释器。 + +## 本地验证 + +| 命令/入口 | 结果 | +|---|---| +| `python3 -m unittest discover -s tests/ci -p 'test_*.py'` | 9 项通过;模拟 Release 响应,没有真实发布 | +| `python3 tests/release/test_release.py` | 3 项通过 | +| `actionlint -shellcheck= .github/workflows/build.yml` | 通过 | +| `npm run test:xterm --prefix tests/prompts`(OFFLINE_RESULTS 指向临时目录) | 4 项通过 | +| `bash tests/ws_subscriptions/run_host.sh`、`run_reviewer.sh`、`run_f1_f2.sh` | 三个当前入口全部通过;未重放历史基线 | +| `bash tests/certificate/run_host.sh`(匹配 SDK MbedTLS、CI 式 Python/临时路径) | subject/time_retry/material/UI/lifecycle/coordinator/API/time_cancel/当前 stop/restart 全部通过 | +| 匹配 ESP-IDF v5.5.2 的 `idf.py -B build/ci-candidate -DSDKCONFIG=.../build/ci-candidate/sdkconfig build` | 真实完整构建成功;没有修改默认配置或 version.txt | +| `check_web_artifacts.py --build-dir build/ci-candidate` | 本次 www.bin 与 flasher 映射通过 | +| `check_web_artifacts.py --web-root build/ci-candidate/esp-idf/ts_webui/web_optimized` | 13 个真实成品资源语法/gzip 通过 | +| `PROJECT_WEB_ROOT=.../web_optimized node --test tests/prompts/xterm-offline.test.cjs tests/ci/test_web_resource_failure.cjs` | 成品中英文正常场景 + 单个资源缺失负例,3 项通过 | +| `git diff --check` | 通过 | + +本地为 macOS,Node 为 v24.10.0;工作流使用 Ubuntu、Node 22。Ubuntu 的最终结论以该 PR 最新提交 CI 为准。本地没有重复跑全部历史/浏览器矩阵;上游工作流按 v2 执行已有完整套件及新增入口。 + +## 发布与边界 + +上游版本仍为 0.6.1,其 Release 已存在。本 PR 无发布资格;如果以后经用户批准按该版本合并 main,完整 CI/Gate 成功后会记录“该版本已存在,本次未发布新资产”。后续尚未发布的合法版本继续自动发布,不要求版本 diff、提交消息版本或人工打标签。 + +未修改固件业务、HTTP 合同、GitHub 设置或版本;未自动合并、打标签、手动触发工作流、发布或刷机。不承诺真机运行,也不承诺阻止所有问题提交进入 main。尚未配置必需检查;以后仅在用户另行授权后配置准确名称 CI Gate。 + +没有产品/范围偏离,也没有需要用户新增决定的事项。运行取消用最终步骤条件实现(v2 允许);actionlint 已验证这些条件的合法位置。 diff --git a/tests/certificate/requirements.txt b/tests/certificate/requirements.txt new file mode 100644 index 0000000..e80ac1a --- /dev/null +++ b/tests/certificate/requirements.txt @@ -0,0 +1 @@ +cryptography==44.0.3 diff --git a/tests/certificate/run_host.sh b/tests/certificate/run_host.sh index 7625f43..51af14b 100755 --- a/tests/certificate/run_host.sh +++ b/tests/certificate/run_host.sh @@ -4,25 +4,25 @@ cd "$(dirname "$0")/../.." : "${IDF_PATH:=/Users/massif/esp/v5.5.2/esp-idf}" : "${CERT_TEST_BUILD:=/tmp/tianshan-certificate-tests}" : "${CERT_MBEDTLS_BUILD:=/tmp/tianshan-cert-mbedtls}" -: "${CERT_TEST_PYTHON:=/Users/massif/.espressif/python_env/idf5.5_py3.12_env/bin/python}" +: "${CERT_TEST_PYTHON:=python3}" export DEVELOPER_DIR="${DEVELOPER_DIR:-/Library/Developer/CommandLineTools}" mkdir -p "$CERT_TEST_BUILD" -cc -std=c11 -g -fsanitize=address,undefined -Itests/certificate/stubs -Icomponents/ts_cert/include -I"$IDF_PATH/components/mbedtls/mbedtls/include" tests/certificate/test_subject.c components/ts_cert/src/ts_cert_subject.c "$CERT_MBEDTLS_BUILD/library/libmbedx509.a" "$CERT_MBEDTLS_BUILD/library/libmbedcrypto.a" -lm -o "$CERT_TEST_BUILD/subject" +cc -std=c11 -D_DEFAULT_SOURCE -g -fsanitize=address,undefined -Itests/certificate/stubs -Icomponents/ts_cert/include -I"$IDF_PATH/components/mbedtls/mbedtls/include" tests/certificate/test_subject.c components/ts_cert/src/ts_cert_subject.c "$CERT_MBEDTLS_BUILD/library/libmbedx509.a" "$CERT_MBEDTLS_BUILD/library/libmbedcrypto.a" -lm -o "$CERT_TEST_BUILD/subject" "$CERT_TEST_BUILD/subject" "$CERT_TEST_PYTHON" tests/certificate/generate_fixtures.py "$CERT_TEST_BUILD/fixtures" -cc -std=c11 -g -fsanitize=address,undefined -Icomponents/ts_cert/include tests/certificate/test_time_retry.c components/ts_cert/src/ts_cert_time.c -o "$CERT_TEST_BUILD/time_retry" +cc -std=c11 -D_DEFAULT_SOURCE -g -fsanitize=address,undefined -Icomponents/ts_cert/include tests/certificate/test_time_retry.c components/ts_cert/src/ts_cert_time.c -o "$CERT_TEST_BUILD/time_retry" "$CERT_TEST_BUILD/time_retry" -cc -std=c11 -g -fsanitize=address,undefined -Wno-unused-function -Itests/certificate/stubs -Icomponents/ts_cert/include -I"$IDF_PATH/components/mbedtls/mbedtls/include" tests/certificate/test_material.c components/ts_cert/src/ts_cert_time.c components/ts_cert/src/ts_cert_subject.c "$CERT_MBEDTLS_BUILD/library/libmbedx509.a" "$CERT_MBEDTLS_BUILD/library/libmbedcrypto.a" -lpthread -o "$CERT_TEST_BUILD/material" +cc -std=c11 -D_DEFAULT_SOURCE -g -fsanitize=address,undefined -Wno-unused-function -Itests/certificate/stubs -Icomponents/ts_cert/include -I"$IDF_PATH/components/mbedtls/mbedtls/include" tests/certificate/test_material.c components/ts_cert/src/ts_cert_time.c components/ts_cert/src/ts_cert_subject.c "$CERT_MBEDTLS_BUILD/library/libmbedx509.a" "$CERT_MBEDTLS_BUILD/library/libmbedcrypto.a" -lpthread -o "$CERT_TEST_BUILD/material" "$CERT_TEST_BUILD/material" "$CERT_TEST_BUILD/fixtures" node tests/certificate/test_ui.cjs "$CERT_TEST_PYTHON" tests/certificate/extract_lifecycle.py "$CERT_TEST_BUILD/lifecycle.inc" -cc -std=c11 -g -fsanitize=address,undefined -I"$CERT_TEST_BUILD" -Itests/certificate/stubs -Icomponents/ts_cert/include -Icomponents/ts_https/include -Icomponents/ts_https/src -I"$IDF_PATH/components/mbedtls/mbedtls/include" tests/certificate/test_lifecycle.c -lpthread -o "$CERT_TEST_BUILD/lifecycle" +cc -std=c11 -D_DEFAULT_SOURCE -g -fsanitize=address,undefined -I"$CERT_TEST_BUILD" -Itests/certificate/stubs -Icomponents/ts_cert/include -Icomponents/ts_https/include -Icomponents/ts_https/src -I"$IDF_PATH/components/mbedtls/mbedtls/include" tests/certificate/test_lifecycle.c -lpthread -o "$CERT_TEST_BUILD/lifecycle" "$CERT_TEST_BUILD/lifecycle" -cc -std=c11 -g -fsanitize=address,undefined -I"$CERT_TEST_BUILD" -Itests/certificate/stubs -Icomponents/ts_cert/include -Icomponents/ts_https/include -Icomponents/ts_https/src -I"$IDF_PATH/components/mbedtls/mbedtls/include" tests/certificate/test_coordinator.c -lpthread -o "$CERT_TEST_BUILD/coordinator" +cc -std=c11 -D_DEFAULT_SOURCE -g -fsanitize=address,undefined -I"$CERT_TEST_BUILD" -Itests/certificate/stubs -Icomponents/ts_cert/include -Icomponents/ts_https/include -Icomponents/ts_https/src -I"$IDF_PATH/components/mbedtls/mbedtls/include" tests/certificate/test_coordinator.c -lpthread -o "$CERT_TEST_BUILD/coordinator" "$CERT_TEST_BUILD/coordinator" -cc -std=c11 -g -fsanitize=address,undefined -Wno-deprecated-declarations -I"$CERT_TEST_BUILD" -Itests/certificate/stubs -Icomponents/ts_cert/include -Icomponents/ts_https/include -I"$IDF_PATH/components/mbedtls/mbedtls/include" -I"$IDF_PATH/components/json/cJSON" tests/certificate/test_api.c components/ts_cert/src/ts_cert_time.c components/ts_cert/src/ts_cert_subject.c "$IDF_PATH/components/json/cJSON/cJSON.c" "$CERT_MBEDTLS_BUILD/library/libmbedx509.a" "$CERT_MBEDTLS_BUILD/library/libmbedcrypto.a" -lpthread -o "$CERT_TEST_BUILD/api" +cc -std=c11 -D_DEFAULT_SOURCE -g -fsanitize=address,undefined -Wno-deprecated-declarations -I"$CERT_TEST_BUILD" -Itests/certificate/stubs -Icomponents/ts_cert/include -Icomponents/ts_https/include -I"$IDF_PATH/components/mbedtls/mbedtls/include" -I"$IDF_PATH/components/json/cJSON" tests/certificate/test_api.c components/ts_cert/src/ts_cert_time.c components/ts_cert/src/ts_cert_subject.c "$IDF_PATH/components/json/cJSON/cJSON.c" "$CERT_MBEDTLS_BUILD/library/libmbedx509.a" "$CERT_MBEDTLS_BUILD/library/libmbedcrypto.a" -lpthread -o "$CERT_TEST_BUILD/api" "$CERT_TEST_BUILD/api" "$CERT_TEST_BUILD/fixtures" -cc -std=c11 -g -fsanitize=address,undefined -I"$CERT_TEST_BUILD" -Itests/certificate/stubs tests/certificate/test_time_cancel.c -lpthread -o "$CERT_TEST_BUILD/time_cancel" +cc -std=c11 -D_DEFAULT_SOURCE -g -fsanitize=address,undefined -I"$CERT_TEST_BUILD" -Itests/certificate/stubs tests/certificate/test_time_cancel.c -lpthread -o "$CERT_TEST_BUILD/time_cancel" "$CERT_TEST_BUILD/time_cancel" -./tests/certificate/test_service_stop.sh +bash tests/certificate/test_service_stop.sh --current-only diff --git a/tests/certificate/test_service_stop.sh b/tests/certificate/test_service_stop.sh index 8daa1db..cc1acd5 100755 --- a/tests/certificate/test_service_stop.sh +++ b/tests/certificate/test_service_stop.sh @@ -1,34 +1,46 @@ #!/bin/bash -# Tests the original baseline and current stop/restart implementation in /tmp. +# Historical evidence stays optional; CI exercises the current stop and restart. set -euo pipefail cd "$(dirname "$0")/../.." export DEVELOPER_DIR="${DEVELOPER_DIR:-/Library/Developer/CommandLineTools}" +if [[ "$#" -gt 1 || ( "$#" -eq 1 && "$1" != "--current-only" ) ]]; then + echo "Usage: $0 [--current-only]" >&2 + exit 1 +fi root=$(mktemp -d /tmp/tianshan-stop-proposal.XXXXXX) -python3 - "$root" <<'PY' +trap 'rm -rf "$root"' EXIT +# Common preparation must also run when the historical baseline is omitted. +python3 - "$root" <<'COMMON' from pathlib import Path -import sys, subprocess -s=subprocess.check_output(["git", "show", "d6ed947a592265fa12754828bc79803fc50c1db2:components/ts_core/ts_service/src/ts_service.c"], text=True) +import sys current=Path('components/ts_core/ts_service/src/ts_service.c').read_text() -a=s.index('static esp_err_t stop_service_internal(ts_service_instance_t *service)\n{') -b=s.index('\n}\n',a)+3 -Path(sys.argv[1],'service_stop.inc').write_text(s[a:b]) a=current.index('esp_err_t ts_service_restart(ts_service_handle_t handle)\n{') b=current.index('\n}\n',a)+3 Path(sys.argv[1],'service_restart.inc').write_text(current[a:b]) -PY -cc -std=c11 -fsanitize=address,undefined -Itests/certificate/stubs -I"$root" tests/certificate/test_service_stop.c -lpthread -o "$root/baseline" -set +e -"$root/baseline" -result=$? -set -e -[[ "$result" = 2 ]] -python3 - "$root" <<'PY' +COMMON +if [[ "${1:-}" != "--current-only" ]]; then + python3 - "$root" <<'BASELINE' +from pathlib import Path +import sys,subprocess +s=subprocess.check_output(['git','show','d6ed947a592265fa12754828bc79803fc50c1db2:components/ts_core/ts_service/src/ts_service.c'],text=True) +a=s.index('static esp_err_t stop_service_internal(ts_service_instance_t *service)\n{') +b=s.index('\n}\n',a)+3 +Path(sys.argv[1],'service_stop.inc').write_text(s[a:b]) +BASELINE + cc -std=c11 -D_DEFAULT_SOURCE -fsanitize=address,undefined -Itests/certificate/stubs -I"$root" tests/certificate/test_service_stop.c -lpthread -o "$root/baseline" + set +e + "$root/baseline" + result=$? + set -e + [[ "$result" = 2 ]] +fi +python3 - "$root" <<'CURRENT' from pathlib import Path import sys s=Path('components/ts_core/ts_service/src/ts_service.c').read_text() a=s.index('static esp_err_t stop_service_internal(ts_service_instance_t *service)\n{') b=s.index('\n}\n',a)+3 Path(sys.argv[1],'service_stop.inc').write_text(s[a:b]) -PY -cc -std=c11 -fsanitize=address,undefined -Itests/certificate/stubs -I"$root" tests/certificate/test_service_stop.c -lpthread -o "$root/current" +CURRENT +cc -std=c11 -D_DEFAULT_SOURCE -fsanitize=address,undefined -Itests/certificate/stubs -I"$root" tests/certificate/test_service_stop.c -lpthread -o "$root/current" "$root/current" diff --git a/tests/ci/test_policy.py b/tests/ci/test_policy.py new file mode 100644 index 0000000..90ef428 --- /dev/null +++ b/tests/ci/test_policy.py @@ -0,0 +1,129 @@ +"""Bounded policy and emitted-resource regressions; no GitHub writes or devices.""" +import gzip +import importlib.util +import json +from pathlib import Path +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[2] + +def load(name): + spec = importlib.util.spec_from_file_location(name, ROOT / 'tools/ci' / (name + '.py')) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + +profile, gate, web, release = [load(name) for name in ('check_profile', 'check_gate', 'check_web_artifacts', 'check_release_state')] + +class PolicyTests(unittest.TestCase): + def test_events_keep_main_release_and_force_full(self): + cases = [ + ('RMinte-AI/TianshanOS','push','refs/heads/main',{},True), + ('RMinte-AI/TianshanOS','push','refs/heads/develop',{},False), + ('massif-01/TianshanOS','push','refs/heads/main',{},False), + ('RMinte-AI/TianshanOS','push','refs/tags/v0.6.1',{},True), + ('RMinte-AI/TianshanOS','release','refs/tags/v0.6.1',{'action':'published'},True), + ('RMinte-AI/TianshanOS','workflow_dispatch','refs/heads/main',{},False), + ] + for repo,event,ref,payload,publish in cases: + with self.subTest(repo=repo,event=event): + self.assertEqual(profile.classify(repo,event,ref,payload),{'run_full':True,'publish_release':publish}) + + def test_only_known_document_pr_is_light(self): + for paths,full in [(['README.md','docs/ci/plan.md'],False),([],True), + (['README.md','app.c'],True),(['docs/vendor.json'],True), + (['.github/workflows/build.yml'],True),(['app.c','docs/app.md'],True)]: + with self.subTest(paths=paths): + self.assertEqual(profile.classify(profile.UPSTREAM,'pull_request','refs/pull/1/merge',{},paths),{'run_full':full,'publish_release':False}) + with self.assertRaises(ValueError): + profile.classify(profile.UPSTREAM,'pull_request','refs/pull/1/merge',{}) + + def test_git_diff_keeps_both_rename_paths_and_deletions(self): + with tempfile.TemporaryDirectory() as folder: + def git(*args): + return subprocess.check_output(['git','-C',folder,*args],text=True).strip() + git('init','-q');git('config','user.email','fixture@example.invalid');git('config','user.name','Fixture') + root=Path(folder);(root/'code.c').write_text('int existing;\n');(root/'gone.c').write_text('int removed;\n') + git('add','.');git('commit','-qm','before');base=git('rev-parse','HEAD') + (root/'docs').mkdir();git('mv','code.c','docs/code.md');git('rm','gone.c');git('commit','-qm','after') + from unittest.mock import patch + original=subprocess.check_output;head=git('rev-parse','HEAD') + with patch.object(profile.subprocess,'check_output',side_effect=lambda args:original(args,cwd=folder)): + paths=profile.changed_paths(base,head) + self.assertEqual(set(paths),{'code.c','docs/code.md','gone.c'}) + + def needs(self,full=True): + return {'changes':{'result':'success','outputs':{'run_full':str(full).lower(),'publish_release':'false'}}, + **{job:{'result':'success' if full else 'skipped'} for job in gate.REQUIRED}} + + def test_gate_requires_selected_success_and_current_run_not_cancelled(self): + for full in (True,False): + gate.check_gate(self.needs(full),False) + for result in ('failure','cancelled','skipped'): + n=self.needs();n['build']['result']=result + with self.subTest(result=result),self.assertRaises(ValueError):gate.check_gate(n,False) + with self.assertRaises(ValueError):gate.check_gate(self.needs(),True) + n=self.needs();n['changes']['result']='failure' + with self.assertRaises(ValueError):gate.check_gate(n,False) + for missing in (None,'yes'): + n=self.needs();n['changes']['outputs']['run_full']=missing + with self.assertRaises(ValueError):gate.check_gate(n,False) + n=self.needs(False);n['changes']['outputs']['publish_release']='true' + with self.assertRaises(ValueError):gate.check_gate(n,False) + n=self.needs(False);n['build']['result']='success' + with self.assertRaises(ValueError):gate.check_gate(n,False) + + def test_release_main_skip_precedes_old_tag_lookup(self): + calls=[] + def get(path):calls.append(path);return {'tag_name':'v0.6.1'} + skip,message=release.prepare_release(profile.UPSTREAM,'main','v0.6.1','new',get) + self.assertTrue(skip);self.assertEqual(message,'该版本已存在,本次未发布新资产');self.assertEqual(len(calls),1) + + def test_main_new_release_allows_absent_tag_but_refuses_wrong_commit(self): + self.assertFalse(release.prepare_release(profile.UPSTREAM,'main','v0.6.1','new',lambda path:None)[0]) + for commit in ('new','old'): + def get(path):return None if '/releases/' in path else {'object':{'type':'commit','sha':commit}} + if commit=='new':self.assertFalse(release.prepare_release(profile.UPSTREAM,'main','v0.6.1','new',get)[0]) + else: + with self.assertRaises(ValueError):release.prepare_release(profile.UPSTREAM,'main','v0.6.1','new',get) + + def test_published_upload_and_query_errors(self): + def get(path):return {'tag_name':'v0.6.1'} if '/releases/' in path else {'object':{'type':'commit','sha':'new'}} + for mode in ('published','tag'): + self.assertFalse(release.prepare_release(profile.UPSTREAM,mode,'v0.6.1','new',get)[0]) + def broken(path):raise RuntimeError('GitHub query failed') + with self.assertRaises(RuntimeError):release.prepare_release(profile.UPSTREAM,'main','v0.6.1','new',broken) + from unittest.mock import patch + import urllib.error + for status in (404,403,500): + with patch.dict('os.environ',{'GH_TOKEN':'fixture'}),patch.object(release.urllib.request,'urlopen',side_effect=urllib.error.HTTPError('https://api.github.com/fixture',status,'fixture',{},None)): + if status==404:self.assertIsNone(release.query('fixture')) + else: + with self.assertRaises(RuntimeError):release.query('fixture') + +class ArtifactTests(unittest.TestCase): + def test_required_gzip_and_emitted_syntax(self): + with tempfile.TemporaryDirectory() as folder: + root=Path(folder) + for name in web.ESSENTIAL: + p=root/name;p.parent.mkdir(parents=True,exist_ok=True);p.write_text('const fixture = 1;' if p.suffix=='.js' else '

fixture

' if p.suffix=='.html' else 'p{color:red}') + Path(str(p)+'.gz').write_bytes(gzip.compress(p.read_bytes())) + web.check_web(root) + packed=root/'index.html.gz';correct=packed.read_bytes();packed.unlink() + with self.assertRaisesRegex(ValueError,'Missing required gzip'):web.check_web(root) + packed.write_bytes(gzip.compress(b'wrong')) + with self.assertRaisesRegex(ValueError,'Gzip differs'):web.check_web(root) + packed.write_bytes(correct);js=root/'js/app.js';js.write_text('const = ;');Path(str(js)+'.gz').write_bytes(gzip.compress(js.read_bytes())) + with self.assertRaises(subprocess.CalledProcessError):web.check_web(root) + + def test_www_must_exist_and_be_mapped_in_same_build(self): + with tempfile.TemporaryDirectory() as folder: + root=Path(folder);(root/'flasher_args.json').write_text(json.dumps({'flash_files':{'0x6a0000':'www.bin'}})) + with self.assertRaises(ValueError):web.check_build(root) + (root/'www.bin').write_bytes(b'fixture');web.check_build(root) + (root/'flasher_args.json').write_text(json.dumps({'flash_files':{}})) + with self.assertRaises(ValueError):web.check_build(root) + +if __name__=='__main__':unittest.main() diff --git a/tests/ci/test_web_resource_failure.cjs b/tests/ci/test_web_resource_failure.cjs new file mode 100644 index 0000000..b6a0fc8 --- /dev/null +++ b/tests/ci/test_web_resource_failure.cjs @@ -0,0 +1,17 @@ +// One negative against the emitted directory and the existing real browser test. +const {test}=require('node:test'); +const assert=require('node:assert/strict'),fs=require('node:fs'),os=require('node:os'),path=require('node:path'),{spawnSync}=require('node:child_process'); +test('missing built stylesheet blocks the actual offline browser scenario',()=>{ + const source=process.env.PROJECT_WEB_ROOT;assert(source,'Set PROJECT_WEB_ROOT to this build output'); + const temp=fs.mkdtempSync(path.join(os.tmpdir(),'ts-built-resource-')); + try{ + const root=path.join(temp,'web');fs.cpSync(source,root,{recursive:true});fs.unlinkSync(path.join(root,'css/style.css')); + // A nested test runner must not inherit the parent's worker context. + const env={...process.env,PROJECT_WEB_ROOT:root,OFFLINE_RESULTS:path.join(temp,'results')}; + delete env.NODE_TEST_CONTEXT; + const result=spawnSync(process.execPath,['--test','--test-name-pattern','en-US:','tests/prompts/xterm-offline.test.cjs'],{ + env,encoding:'utf8',timeout:60000}); + assert.equal(result.error,undefined);assert.notEqual(result.status,0); + assert((result.stdout+result.stderr).includes('Unexpected resource failures'),result.stdout+result.stderr); + }finally{fs.rmSync(temp,{recursive:true,force:true});} +}); diff --git a/tests/prompts/xterm-offline.test.cjs b/tests/prompts/xterm-offline.test.cjs index 58ff690..9a7c9ce 100644 --- a/tests/prompts/xterm-offline.test.cjs +++ b/tests/prompts/xterm-offline.test.cjs @@ -5,20 +5,21 @@ const root=path.resolve(process.env.PROJECT_WEB_ROOT||'components/ts_webui/web') for(const language of ['en-US','zh-CN'])test(`${language}: cold offline real xterm and terminal lifecycle`,async()=>{ const browser=await chromium.launch({channel:'chrome',headless:true}); const context=await browser.newContext({serviceWorkers:'block',viewport:{width:1280,height:900}}); - const requests=[],errors=[];const origin='http://fixture.local'; + const requests=[],errors=[],resourceFailures=[];const origin='http://fixture.local'; try{ await context.route('**/*',async route=>{const u=new URL(route.request().url()); - if(u.origin!==origin){await route.abort();return;} + if(u.origin!==origin){resourceFailures.push({url:u.href,reason:'External request'});await route.abort();return;} if(u.pathname.startsWith('/api/')){await route.fulfill({json:{code:0,data:{}}});return;} const file=path.join(root,u.pathname==='/'?'index.html':u.pathname); - try{await route.fulfill({body:fs.readFileSync(file),contentType:({'.js':'application/javascript','.css':'text/css','.html':'text/html','.woff2':'font/woff2'})[path.extname(file)]||'application/octet-stream'});}catch{await route.abort();} + try{await route.fulfill({body:fs.readFileSync(file),contentType:({'.js':'application/javascript','.css':'text/css','.html':'text/html','.woff2':'font/woff2'})[path.extname(file)]||'application/octet-stream'});}catch(error){resourceFailures.push({url:u.href,reason:error.message});await route.abort();} }); await context.addInitScript(language=>{ localStorage.setItem('ts_language',language);window.sent=[]; window.WebSocket=class{static OPEN=1;static CONNECTING=0;constructor(){this.readyState=0;setTimeout(()=>{this.readyState=1;this.onopen?.();},0);}send(s){sent.push(JSON.parse(s));}close(){this.readyState=3;}}; },language); - const page=await context.newPage();page.on('request',r=>requests.push({url:r.url(),origin:new URL(r.url()).origin}));page.on('pageerror',e=>errors.push(e.message)); - await page.goto(origin);await page.waitForFunction(()=>i18n.isReady()); + const page=await context.newPage();page.on('request',r=>requests.push({url:r.url(),origin:new URL(r.url()).origin}));page.on('pageerror',e=>errors.push(e.message));page.on('requestfailed',r=>resourceFailures.push({url:r.url(),reason:r.failure()?.errorText})); + await page.goto(origin);await page.waitForFunction(()=>i18n.isReady());await page.waitForLoadState('networkidle'); + assert.deepEqual(resourceFailures,[],'Unexpected resource failures'); assert.equal(await page.evaluate(()=>typeof Terminal),'undefined'); await page.evaluate(()=>{closeLoginModal();return loadTerminalPage();}); await page.waitForFunction(()=>webTerminal?.ws?.readyState===1); @@ -45,8 +46,9 @@ for(const language of ['en-US','zh-CN'])test(`${language}: cold offline real xte assert(await page.evaluate(()=>oldTerminal.destroyed&&oldTerminal.terminal===null)); await page.evaluate(()=>loadTerminalPage());await page.waitForFunction(()=>webTerminal?.terminal&&webTerminal.ws?.readyState===1); assert.equal(requests.filter(r=>r.url.includes('/vendor/xterm/')).length,3); - assert(requests.every(r=>r.origin===origin));assert.deepEqual(errors,[]); + await page.waitForLoadState('networkidle'); + assert(requests.every(r=>r.origin===origin));assert.deepEqual(errors,[]);assert.deepEqual(resourceFailures,[],'Unexpected resource failures'); const out=process.env.OFFLINE_RESULTS||'docs/repair/xterm-local';fs.mkdirSync(out,{recursive:true}); - fs.writeFileSync(path.join(out,`requests-${language}.json`),JSON.stringify({requests,errors,realXterm:true,externalRequests:0},null,2)); + fs.writeFileSync(path.join(out,`requests-${language}.json`),JSON.stringify({requests,errors,resourceFailures,realXterm:true,externalRequests:0},null,2)); }finally{await context.close();await browser.close();} }); diff --git a/tests/release/test_release.py b/tests/release/test_release.py index 2085215..9be022c 100644 --- a/tests/release/test_release.py +++ b/tests/release/test_release.py @@ -3,7 +3,6 @@ import tempfile import unittest from pathlib import Path -from types import SimpleNamespace ROOT = Path(__file__).resolve().parents[2] spec = importlib.util.spec_from_file_location('check_release', ROOT / 'tools/check_release.py') @@ -32,26 +31,42 @@ def test_mismatch_and_missing_notes_rejected(self): module.validate('v0.5.2', '0.5.2', root) def test_actual_workflow_release_condition(self): - workflow = (ROOT / '.github/workflows/build.yml').read_text() - release = workflow.split('\n release:\n', 1)[1] - expression = re.search(r'^ if: (.+)$', release, re.M).group(1) - expression = expression.replace('&&', ' and ').replace('||', ' or ') + policy_spec = importlib.util.spec_from_file_location('check_profile', ROOT / 'tools/ci/check_profile.py') + policy = importlib.util.module_from_spec(policy_spec) + policy_spec.loader.exec_module(policy) cases = [ - ('RMinte-AI/TianshanOS', 'push', 'refs/heads/main', True), - ('massif-01/TianshanOS', 'push', 'refs/heads/main', False), - ('RMinte-AI/TianshanOS', 'pull_request', 'refs/pull/41/merge', False), - ('RMinte-AI/TianshanOS', 'push', 'refs/heads/develop', False), - ('RMinte-AI/TianshanOS', 'push', 'refs/tags/v0.5.2', True), - ('RMinte-AI/TianshanOS', 'workflow_dispatch', 'refs/heads/main', False), + ('RMinte-AI/TianshanOS', 'push', 'refs/heads/main', {}, True), + ('massif-01/TianshanOS', 'push', 'refs/heads/main', {}, False), + ('RMinte-AI/TianshanOS', 'pull_request', 'refs/pull/41/merge', {}, False), + ('RMinte-AI/TianshanOS', 'push', 'refs/heads/develop', {}, False), + ('RMinte-AI/TianshanOS', 'push', 'refs/tags/v0.5.2', {}, True), + ('RMinte-AI/TianshanOS', 'release', 'refs/tags/v0.5.2', {'action': 'published'}, True), + ('RMinte-AI/TianshanOS', 'workflow_dispatch', 'refs/heads/main', {}, False), ] - for repo, event, ref, expected in cases: - github = SimpleNamespace(repository=repo, event_name=event, ref=ref) + for repo, event, ref, payload, expected in cases: with self.subTest(repo=repo, event=event, ref=ref): - self.assertEqual(eval(expression, {'__builtins__': {}}, {'github': github, 'startsWith': str.startswith}), expected) - self.assertIn('needs: [build, web-tests, runtime-tests]', release) - self.assertIn('target_commitish: ${{ github.sha }}', release) - self.assertIn('body_path: docs/releases/${{ steps.release_tag.outputs.tag }}.md', release) + self.assertEqual(policy.classify(repo, event, ref, payload, ['README.md'])['publish_release'], expected) + workflow = (ROOT / '.github/workflows/build.yml').read_text() + jobs = workflow.split('\njobs:\n', 1)[1] + def job(name): + return re.search(r'^ ' + re.escape(name) + r':\n(.*?)(?=^ [\w-]+:|\Z)', jobs, re.M | re.S).group(1) + release = job('release') + dependencies = re.search(r'^ needs: \[(.*?)\]$', release, re.M).group(1) + self.assertEqual(set(x.strip() for x in dependencies.split(',')), {'changes', 'build', 'ci-gate'}) + expression = re.search(r'^ if: (.+)$', release, re.M).group(1) + self.assertEqual(re.sub(r'\s+', '', expression), "${{!cancelled()&&needs.changes.outputs.publish_release=='true'&&needs.build.result=='success'&&needs['ci-gate'].result=='success'}}") + self.assertIn('target_commitish: ${{ needs.build.outputs.commit }}', release) + self.assertIn('body_path: docs/releases/${{ steps.preflight.outputs.tag }}.md', release) self.assertIn('generate_release_notes: false', release) + self.assertIn('python3 tools/ci/check_profile.py', job('changes')) + gate = job('ci-gate') + selected = re.search(r'^ needs: \[(.*?)\]$', gate, re.M).group(1) + self.assertEqual(set(x.strip() for x in selected.split(',')), {'changes', 'web-tests', 'runtime-tests', 'build', 'web-artifacts'}) + self.assertIn('if: always()', gate) + self.assertIn('if: ${{ always() && !cancelled() }}', gate) + self.assertIn('if: ${{ always() && cancelled() }}', gate) + self.assertIn('CI Gate failed: workflow cancelled at the final decision', gate) + self.assertNotIn('paths-ignore:', workflow) if __name__ == '__main__': diff --git a/tools/ci/check_gate.py b/tools/ci/check_gate.py new file mode 100644 index 0000000..e9136f4 --- /dev/null +++ b/tools/ci/check_gate.py @@ -0,0 +1,41 @@ +"""The required status accepts only the checks selected by Check scope.""" +import json +import os + +REQUIRED = ('web-tests', 'runtime-tests', 'build', 'web-artifacts') + + +def boolean(value): + if value not in ('true', 'false'): + raise ValueError(f'Invalid boolean output: {value!r}') + return value == 'true' + + +def check_gate(needs, cancelled): + if cancelled: + raise ValueError('The workflow is cancelled at the final decision') + changes = needs.get('changes', {}) + if changes.get('result') != 'success': + raise ValueError('Check scope did not succeed') + outputs = changes.get('outputs', {}) + full = boolean(outputs.get('run_full')) + publish = boolean(outputs.get('publish_release')) + if publish and not full: + raise ValueError('A release cannot use the lightweight path') + expected = 'success' if full else 'skipped' + for job in REQUIRED: + actual = needs.get(job, {}).get('result') + if actual != expected: + raise ValueError(f'{job}: expected {expected}, received {actual}') + return 'Full checks passed' if full else 'Document checks passed; heavy checks intentionally skipped' + + +def main(): + try: + print(check_gate(json.loads(os.environ['NEEDS_JSON']), boolean(os.environ['RUN_CANCELLED']))) + except (ValueError, KeyError) as error: + raise SystemExit(f'CI Gate failed: {error}') + + +if __name__ == '__main__': + main() diff --git a/tools/ci/check_profile.py b/tools/ci/check_profile.py new file mode 100644 index 0000000..95a684d --- /dev/null +++ b/tools/ci/check_profile.py @@ -0,0 +1,61 @@ +"""Select required checks and release eligibility from the actual Actions event.""" +import json +import os +from pathlib import Path +import re +import subprocess + +UPSTREAM = 'RMinte-AI/TianshanOS' +TAG = re.compile(r'v\d+\.\d+\.\d+(?:-[A-Za-z0-9.]+)?') + + +def changed_paths(base, head): + raw = subprocess.check_output(['git', 'diff', '--name-status', '-z', '-M', base, head, '--']) + fields = raw.decode().split('\0') + paths = [] + at = 0 + while at < len(fields) - 1: + status = fields[at] + count = 2 if status.startswith(('R', 'C')) else 1 + if at + count >= len(fields) - 1: + raise ValueError('Incomplete git diff record') + paths.extend(fields[at + 1:at + count + 1]) + at += count + 1 + return paths + + +def ordinary_document(path): + return path in ('README.md', 'README_EN.md') or (path.startswith('docs/') and path.endswith('.md')) + + +def classify(repository, event_name, ref, event, paths=None): + if not repository or not ref: + raise ValueError('Missing repository or ref') + if event_name not in ('pull_request', 'push', 'release', 'workflow_dispatch'): + raise ValueError('Unsupported Actions event') + if event_name == 'pull_request' and paths is None: + raise ValueError('PR paths were not obtained') + light = event_name == 'pull_request' and bool(paths) and all(ordinary_document(p) for p in paths) + tag_push = event_name == 'push' and ref.startswith('refs/tags/') and TAG.fullmatch(ref.removeprefix('refs/tags/')) is not None + eligible = repository == UPSTREAM and ( + (event_name == 'push' and ref == 'refs/heads/main') or tag_push or + (event_name == 'release' and event.get('action') == 'published')) + return {'run_full': not light, 'publish_release': eligible} + + +def main(): + event = json.loads(Path(os.environ['GITHUB_EVENT_PATH']).read_text()) + name = os.environ['GITHUB_EVENT_NAME'] + paths = None + if name == 'pull_request': + pr = event['pull_request'] + paths = changed_paths(pr['base']['sha'], pr['head']['sha']) + result = classify(os.environ['GITHUB_REPOSITORY'], name, os.environ['GITHUB_REF'], event, paths) + with open(os.environ['GITHUB_OUTPUT'], 'a') as output: + for key, value in result.items(): + output.write(f'{key}={str(value).lower()}\n') + print(json.dumps(result)) + + +if __name__ == '__main__': + main() diff --git a/tools/ci/check_release_state.py b/tools/ci/check_release_state.py new file mode 100644 index 0000000..b6867ac --- /dev/null +++ b/tools/ci/check_release_state.py @@ -0,0 +1,76 @@ +"""Read-only preflight for an already eligible release job; never writes GitHub.""" +import json +import os +from pathlib import Path +import subprocess +import sys +import urllib.error +import urllib.parse +import urllib.request + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from check_release import validate + + +def query(path): + request = urllib.request.Request('https://api.github.com/' + path, headers={ + 'Authorization': 'Bearer ' + os.environ['GH_TOKEN'], + 'Accept': 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28'}) + try: + with urllib.request.urlopen(request, timeout=30) as response: + return json.load(response) + except urllib.error.HTTPError as error: + if error.code == 404: + return None + raise RuntimeError(f'GitHub query failed: HTTP {error.code} for {path}') from error + except urllib.error.URLError as error: + raise RuntimeError(f'GitHub query failed for {path}: {error.reason}') from error + + +def prepare_release(repository, mode, tag, build_commit, get=query): + release = get(f'repos/{repository}/releases/tags/{urllib.parse.quote(tag, safe="")}') + if mode == 'main' and release is not None: + return True, '该版本已存在,本次未发布新资产' + if mode == 'published' and release is None: + raise ValueError('The published Release no longer exists') + ref = get(f'repos/{repository}/git/ref/tags/{urllib.parse.quote(tag, safe="")}') + if ref is None: + if mode != 'main': + raise ValueError('The release event tag does not exist') + else: + obj = ref['object'] + while obj['type'] == 'tag': + annotated = get(f'repos/{repository}/git/tags/{obj["sha"]}') + if annotated is None: + raise ValueError('Cannot resolve annotated release tag') + obj = annotated['object'] + if obj['type'] != 'commit' or obj['sha'] != build_commit: + raise ValueError('Release tag differs from the actual build commit; will not move the tag') + return False, 'Release preflight passed for this build' + + +def main(): + event_name = os.environ['GITHUB_EVENT_NAME'] + ref = os.environ['GITHUB_REF'] + version = os.environ['BUILD_VERSION'] + if event_name == 'release': + mode = 'published' + tag = json.loads(Path(os.environ['GITHUB_EVENT_PATH']).read_text())['release']['tag_name'] + elif event_name == 'push' and ref.startswith('refs/tags/'): + mode, tag = 'tag', ref.removeprefix('refs/tags/') + elif event_name == 'push' and ref == 'refs/heads/main': + mode, tag = 'main', 'v' + Path('version.txt').read_text().strip() + else: + raise ValueError('Preflight invoked without a release route') + validate(tag, version) + commit = os.environ['BUILD_COMMIT'] + if subprocess.check_output(['git', 'rev-parse', 'HEAD'], text=True).strip() != commit: + raise ValueError('Release checkout is not the build commit') + skip, message = prepare_release(os.environ['GITHUB_REPOSITORY'], mode, tag, commit) + with open(os.environ['GITHUB_OUTPUT'], 'a') as output: + output.write(f'tag={tag}\nskip_release={str(skip).lower()}\n') + print(message) + + +if __name__ == '__main__': + main() diff --git a/tools/ci/check_web_artifacts.py b/tools/ci/check_web_artifacts.py new file mode 100644 index 0000000..deeed88 --- /dev/null +++ b/tools/ci/check_web_artifacts.py @@ -0,0 +1,57 @@ +"""Check the emitted Web directory and the firmware build's www mapping.""" +import argparse +import gzip +import json +from pathlib import Path +import subprocess + +ESSENTIAL = ('index.html', 'js/app.js', 'js/api.js', 'js/router.js', 'js/terminal.js', + 'js/lang/en-US.js', 'js/lang/zh-CN.js', 'vendor/xterm/xterm.min.js', + 'vendor/xterm/fit.min.js', 'vendor/xterm/xterm.css') + + +def check_build(build): + build = Path(build).resolve() + www = build / 'www.bin' + if not www.is_file() or not www.stat().st_size: + raise ValueError('This build did not generate www.bin') + files = json.loads((build / 'flasher_args.json').read_text())['flash_files'] + if www not in [(build / path).resolve() for path in files.values()]: + raise ValueError('This build does not flash its www.bin') + print('PASS same-build www.bin and flasher mapping') + + +def check_web(root): + root = Path(root) + for name in ESSENTIAL: + if not (root / name).is_file(): + raise ValueError(f'Missing built resource: {name}') + resources = sorted(p for p in root.rglob('*') if p.suffix in ('.js', '.css', '.html')) + for resource in resources: + packed = Path(str(resource) + '.gz') + if not packed.is_file(): + raise ValueError(f'Missing required gzip: {packed.relative_to(root)}') + if gzip.decompress(packed.read_bytes()) != resource.read_bytes(): + raise ValueError(f'Gzip differs from built resource: {resource.relative_to(root)}') + if resource.suffix == '.js': + subprocess.run(['node', '--check', str(resource)], check=True, capture_output=True) + for packed in root.rglob('*.gz'): + if not Path(str(packed)[:-3]).is_file(): + raise ValueError(f'Gzip has no source: {packed.relative_to(root)}') + print(f'PASS {len(resources)} built resources: syntax and required gzip identity') + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + mode = parser.add_mutually_exclusive_group(required=True) + mode.add_argument('--build-dir', type=Path) + mode.add_argument('--web-root', type=Path) + args = parser.parse_args() + if args.build_dir: + check_build(args.build_dir) + else: + check_web(args.web_root) + + +if __name__ == '__main__': + main() From b4aa2ac850cdc66e7717bfb1ccaf15e5d95b4ffc Mon Sep 17 00:00:00 2001 From: Alfred Date: Sun, 11 Oct 2026 00:04:49 +0800 Subject: [PATCH 2/4] docs: clean approved CI design whitespace --- ...\216\345\256\241\346\237\245\344\272\244\346\216\245-v2.md" | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git "a/docs/ci/CI\346\224\271\350\277\233\350\256\276\350\256\241\344\270\216\345\256\241\346\237\245\344\272\244\346\216\245-v2.md" "b/docs/ci/CI\346\224\271\350\277\233\350\256\276\350\256\241\344\270\216\345\256\241\346\237\245\344\272\244\346\216\245-v2.md" index 75472ee..4866b17 100644 --- "a/docs/ci/CI\346\224\271\350\277\233\350\256\276\350\256\241\344\270\216\345\256\241\346\237\245\344\272\244\346\216\245-v2.md" +++ "b/docs/ci/CI\346\224\271\350\277\233\350\256\276\350\256\241\344\270\216\345\256\241\346\237\245\344\272\244\346\216\245-v2.md" @@ -1,6 +1,6 @@ # GitHub Actions 改进设计与审查交接 v2 -日期:2026-10-10(Asia/Shanghai) +日期:2026-10-10(Asia/Shanghai) 状态:完整实施设计,按用户明确要求修订;本轮仅修改设计,不执行代码、工作流或 GitHub 设置变更。 ## 修订记录与本版效力 @@ -337,4 +337,3 @@ GitHub规则仍后置:main检查成功、Gate名称与文档路径验收有效 11. 实施前是否重新核对main/PR #47等并行工作,增量保留后来新增的package.json/run_all.sh测试入口。 本版发布规则已按用户决定确定,技术实现仍待审查及用户授权。本轮仅新增设计v2,未改代码、工作流或仓库规则,未执行任何测试、发布或分支合并。 - From 1080387ef2f32b5c8c54d187835e182eee4d1c42 Mon Sep 17 00:00:00 2001 From: Alfred Date: Sun, 11 Oct 2026 00:08:02 +0800 Subject: [PATCH 3/4] test: link libm for Linux WebSocket host regressions --- docs/ci/RESULT.md | 2 ++ tests/ws_subscriptions/run_f1_f2.sh | 2 +- tests/ws_subscriptions/run_host.sh | 2 +- tests/ws_subscriptions/run_reviewer.sh | 2 +- 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/ci/RESULT.md b/docs/ci/RESULT.md index 64e9be5..2e8bcfd 100644 --- a/docs/ci/RESULT.md +++ b/docs/ci/RESULT.md @@ -42,4 +42,6 @@ 未修改固件业务、HTTP 合同、GitHub 设置或版本;未自动合并、打标签、手动触发工作流、发布或刷机。不承诺真机运行,也不承诺阻止所有问题提交进入 main。尚未配置必需检查;以后仅在用户另行授权后配置准确名称 CI Gate。 +Ubuntu 首次运行在新接入的 WebSocket 主机入口链接阶段报告 floor/libm 缺失。三个入口编译包含 ts_ws_subscriptions.c,因此补显式 -lm;不改业务或断言,ASan/UBSan 保留。修复后再次执行三个当前入口,并以最新提交 CI 验收。 + 没有产品/范围偏离,也没有需要用户新增决定的事项。运行取消用最终步骤条件实现(v2 允许);actionlint 已验证这些条件的合法位置。 diff --git a/tests/ws_subscriptions/run_f1_f2.sh b/tests/ws_subscriptions/run_f1_f2.sh index 7c0b664..2abb354 100644 --- a/tests/ws_subscriptions/run_f1_f2.sh +++ b/tests/ws_subscriptions/run_f1_f2.sh @@ -26,7 +26,7 @@ HASH fi export DEVELOPER_DIR=/Library/Developer/CommandLineTools cjson="${IDF_PATH:-/Users/massif/esp/v5.5.2/esp-idf}/components/json/cJSON" -"${CC:-cc}" -std=gnu11 -g -Wno-deprecated-declarations -fsanitize=address,undefined -Itests/ws_subscriptions/stubs -Icomponents/ts_webui/include -Icomponents/ts_webui/src -Icomponents/ts_security/include -Icomponents/ts_automation/include -Icomponents/ts_drivers/include -Icomponents/ts_console/include -Icomponents/ts_net/include -Wno-macro-redefined -I"$cjson" -I"$build" tests/ws_subscriptions/test_f1_f2.c "$cjson/cJSON.c" -o "$build/f1f2" +"${CC:-cc}" -std=gnu11 -g -Wno-deprecated-declarations -fsanitize=address,undefined -Itests/ws_subscriptions/stubs -Icomponents/ts_webui/include -Icomponents/ts_webui/src -Icomponents/ts_security/include -Icomponents/ts_automation/include -Icomponents/ts_drivers/include -Icomponents/ts_console/include -Icomponents/ts_net/include -Wno-macro-redefined -I"$cjson" -I"$build" tests/ws_subscriptions/test_f1_f2.c "$cjson/cJSON.c" -lm -o "$build/f1f2" failed=0 scenarios="f1 f2" if grep -q TS_WS_POWER_SLOTS components/ts_webui/include/ts_ws_transport.h; then scenarios="$scenarios late early identity exec continuous power_retry capacity retry_limit stop timeout class power_order fair"; fi diff --git a/tests/ws_subscriptions/run_host.sh b/tests/ws_subscriptions/run_host.sh index f3971e8..787c52c 100755 --- a/tests/ws_subscriptions/run_host.sh +++ b/tests/ws_subscriptions/run_host.sh @@ -5,7 +5,7 @@ build=$(mktemp -d /tmp/ts-ws-host.XXXXXX) trap 'rm -rf "$build"' EXIT export DEVELOPER_DIR=/Library/Developer/CommandLineTools cjson="${IDF_PATH:-/Users/massif/esp/v5.5.2/esp-idf}/components/json/cJSON" -cc -std=gnu11 -g -Wno-deprecated-declarations -fsanitize=address,undefined -Itests/ws_subscriptions/stubs -Icomponents/ts_webui/include -Icomponents/ts_webui/src -I"$cjson" tests/ws_subscriptions/test_manager.c "$cjson/cJSON.c" -o "$build/manager" +cc -std=gnu11 -g -Wno-deprecated-declarations -fsanitize=address,undefined -Itests/ws_subscriptions/stubs -Icomponents/ts_webui/include -Icomponents/ts_webui/src -I"$cjson" tests/ws_subscriptions/test_manager.c "$cjson/cJSON.c" -lm -o "$build/manager" "$build/manager" cc -std=gnu11 -g -Wno-deprecated-declarations -fsanitize=address,undefined -Icomponents/ts_core/ts_event/include -Itests/ws_subscriptions/stubs -Icomponents/ts_core/ts_event/src -I"$cjson" tests/ws_subscriptions/test_event.c -o "$build/event" "$build/event" diff --git a/tests/ws_subscriptions/run_reviewer.sh b/tests/ws_subscriptions/run_reviewer.sh index 200a1bf..caa6605 100644 --- a/tests/ws_subscriptions/run_reviewer.sh +++ b/tests/ws_subscriptions/run_reviewer.sh @@ -18,7 +18,7 @@ for source,names,dest in [ parts.append(s[m.start():s.index('\n}',m.start())+2]) Path(sys.argv[1],dest).write_text('\n'.join(parts)) PY -cc -std=gnu11 -g -Wno-deprecated-declarations -fsanitize=address,undefined -Itests/ws_subscriptions/stubs -Icomponents/ts_webui/include -Icomponents/ts_webui/src -Icomponents/ts_security/include -Icomponents/ts_automation/include -Icomponents/ts_drivers/include -Icomponents/ts_console/include -Icomponents/ts_net/include -Wno-macro-redefined -I"$cjson" -I"$build" tests/ws_subscriptions/test_reviewer.c "$cjson/cJSON.c" -o "$build/reviewer" +cc -std=gnu11 -g -Wno-deprecated-declarations -fsanitize=address,undefined -Itests/ws_subscriptions/stubs -Icomponents/ts_webui/include -Icomponents/ts_webui/src -Icomponents/ts_security/include -Icomponents/ts_automation/include -Icomponents/ts_drivers/include -Icomponents/ts_console/include -Icomponents/ts_net/include -Wno-macro-redefined -I"$cjson" -I"$build" tests/ws_subscriptions/test_reviewer.c "$cjson/cJSON.c" -lm -o "$build/reviewer" "$build/reviewer" cc -std=gnu11 -g -fsanitize=address,undefined -Itests/ws_subscriptions/stubs -I"$cjson" -I"$build" tests/ws_subscriptions/test_reviewer_service.c -o "$build/service" From 2c1ffd2ff4f3a8aa04067aede432e05672cfa2b0 Mon Sep 17 00:00:00 2001 From: Alfred Date: Sun, 11 Oct 2026 02:48:40 +0800 Subject: [PATCH 4/4] fix(ci): classify PR changes from merge base --- tests/ci/test_policy.py | 23 +++++++++++++++++++++++ tools/ci/check_profile.py | 2 +- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/tests/ci/test_policy.py b/tests/ci/test_policy.py index 90ef428..b5de8ba 100644 --- a/tests/ci/test_policy.py +++ b/tests/ci/test_policy.py @@ -2,6 +2,7 @@ import gzip import importlib.util import json +import os from pathlib import Path import subprocess import tempfile @@ -54,6 +55,28 @@ def git(*args): paths=profile.changed_paths(base,head) self.assertEqual(set(paths),{'code.c','docs/code.md','gone.c'}) + def test_document_pr_ignores_changes_only_on_advanced_main(self): + with tempfile.TemporaryDirectory() as folder: + def git(*args): + return subprocess.check_output(['git','-C',folder,*args],text=True).strip() + git('init','-q','-b','main');git('config','user.email','fixture@example.invalid');git('config','user.name','Fixture') + root=Path(folder);(root/'README.md').write_text('Original documentation\n');(root/'main.c').write_text('int original;\n') + git('add','.');git('commit','-qm','A: common start');start=git('rev-parse','HEAD') + (root/'main.c').write_text('int main_only;\n') + git('commit','-qam','B: main code change');base=git('rev-parse','HEAD') + git('checkout','-qb','docs',start) + (root/'README.md').write_text('Updated documentation\n') + git('commit','-qam','C: documentation change');head=git('rev-parse','HEAD') + previous=Path.cwd() + try: + os.chdir(folder) + paths=profile.changed_paths(base,head) + finally: + os.chdir(previous) + self.assertEqual(paths,['README.md']) + self.assertEqual(profile.classify(profile.UPSTREAM,'pull_request','refs/pull/1/merge',{},paths), + {'run_full':False,'publish_release':False}) + def needs(self,full=True): return {'changes':{'result':'success','outputs':{'run_full':str(full).lower(),'publish_release':'false'}}, **{job:{'result':'success' if full else 'skipped'} for job in gate.REQUIRED}} diff --git a/tools/ci/check_profile.py b/tools/ci/check_profile.py index 95a684d..d6c834f 100644 --- a/tools/ci/check_profile.py +++ b/tools/ci/check_profile.py @@ -10,7 +10,7 @@ def changed_paths(base, head): - raw = subprocess.check_output(['git', 'diff', '--name-status', '-z', '-M', base, head, '--']) + raw = subprocess.check_output(['git', 'diff', '--name-status', '-z', '-M', '--merge-base', base, head, '--']) fields = raw.decode().split('\0') paths = [] at = 0