From 80728a4ebd98434a5633b9cc91e02d547b5ec13a Mon Sep 17 00:00:00 2001 From: nick Date: Mon, 19 Oct 2026 19:10:18 +0800 Subject: [PATCH 1/4] ci(checks): scope source PR validation to related modules --- .github/scripts/ci-policy.mjs | 90 ++++++++-- .github/workflows/ci.yml | 51 +++--- AGENTS.md | 11 +- tests/ci/README.md | 36 +++- tests/ci/README_CN.md | 30 +++- tests/ci/module-validation.test.mjs | 237 +++++++++++++++++++++++++ tests/ci/policy.test.mjs | 96 ++++++---- tests/ci/workflow-concurrency.test.mjs | 2 +- 8 files changed, 464 insertions(+), 89 deletions(-) create mode 100644 tests/ci/module-validation.test.mjs diff --git a/.github/scripts/ci-policy.mjs b/.github/scripts/ci-policy.mjs index 9912e1916..90f5a04f8 100644 --- a/.github/scripts/ci-policy.mjs +++ b/.github/scripts/ci-policy.mjs @@ -8,9 +8,13 @@ const cppPath = /\.(?:c|cc|cpp|cxx|c\+\+|h|hh|hpp|hxx|inl|ipp|tpp|proto)$/i; const docPath = /\.(?:md|mdx|markdown|rst|adoc|txt|html|css|js|mjs|cjs|ts|tsx|jsx|astro|json|yaml|yml|svg|png|jpe?g|gif|webp|ico|pdf|woff2?|ttf|otf)$/i; const dependencyName = /^(?:package(?:-lock)?\.json|npm-shrinkwrap\.json|pnpm-lock\.yaml|yarn\.lock|vcpkg(?:-configuration)?\.json|conanfile\.(?:py|txt)|conan\.lock|requirements(?:-[\w.-]+)?\.txt|.*\.lock|\.gitmodules)$/i; +function isRepositoryPath(file) { + return typeof file === 'string' && file.length > 0 && !/[\x00-\x1f\x7f\\]/.test(file) && + !posix.isAbsolute(file) && posix.normalize(file) === file && !file.split('/').includes('..'); +} + export function isDocumentationPath(file) { - if (typeof file !== 'string' || !file || /[\x00-\x1f\x7f\\]/.test(file) || - posix.isAbsolute(file) || posix.normalize(file) !== file || file.split('/').includes('..')) return false; + if (!isRepositoryPath(file)) return false; const name = posix.basename(file); if (cppPath.test(file) || /^CMake(?:Lists\.txt|(?:User)?Presets\.json|Cache\.txt)$/i.test(name) || /\.cmake$/i.test(file) || dependencyName.test(name) || file.split('/').includes('.github') || /^action\.ya?ml$/i.test(name) || @@ -64,8 +68,28 @@ export function inspectSite(root) { return { present, available }; } +const nativeJobs = ['network', 'applications', 'database']; + +// Shared SDK/build inputs affect every consumer. Only known module paths narrow PRs. +function modulesForPath(file) { + if (isDocumentationPath(file)) return []; + if (!isRepositoryPath(file)) return nativeJobs; + if (!cppPath.test(file) && !file.endsWith('/CMakeLists.txt')) return nativeJobs; + if (/^(?:tests\/network\/|benchmarks\/network_buffers\.cpp$)/.test(file)) return ['network']; + if (/^(?:tests\/database\/|benchmarks\/database\.cpp$)/.test(file)) return ['database']; + if (/^(?:include\/ark\/|src\/plugins\/)(?:net|http|bus)\//.test(file)) return ['network', 'applications']; + if (/^(?:include\/ark\/|src\/plugins\/)(?:mysql|redis)\//.test(file) || + /^examples\/(?:mysql_sample|redis_sample)\//.test(file)) return ['applications', 'database']; + if (/^src\/plugins\/detail\//.test(file)) return ['applications', 'database']; + if (/^(?:src\/(?:app|server|config|proto)\/|tests\/(?:core|app|consumer)\/|examples\/)/.test(file) || + /^(?:include\/ark\/|src\/plugins\/)(?:kernel|utility|consul|etcd_client|kafka|osssdk|physic)\//.test(file)) { + return ['applications']; + } + return nativeJobs; +} + export function selectScope({ eventName, files, site, enumerationFailed = false }) { - const known = !enumerationFailed && Array.isArray(files); + const known = !enumerationFailed && Array.isArray(files) && files.every(isRepositoryPath); const paths = known ? files : []; const nonempty = known && paths.length > 0; const scopedEvent = eventName === 'pull_request' || eventName === 'merge_group'; @@ -75,32 +99,72 @@ export function selectScope({ eventName, files, site, enumerationFailed = false const ciTouched = paths.some(file => file.startsWith('.github/') || file.startsWith('tests/ci/')); const siteReferenced = site.present || paths.some(file => file.startsWith('docs/site/')); const docs = siteReferenced && (eventName !== 'pull_request' || !known || docsTouched || ciTouched); + const native = !docsOnly; + const selected = new Set(); + const narrow = native && eventName === 'pull_request' && nonempty && site.available; + for (const file of paths) { + for (const job of modulesForPath(file)) selected.add(job); + } + const unknown = narrow && paths.some(file => modulesForPath(file) === nativeJobs); + if (native && (!narrow || unknown)) { + for (const job of nativeJobs) selected.add(job); + } return { - full_cpp: !docsOnly, + event_name: eventName, + validation_stage: !native ? 'documentation' : narrow ? 'modules' : 'full', + native_check: native, + network_check: native && selected.has('network'), + applications_check: native && selected.has('applications'), + database_check: native && selected.has('database'), + format_check: native, docs_check: docs, readme_check: readme, site_available: site.available, files_known: known, reason: docsOnly ? 'documentation-only' : !scopedEvent ? 'full-event' : !known ? 'enumeration-unavailable' : !nonempty ? 'empty-diff' : - !site.available ? 'site-unavailable' : 'code-build-or-unknown-path', + !site.available ? 'site-unavailable' : eventName === 'merge_group' ? 'code-build-or-unknown-path' : + unknown ? 'unknown-path' : 'module-paths', }; } -export function validateGate(needs) { +export function validateGate(needs, eventName) { if (!needs || needs.scope?.result !== 'success') throw new Error('scope job did not succeed'); + if (!['pull_request', 'merge_group', 'workflow_dispatch'].includes(eventName) || + needs.scope.outputs?.event_name !== eventName) throw new Error('scope event does not match this run'); const flags = {}; - for (const key of ['full_cpp', 'docs_check', 'readme_check', 'site_available', 'files_known']) { + for (const key of ['native_check', 'network_check', 'applications_check', 'database_check', 'format_check', + 'docs_check', 'readme_check', 'site_available', 'files_known']) { const value = needs.scope.outputs?.[key]; if (value !== 'true' && value !== 'false') throw new Error(`invalid scope output: ${key}`); flags[key] = value === 'true'; } - if (!flags.full_cpp && (!flags.docs_check || !flags.site_available || !flags.files_known)) { - throw new Error('documentation-only scope is incomplete'); + const reason = needs.scope.outputs.reason; + const uncertain = !flags.files_known || ['empty-diff', 'site-unavailable', 'enumeration-unavailable'].includes(reason); + const stage = !flags.native_check ? 'documentation' : eventName === 'pull_request' && !uncertain ? 'modules' : 'full'; + if (needs.scope.outputs.validation_stage !== stage) throw new Error('invalid validation stage'); + if (!['documentation-only', 'full-event', 'enumeration-unavailable', 'empty-diff', 'site-unavailable', + 'code-build-or-unknown-path', 'unknown-path', 'module-paths'].includes(reason) || + (!flags.native_check && reason !== 'documentation-only') || + (flags.native_check && reason === 'documentation-only') || (stage === 'full' && reason === 'module-paths')) { + throw new Error('invalid scope reason'); + } + if (!flags.native_check && (!flags.docs_check || !flags.site_available || !flags.files_known || + nativeJobs.some(job => flags[`${job}_check`]))) throw new Error('documentation-only scope is incomplete'); + if (flags.format_check !== flags.native_check) throw new Error('native validation requires formatting'); + if (flags.native_check && !nativeJobs.some(job => flags[`${job}_check`])) throw new Error('native scope has no regressions'); + const fullModules = stage === 'full' || !flags.files_known || + ['unknown-path', 'empty-diff', 'site-unavailable', 'enumeration-unavailable'].includes(needs.scope.outputs.reason); + if (flags.native_check && fullModules && nativeJobs.some(job => !flags[`${job}_check`])) { + throw new Error('full or uncertain scope requires every native module'); + } + if (eventName === 'workflow_dispatch' && !flags.native_check) throw new Error('manual validation must be full'); + if (eventName !== 'pull_request' && flags.site_available && !flags.docs_check) { + throw new Error('queue and manual runs require the available documentation site'); } const required = { - network: flags.full_cpp, applications: flags.full_cpp, database: flags.full_cpp, - documentation: flags.docs_check, readme: flags.readme_check, + network: flags.network_check, applications: flags.applications_check, database: flags.database_check, + formatting: flags.format_check, documentation: flags.docs_check, readme: flags.readme_check, }; for (const [job, mustRun] of Object.entries(required)) { const expected = mustRun ? 'success' : 'skipped'; @@ -132,7 +196,7 @@ function classify() { if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, output); const summary = `CI scope: ${decision.reason}\n\n${output}\n` + (!decision.site_available ? (decision.docs_check ? 'The required Nimbus site is missing or invalid; its job must fail.\n' : - 'Nimbus is not introduced in this checkout; site validation is explicitly not required and C++ remains full.\n') : ''); + 'Nimbus is not introduced in this checkout; site validation is explicitly not required and all native modules remain required.\n') : ''); if (process.env.GITHUB_STEP_SUMMARY) appendFileSync(process.env.GITHUB_STEP_SUMMARY, summary); console.log(JSON.stringify({ ...decision, file_count: files?.length ?? null }, null, 2)); } @@ -141,7 +205,7 @@ if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1] try { if (process.argv[2] === 'classify') classify(); else if (process.argv[2] === 'gate') { - validateGate(JSON.parse(process.env.CI_NEEDS_JSON)); + validateGate(JSON.parse(process.env.CI_NEEDS_JSON), process.env.GITHUB_EVENT_NAME); console.log('Every required job succeeded; only explicitly unnecessary jobs were skipped.'); } else if (process.argv[2] === 'require-site') { if (!inspectSite(process.cwd()).available) throw new Error('Required Nimbus package, lockfile or check script is unavailable'); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bec133336..d5a2ec467 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,13 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 outputs: - full_cpp: ${{ steps.scope.outputs.full_cpp }} + event_name: ${{ steps.scope.outputs.event_name }} + validation_stage: ${{ steps.scope.outputs.validation_stage }} + native_check: ${{ steps.scope.outputs.native_check }} + network_check: ${{ steps.scope.outputs.network_check }} + applications_check: ${{ steps.scope.outputs.applications_check }} + database_check: ${{ steps.scope.outputs.database_check }} + format_check: ${{ steps.scope.outputs.format_check }} docs_check: ${{ steps.scope.outputs.docs_check }} readme_check: ${{ steps.scope.outputs.readme_check }} site_available: ${{ steps.scope.outputs.site_available }} @@ -42,7 +48,7 @@ jobs: documentation: name: CI - documentation validation needs: scope - if: ${{ !cancelled() && needs.scope.outputs.docs_check == 'true' }} + if: ${{ !cancelled() && (needs.scope.result != 'success' || needs.scope.outputs.docs_check != 'false') }} runs-on: ubuntu-latest timeout-minutes: 15 steps: @@ -59,7 +65,7 @@ jobs: readme: name: CI - README file links needs: scope - if: ${{ !cancelled() && needs.scope.outputs.readme_check == 'true' }} + if: ${{ !cancelled() && (needs.scope.result != 'success' || needs.scope.outputs.readme_check != 'false') }} runs-on: ubuntu-latest timeout-minutes: 5 steps: @@ -70,20 +76,29 @@ jobs: with: node-version: 26 - run: node .github/scripts/check-readme-links.mjs + formatting: + name: CI - C++ formatting + needs: scope + if: ${{ !cancelled() && (needs.scope.result != 'success' || needs.scope.outputs.format_check != 'false') }} + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - name: Install clang-format 18 + run: sudo apt-get update && sudo apt-get install -y clang-format-18 + - name: Check tracked source formatting + run: CLANG_FORMAT=clang-format-18 bash scripts/format.sh --check network: name: Network - ${{ matrix.os }} - Boost - ${{ matrix.sanitizer }} needs: scope - if: ${{ !cancelled() && (needs.scope.result != 'success' || needs.scope.outputs.full_cpp != 'false') }} + if: ${{ !cancelled() && (needs.scope.result != 'success' || needs.scope.outputs.network_check != 'false') }} runs-on: ${{ matrix.os }} timeout-minutes: 45 strategy: fail-fast: false - matrix: - os: [ubuntu-latest, macos-latest, windows-latest] - sanitizer: [none] - include: - - {os: ubuntu-latest, sanitizer: address} - - {os: ubuntu-latest, sanitizer: thread} + matrix: ${{ fromJSON(github.event_name == 'pull_request' && needs.scope.result == 'success' && needs.scope.outputs.validation_stage == 'modules' && '{"include":[{"os":"ubuntu-latest","sanitizer":"none"}]}' || '{"include":[{"os":"ubuntu-latest","sanitizer":"none"},{"os":"macos-latest","sanitizer":"none"},{"os":"windows-latest","sanitizer":"none"},{"os":"ubuntu-latest","sanitizer":"address"},{"os":"ubuntu-latest","sanitizer":"thread"}]}') }} steps: - uses: actions/checkout@v4 - name: Initialize pinned dependencies @@ -145,13 +160,12 @@ jobs: applications: name: Applications - ${{ matrix.os }} - C++20 needs: scope - if: ${{ !cancelled() && (needs.scope.result != 'success' || needs.scope.outputs.full_cpp != 'false') }} + if: ${{ !cancelled() && (needs.scope.result != 'success' || needs.scope.outputs.applications_check != 'false') }} runs-on: ${{ matrix.os }} timeout-minutes: 45 strategy: fail-fast: false - matrix: - os: [ubuntu-latest, macos-latest, windows-latest] + matrix: ${{ fromJSON(github.event_name == 'pull_request' && needs.scope.result == 'success' && needs.scope.outputs.validation_stage == 'modules' && '{"include":[{"os":"ubuntu-latest"}]}' || '{"include":[{"os":"ubuntu-latest"},{"os":"macos-latest"},{"os":"windows-latest"}]}') }} steps: - uses: actions/checkout@v4 - name: Initialize pinned dependencies @@ -208,17 +222,12 @@ jobs: database: name: Database - ${{ matrix.os }} - ${{ matrix.asio }} needs: scope - if: ${{ !cancelled() && (needs.scope.result != 'success' || needs.scope.outputs.full_cpp != 'false') }} + if: ${{ !cancelled() && (needs.scope.result != 'success' || needs.scope.outputs.database_check != 'false') }} runs-on: ${{ matrix.os }} timeout-minutes: 45 strategy: fail-fast: false - matrix: - include: - - {os: ubuntu-latest, asio: Boost, standalone: 'OFF', features: 'database;boost'} - - {os: ubuntu-latest, asio: Standalone, standalone: 'ON', features: database} - - {os: macos-latest, asio: Boost, standalone: 'OFF', features: 'database;boost'} - - {os: windows-latest, asio: Boost, standalone: 'OFF', features: 'database;boost'} + matrix: ${{ fromJSON(github.event_name == 'pull_request' && needs.scope.result == 'success' && needs.scope.outputs.validation_stage == 'modules' && '{"include":[{"os":"ubuntu-latest","asio":"Boost","standalone":"OFF","features":"database;boost"}]}' || '{"include":[{"os":"ubuntu-latest","asio":"Boost","standalone":"OFF","features":"database;boost"},{"os":"ubuntu-latest","asio":"Standalone","standalone":"ON","features":"database"},{"os":"macos-latest","asio":"Boost","standalone":"OFF","features":"database;boost"},{"os":"windows-latest","asio":"Boost","standalone":"OFF","features":"database;boost"}]}') }} steps: - uses: actions/checkout@v4 - name: Initialize pinned dependencies @@ -289,7 +298,7 @@ jobs: required: name: CI - required if: always() - needs: [scope, network, applications, database, documentation, readme] + needs: [scope, network, applications, database, formatting, documentation, readme] runs-on: ubuntu-latest timeout-minutes: 5 steps: diff --git a/AGENTS.md b/AGENTS.md index 4ee276433..86316890d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,9 +51,14 @@ branch or worktree when the existing checkout contains uncommitted changes. - Pending, skipped, cancelled and successful checks are distinct states. A cancelled run is not validation. If a run is cancelled for scheduling, record the reason and resume its acceptance checks; do not leave it indefinitely red. -- Use the repository's change-scope policy: documentation changes run the relevant - documentation/link checks; code, build, dependencies and CI changes run the - full configured matrix. Never fabricate a passing required result. +- Use the repository's change-scope policy: documentation-only changes run the + relevant documentation/link checks. Source PRs run the affected native modules + on Linux Boost and clang-format 18; shared SDK, build, dependency, CI and unknown + paths require every source module when the changed range is known. Unavailable, + malformed or empty ranges fall back to the full matrix. Code merge-queue + candidates and manual runs retain the full cross-platform and sanitizer matrix. + Source checks admit a PR to the queue; + they do not establish final matrix acceptance. Never fabricate a passing result. - Include what changed, why, validation and remaining limits in PR descriptions. Verify the merge result before saying that a PR has merged. diff --git a/tests/ci/README.md b/tests/ci/README.md index 167ec9ba6..4e4e9b504 100644 --- a/tests/ci/README.md +++ b/tests/ci/README.md @@ -8,14 +8,34 @@ node --test tests/ci/*.test.mjs The tests use temporary local Git repositories. No GitHub API token, network service, C++ rebuild or database is required. They verify complete rename-aware path decisions, -safe fallback to the full C++ matrix, documentation availability and strict job results. -PRs and merge-group candidates select checks from their complete changed paths. -Queue candidates compare the event's `base_sha` directly with `head_sha`, including -changes from earlier queued PRs. Only a known, nonempty documentation-only range -with an available site can skip native jobs; code, build, dependency, CI, unknown -paths or unavailable enumeration retain the full C++ matrix. Merge groups retain -site validation when the site is present, and README changes require their link -check. Manual runs retain the full C++ matrix. Protected `main` accepts only candidates +event-specific module selection, documentation availability and strict job results. + +Source PRs run the related native modules on Linux with Boost, reusing the existing +configure/build/regression steps. Core/application tests select Applications; +network tests select Network; database tests select Database. Network/HTTP/Bus +plugin changes also select Applications, and MySQL/Redis/shared database support +changes select Applications and Database. Public core headers, shared build or +dependency inputs, CI changes and unknown paths select all three source modules. +Missing, invalid or empty enumeration falls back to the full 12-variant matrix. +Native changes run +the fatal clang-format 18 check. Documentation and README link checks retain their +existing path rules; source CI success reports module validation, not full matrix +acceptance. + +Merge-group candidates compare the event's `base_sha` directly with `head_sha`, +including changes from earlier queued PRs. A candidate containing code, build, +dependency, CI or unknown paths runs all 12 native variants: Network on Linux, +macOS and Windows plus Linux ASan/UBSan and TSan; Applications on Linux, macOS and +Windows; Database on Linux Boost, Linux Standalone, macOS Boost and Windows Boost. +Manual runs retain the same full matrix. Only a known, nonempty documentation-only +range with an available site can skip native jobs. Queue candidates retain site +validation when the site is present, and README changes require their link check. + +The required gate validates the actual workflow event and the declared validation +stage, then requires success from each selected job and an explicit skipped result +from each unnecessary job. Failed, cancelled, unknown or missing results fail; +a source module result cannot substitute for a queue result. Full acceptance still +requires the combined queue candidate. Protected `main` accepts only candidates that passed their required checks; its resulting push does not repeat CI for the same accepted tree. The separate documentation workflow still publishes on main pushes. README badges display the merge-group workflow status. diff --git a/tests/ci/README_CN.md b/tests/ci/README_CN.md index ac2781267..717eea4c8 100644 --- a/tests/ci/README_CN.md +++ b/tests/ci/README_CN.md @@ -7,14 +7,28 @@ node --test tests/ci/*.test.mjs ``` 测试使用临时的本地 Git 仓库,不需要 GitHub API token、网络服务、C++ 重编译或数据库。 -覆盖包含重命名旧路径的完整文件集合、无法确定范围时执行完整 C++ 矩阵、文档站可用性, -以及各任务结果的严格校验。PR 和 Merge Queue 候选按完整变更范围选择检查。合并候选直接比较 -事件中的 `base_sha` 与 `head_sha`,包含前序排队 PR 带来的变更。只有范围已知、非空、 -文档站可用的纯文档变更才跳过原生任务;代码、构建、依赖、CI、未知路径或无法枚举范围时, -仍执行完整 C++ 矩阵。文档站存在时,合并候选仍检查文档;README 变更还需通过其链接检查。 -手动运行保留完整 C++ 矩阵。 -受保护的 `main` 只接收已通过必需检查的合并候选;合入后的推送不再对同一代码树重复执行 CI。 -独立的文档工作流仍在 main 推送后发布文档。README 徽章展示合并候选的工作流状态。 +覆盖包含重命名旧路径的完整文件集合、按事件选择模块、文档站可用性,以及任务结果的严格校验。 + +源分支 PR 复用现有配置、编译和回归步骤,在 Linux Boost 上执行相关模块。 +Core/Application 测试选择 Applications;网络测试选择 Network;数据库测试选择 Database。 +Network、HTTP、Bus 插件变更还检查 Applications;MySQL、Redis 和共享数据库支持代码变更 +检查 Applications 与 Database。公共核心头文件、共享构建或依赖、CI 和未知路径变更 +检查全部三个源分支模块;枚举缺失、无效或为空时回退到全部 12 个任务的完整矩阵。 +原生代码检查同时要求 clang-format 18 通过。文档和 README 链接检查沿用现有路径规则; +源分支 CI 成功只表示模块验证通过。 + +Merge Queue 候选直接比较事件中的 `base_sha` 与 `head_sha`,包含前序排队 PR 带来的变更。 +候选包含代码、构建、依赖、CI 或未知路径时,执行全部 12 个原生任务:Network 的 Linux、macOS、 +Windows 与 Linux ASan/UBSan、TSan;Applications 的 Linux、macOS、Windows;Database 的 +Linux Boost、Linux Standalone、macOS Boost、Windows Boost。手动运行保留相同完整矩阵。 +只有范围已知、非空、文档站可用的纯文档变更才跳过原生任务。文档站存在时,合并候选仍检查文档; +README 变更还需通过其链接检查。 + +required gate 核对当前工作流的真实事件和声明的验证阶段,然后要求选中的任务成功、 +不需要的任务明确跳过。失败、取消、未知或缺失的结果均不通过;源分支结果不能替代队列结果。 +最终验收仍要求包含前序变更的合并候选通过检查。受保护的 `main` 只接收已通过必需检查的候选; +合入后的推送不再对同一代码树重复执行 CI。独立的文档工作流仍在 main 推送后发布文档。 +README 徽章展示合并候选的工作流状态。 每个既有的 PR/引用并发分组最多执行一轮,只保留最新进入该分组的一轮等待任务。 新运行等待旧轮结束,不再自动取消正在执行的旧轮。每个已运行的 job 保留原有超时限制, diff --git a/tests/ci/module-validation.test.mjs b/tests/ci/module-validation.test.mjs new file mode 100644 index 000000000..1dc78edc7 --- /dev/null +++ b/tests/ci/module-validation.test.mjs @@ -0,0 +1,237 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; +import { selectScope, validateGate } from '../../.github/scripts/ci-policy.mjs'; + +const site = { present: true, available: true }; +const jobs = ['network', 'applications', 'database']; +const plan = (files, eventName = 'pull_request', extra = {}) => selectScope({ files, eventName, site, ...extra }); +const selected = scope => jobs.filter(job => scope[`${job}_check`]); +const results = scope => ({ + scope: { result: 'success', outputs: Object.fromEntries(Object.entries(scope).map(([key, value]) => [key, String(value)])) }, + ...Object.fromEntries([...jobs, 'formatting', 'documentation', 'readme'].map(job => { + const flag = job === 'formatting' ? 'format_check' : job === 'documentation' ? 'docs_check' : `${job}_check`; + return [job, { result: scope[flag] ? 'success' : 'skipped' }]; + })), +}); +const workflow = readFileSync(new URL('../../.github/workflows/ci.yml', import.meta.url), 'utf8').replaceAll('\r\n', '\n'); +const jobBlock = name => workflow.split(/(?=^ [a-z][a-z0-9_-]*:$)/m).find(block => block.startsWith(` ${name}:\n`)); + +function matrices(name) { + const block = jobBlock(name); + const expression = block.match(/^ matrix: \$\{\{ fromJSON\(github.event_name == 'pull_request' && needs.scope.result == 'success' && needs.scope.outputs.validation_stage == 'modules' && '([^']+)' \|\| '([^']+)'\) \}\}$/m); + assert.ok(expression, `${name}: choose the reduced matrix only from the actual PR event`); + return { source: JSON.parse(expression[1]).include, full: JSON.parse(expression[2]).include }; +} + +test('source paths select actual module regressions and propagate plugin dependencies', () => { + const cases = [ + ['tests/core/containers.cpp', ['applications']], + ['tests/core/CMakeLists.txt', ['applications']], + ['src/app/main.cpp', ['applications']], + ['src/config/application_manifest.cpp', ['applications']], + ['src/server/game/game_module_impl.cpp', ['applications']], + ['src/proto/CMakeLists.txt', ['applications']], + ['examples/sample1/sample1.cpp', ['applications']], + ['include/ark/kernel/node.hpp', ['applications']], + ['tests/network/rudp.cpp', ['network']], + ['tests/network/CMakeLists.txt', ['network']], + ['benchmarks/network_buffers.cpp', ['network']], + ['src/plugins/net/rudp_client.cpp', ['network', 'applications']], + ['include/ark/net/rudp_server.hpp', ['network', 'applications']], + ['include/ark/http/http_server_module.hpp', ['network', 'applications']], + ['src/plugins/bus/msg_module_impl.cpp', ['network', 'applications']], + ['tests/database/transactions_awaitable.cpp', ['database']], + ['tests/database/CMakeLists.txt', ['database']], + ['benchmarks/database.cpp', ['database']], + ['src/plugins/mysql/mysql_module_impl.cpp', ['applications', 'database']], + ['src/plugins/redis/detail/redis_service.hpp', ['applications', 'database']], + ['include/ark/redis/redis_module.hpp', ['applications', 'database']], + ['src/plugins/detail/database_awaitable.hpp', ['applications', 'database']], + ['examples/mysql_sample/main.cpp', ['applications', 'database']], + ]; + for (const [file, expected] of cases) { + const scope = plan([file]); + assert.deepEqual(selected(scope), expected, file); + assert.equal(scope.validation_stage, 'modules', file); + assert.equal(scope.format_check, true, file); + validateGate(results(scope), 'pull_request'); + } + assert.deepEqual(selected(plan(['tests/network/rudp.cpp', 'tests/database/redis_service.cpp'])), ['network', 'database']); + const mixed = plan(['tests/core/containers.cpp', 'docs/reference/kernel.md']); + assert.deepEqual(selected(mixed), ['applications']); + assert.equal(mixed.docs_check, true); +}); + +test('public core headers, build/dependency/CI inputs and unknown paths retain every source regression', () => { + for (const file of ['include/ark/container/vector.hpp', 'include/ark/task.hpp', 'include/ark/hash/crc.hpp', + 'src/core/CMakeLists.txt', 'CMakeLists.txt', 'cmake/utils.cmake', 'vcpkg.json', '.gitmodules', + '3rdparty/spdlog', 'ports/asio/portfile.cmake', '.github/workflows/ci.yml', + '.github/scripts/ci-policy.mjs', 'tests/ci/module-validation.test.mjs', 'AGENTS.md', '.clang-format', + 'src/plugins/new_plugin/new_module.cpp', 'src/plugins/net/unknown.bin', 'scripts/new-check.sh', + 'docs/example.cpp', 'docs/site/package-lock.json', 'docs/unknown.bin', 'LICENSE']) { + const scope = plan([file]); + assert.deepEqual(selected(scope), jobs, file); + assert.equal(scope.validation_stage, 'modules', file); + assert.equal(scope.reason, 'unknown-path', file); + validateGate(results(scope), 'pull_request'); + } +}); + +test('unavailable, invalid or empty source enumeration cannot skip native regressions or formatting', () => { + for (const scope of [plan(undefined, 'pull_request', { enumerationFailed: true }), plan([]), + plan([null]), plan(['../tests/network/test.cpp']), plan(['tests/network/../database/example.cpp']), + plan(['tests/network/test.cpp\n']), plan(['tests/network/test.cpp'], 'pull_request', { site: { present: true, available: false } })]) { + assert.deepEqual(selected(scope), jobs); + assert.equal(scope.format_check, true); + assert.equal(scope.validation_stage, 'full'); + validateGate(results(scope), 'pull_request'); + for (const job of jobs) { + const invalid = results(scope); + invalid.scope.outputs[`${job}_check`] = 'false'; + invalid[job].result = 'skipped'; + assert.throws(() => validateGate(invalid, 'pull_request')); + } + } +}); + +test('queue code and manual runs require all modules while documentation-only queue candidates stay lightweight', () => { + for (const file of ['tests/network/rudp.cpp', 'tests/core/containers.cpp', 'tests/database/mysql_service.cpp', + 'include/ark/kernel/node.hpp', 'CMakeLists.txt', 'unknown.bin']) { + const scope = plan([file], 'merge_group'); + assert.deepEqual(selected(scope), jobs, file); + assert.equal(scope.validation_stage, 'full'); + validateGate(results(scope), 'merge_group'); + } + const manual = plan(['docs/guide.md'], 'workflow_dispatch'); + assert.deepEqual(selected(manual), jobs); + assert.equal(manual.validation_stage, 'full'); + validateGate(results(manual), 'workflow_dispatch'); + for (const eventName of ['pull_request', 'merge_group']) { + const docs = plan(['README.md', 'docs/guide.md'], eventName); + assert.deepEqual(selected(docs), []); + assert.equal(docs.validation_stage, 'documentation'); + assert.equal(docs.format_check, false); + assert.equal(docs.docs_check, true); + assert.equal(docs.readme_check, true); + validateGate(results(docs), eventName); + } +}); + +test('required results distinguish source modules from queue acceptance and reject event/stage substitution', () => { + const source = results(plan(['tests/network/rudp.cpp'])); + assert.equal(source.network.result, 'success'); + assert.equal(source.applications.result, 'skipped'); + assert.equal(source.database.result, 'skipped'); + validateGate(source, 'pull_request'); + assert.throws(() => validateGate(source, 'merge_group')); + assert.throws(() => validateGate(source, 'workflow_dispatch')); + for (const eventName of [undefined, '', 'unknown', 'push']) assert.throws(() => validateGate(source, eventName)); + for (const stage of [undefined, '', 'full', 'documentation', 'unknown']) { + const invalid = structuredClone(source); + invalid.scope.outputs.validation_stage = stage; + assert.throws(() => validateGate(invalid, 'pull_request')); + } + const queue = results(plan(['tests/network/rudp.cpp'], 'merge_group')); + for (const job of jobs) { + const invalid = structuredClone(queue); + invalid.scope.outputs[`${job}_check`] = 'false'; + invalid[job].result = 'skipped'; + assert.throws(() => validateGate(invalid, 'merge_group')); + } + for (const reason of [undefined, '', 'unknown']) { + const invalid = structuredClone(source); + invalid.scope.outputs.reason = reason; + assert.throws(() => validateGate(invalid, 'pull_request')); + } +}); + +test('source module, full queue and manual plans reject failed cancelled skipped and unknown required results', () => { + for (const scope of [plan(['tests/network/rudp.cpp']), plan(['tests/database/redis_service.cpp']), + plan(['tests/core/containers.cpp']), plan(['unknown.bin']), plan(['tests/network/rudp.cpp'], 'merge_group'), + plan([], 'workflow_dispatch')]) { + const valid = results(scope); + for (const job of [...jobs, 'formatting', 'documentation', 'readme']) { + for (const state of ['success', 'skipped', 'failure', 'cancelled', 'unknown', 'neutral', 'timed_out', '', undefined]) { + if (state === valid[job].result) continue; + const invalid = structuredClone(valid); + invalid[job].result = state; + assert.throws(() => validateGate(invalid, scope.event_name), `${scope.validation_stage}/${job}/${state}`); + } + const absent = structuredClone(valid); + delete absent[job]; + assert.throws(() => validateGate(absent, scope.event_name)); + } + } +}); + +test('workflow source jobs reuse Linux Boost and full acceptance retains exactly all twelve native variants', () => { + const network = matrices('network'); + assert.deepEqual(network.source, [{ os: 'ubuntu-latest', sanitizer: 'none' }]); + assert.deepEqual(network.full, [ + { os: 'ubuntu-latest', sanitizer: 'none' }, { os: 'macos-latest', sanitizer: 'none' }, + { os: 'windows-latest', sanitizer: 'none' }, { os: 'ubuntu-latest', sanitizer: 'address' }, + { os: 'ubuntu-latest', sanitizer: 'thread' }, + ]); + const applications = matrices('applications'); + assert.deepEqual(applications.source, [{ os: 'ubuntu-latest' }]); + assert.deepEqual(applications.full, [{ os: 'ubuntu-latest' }, { os: 'macos-latest' }, { os: 'windows-latest' }]); + const database = matrices('database'); + assert.deepEqual(database.source, [{ os: 'ubuntu-latest', asio: 'Boost', standalone: 'OFF', features: 'database;boost' }]); + assert.deepEqual(database.full, [ + { os: 'ubuntu-latest', asio: 'Boost', standalone: 'OFF', features: 'database;boost' }, + { os: 'ubuntu-latest', asio: 'Standalone', standalone: 'ON', features: 'database' }, + { os: 'macos-latest', asio: 'Boost', standalone: 'OFF', features: 'database;boost' }, + { os: 'windows-latest', asio: 'Boost', standalone: 'OFF', features: 'database;boost' }, + ]); + assert.equal(network.full.length + applications.full.length + database.full.length, 12); + for (const job of jobs) { + assert.ok(jobBlock(job).includes(`needs.scope.result != 'success' || needs.scope.outputs.${job}_check != 'false'`), + 'missing/failed classifier outputs still run regressions'); + assert.ok(workflow.includes(` ${job}_check: \${{ steps.scope.outputs.${job}_check }}`)); + } + assert.match(jobBlock('network'), /-fsanitize=address,undefined -fno-omit-frame-pointer/); + assert.match(jobBlock('network'), /-fsanitize=thread -fno-omit-frame-pointer/); + assert.match(jobBlock('network'), /TSAN_OPTIONS: halt_on_error=1:second_deadlock_stack=1/); + assert.match(jobBlock('network'), /run: cmake --build ci-build --target check-network/); + assert.match(jobBlock('applications'), /run: cmake --build ci-build --target check-core/); + assert.match(jobBlock('database'), /run: cmake --build ci-database --target check-database\n/); + assert.match(jobBlock('database'), /run: cmake --build ci-database --target check-database-benchmark/); +}); + +test('formatting is fatal and required alongside event-aware module results', () => { + const formatting = jobBlock('formatting'); + assert.match(formatting, /needs.scope.result != 'success' \|\| needs.scope.outputs.format_check != 'false'/); + assert.match(formatting, /sudo apt-get install -y clang-format-18/); + assert.match(formatting, /CLANG_FORMAT=clang-format-18 bash scripts\/format.sh --check/); + assert.doesNotMatch(formatting, /continue-on-error:/); + assert.match(jobBlock('required'), /needs: \[scope, network, applications, database, formatting, documentation, readme\]/); + assert.match(jobBlock('required'), /if: always\(\)/); + const script = readFileSync(new URL('../../.github/scripts/ci-policy.mjs', import.meta.url), 'utf8'); + assert.match(script, /validateGate\(JSON.parse\(process.env.CI_NEEDS_JSON\), process.env.GITHUB_EVENT_NAME\)/); + for (const [job, flag] of [['documentation', 'docs_check'], ['readme', 'readme_check']]) { + assert.ok(jobBlock(job).includes(`needs.scope.result != 'success' || needs.scope.outputs.${flag} != 'false'`)); + } +}); + +test('module flags and workflow matrix guards keep uncertain source ranges and queue candidates on full acceptance', () => { + const count = (scope, scopeResult = 'success') => jobs.reduce((total, job) => { + if (scopeResult === 'success' && !scope[`${job}_check`]) return total; + const variants = matrices(job); + return total + (scopeResult === 'success' && scope.event_name === 'pull_request' && scope.validation_stage === 'modules' ? + variants.source.length : variants.full.length); + }, 0); + assert.equal(count(plan(['tests/network/rudp.cpp'])), 1); + assert.equal(count(plan(['tests/network/rudp.cpp']), 'failure'), 12); + assert.equal(count(plan(['src/plugins/net/rudp_client.cpp'])), 2); + assert.equal(count(plan(['src/plugins/mysql/mysql_module_impl.cpp'])), 2); + assert.equal(count(plan(['tests/core/containers.cpp'])), 1); + assert.equal(count(plan(['include/ark/container/vector.hpp'])), 3); + assert.equal(count(plan(['unknown.bin'])), 3); + assert.equal(count(plan(undefined, 'pull_request', { enumerationFailed: true })), 12); + assert.equal(count(plan([])), 12); + assert.equal(count(plan(['../bad.cpp'])), 12); + assert.equal(count(plan(['tests/network/rudp.cpp'], 'merge_group')), 12); + assert.equal(count(plan(['docs/guide.md'], 'workflow_dispatch')), 12); + assert.equal(count(plan(['docs/guide.md'], 'merge_group')), 0); +}); diff --git a/tests/ci/policy.test.mjs b/tests/ci/policy.test.mjs index 161faa8b6..76c30f85d 100644 --- a/tests/ci/policy.test.mjs +++ b/tests/ci/policy.test.mjs @@ -5,7 +5,9 @@ import { tmpdir } from 'node:os'; import { dirname, resolve } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import test from 'node:test'; -import { changedPaths, decodePaths, inspectSite, isDocumentationPath, selectScope, validateGate } from '../../.github/scripts/ci-policy.mjs'; +import { changedPaths, decodePaths, inspectSite, isDocumentationPath, selectScope, validateGate as validateActualGate } from '../../.github/scripts/ci-policy.mjs'; + +const validateGate = needs => validateActualGate(needs, needs.scope?.outputs?.event_name); const site = { present: true, available: true }; const decision = (files, extra = {}) => selectScope({ eventName: 'pull_request', files, site, ...extra }); @@ -52,14 +54,14 @@ function classifyEvent(repo, eventName, payload) { test('only recognized documentation paths qualify for the lightweight plan', () => { for (const file of ['README.md', 'README_CN.md', 'docs/guide.md', 'docs/site/src/Header.astro', 'docs/assets/chart.svg']) { assert.equal(isDocumentationPath(file), true, file); - assert.equal(decision([file]).full_cpp, false, file); + assert.equal(decision([file]).native_check, false, file); } assert.equal(decision(['README.md']).readme_check, true); assert.equal(decision(['docs/guide.md']).readme_check, false); assert.equal(decision(['README.md', 'docs/guide.md']).docs_check, true); }); -test('code, build, dependency, CI and unknown paths force the entire C++ matrix', () => { +test('code, build, dependency, CI and unknown paths cannot skip native regressions', () => { const paths = ['include/ark/xml.hpp', 'CMakeLists.txt', 'cmake/FindZstd.cmake', 'vcpkg.json', '.gitmodules', '.github/workflows/ci.yml', '.github/scripts/ci-policy.mjs', 'tests/core/templates.cpp', 'scripts/format.sh', 'LICENSE', 'README_CN.md.bak', 'docs/example.cpp', 'docs/example.h', 'docs/message.proto', 'docs/CMakeLists.txt', @@ -68,7 +70,7 @@ test('code, build, dependency, CI and unknown paths force the entire C++ matrix' 'docs/.github/workflows/example.yml', 'docs/requirements.txt', 'docs/../README.md', './docs/guide.md', 'docs//guide.md']; for (const file of paths) { // requirements.txt is a dependency file even though ordinary text is documentation. - assert.equal(decision(['docs/guide.md', file]).full_cpp, true, file); + assert.equal(decision(['docs/guide.md', file]).native_check, true, file); } }); @@ -77,31 +79,31 @@ test('a code path beyond 3000 documentation entries cannot be truncated out of t files.push('src/last.cpp'); const decoded = decodePaths(Buffer.from(files.join('\0') + '\0')); assert.equal(decoded.length, 3002); - assert.equal(decision(decoded).full_cpp, true); + assert.equal(decision(decoded).native_check, true); }); test('empty, missing, truncated and invalid UTF-8 path lists never grant a lightweight result', () => { - assert.equal(decision([]).full_cpp, true); - assert.equal(decision(undefined, { enumerationFailed: true }).full_cpp, true); + assert.equal(decision([]).native_check, true); + assert.equal(decision(undefined, { enumerationFailed: true }).native_check, true); assert.throws(() => decodePaths(Buffer.from('docs/guide.md'))); assert.throws(() => decodePaths(Buffer.from([0xff, 0]))); assert.throws(() => decodePaths(Buffer.from('docs/a.md\0docs/a.md\0'))); assert.throws(() => decodePaths(Buffer.from('\0'))); assert.deepEqual(decodePaths(Buffer.from('docs/a.md\0README.md\0')), ['docs/a.md', 'README.md']); - assert.equal(decision(['docs/a\nname.md']).full_cpp, true); + assert.equal(decision(['docs/a\nname.md']).native_check, true); }); test('events without changed-path scoping cannot grant a lightweight classifier result', () => { for (const eventName of ['push', 'workflow_dispatch', 'unknown']) { const plan = decision(['docs/guide.md'], { eventName }); - assert.equal(plan.full_cpp, true); + assert.equal(plan.native_check, true); assert.equal(plan.docs_check, true); } }); test('a missing legacy site forces full C++ and does not falsely report a site check', () => { const plan = decision(['README.md'], { site: { present: false, available: false } }); - assert.equal(plan.full_cpp, true); + assert.equal(plan.native_check, true); assert.equal(plan.docs_check, false); assert.equal(plan.readme_check, true); assert.equal(decision(['src/main.cpp'], { site: { present: false, available: false } }).readme_check, false); @@ -109,7 +111,7 @@ test('a missing legacy site forces full C++ and does not falsely report a site c test('CI policy changes also validate the site when it has been introduced', () => { const plan = decision(['.github/workflows/ci.yml', 'tests/ci/policy.test.mjs']); - assert.equal(plan.full_cpp, true); + assert.equal(plan.native_check, true); assert.equal(plan.docs_check, true); assert.equal(plan.readme_check, false); }); @@ -118,9 +120,9 @@ test('an introduced, incomplete or removed site requires a failing preflight ins const incomplete = { present: true, available: false }; assert.equal(decision(['docs/guide.md'], { site: incomplete }).docs_check, true); const removed = decision(['docs/site/package-lock.json'], { site: { present: false, available: false } }); - assert.equal(removed.full_cpp, true); + assert.equal(removed.native_check, true); assert.equal(removed.docs_check, true); - assert.equal(decision(['.github/workflows/docs.yml', 'docs/site/package-lock.json']).full_cpp, true); + assert.equal(decision(['.github/workflows/docs.yml', 'docs/site/package-lock.json']).native_check, true); }); test('site availability requires its actual lockfile and npm check command', t => { @@ -140,7 +142,7 @@ test('a real Git rename exposes both the removed code path and added documentati const head = repo.commit(); const files = changedPaths({ root: repo.root, base: repo.base, head }); assert.deepEqual(files.sort(), ['docs/moved.md', 'src/main.cpp']); - assert.equal(decision(files).full_cpp, true); + assert.equal(decision(files).native_check, true); }); test('merge-base comparison excludes unrelated changes made on the base branch', t => { @@ -152,7 +154,7 @@ test('merge-base comparison excludes unrelated changes made on the base branch', repo.write('src/main.cpp', 'int main() { return 1; }\n'); const base = repo.commit(); assert.deepEqual(changedPaths({ root: repo.root, base, head }), ['docs/guide.md']); - assert.equal(decision(changedPaths({ root: repo.root, base, head })).full_cpp, false); + assert.equal(decision(changedPaths({ root: repo.root, base, head })).native_check, false); }); test('Git errors, shallow history, bad SHAs and command-looking arguments cannot approve skipping C++', () => { @@ -180,23 +182,46 @@ test('the real classifier handles a fork PR from pinned local history without an delete env.GH_TOKEN; const script = fileURLToPath(new URL('../../.github/scripts/ci-policy.mjs', import.meta.url)); const result = JSON.parse(execFileSync(process.execPath, [script, 'classify'], { cwd: repo.root, env, encoding: 'utf8' })); - assert.equal(result.full_cpp, false); + assert.equal(result.native_check, false); assert.equal(result.docs_check, true); assert.equal(result.readme_check, false); - assert.match(readFileSync(env.GITHUB_OUTPUT, 'utf8'), /full_cpp=false\n/); + assert.match(readFileSync(env.GITHUB_OUTPUT, 'utf8'), /native_check=false\n/); }); function results(plan) { return { scope: { result: 'success', outputs: Object.fromEntries(Object.entries(plan).map(([key, value]) => [key, String(value)])) }, - network: { result: plan.full_cpp ? 'success' : 'skipped' }, - applications: { result: plan.full_cpp ? 'success' : 'skipped' }, - database: { result: plan.full_cpp ? 'success' : 'skipped' }, + network: { result: plan.network_check ? 'success' : 'skipped' }, + applications: { result: plan.applications_check ? 'success' : 'skipped' }, + database: { result: plan.database_check ? 'success' : 'skipped' }, + formatting: { result: plan.format_check ? 'success' : 'skipped' }, documentation: { result: plan.docs_check ? 'success' : 'skipped' }, readme: { result: plan.readme_check ? 'success' : 'skipped' }, }; } +test('the real classifier distinguishes module PR validation from full queue and unavailable-range validation', t => { + const repo = repository(t); + const base = addSite(repo); + repo.write('tests/network/probe.cpp', 'int network_probe() { return 0; }\n'); + const head = repo.commit(); + const source = classifyEvent(repo, 'pull_request', { pull_request: { base: { sha: base }, head: { sha: head } } }).plan; + assert.equal(source.validation_stage, 'modules'); + assert.equal(source.network_check, true); + assert.equal(source.applications_check, false); + assert.equal(source.database_check, false); + validateGate(results(source)); + const queue = classifyEvent(repo, 'merge_group', { merge_group: { base_sha: base, head_sha: head } }).plan; + assert.equal(queue.validation_stage, 'full'); + for (const key of ['network_check', 'applications_check', 'database_check']) assert.equal(queue[key], true); + validateGate(results(queue)); + const missing = classifyEvent(repo, 'pull_request', {}).plan; + assert.equal(missing.validation_stage, 'full'); + assert.equal(missing.files_known, false); + for (const key of ['network_check', 'applications_check', 'database_check']) assert.equal(missing[key], true); + validateGate(results(missing)); +}); + test('a documentation-only merge queue candidate runs its required site check and explicitly skips native jobs', t => { const repo = repository(t); repo.write('docs/site/package.json', JSON.stringify({ scripts: { check: 'node check.mjs' } })); @@ -212,7 +237,7 @@ test('a documentation-only merge queue candidate runs its required site check an delete env.GITHUB_TOKEN; delete env.GH_TOKEN; const plan = JSON.parse(execFileSync(process.execPath, [script, 'classify'], { cwd: repo.root, env, encoding: 'utf8' })); - assert.equal(plan.full_cpp, false); + assert.equal(plan.native_check, false); assert.equal(plan.docs_check, true); assert.equal(plan.readme_check, false); assert.equal(plan.files_known, true); @@ -220,7 +245,7 @@ test('a documentation-only merge queue candidate runs its required site check an const valid = results(plan); execFileSync(process.execPath, [script, 'gate'], { cwd: repo.root, env: { ...env, CI_NEEDS_JSON: JSON.stringify(valid) }, stdio: ['ignore', 'pipe', 'pipe'] }); - for (const job of ['network', 'applications', 'database', 'documentation']) { + for (const job of ['network', 'applications', 'database', 'formatting', 'documentation']) { for (const result of [job === 'documentation' ? 'skipped' : 'success', 'failure', 'cancelled', undefined]) { const invalid = structuredClone(valid); invalid[job].result = result; @@ -244,10 +269,10 @@ test('a merge queue candidate includes earlier queued code even when the last PR } const head = repo.git('rev-parse', 'HEAD'); const sourcePlan = classifyEvent(repo, 'pull_request', { pull_request: { base: { sha: base }, head: { sha: docsHead } } }).plan; - assert.equal(sourcePlan.full_cpp, false); + assert.equal(sourcePlan.native_check, false); const { plan } = classifyEvent(repo, 'merge_group', { merge_group: { base_sha: base, head_sha: head } }); assert.deepEqual(changedPaths({ root: repo.root, base, head, pullRequest: false }), ['docs/guide.md', 'src/main.cpp']); - assert.equal(plan.full_cpp, true); + assert.equal(plan.native_check, true); assert.equal(plan.files_known, true); assert.equal(plan.reason, 'code-build-or-unknown-path'); validateGate(results(plan)); @@ -271,7 +296,7 @@ test('merge group enumeration compares its exact base and head rather than a PR assert.deepEqual(changedPaths({ root: repo.root, base, head }), ['docs/guide.md']); const { plan } = classifyEvent(repo, 'merge_group', { merge_group: { base_sha: base, head_sha: head } }); assert.equal(plan.files_known, true); - assert.equal(plan.full_cpp, true); + assert.equal(plan.native_check, true); assert.equal(plan.file_count, 2); assert.equal(plan.reason, 'code-build-or-unknown-path'); }); @@ -283,7 +308,7 @@ test('merge queue documentation renames cannot hide a removed code path', t => { const head = repo.commit(); const { plan } = classifyEvent(repo, 'merge_group', { merge_group: { base_sha: base, head_sha: head } }); assert.equal(plan.files_known, true); - assert.equal(plan.full_cpp, true); + assert.equal(plan.native_check, true); assert.equal(plan.file_count, 2); }); @@ -297,11 +322,11 @@ test('missing, invalid and unavailable merge group objects fall back to full che { merge_group: { base_sha: base, head_sha: 'f'.repeat(40) } }, { merge_group: { base_sha: '--help', head_sha: head } }, '{invalid JSON']) { const { plan, output } = classifyEvent(repo, 'merge_group', payload); - assert.equal(plan.full_cpp, true); + assert.equal(plan.native_check, true); assert.equal(plan.docs_check, true); assert.equal(plan.files_known, false); assert.equal(plan.reason, 'enumeration-unavailable'); - assert.match(output, /full_cpp=true\n/); + assert.match(output, /native_check=true\n/); validateGate(results(plan)); } }); @@ -311,12 +336,12 @@ test('empty merge ranges and unavailable documentation sites never skip the nati const base = addSite(repo); const empty = classifyEvent(repo, 'merge_group', { merge_group: { base_sha: base, head_sha: base } }).plan; assert.equal(empty.files_known, true); - assert.equal(empty.full_cpp, true); + assert.equal(empty.native_check, true); assert.equal(empty.reason, 'empty-diff'); repo.git('rm', 'docs/site/package-lock.json'); const head = repo.commit(); const unavailable = classifyEvent(repo, 'merge_group', { merge_group: { base_sha: base, head_sha: head } }).plan; - assert.equal(unavailable.full_cpp, true); + assert.equal(unavailable.native_check, true); assert.equal(unavailable.docs_check, true); assert.equal(unavailable.site_available, false); assert.equal(unavailable.reason, 'site-unavailable'); @@ -331,7 +356,7 @@ test('a known README-only merge candidate requires both site and README validati repo.write('README.md', '# Candidate README\n'); const head = repo.commit(); const { plan, output } = classifyEvent(repo, 'merge_group', { merge_group: { base_sha: base, head_sha: head } }); - assert.equal(plan.full_cpp, false); + assert.equal(plan.native_check, false); assert.equal(plan.docs_check, true); assert.equal(plan.readme_check, true); assert.equal(plan.files_known, true); @@ -347,7 +372,7 @@ test('merge candidates with build, dependency, CI, unknown or embedded code path 'vcpkg.json', 'docs/site/package-lock.json', '.github/scripts/ci-policy.mjs', 'tests/ci/policy.test.mjs', 'docs/unknown.bin', 'scripts/format.sh']) { const plan = decision(['docs/guide.md', file], { eventName: 'merge_group' }); - assert.equal(plan.full_cpp, true, file); + assert.equal(plan.native_check, true, file); assert.equal(plan.docs_check, true, file); assert.equal(plan.reason, 'code-build-or-unknown-path', file); } @@ -363,7 +388,7 @@ test('a real shallow candidate checkout cannot grant documentation-only coverage execFileSync('git', ['clone', '--depth', '1', pathToFileURL(repo.root).href, shallow], { stdio: ['ignore', 'pipe', 'pipe'] }); const { plan } = classifyEvent({ root: shallow }, 'merge_group', { merge_group: { base_sha: base, head_sha: head } }); - assert.equal(plan.full_cpp, true); + assert.equal(plan.native_check, true); assert.equal(plan.docs_check, true); assert.equal(plan.files_known, false); assert.equal(plan.reason, 'enumeration-unavailable'); @@ -380,7 +405,7 @@ test('every required/optional job rejects all unexpected success, skipped, failu for (const plan of [decision(['README.md']), decision(['src/main.cpp']), decision(['docs/guide.md'], { eventName: 'merge_group' }), decision(['README.md'], { eventName: 'merge_group' })]) { const valid = results(plan); - for (const job of ['network', 'applications', 'database', 'documentation', 'readme']) { + for (const job of ['network', 'applications', 'database', 'formatting', 'documentation', 'readme']) { for (const state of ['success', 'skipped', 'failure', 'cancelled', 'neutral', 'timed_out', '', undefined]) { if (state === valid[job].result) continue; const invalid = structuredClone(valid); @@ -401,7 +426,8 @@ test('missing classifier results, non-string booleans and contradictory skip pla invalid.scope.result = result; assert.throws(() => validateGate(invalid)); } - for (const key of ['full_cpp', 'docs_check', 'readme_check', 'site_available', 'files_known']) { + for (const key of ['native_check', 'network_check', 'applications_check', 'database_check', 'format_check', + 'docs_check', 'readme_check', 'site_available', 'files_known']) { for (const value of [undefined, null, '', 'TRUE', 'False', '0', '1', true, false]) { const invalid = structuredClone(valid); invalid.scope.outputs[key] = value; diff --git a/tests/ci/workflow-concurrency.test.mjs b/tests/ci/workflow-concurrency.test.mjs index d0e3d9e72..e9ddab4ca 100644 --- a/tests/ci/workflow-concurrency.test.mjs +++ b/tests/ci/workflow-concurrency.test.mjs @@ -32,7 +32,7 @@ test('the required gate still runs after failed needs and invokes the fatal resu assert.ok(required, 'retain the required gate'); assert.match(required, /^ name: CI - required$/m); assert.match(required, /^ if: always\(\)$/m); - assert.match(required, /^ needs: \[scope, network, applications, database, documentation, readme\]$/m); + assert.match(required, /^ needs: \[scope, network, applications, database, formatting, documentation, readme\]$/m); assert.match(required, /^ CI_NEEDS_JSON: \$\{\{ toJSON\(needs\) \}\}$/m); assert.match(required, /^ run: node \.github\/scripts\/ci-policy\.mjs gate$/m); assert.doesNotMatch(required, /^ *continue-on-error:/m, 'gate errors must fail the job'); From 0683aaad9fb6e8d9463b1eba9d12629c7dc5b2c2 Mon Sep 17 00:00:00 2001 From: nick Date: Mon, 19 Oct 2026 19:16:03 +0800 Subject: [PATCH 2/4] fix(ci): reject unavailable-site module plans --- .github/scripts/ci-policy.mjs | 3 ++- tests/ci/module-validation.test.mjs | 6 ++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/scripts/ci-policy.mjs b/.github/scripts/ci-policy.mjs index 90f5a04f8..d7c2f082f 100644 --- a/.github/scripts/ci-policy.mjs +++ b/.github/scripts/ci-policy.mjs @@ -140,7 +140,8 @@ export function validateGate(needs, eventName) { flags[key] = value === 'true'; } const reason = needs.scope.outputs.reason; - const uncertain = !flags.files_known || ['empty-diff', 'site-unavailable', 'enumeration-unavailable'].includes(reason); + const uncertain = !flags.files_known || !flags.site_available || + ['empty-diff', 'site-unavailable', 'enumeration-unavailable'].includes(reason); const stage = !flags.native_check ? 'documentation' : eventName === 'pull_request' && !uncertain ? 'modules' : 'full'; if (needs.scope.outputs.validation_stage !== stage) throw new Error('invalid validation stage'); if (!['documentation-only', 'full-event', 'enumeration-unavailable', 'empty-diff', 'site-unavailable', diff --git a/tests/ci/module-validation.test.mjs b/tests/ci/module-validation.test.mjs index 1dc78edc7..7b60dfeff 100644 --- a/tests/ci/module-validation.test.mjs +++ b/tests/ci/module-validation.test.mjs @@ -146,6 +146,12 @@ test('required results distinguish source modules from queue acceptance and reje } }); +test('unavailable site outputs cannot qualify for module-only validation', () => { + const invalid = results(plan(['tests/network/rudp.cpp'])); + invalid.scope.outputs.site_available = 'false'; + assert.throws(() => validateGate(invalid, 'pull_request')); +}); + test('source module, full queue and manual plans reject failed cancelled skipped and unknown required results', () => { for (const scope of [plan(['tests/network/rudp.cpp']), plan(['tests/database/redis_service.cpp']), plan(['tests/core/containers.cpp']), plan(['unknown.bin']), plan(['tests/network/rudp.cpp'], 'merge_group'), From e027f22958cf253dc59537d40641b2aa1d1b05b3 Mon Sep 17 00:00:00 2001 From: nick Date: Mon, 19 Oct 2026 19:24:23 +0800 Subject: [PATCH 3/4] style(examples): keep measurement labels in one stream literal --- examples/sample4/sample4_module.cpp | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/examples/sample4/sample4_module.cpp b/examples/sample4/sample4_module.cpp index c4909478a..2147f044a 100644 --- a/examples/sample4/sample4_module.cpp +++ b/examples/sample4/sample4_module.cpp @@ -361,8 +361,7 @@ void sample4_module::move_actor(const sample_scene& scene, sample_actor_directio std::cout << "scene id = " << scene.scene_id << " ray cast distance = " << distance << " total count = " << total_count << "(w) actor count = " << actor_count << " move count = " << move_count_base << "(w) total time = " << time << "(s) per time = " << average_time - << "(us)" - << " hit = " << hit << " missed = " << missed << std::endl; + << "(us) hit = " << hit << " missed = " << missed << std::endl; ///reset actor position re_random_position(scene); From 55553010d95e61a628f7646057b8934d6c117195 Mon Sep 17 00:00:00 2001 From: nick Date: Mon, 19 Oct 2026 19:47:34 +0800 Subject: [PATCH 4/4] fix(ci): export the scope reason to the required gate --- .github/workflows/ci.yml | 1 + tests/ci/module-validation.test.mjs | 12 ++++++++++++ 2 files changed, 13 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d5a2ec467..63f20e8a4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,6 +23,7 @@ jobs: outputs: event_name: ${{ steps.scope.outputs.event_name }} validation_stage: ${{ steps.scope.outputs.validation_stage }} + reason: ${{ steps.scope.outputs.reason }} native_check: ${{ steps.scope.outputs.native_check }} network_check: ${{ steps.scope.outputs.network_check }} applications_check: ${{ steps.scope.outputs.applications_check }} diff --git a/tests/ci/module-validation.test.mjs b/tests/ci/module-validation.test.mjs index 7b60dfeff..a6f513b73 100644 --- a/tests/ci/module-validation.test.mjs +++ b/tests/ci/module-validation.test.mjs @@ -24,6 +24,18 @@ function matrices(name) { return { source: JSON.parse(expression[1]).include, full: JSON.parse(expression[2]).include }; } +test('actual workflow output bindings provide complete plans to the required gate', () => { + const outputs = [...jobBlock('scope').matchAll(/^ (\w+): \$\{\{ steps\.scope\.outputs\.(\w+) \}\}$/gm)]; + assert.ok(outputs.length > 0); + for (const scope of [plan(['tests/network/rudp.cpp']), plan(['include/ark/core/module.hpp']), + plan(['README.md']), plan(['tests/database/redis_service.cpp'], 'merge_group'), + plan(['README.md'], 'merge_group'), plan([], 'workflow_dispatch'), plan([], 'pull_request')]) { + const needs = results(scope); + needs.scope.outputs = Object.fromEntries(outputs.map(([, name, source]) => [name, String(scope[source])])); + validateGate(needs, scope.event_name); + } +}); + test('source paths select actual module regressions and propagate plugin dependencies', () => { const cases = [ ['tests/core/containers.cpp', ['applications']],