diff --git a/.github/scripts/ci-policy.mjs b/.github/scripts/ci-policy.mjs index b7ba92331..e4a53e88e 100644 --- a/.github/scripts/ci-policy.mjs +++ b/.github/scripts/ci-policy.mjs @@ -22,7 +22,7 @@ export function isDocumentationPath(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) || /^\.clang-(?:format|tidy)$/.test(name)) return false; - if (file === 'README.md' || file === 'README_CN.md' || pluginIndexPaths.has(file) || testGuidePaths.has(file)) return true; + if (file === 'AGENTS.md' || file === 'README.md' || file === 'README_CN.md' || pluginIndexPaths.has(file) || testGuidePaths.has(file)) return true; return file.startsWith('docs/') && (docPath.test(file) || name === 'LICENSE' || name === '.gitignore'); } @@ -98,7 +98,7 @@ export function selectScope({ eventName, files, site, enumerationFailed = false const scopedEvent = eventName === 'pull_request' || eventName === 'merge_group'; const docsOnly = scopedEvent && nonempty && site.available && paths.every(isDocumentationPath); const readme = paths.some(file => file === 'README.md' || file === 'README_CN.md'); - const docsTouched = readme || paths.some(file => file.startsWith('docs/') || pluginIndexPaths.has(file) || testGuidePaths.has(file)); + const docsTouched = readme || paths.some(file => file === 'AGENTS.md' || file.startsWith('docs/') || pluginIndexPaths.has(file) || testGuidePaths.has(file)); 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); diff --git a/docs/project/contributing.md b/docs/project/contributing.md index 157c15b20..297512f87 100644 --- a/docs/project/contributing.md +++ b/docs/project/contributing.md @@ -15,6 +15,19 @@ npm run check --prefix docs/site The site provides language switching, search, code highlighting and plain Markdown endpoints for AI readers. Pull requests run the documentation checks without publishing. After a change is merged into `main`, the documentation workflow builds and publishes GitHub Pages; maintainers can also trigger it manually. Node.js 22.12+ is needed for the documentation build; it is not needed to compile ark or its C++ tests. +## CI documentation scope + +The exact root `AGENTS.md` file is documentation. When the changed range is known, +nonempty and confined to recognized documentation, and the Nimbus site is available, +both source PRs and merge-group candidates require the documentation checks and +`CI - required`, while native jobs are explicitly skipped. Nimbus checks the site; +root README link checks run only when a root README changes. + +Nested unknown `AGENTS.md` paths, similarly named code, mixed source changes, +unavailable ranges or an unavailable site retain native checks. Manual runs keep +the full native matrix. Changes to the CI policy itself still require native +acceptance; this documentation exception does not change that requirement. + ## CI dependency archive cache CI restores vcpkg package archives from the runner temporary directory with diff --git a/docs/project/contributing_CN.md b/docs/project/contributing_CN.md index 611db3369..efd4752f8 100644 --- a/docs/project/contributing_CN.md +++ b/docs/project/contributing_CN.md @@ -15,6 +15,16 @@ npm run check --prefix docs/site 站点提供语言切换、搜索、代码高亮,以及供 AI 阅读的纯 Markdown 接口。PR 只运行文档检查,不发布站点。变更合并到 `main` 后,文档工作流构建并发布 GitHub Pages;维护者也可手动触发。文档构建需要 Node.js 22.12+;编译 ark 与 C++ 测试不需要 Node.js。 +## CI 文档范围 + +根目录的精确文件 `AGENTS.md` 归为文档。改动范围已知且非空、仅包含已识别的文档, +并且 Nimbus 文档站可用时,源分支 PR 与合并候选均要求文档检查和 `CI - required`, +原生任务明确跳过。Nimbus 检查文档站;只有根 README 变化时才检查其链接。 + +嵌套的未知 `AGENTS.md` 路径、名称相近的代码、混合源码变更、范围不可用或文档站不可用时, +仍保留原生检查。手动运行保留完整原生矩阵。CI 策略本身的变更仍须完成原生验收, +这项文档例外不会改变该要求。 + ## CI 依赖包归档缓存 CI 使用 `actions/cache` 恢复执行机临时目录中的 vcpkg 包归档。 diff --git a/tests/ci/README.md b/tests/ci/README.md index fa7d1423f..60a681737 100644 --- a/tests/ci/README.md +++ b/tests/ci/README.md @@ -96,6 +96,14 @@ their own refs. Independent groups do not enforce PR creation order. The require gate still rejects failed, cancelled or missing results. Manual cancellation can still prevent the `always()` summary from completing. See [GitHub concurrency rules](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#concurrency). +The exact root `AGENTS.md` path also qualifies as documentation. A known, +nonempty range confined to that file and other recognized documentation selects +Nimbus and the final required gate on both PRs and merge-group candidates, with +native jobs explicitly skipped when the site is available. This does not extend +the root README link check or exempt nested `AGENTS.md` files, similarly named +code, mixed source changes or uncertain ranges. Manual runs retain the full +native matrix. This CI policy change itself still requires native acceptance. + Within `src/`, only the exact plugin index paths `src/plugins/README.md` and `src/plugins/README_CN.md` additionally qualify as documentation. Changes confined to these indexes select the Nimbus check on PRs and documentation-only queue diff --git a/tests/ci/README_CN.md b/tests/ci/README_CN.md index f78798d0f..9ee8db541 100644 --- a/tests/ci/README_CN.md +++ b/tests/ci/README_CN.md @@ -66,6 +66,12 @@ README 徽章展示合并候选的工作流状态。 required gate 仍拒绝失败、取消或缺失的结果。手动取消仍可能使 `always()` 汇总无法完成。 详见 [GitHub 并发规则](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#concurrency)。 +根目录的精确路径 `AGENTS.md` 也归为文档。范围已知且非空、文档站可用时, +仅修改该文件和其他已识别文档的 PR 与合并候选均要求 Nimbus 检查和最终必需门禁, +并明确跳过原生任务。这不会扩展根 README 链接检查,也不会豁免嵌套的 `AGENTS.md`、 +名称相近的代码、混合源码变更或不确定范围。手动运行仍保留完整原生矩阵。 +本次 CI 策略变更自身仍须完成原生验收。 + `src/` 下仅将两个精确的插件索引路径 `src/plugins/README.md` 与 `src/plugins/README_CN.md` 归为文档。 文档站可用时,只修改这两个索引的 PR 与纯文档合并候选会选择 Nimbus 检查。 这不会将根 README 链接检查扩展到插件索引;Nimbus 验证生成的文档站,不直接检查索引文件。 diff --git a/tests/ci/module-validation.test.mjs b/tests/ci/module-validation.test.mjs index fe96edd87..1fa8aaf95 100644 --- a/tests/ci/module-validation.test.mjs +++ b/tests/ci/module-validation.test.mjs @@ -30,7 +30,8 @@ test('actual workflow output bindings provide complete plans to the required gat 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']), plan(['AGENTS.md']), plan(['AGENTS.md'], 'merge_group'), + 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])])); @@ -113,7 +114,7 @@ test('public core headers, build/dependency/CI inputs and unknown paths retain e '3rdparty/spdlog', 'ports/asio/portfile.cmake', '.github/workflows/ci.yml', 'config/timers.app.toml', 'resources/excel/item.xlsx', 'resources/photon/proto/protocol.proto', 'tools/gen-config.sh', 'deployment/scripts/run.sh', - '.github/scripts/ci-policy.mjs', 'tests/ci/module-validation.test.mjs', 'AGENTS.md', '.clang-format', + '.github/scripts/ci-policy.mjs', 'tests/ci/module-validation.test.mjs', 'src/AGENTS.md', '.clang-format', 'application/unknown.bin', 'application/build.cmake', 'applications/main.cpp', 'src/app/main.cpp', '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']) { diff --git a/tests/ci/policy.test.mjs b/tests/ci/policy.test.mjs index 2a0810544..46c3bdbc8 100644 --- a/tests/ci/policy.test.mjs +++ b/tests/ci/policy.test.mjs @@ -64,6 +64,99 @@ test('only recognized documentation paths qualify for the lightweight plan', () assert.equal(decision(['README.md', 'docs/guide.md']).docs_check, true); }); +test('root AGENTS.md requires documentation and the final gate without native work at both scoped stages', () => { + assert.equal(isDocumentationPath('AGENTS.md'), true); + for (const eventName of ['pull_request', 'merge_group']) { + for (const files of [['AGENTS.md'], ['AGENTS.md', 'docs/guide.md']]) { + const scope = decision(files, { eventName }); + assert.equal(scope.validation_stage, 'documentation'); + assert.equal(scope.reason, 'documentation-only'); + assert.equal(scope.docs_check, true); + assert.equal(scope.readme_check, false); + for (const key of ['native_check', 'format_check', 'network_check', 'applications_check', 'database_check']) { + assert.equal(scope[key], false, `${eventName}/${key}`); + } + validateGate(results(scope)); + for (const outcome of ['skipped', 'failure', 'cancelled', undefined]) { + const invalid = results(scope); + invalid.validation.outputs.documentation_outcome = outcome; + assert.throws(() => validateGate(invalid), `${eventName}/${outcome}`); + } + for (const result of ['skipped', 'failure', 'cancelled', undefined]) { + const invalid = results(scope); + invalid.validation.result = result; + assert.throws(() => validateGate(invalid), `${eventName}/validation/${result}`); + } + } + assert.equal(decision(['AGENTS.md', 'README.md'], { eventName }).readme_check, true); + } +}); + +test('an exact root AGENTS.md Git change selects documentation through the real PR and merge-group classifier', t => { + const repo = repository(t); + const base = addSite(repo); + repo.write('AGENTS.md', '# Contributor and agent instructions\n'); + const head = repo.commit(); + assert.deepEqual(changedPaths({ root: repo.root, base, head }), ['AGENTS.md']); + for (const eventName of ['pull_request', 'merge_group']) { + const payload = eventName === 'pull_request' ? { pull_request: { base: { sha: base }, head: { sha: head } } } : + { merge_group: { base_sha: base, head_sha: head } }; + const { plan: scope, output } = classifyEvent(repo, eventName, payload); + assert.equal(scope.validation_stage, 'documentation'); + assert.equal(scope.docs_check, true); + assert.equal(scope.readme_check, false); + for (const key of ['native_check', 'format_check', 'network_check', 'applications_check', 'database_check']) { + assert.equal(scope[key], false, `${eventName}/${key}`); + assert.match(output, new RegExp(`^${key}=false$`, 'm')); + } + validateGate(results(scope)); + } +}); + +test('AGENTS neighbors and nested unknown paths cannot inherit the root documentation exception', () => { + const paths = ['agents.md', 'AGENTS.MD', 'AGENTS_CN.md', 'AGENTS.md.bak', 'AGENTS.md.js', + 'AGENTS.cpp', 'AGENTS.md.hpp', 'nested/AGENTS.md', 'src/AGENTS.md', 'tests/AGENTS.md', + 'src/plugins/net/AGENTS.md', '.github/AGENTS.md', 'docs/AGENTS.cpp', './AGENTS.md', + 'nested/../AGENTS.md', 'nested//AGENTS.md', 'nested\\AGENTS.md']; + for (const file of paths) { + assert.equal(isDocumentationPath(file), false, file); + for (const eventName of ['pull_request', 'merge_group']) { + const scope = decision(['AGENTS.md', file], { eventName }); + assert.equal(scope.native_check, true, `${eventName}/${file}`); + assert.equal(scope.format_check, true); + assert.equal(scope.docs_check, true); + for (const job of ['network', 'applications', 'database']) assert.equal(scope[`${job}_check`], true); + validateGate(results(scope)); + } + } +}); + +test('AGENTS mixed with code keeps source module selection and every merge-candidate native module', () => { + for (const eventName of ['pull_request', 'merge_group']) { + const scope = decision(['AGENTS.md', 'tests/core/containers.cpp'], { eventName }); + assert.equal(scope.native_check, true); + assert.equal(scope.docs_check, true); + assert.equal(scope.format_check, true); + assert.equal(scope.validation_stage, eventName === 'pull_request' ? 'modules' : 'full'); + for (const job of ['network', 'applications', 'database']) { + assert.equal(scope[`${job}_check`], eventName !== 'pull_request' || job === 'applications'); + } + validateGate(results(scope)); + } +}); + +test('AGENTS never exempts manual events, unavailable sites or uncertain changed paths from native checks', () => { + for (const extra of [{ eventName: 'workflow_dispatch' }, { enumerationFailed: true }, + { site: { present: true, available: false } }, { site: { present: false, available: false } }]) { + const scope = decision(['AGENTS.md'], extra); + assert.equal(scope.native_check, true); + assert.equal(scope.validation_stage, 'full'); + for (const job of ['network', 'applications', 'database']) assert.equal(scope[`${job}_check`], true); + if (extra.site?.present) assert.equal(scope.docs_check, true); + validateGate(results(scope)); + } +}); + test('only the two exact plugin indexes select documentation and keep root README checks separate', () => { const indexes = ['src/plugins/README.md', 'src/plugins/README_CN.md']; for (const eventName of ['pull_request', 'merge_group']) {