diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4b5a3cd..0c87f77 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,8 +9,52 @@ on: permissions: contents: read +# Only superseded PR runs are cancelled; main/release evidence is never cancelled here. +concurrency: + group: ci-${{ github.event_name }}-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: + change-scope: + runs-on: ubuntu-latest + outputs: + mode: ${{ steps.scope.outputs.mode }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "24" + - name: Classify the entire PR conservatively + id: scope + env: + CPS_PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} + CPS_PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: node scripts/ci-docs.mjs classify + - name: Verify classification and gate regressions without installing dependencies + run: node --test test/ci-docs.test.js + + docs-check: + needs: change-scope + if: needs.change-scope.outputs.mode == 'docs' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "24" + - name: Check ordinary documentation + env: + CPS_PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} + CPS_PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: node scripts/ci-docs.mjs check + test: + needs: change-scope + if: needs.change-scope.outputs.mode == 'full' runs-on: ${{ matrix.os }} strategy: fail-fast: false @@ -39,6 +83,8 @@ jobs: - run: npm test workspace-contract: + needs: change-scope + if: needs.change-scope.outputs.mode == 'full' runs-on: ${{ matrix.os }} strategy: fail-fast: false @@ -57,6 +103,8 @@ jobs: run: npm run architecture:check root-package-compat: + needs: change-scope + if: needs.change-scope.outputs.mode == 'full' runs-on: ${{ matrix.os }} strategy: fail-fast: false @@ -77,6 +125,8 @@ jobs: - run: npm run package:smoke:lifecycle web-build: + needs: change-scope + if: needs.change-scope.outputs.mode == 'full' runs-on: ${{ matrix.os }} strategy: fail-fast: false @@ -94,6 +144,8 @@ jobs: - run: npm run web:build web-browser: + needs: change-scope + if: needs.change-scope.outputs.mode == 'full' runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -107,6 +159,8 @@ jobs: - run: npm run web:test:e2e electron-desktop: + needs: change-scope + if: needs.change-scope.outputs.mode == 'full' runs-on: ${{ matrix.os }} strategy: fail-fast: false @@ -167,6 +221,8 @@ jobs: retention-days: 7 electron-release-candidate: + needs: change-scope + if: needs.change-scope.outputs.mode == 'full' runs-on: ${{ matrix.runner }} strategy: fail-fast: false @@ -258,7 +314,8 @@ jobs: retention-days: 30 electron-candidate-set: - needs: electron-release-candidate + needs: [change-scope, electron-release-candidate] + if: needs.change-scope.outputs.mode == 'full' runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -284,6 +341,8 @@ jobs: retention-days: 30 dependency-audit: + needs: change-scope + if: needs.change-scope.outputs.mode == 'full' runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -298,6 +357,8 @@ jobs: run: npm audit --audit-level=high cross-runtime-fixtures: + needs: change-scope + if: needs.change-scope.outputs.mode == 'full' runs-on: windows-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -341,6 +402,8 @@ jobs: retention-days: 30 desktop-test: + needs: change-scope + if: needs.change-scope.outputs.mode == 'full' runs-on: windows-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -359,6 +422,8 @@ jobs: run: dotnet test desktop/CodexProviderSync.GuiE2E.Tests/CodexProviderSync.GuiE2E.Tests.csproj --configuration Release --no-build desktop-macos: + needs: change-scope + if: needs.change-scope.outputs.mode == 'full' runs-on: macos-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -373,6 +438,8 @@ jobs: - run: dotnet build desktop/CodexProviderSync.Mac/CodexProviderSync.Mac.csproj --configuration Release desktop-linux-lock: + needs: change-scope + if: needs.change-scope.outputs.mode == 'full' runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -382,8 +449,9 @@ jobs: - run: dotnet test desktop/CodexProviderSync.Core.Tests/CodexProviderSync.Core.Tests.csproj --filter FullyQualifiedName~LockServiceTests c10-evidence-bundle: - if: ${{ always() }} + if: ${{ always() && needs.change-scope.result == 'success' && needs.change-scope.outputs.mode == 'full' }} needs: + - change-scope - test - workspace-contract - root-package-compat @@ -424,7 +492,7 @@ jobs: CPS_REQUIRED_JOB_RESULTS_JSON: ${{ toJSON(needs) }} CPS_SOURCE_HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} CPS_EVENT_BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} - run: npm run evidence:c10 + run: node scripts/ci-docs.mjs c10 - name: Upload the non-release C10 evidence bundle uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: @@ -438,6 +506,8 @@ jobs: name: ci-gate if: ${{ always() }} needs: + - change-scope + - docs-check - test - workspace-contract - root-package-compat @@ -454,37 +524,11 @@ jobs: - c10-evidence-bundle runs-on: ubuntu-latest steps: - - name: Verify required jobs + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "24" + - name: Verify required jobs without accepting unexpected skips env: - NODE_TEST_RESULT: ${{ needs.test.result }} - WORKSPACE_CONTRACT_RESULT: ${{ needs.workspace-contract.result }} - ROOT_PACKAGE_COMPAT_RESULT: ${{ needs.root-package-compat.result }} - WEB_BUILD_RESULT: ${{ needs.web-build.result }} - WEB_BROWSER_RESULT: ${{ needs.web-browser.result }} - ELECTRON_DESKTOP_RESULT: ${{ needs.electron-desktop.result }} - ELECTRON_RELEASE_CANDIDATE_RESULT: ${{ needs.electron-release-candidate.result }} - ELECTRON_CANDIDATE_SET_RESULT: ${{ needs.electron-candidate-set.result }} - DEPENDENCY_AUDIT_RESULT: ${{ needs.dependency-audit.result }} - CROSS_RUNTIME_FIXTURES_RESULT: ${{ needs.cross-runtime-fixtures.result }} - DESKTOP_TEST_RESULT: ${{ needs.desktop-test.result }} - MACOS_TEST_RESULT: ${{ needs.desktop-macos.result }} - LINUX_LOCK_RESULT: ${{ needs.desktop-linux-lock.result }} - C10_EVIDENCE_RESULT: ${{ needs.c10-evidence-bundle.result }} - run: | - if [ "$NODE_TEST_RESULT" != "success" ] || - [ "$WORKSPACE_CONTRACT_RESULT" != "success" ] || - [ "$ROOT_PACKAGE_COMPAT_RESULT" != "success" ] || - [ "$WEB_BUILD_RESULT" != "success" ] || - [ "$WEB_BROWSER_RESULT" != "success" ] || - [ "$ELECTRON_DESKTOP_RESULT" != "success" ] || - [ "$ELECTRON_RELEASE_CANDIDATE_RESULT" != "success" ] || - [ "$ELECTRON_CANDIDATE_SET_RESULT" != "success" ] || - [ "$DEPENDENCY_AUDIT_RESULT" != "success" ] || - [ "$CROSS_RUNTIME_FIXTURES_RESULT" != "success" ] || - [ "$DESKTOP_TEST_RESULT" != "success" ] || - [ "$MACOS_TEST_RESULT" != "success" ] || - [ "$LINUX_LOCK_RESULT" != "success" ] || - [ "$C10_EVIDENCE_RESULT" != "success" ]; then - echo "One or more required CI jobs did not succeed." - exit 1 - fi + CPS_CI_NEEDS: ${{ toJSON(needs) }} + run: node scripts/ci-docs.mjs gate diff --git a/apps/desktop/electron-builder.yml b/apps/desktop/electron-builder.yml index 94cec65..cc95c28 100644 --- a/apps/desktop/electron-builder.yml +++ b/apps/desktop/electron-builder.yml @@ -1,6 +1,6 @@ appId: com.dailin521.codex-provider-sync productName: Codex Provider Sync -compression: maximum +compression: normal electronLanguages: - en-US - zh-CN diff --git a/desktop/CodexProviderSync.Core.Tests/CrossRuntimeStateDbLockTests.cs b/desktop/CodexProviderSync.Core.Tests/CrossRuntimeStateDbLockTests.cs index b2cfc93..3b94264 100644 --- a/desktop/CodexProviderSync.Core.Tests/CrossRuntimeStateDbLockTests.cs +++ b/desktop/CodexProviderSync.Core.Tests/CrossRuntimeStateDbLockTests.cs @@ -5,6 +5,12 @@ namespace CodexProviderSync.Core.Tests; +[CollectionDefinition("Cross-runtime process locks", DisableParallelization = true)] +public sealed class CrossRuntimeProcessLockCollection; + +// Keep the real Node/.NET contenders inside each test concurrent, while avoiding +// unrelated process-heavy classes competing for the hosted runner's startup budget. +[Collection("Cross-runtime process locks")] public sealed class CrossRuntimeStateDbLockTests { private static readonly TimeSpan NodeReadinessTimeout = TimeSpan.FromSeconds(30); @@ -216,7 +222,11 @@ public async Task RealNodeHomeOwner_MakesDotNetStatusReturnItsLastCompleteSnapsh string script = $$""" import { acquireLock } from {{JsonSerializer.Serialize(LockingModuleUrl())}}; - const release = await acquireLock({{JsonSerializer.Serialize(fixture.CodexHome)}}, "node-home-status-writer"); + process.stderr.write("lock-parity: acquiring-home-lock\n"); + const release = await acquireLock({{JsonSerializer.Serialize(fixture.CodexHome)}}, "node-home-status-writer", { + onCandidateReady: () => process.stderr.write("lock-parity: candidate-ready\n") + }); + process.stderr.write("lock-parity: home-lock-acquired\n"); console.log(JSON.stringify({ ready: true })); await new Promise((resolve) => process.stdin.once("data", resolve)); await release(); diff --git a/docs/CI_PERFORMANCE_ZH.md b/docs/CI_PERFORMANCE_ZH.md new file mode 100644 index 0000000..0d6d7f5 --- /dev/null +++ b/docs/CI_PERFORMANCE_ZH.md @@ -0,0 +1,25 @@ +# CI 与打包提速记录 + +## 本次范围 + +- 普通文档 PR 使用白名单轻量检查,规则见 [Windows 发布流程](WINDOWS_ELECTRON_RELEASE_ZH.md)。按整个 PR 判断,代码 PR 后续只补文档仍完整检查。 +- 同一 PR 新提交取消旧运行;main 和独立发布运行保留。 +- electron-builder 使用 `compression: normal`,保留原包大小限制、产物审计和容器验收。 +- main 最终源码的跨平台检查与发布门禁不变。RC 与正式更新渠道的产物复用、不稳定测试诊断留待后续处理。 + +## Windows ZIP 压缩实测 + +2026-09-11,在同一台 Windows 开发机上,使用锁定的 electron-builder 26.15.7 内部 `archive('zip', ..., { compression, withoutDir: true, preserveSymlinks: false })`,对同一份本地 1.0.2 程序内容分别压缩一次。未设置压缩级别环境变量。该测量用于选择压缩配置,不是新源码的正式发布证据;测试期间机器也执行了其他验证,不能据此承诺 CI 的固定耗时。 + +| 配置 | 耗时 | ZIP 字节数 | +| --- | ---: | ---: | +| maximum | 310.14 秒 | 128,959,840 | +| normal | 42.48 秒 | 129,224,083 | + +本次测量耗时下降约 86%,大小增加 264,243 字节(约 258 KiB,0.20%)。普通压缩 ZIP 低于现有 130 MiB 上限。该版本打包器的 maximum ZIP 使用 `-mx=9 -mfb=258 -mpass=15`,normal 使用 `-mx=7`。 + +两份 ZIP 解压后的 18 个文件逐个 SHA-256 相同。共同的 `resources/app.asar` SHA-256:`914b88f24d954c1ad9a2f5e4db82f1c52b9db67bfa9020e7e36efacf82e7ee9e`。 + +normal ZIP 解压后的生产包 4 项测试通过,包含真实公开更新检查、SQLite、Provider 切换、隔离数据 Sync → Restore。普通压缩 NSIS 通过 prepackaged 构建,大小 104,601,532 字节,低于 105 MiB 上限;未在本机执行实际安装/卸载。macOS/Linux 新压缩配置和真实安装验收仍需后续 CI 验证。 + +复测时使用固定输入目录及新的输出路径,记录打包器版本、源文件哈希、环境变量、耗时、大小及解压后的逐文件哈希。不要复用已存在 ZIP,以免打包器按时间戳跳过压缩;不要放宽包大小限制来使测量通过。 diff --git a/docs/MAIN_BRANCH_PROTECTION.md b/docs/MAIN_BRANCH_PROTECTION.md index b3fa649..cf21398 100644 --- a/docs/MAIN_BRANCH_PROTECTION.md +++ b/docs/MAIN_BRANCH_PROTECTION.md @@ -148,7 +148,8 @@ PR #63 已在 `.github/workflows/ci.yml` 增加名称固定的汇总 Job: - Windows Desktop 测试必须成功 - macOS 构建和 Core 测试必须成功 - Linux 文件锁测试必须成功 -- 任一依赖失败、取消或跳过,`ci-gate` 都失败 +- 完整 CI 模式下任一业务依赖失败、取消或跳过,`ci-gate` 都失败 +- 普通文档 PR 例外:整个 PR 非空差异均在根 README、CHANGELOG、版本中文发布说明白名单中时,分类和文档校验必须成功,重型任务及 C10 必须全部明确为 skipped,才允许 `ci-gate` 通过。main push 始终完整;轻量结果不作为发布证据。具体范围见 [ADR-0039 增补](adr/0039-windows-first-electron-release.md) 和 [发布流程](WINDOWS_ELECTRON_RELEASE_ZH.md)。上方旧四任务 YAML 仅说明稳定汇总门禁原理;当前精确任务清单与模式判定以 `.github/workflows/ci.yml`、`scripts/ci-docs.mjs` 为准。 - Ruleset 只绑定名称稳定的 `ci-gate` 未来可以修改矩阵版本或增加内部检查,而不必同步修改 `main` 的 Required status diff --git a/docs/WINDOWS_ELECTRON_RELEASE_ZH.md b/docs/WINDOWS_ELECTRON_RELEASE_ZH.md index fcafb10..1306d7d 100644 --- a/docs/WINDOWS_ELECTRON_RELEASE_ZH.md +++ b/docs/WINDOWS_ELECTRON_RELEASE_ZH.md @@ -5,12 +5,20 @@ ## 1. 提交与合并 1. 保留本地已有变更,核对源码、合同、配套生成物与测试清单。不要提交 `output/`、Playwright traces、真实数据或本地安装目录。 -2. 首次 V1 迁移使用 V1 分支;后续补丁从最新 `origin/main` 创建独立修复分支。以可审计提交推送并创建指向 main 的 PR,等待当前 head 对应的完整 `ci-gate` 成功;旧 head 的绿灯不可复用。 +2. 首次 V1 迁移使用 V1 分支;后续补丁从最新 `origin/main` 创建独立修复分支。以可审计提交推送并创建指向 main 的 PR,等待当前 head 对应的 `ci-gate` 成功;产品改动必须完整检查,普通文档例外见下文,旧 head 的绿灯不可复用。 3. 处理所有未解决审查,采用 merge commit 合并,禁止 squash、rebase、force-push 或绕过失败门禁。 4. main 合入后的实际 SHA 必须再次通过 `ci-gate`。所有产物与测试结论记录实际 SHA,不以 PR 分支名代替。 部分重跑:GitHub 的“仅重跑失败 job”会保留本次 workflow 中已成功 job 的产物。C10 可复用同一仓库、同一 `runId`、同一 tested commit 且来自当前或更早正整数 `runAttempt` 的历史 Release 验证证据;拒绝跨运行、跨提交、未来或无效次数。全部 required jobs 仍必须成功,Release 资产/二进制/备份哈希校验不变。新 bundle 的 `workflow.runAttempt` 记录当前次数,`historicalFormalRelease.sourceRunAttempt` 如实记录证据来源次数,不改写源证据;v1 schema 将该新增字段设为可选,仅为兼容历史 bundle。重跑成功不抹去之前失败的记录,也不授权发布。 +### 普通文档 PR 的轻量检查 + +仅 `pull_request` 的整个 base…head 差异非空、且所有路径都属于根 `README.md`、`CHANGELOG.md` 或 `docs/release-notes/v*-zh.md` 时,允许轻量检查。不按最后一次提交判断;重命名检查新旧两个路径。合同、ADR、行为夹具、发布流程文档、工作流、配置、依赖和代码均触发完整 CI;未知路径或空差异也走完整 CI,Git 比较失败直接使门禁失败。 + +轻量检查无需安装依赖,验证改动文档的本地链接目标、全项目版本一致性和改动的中文发布说明。删除白名单文档会使检查失败,避免遗漏未修改文档中的入链;本地目标支持平衡或转义括号。远程链接与 Markdown 锚点不做可达性验证。重型任务和 C10 明确跳过,但 `ci-gate` 始终执行,只有分类与文档检查成功且跳过状态符合预期才通过。轻量 PR 不生成候选包或 C10 发布证据。 + +main push 始终完整执行全部原有任务和 C10,最终 SHA 的发布门禁不变。同一 PR 新提交可取消过期检查;main 检查和独立发布工作流不被该规则取消。首次提交前一次性核对代码、版本、文档和测试要求,避免补漏引发重复 CI。 + ## 2. 候选包与独立流程 `.github/workflows/publish-electron-windows.yml` 是独立的手动候选准备流程。现有 `publish.yml` 仍构建 Legacy .NET;`publish-npm.yml` 仍发布 npm。本轮不得运行后两者。 diff --git a/docs/adr/0039-windows-first-electron-release.md b/docs/adr/0039-windows-first-electron-release.md index f120007..051043a 100644 --- a/docs/adr/0039-windows-first-electron-release.md +++ b/docs/adr/0039-windows-first-electron-release.md @@ -6,6 +6,14 @@ ## 决策 +### 2026-09-12:普通文档 PR 的合并检查例外 + +维护者确认 CI 提速后,普通文档 PR 可采用轻量检查,取代本文对这类 PR 一律完整 CI 的要求。范围仅为整个 PR 非空差异全部属于根 `README.md`、`CHANGELOG.md` 或版本中文发布说明;不按最后提交判断,不包含本 ADR、行为契约、工作流、依赖或代码。删除文档不能通过,重命名检查两侧,分类失败不放行。文档链接、版本与公告检查通过后,稳定名称 `ci-gate` 仅接受精确清单中预期的跳过状态。 + +轻量检查不产生 C10 或候选产物。产品 PR、main push 和正式发布继续遵守下述完整跨平台及最终 SHA 门禁;本增补不允许用轻量 PR 绿灯发布。同一 PR 的旧检查可由新提交取消,main 和独立发布运行不受该取消规则影响。规则及夹具分别见 [发布流程](../WINDOWS_ELECTRON_RELEASE_ZH.md)、[行为夹具](../migration/BEHAVIOR_FIXTURES_ZH.md)。 + +### 原始发布决策与后续版本约束 + 2026-09-11 补丁发布约束:输入与 Draft 创建前均从已核验 tag 对象读取根/桌面 `package.json`,两个源码版本必须等于正式版本或 RC 的基础版本;不能用 dispatch 分支清单替代,不允许将 1.0.1 源码注入成 1.0.2。Release 存在性检查同样支持严格 1.0.x,仍拒绝覆盖已有 Draft/公开版本。 维护者确认先交付已进行人工功能测试的 Windows Electron。整理并推送 V1,在最终 PR 的完整 CI 通过后,使用 merge commit 保留既有 checkpoint 历史。最新 main 必须已包含于候选;main 合入后的实际 commit 需要重新取得成功的 `ci-gate` 与绑定该 SHA 的产物证据。 diff --git a/docs/architecture/contracts/CORE_EXTERNAL_BEHAVIOR_ZH.md b/docs/architecture/contracts/CORE_EXTERNAL_BEHAVIOR_ZH.md index b2bb514..13f4d52 100644 --- a/docs/architecture/contracts/CORE_EXTERNAL_BEHAVIOR_ZH.md +++ b/docs/architecture/contracts/CORE_EXTERNAL_BEHAVIOR_ZH.md @@ -1036,7 +1036,8 @@ ADR-0042 补充:`stable-updater` 显式启用 Windows 安装版应用内下载 - Electron 优先使用 `node:sqlite`;`better-sqlite3` 作为 production fallback 必须针对当前 Electron ABI 验证加载。ASAR 只能引用当前 target 的一个 native binding,且该 binding 是 `app.asar.unpacked` 中唯一文件;其它平台 prebuild、source、build/deps 不得进入包。 - 每个最终容器都必须实际解包或安装,并与 staging audit 逐字段一致;审计覆盖 ASAR 全文件/block hash、embedded header binding、fuse wire、敏感路径/文件/高置信 token、fixture/test/source map、native binding 与 production buildId。Windows NSIS 还必须完成静默卸载清理。 - packaged smoke 只使用临时 synthetic fixture,以隐藏窗口启动正式 executable,验证 production test bridge 不存在、真实 SQLite Status、Sync→Restore byte/hash 回环与正常退出;不得访问真实 Codex Home、`auth.json`、凭据或消息正文。 -- 每个目标输出 CycloneDX SBOM、最终容器报告、release manifest 和 `SHA256SUMS.txt`。checksum 必须精确覆盖所有资产与 metadata;aggregate 必须证明四目标 version/commit/lockfile/tool versions/fuse policy/audit policy 一致,且任一 matrix job 失败、取消或跳过都使唯一 `ci-gate` 失败。 +- 完整 CI 中每个目标输出 CycloneDX SBOM、最终容器报告、release manifest 和 `SHA256SUMS.txt`。checksum 必须精确覆盖所有资产与 metadata;aggregate 必须证明四目标 version/commit/lockfile/tool versions/fuse policy/audit policy 一致,且任一 matrix job 失败、取消或跳过都使唯一 `ci-gate` 失败。 +- 2026-09-12 普通文档 PR 例外(ADR-0039 增补):仅整个 PR 的非空 base…head 差异均为根 `README.md`、`CHANGELOG.md` 或 `docs/release-notes/v*-zh.md` 时,允许文档检查成功后按固定清单跳过重型任务及 C10。重命名检查新旧路径,删除文档拒绝通过;未知路径、空差异走完整 CI,差异读取失败使门禁失败。`ci-gate` 始终执行,分类、文档校验或任务结果不符合预期均拒绝。main push 始终完整检查;轻量 PR 不生成候选包/C10,也不能授权发布或替代最终 main SHA 的完整证据。此例外不改变 Core 业务行为或 Provider I/O。 - 推送 tag 不得自动发布。旧发布工作流改为显式 `workflow_dispatch` 并要求既有 `v` 前缀 tag 位于 `main`;这只是发布授权后的入口,不表示当前已获 tag、npm/GitHub Release、签名、公证或更新通道授权。 ### 16.9 Desktop Host UI、日志与 Profile 扩展(不改变 CoreFacade) diff --git a/docs/migration/BEHAVIOR_FIXTURES_ZH.md b/docs/migration/BEHAVIOR_FIXTURES_ZH.md index 25c9e2c..2b80a51 100644 --- a/docs/migration/BEHAVIOR_FIXTURES_ZH.md +++ b/docs/migration/BEHAVIOR_FIXTURES_ZH.md @@ -1,5 +1,9 @@ # vNext 行为兼容 Fixture 清单 +## ADR-0039 增补:普通文档 PR 的 CI 分流 + +`test/ci-docs.test.js` 验证整个 PR 比较(早期代码改动不可被后续文档提交掩盖)、重命名两侧、白名单/未知/空差异、main 始终完整、分类失败、精确任务清单、非预期失败/取消/跳过拒绝,以及 C10 业务结果保真。链接夹具覆盖删除公告入链、括号路径/标题、非渲染示例、百分号及 HTML 目标。`test/release-packaging-contract.test.js` 继续验证四目标和稳定 gate;不以纯文档跳过结果生成或替代正式发布证据。 + ## ADR-0043:正常聊天不阻断 Status `test/status-coordination.test.js`:持续正文追加、非 Provider SQLite 更新/WAL checkpoint 仍返回完整 Provider 状态;不整读正文/数据库。持续 Provider/archived/config 漂移、revision 故障、实际锁仍阻断;HTTP DTO 一致。Sync Plan/Apply 和完整 Diagnostics 的原门禁保留。 diff --git a/scripts/ci-docs.mjs b/scripts/ci-docs.mjs new file mode 100644 index 0000000..07ee552 --- /dev/null +++ b/scripts/ci-docs.mjs @@ -0,0 +1,235 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; +import { pathToFileURL } from "node:url"; +import { verifyReleaseVersion } from "./verify-release-version.js"; +import { readReleaseMetadata } from "./read-release-metadata.js"; + +export const HEAVY_JOBS = Object.freeze([ + "test", "workspace-contract", "root-package-compat", "web-build", "web-browser", + "electron-desktop", "electron-release-candidate", "electron-candidate-set", + "dependency-audit", "cross-runtime-fixtures", "desktop-test", "desktop-macos", + "desktop-linux-lock", "c10-evidence-bundle", +]); + +export function isOrdinaryDoc(file) { + return typeof file === "string" && /^(?:README\.md|CHANGELOG\.md|docs\/release-notes\/v[0-9][0-9A-Za-z.+-]*-zh\.md)$/.test(file); +} + +export function classifyChanges(event, files) { + return event === "pull_request" && Array.isArray(files) && files.length > 0 && files.every(isOrdinaryDoc) + ? "docs" : "full"; +} + +export function changedFiles(root, base, head) { + assert.match(base ?? "", /^[a-f0-9]{40}$/); + assert.match(head ?? "", /^[a-f0-9]{40}$/); + // Compare the whole PR, not the last commit. Disable rename detection so both paths count. + const result = execFileSync("git", ["diff", "--name-only", "--no-renames", "-z", `${base}...${head}`, "--"], + { cwd: root, encoding: "utf8", stdio: "pipe", maxBuffer: 16 * 1024 * 1024 }); + return result.split("\0").filter(Boolean); +} + +export function verifyGate(event, needs) { + assert.equal(needs["change-scope"]?.result, "success", "Change classification must succeed"); + const mode = needs["change-scope"].outputs?.mode; + assert.ok(mode === "docs" || mode === "full", "Missing or invalid CI mode"); + assert.deepEqual(Object.keys(needs).sort(), ["change-scope", "docs-check", ...HEAVY_JOBS].sort(), "CI job inventory drift"); + if (mode === "docs") { + assert.equal(event, "pull_request", "Only PRs may skip full CI"); + assert.equal(needs["docs-check"].result, "success", "Documentation checks must pass"); + for (const job of HEAVY_JOBS) assert.equal(needs[job]?.result, "skipped", `${job} must explicitly skip in docs mode`); + } else { + assert.equal(needs["docs-check"].result, "skipped"); + for (const job of HEAVY_JOBS) assert.equal(needs[job]?.result, "success", `${job} did not succeed`); + } +} + +export function c10BusinessJobs(needs) { + assert.equal(needs["change-scope"]?.result, "success"); + assert.equal(needs["change-scope"]?.outputs?.mode, "full"); + const { "change-scope": control, ...jobs } = needs; + assert.deepEqual(Object.keys(jobs).sort(), HEAVY_JOBS.filter((job) => job !== "c10-evidence-bundle").sort()); + return jobs; +} + +function readLinkDestination(text, start) { + let end = start; + const angled = text[start] === "<"; + if (angled) end++; + const contentStart = end; + let depth = 0; + for (; end < text.length; end++) { + const char = text[end]; + if (char === "\\" && /[!"#$%&'()*+,\-./:;<=>?@[\]\\^_`{|}~]/.test(text[end + 1] ?? "")) { end++; continue; } + if (angled) { + if (char === ">") return { target: text.slice(contentStart, end), end: end + 1 }; + if (char === "\n" || char === "<") return null; + } else { + if (/\s/.test(char) || (char === ")" && depth === 0)) break; + if (char === "(") depth++; + if (char === ")") depth--; + } + } + return !angled && depth === 0 && end > start ? { target: text.slice(start, end), end } : null; +} + +function maskExamples(body) { + // Match code delimiters by their full run length; shorter runs are code content. + // Scan in source order so fence-like text inside comments/code cannot hide later links. + let result = ""; + for (let i = 0; i < body.length;) { + let end = i; + const lineStart = i === 0 || body[i - 1] === "\n"; + const indented = lineStart && /^(?: {4}| {0,3}\t)/.test(body.slice(i)); + const preceding = body.slice(0, i).trimEnd(); + // Indented code cannot interrupt a paragraph. Only start after a blank line + // (or at BOF); consume its indented continuation and intervening blank lines. + const blankBefore = i === 0 || /\n[ \t]*\r?\n$/.test(body.slice(0, i)); + const fence = lineStart && /^ {0,3}(`{3,}|~{3,})([^\n]*)/.exec(body.slice(i)); + if (indented && (blankBefore || !preceding)) { + end = i; + while (end < body.length) { + const lineEnd = body.indexOf("\n", end); + const next = lineEnd < 0 ? body.length : lineEnd + 1; + const line = body.slice(end, next); + if (!/^(?: {4}| {0,3}\t)/.test(line) && line.trim()) break; + end = next; + } + } else if (fence && !(fence[1][0] === "`" && fence[2].includes("`"))) { + const closers = /^ {0,3}(`{3,}|~{3,})[ \t]*\r?$/gm; + closers.lastIndex = i + fence[0].length; + end = body.length; + let close; + while ((close = closers.exec(body))) { + if (close[1][0] === fence[1][0] && close[1].length >= fence[1].length) { end = closers.lastIndex; break; } + } + } else if (body.startsWith("", i + 4); + end = close < 0 ? body.length : close + 3; + } else if (body[i] === "`") { + let escapes = 0; + for (let j = i - 1; j >= 0 && body[j] === "\\"; j--) escapes++; + const opening = /^`+/.exec(body.slice(i))[0]; + if (!(escapes % 2)) { + const runs = /`+/g; + runs.lastIndex = i + opening.length; + let run; + while ((run = runs.exec(body))) { + if (run[0].length === opening.length) { end = runs.lastIndex; break; } + } + } + if (end === i) { result += opening; i += opening.length; continue; } + } + if (end > i) { result += body.slice(i, end).replace(/[^\r\n]/g, " "); i = end; } + else result += body[i++]; + } + return result; +} + +function hasLinkEnd(text) { + if (/^\s*\)/.test(text)) return true; + const title = /^\s+(["'(])/.exec(text); + if (!title) return false; + const close = title[1] === "(" ? ")" : title[1]; + for (let i = title[0].length; i < text.length; i++) { + if (text[i] === "\\") { i++; continue; } + if (text[i] === close) return /^\s*\)/.test(text.slice(i + 1)); + } + return false; +} + +function decodeEntities(target) { + return target.replace(/&(#x[0-9a-f]+|#[0-9]+|[a-z][a-z0-9]+);/gi, (entity, value) => { + if (value[0] === "#") { + const code = value[1].toLowerCase() === "x" ? parseInt(value.slice(2), 16) : Number(value.slice(1)); + assert.ok(code > 0 && code <= 0x10ffff && !(code >= 0xd800 && code <= 0xdfff), "Invalid link character entity"); + return String.fromCodePoint(code); + } + const named = { amp: "&", quot: '"', apos: "'", lt: "<", gt: ">", percnt: "%" }; + assert.ok(Object.prototype.hasOwnProperty.call(named, value), `Unsupported link character entity: ${entity}`); + return named[value]; + }); +} + +function decodePercent(target) { + // Decode one UTF-8 sequence at a time, retaining literal or malformed percent text. + return target.replace(/%(?:[0-7][0-9a-f]|[cd][0-9a-f]%[89ab][0-9a-f]|e[0-9a-f](?:%[89ab][0-9a-f]){2}|f[0-4](?:%[89ab][0-9a-f]){3})/gi, + (sequence) => { try { return decodeURIComponent(sequence); } catch { return sequence; } }); +} + +function linkTargets(body) { + const targets = []; + for (const match of body.matchAll(/\]\(\s*/g)) { + let backslashes = 0; + for (let i = match.index - 1; i >= 0 && body[i] === "\\"; i--) backslashes++; + if (backslashes % 2) continue; + const destination = readLinkDestination(body, match.index + match[0].length); + if (!destination) continue; + if (hasLinkEnd(body.slice(destination.end))) targets.push(destination.target); + } + for (const match of body.matchAll(/^\s*\[(?!\^)[^\]]+\]:\s*/gm)) { + const destination = readLinkDestination(body, match.index + match[0].length); + if (destination) targets.push(destination.target); + } + for (const tag of body.matchAll(/<(img|a)\b(?:[^>"']|"[^"]*"|'[^']*')*>/gi)) { + const attribute = tag[1].toLowerCase() === "img" ? "src" : "href"; + const attributes = tag[0].slice(tag[1].length + 1, -1); + for (const match of attributes.matchAll(/([^\s=/>]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g)) { + if (match[1].toLowerCase() === attribute) targets.push(match[2] ?? match[3] ?? match[4] ?? ""); + } + } + return targets; +} + +export function verifyLocalLinks(root, file) { + const absolute = path.join(root, file); + assert.ok(fs.existsSync(absolute), `${file}: missing document; deleted documents cannot pass the lightweight check`); + assert.ok(fs.lstatSync(absolute).isFile(), `${file} must be a regular document`); + const body = maskExamples(fs.readFileSync(absolute, "utf8")); + // Inline links/images and reference destinations; remote URLs and anchors are not fetched. + const targets = linkTargets(body); + for (let target of targets) { + target = decodeEntities(target.replace(/\\([!"#$%&'()*+,\-./:;<=>?@[\]\\^_`{|}~])/g, "$1")); + if (/^(?:[a-z][a-z0-9+.-]*:|\/\/|#)/i.test(target)) continue; + target = decodePercent(target.split(/[?#]/)[0]); + if (!target) continue; + const destination = target.startsWith("/") ? path.join(root, target) : path.resolve(path.dirname(absolute), target); + const relative = path.relative(root, destination); + assert.ok(relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative), `${file}: link escapes repository`); + assert.ok(fs.existsSync(destination), `${file}: missing local link ${target}`); + } +} + +function main() { + const root = process.cwd(); + const event = process.env.GITHUB_EVENT_NAME; + const command = process.argv[2]; + if (command === "classify") { + const files = event === "pull_request" ? changedFiles(root, process.env.CPS_PR_BASE_SHA, process.env.CPS_PR_HEAD_SHA) : []; + const mode = classifyChanges(event, files); + fs.appendFileSync(process.env.GITHUB_OUTPUT, `mode=${mode}\n`); + console.log(`CI mode: ${mode}; ${files.length} changed PR paths`); + } else if (command === "check") { + const files = changedFiles(root, process.env.CPS_PR_BASE_SHA, process.env.CPS_PR_HEAD_SHA); + assert.equal(classifyChanges(event, files), "docs", "Recheck entire PR before accepting docs mode"); + for (const file of files) verifyLocalLinks(root, file); + const version = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8")).version; + verifyReleaseVersion({ rootDir: root, tag: `v${version}` }); + for (const file of files.filter((entry) => entry.startsWith("docs/release-notes/"))) { + readReleaseMetadata({ rootDir: root, tag: path.basename(file, "-zh.md") }); + } + console.log("Documentation links, version consistency and changed release announcements passed."); + } else if (command === "gate") { + verifyGate(event, JSON.parse(process.env.CPS_CI_NEEDS)); + } else if (command === "c10") { + const needs = c10BusinessJobs(JSON.parse(process.env.CPS_REQUIRED_JOB_RESULTS_JSON)); + // Keep the existing exact C10 business-job inventory and evidence schema unchanged. + execFileSync(process.execPath, ["scripts/write-c10-evidence-bundle.mjs"], { + stdio: "inherit", env: { ...process.env, CPS_REQUIRED_JOB_RESULTS_JSON: JSON.stringify(needs) }, + }); + } else throw new Error("Usage: node scripts/ci-docs.mjs classify|check|gate|c10"); +} + +if (process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url) main(); diff --git a/test/ci-docs.test.js b/test/ci-docs.test.js new file mode 100644 index 0000000..f2d4301 --- /dev/null +++ b/test/ci-docs.test.js @@ -0,0 +1,220 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; +import test from "node:test"; +import { changedFiles, classifyChanges, c10BusinessJobs, HEAVY_JOBS, verifyGate, verifyLocalLinks } from "../scripts/ci-docs.mjs"; + +function fixture(run) { + const base = process.platform === "win32" && fs.existsSync("D:/Temp") ? "D:/Temp" : os.tmpdir(); + const root = fs.mkdtempSync(path.join(base, "cps-docs-ci-")); + try { return run(root); } + finally { fs.rmSync(root, { recursive: true, force: true }); } +} + +test("only explicit ordinary docs in a nonempty whole PR permit the light path", () => { + const docs = ["README.md", "CHANGELOG.md", "docs/release-notes/v1.0.2-zh.md"]; + assert.equal(classifyChanges("pull_request", docs), "docs"); + for (const file of ["src/main.js", "package-lock.json", ".github/workflows/ci.yml", "AGENTS.md", + "docs/adr/0042.md", "docs/architecture/contracts/CORE.md", "docs/migration/BEHAVIOR_FIXTURES_ZH.md", + "docs/WINDOWS_ELECTRON_RELEASE_ZH.md", "docs/release-notes/../config.md", "README.md\nother.js"]) { + assert.equal(classifyChanges("pull_request", [...docs, file]), "full", file); + } + for (const event of ["push", undefined, "workflow_dispatch"]) assert.equal(classifyChanges(event, docs), "full"); + for (const files of [[], undefined, null, [""]]) assert.equal(classifyChanges("pull_request", files), "full"); +}); + +test("whole PR comparison retains earlier code changes and both sides of renames", () => fixture((root) => { + const git = (...args) => execFileSync("git", args, { cwd: root, encoding: "utf8" }).trim(); + git("init", "--quiet"); + git("config", "core.autocrlf", "false"); + git("config", "user.name", "CI fixture"); + git("config", "user.email", "fixture@example.invalid"); + fs.writeFileSync(path.join(root, "README.md"), "initial\n"); + git("add", "."); git("commit", "--quiet", "-m", "base"); + const base = git("rev-parse", "HEAD"); + fs.writeFileSync(path.join(root, "code.js"), "// product change\n"); + git("add", "."); git("commit", "--quiet", "-m", "code"); + fs.appendFileSync(path.join(root, "README.md"), "docs followup\n"); + git("add", "."); git("commit", "--quiet", "-m", "docs"); + assert.equal(classifyChanges("pull_request", changedFiles(root, base, git("rev-parse", "HEAD"))), "full"); + const beforeRename = git("rev-parse", "HEAD"); + git("mv", "code.js", "CHANGELOG.md"); git("commit", "--quiet", "-m", "rename"); + assert.deepEqual(changedFiles(root, beforeRename, git("rev-parse", "HEAD")).sort(), ["CHANGELOG.md", "code.js"]); + assert.throws(() => changedFiles(root, "invalid", beforeRename)); + assert.throws(() => changedFiles(root, "a".repeat(40), beforeRename)); +})); + +function results(mode) { + return { "change-scope": { result: "success", outputs: { mode } }, + "docs-check": { result: mode === "docs" ? "success" : "skipped" }, + ...Object.fromEntries(HEAVY_JOBS.map((job) => [job, { result: mode === "docs" ? "skipped" : "success" }])) }; +} + +test("gate accepts documented skips only for a verified PR docs run", () => { + verifyGate("pull_request", results("docs")); + verifyGate("push", results("full")); + assert.throws(() => verifyGate("push", results("docs"))); + for (const mode of ["docs", "full"]) { + for (const result of ["failure", "cancelled", "skipped"]) { + const needs = results(mode); needs["change-scope"].result = result; + assert.throws(() => verifyGate("pull_request", needs)); + } + for (const job of HEAVY_JOBS) { + for (const result of ["failure", "cancelled", mode === "full" ? "skipped" : "success"]) { + const needs = results(mode); needs[job].result = result; + assert.throws(() => verifyGate("pull_request", needs), `${mode}: ${job}: ${result}`); + } + } + } + const failedDocs = results("docs"); failedDocs["docs-check"].result = "failure"; + assert.throws(() => verifyGate("pull_request", failedDocs)); + const unknownMode = results("full"); delete unknownMode["change-scope"].outputs.mode; + assert.throws(() => verifyGate("pull_request", unknownMode)); + const missing = results("full"); delete missing["desktop-test"]; + assert.throws(() => verifyGate("push", missing)); +}); + +test("local links validate existing destinations without remote requests", () => fixture((root) => { + fs.writeFileSync(path.join(root, "target.md"), "# target\n"); + const readme = path.join(root, "README.md"); + fs.writeFileSync(readme, "[local](target.md#section)\n[remote](https://example.invalid/no-network)\n[ref]: target.md\n"); + verifyLocalLinks(root, "README.md"); + fs.writeFileSync(readme, "[broken](missing.md)\n"); + assert.throws(() => verifyLocalLinks(root, "README.md"), /missing local link/); + fs.writeFileSync(readme, "[escape](../outside.md)\n"); + assert.throws(() => verifyLocalLinks(root, "README.md"), /escapes repository/); +})); + +test("deleted ordinary release notes fail even when their incoming links are unchanged", () => fixture((root) => { + const note = "docs/release-notes/v1.0.1-zh.md"; + fs.mkdirSync(path.join(root, "docs/release-notes"), { recursive: true }); + fs.writeFileSync(path.join(root, "README.md"), `[Release](${note})\n`); + fs.writeFileSync(path.join(root, note), "Release notes\n"); + verifyLocalLinks(root, note); + fs.unlinkSync(path.join(root, note)); + assert.equal(classifyChanges("pull_request", [note]), "docs"); + assert.throws(() => verifyLocalLinks(root, note), /missing document/); +})); + +test("local destinations preserve balanced, escaped and angle-bracket parentheses", () => fixture((root) => { + const readme = path.join(root, "README.md"); + for (const file of ["guide(v2).md", "guide(v2(nested)).md", "guide).md", "guide (v2).md"]) { + fs.writeFileSync(path.join(root, file), "guide\n"); + } + fs.writeFileSync(readme, [ + '[guide](guide(v2).md "Title")', + "![nested](guide(v2(nested)).md)", + String.raw`[escaped](guide\(v2\).md)`, + String.raw`[escaped close](guide\).md)`, + '[angle]( "Title")', + "[reference]: guide(v2(nested)).md", + String.raw`[escaped-reference]: guide\).md`, + '[angle-reference]: "Title"', + ].join("\n")); + verifyLocalLinks(root, "README.md"); + for (const link of ["[broken](missing(v2).md)", String.raw`[broken](missing\).md)`, + "[broken]()", "[ref]: missing(v2).md"]) { + fs.writeFileSync(readme, link); + assert.throws(() => verifyLocalLinks(root, "README.md"), /missing local link/); + } +})); + +test("parenthesized titles validate active destinations", () => fixture((root) => { + fs.writeFileSync(path.join(root, "target.md"), "target"); + const readme = path.join(root, "README.md"); + fs.writeFileSync(readme, "[guide](target.md (legacy))"); + verifyLocalLinks(root, "README.md"); + fs.writeFileSync(readme, "[guide](missing.md (legacy))"); + assert.throws(() => verifyLocalLinks(root, "README.md"), /missing local link missing.md/); +})); + +test("code spans, fences and HTML comments do not expose example links", () => fixture((root) => { + const readme = path.join(root, "README.md"); + const examples = [ + "`[example](missing.md)`", "`` ` [example](missing.md) ``", + "```inline [example](missing.md) ```", "", + "````markdown\n```\n[example](missing.md)\n````", + "~~~html\n\n~~~", "`example`", + "", + ].join("\n"); + fs.writeFileSync(readme, examples); + verifyLocalLinks(root, "README.md"); + fs.writeFileSync(readme, `${examples}\n[active](missing.md (title))`); + assert.throws(() => verifyLocalLinks(root, "README.md"), /missing local link/); + fs.writeFileSync(readme, "\n[active](missing.md)"); + assert.throws(() => verifyLocalLinks(root, "README.md"), /missing local link/); + fs.writeFileSync(readme, "````\n[example](missing.md)\n```"); + verifyLocalLinks(root, "README.md"); // Unclosed fence continues to EOF. +})); + +test("footnotes and indented code do not create spurious destinations", () => fixture((root) => { + const readme = path.join(root, "README.md"); + for (const body of ["[^1]: Explanatory prose", " [sample](missing.md)\n ", + "Paragraph\n\n\t[sample](missing.md)\n\n [sample](missing.md)"]) { + fs.writeFileSync(readme, body); + verifyLocalLinks(root, "README.md"); + } + for (const body of ["[^1]: See [active](missing.md)", " [sample](ignored.md)\n\n[active](missing.md)", + "Paragraph\n [active](missing.md)"]) { + fs.writeFileSync(readme, body); + assert.throws(() => verifyLocalLinks(root, "README.md"), /missing local link/); + } +})); + +test("literal percent and valid UTF-8 escapes coexist in local paths", () => fixture((root) => { + const readme = path.join(root, "README.md"); + for (const name of ["100%.md", "100% 文.md", "%FF.md"]) fs.writeFileSync(path.join(root, name), "target"); + for (const target of ["100%.md", "100%25.md", "100%%20%E6%96%87.md", "%FF.md"]) { + fs.writeFileSync(readme, `[guide](${target})`); + verifyLocalLinks(root, "README.md"); + } + fs.writeFileSync(readme, "[guide](missing%20%E6%96%87%.md)"); + assert.throws(() => verifyLocalLinks(root, "README.md"), /missing local link missing 文%.md/); +})); + +test("rendered HTML image sources and anchor destinations are validated", () => fixture((root) => { + const readme = path.join(root, "README.md"); + fs.writeFileSync(path.join(root, "target.md"), "target"); + fs.writeFileSync(path.join(root, "image&photo.png"), "image"); + fs.writeFileSync(readme, `link\n\nlink`); + verifyLocalLinks(root, "README.md"); + for (const html of ['', "link", "", + '', '']) { + fs.writeFileSync(readme, html); + assert.throws(() => verifyLocalLinks(root, "README.md"), /missing local link|escapes repository/); + } + fs.writeFileSync(readme, ''); + assert.throws(() => verifyLocalLinks(root, "README.md"), /Unsupported link character entity/); +})); + +test("C10 retains exactly the original business results including failures", () => { + const needs = results("full"); + delete needs["docs-check"]; + delete needs["c10-evidence-bundle"]; + needs["desktop-test"].result = "failure"; + const jobs = c10BusinessJobs(needs); + assert.equal(Object.keys(jobs).length, 13); + assert.equal(jobs["desktop-test"].result, "failure"); + assert.ok(needs["change-scope"], "Do not mutate caller evidence"); + assert.throws(() => c10BusinessJobs({ ...needs, unexpected: { result: "success" } })); + assert.throws(() => c10BusinessJobs({ ...needs, "change-scope": { result: "success", outputs: { mode: "docs" } } })); +}); + +test("workflow wires every heavy job and C10 to full mode while always emitting the gate", () => { + const workflow = fs.readFileSync(new URL("../.github/workflows/ci.yml", import.meta.url), "utf8"); + const jobs = Object.fromEntries([...workflow.slice(workflow.indexOf("jobs:")).matchAll(/^ ([a-z][a-z0-9-]+):\r?\n([\s\S]*?)(?=^ [a-z][a-z0-9-]+:|$(?![\s\S]))/gm)] + .map((match) => [match[1], match[2]])); + assert.deepEqual(Object.keys(jobs).sort(), ["change-scope", "docs-check", "ci-gate", ...HEAVY_JOBS].sort()); + for (const job of HEAVY_JOBS) { + assert.match(jobs[job], /needs[\s\S]*change-scope/); + assert.match(jobs[job], /if: .*needs\.change-scope\.outputs\.mode == 'full'/); + assert.ok(jobs["ci-gate"].includes(`- ${job}\n`) || jobs["ci-gate"].includes(`- ${job}\r\n`)); + } + assert.match(jobs["ci-gate"], /if: \$\{\{ always\(\) \}\}/); + assert.match(jobs["ci-gate"], /CPS_CI_NEEDS: \$\{\{ toJSON\(needs\) \}\}/); + assert.match(jobs["ci-gate"], /node scripts\/ci-docs\.mjs gate/); + assert.match(jobs["c10-evidence-bundle"], /node scripts\/ci-docs\.mjs c10/); + assert.match(workflow, /cancel-in-progress: \$\{\{ github\.event_name == 'pull_request' \}\}/); +}); diff --git a/test/release-packaging-contract.test.js b/test/release-packaging-contract.test.js index 9a4e99f..49cfac3 100644 --- a/test/release-packaging-contract.test.js +++ b/test/release-packaging-contract.test.js @@ -63,11 +63,14 @@ test("CI requires all four native Electron candidates and their aggregate index" "desktop:smoke:candidate:artifacts", "desktop:verify:candidate:set" ]) assert.match(workflow, new RegExp(value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); - assert.match(workflow, /ELECTRON_RELEASE_CANDIDATE_RESULT/); - assert.match(workflow, /ELECTRON_CANDIDATE_SET_RESULT/); + const gate = workflow.slice(workflow.indexOf(" ci-gate:")); + assert.match(gate, /- electron-release-candidate/); + assert.match(gate, /- electron-candidate-set/); + assert.match(gate, /CPS_CI_NEEDS: \$\{\{ toJSON\(needs\) \}\}/); + assert.match(gate, /node scripts\/ci-docs\.mjs gate/); assert.match(workflow, /c10-evidence-bundle:/); assert.match(workflow, /CPS_REQUIRED_JOB_RESULTS_JSON: \$\{\{ toJSON\(needs\) \}\}/); - assert.match(workflow, /C10_EVIDENCE_RESULT/); + assert.match(gate, /- c10-evidence-bundle/); assert.match(workflow, /vnext-c10-evidence-\$\{\{ github\.sha \}\}/); assert.ok((workflow.match(/retention-days: 30/g) || []).length >= 3); assert.match(workflow, /if-no-files-found: error/);