diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e3c65f2b6..89fa9892c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,8 +2,6 @@ name: CI run-name: CI - ${{ github.event_name }} - ${{ github.ref_name }} on: - push: - branches: [main] pull_request: branches: [main] merge_group: @@ -15,7 +13,7 @@ permissions: concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true + cancel-in-progress: false jobs: scope: @@ -130,13 +128,6 @@ jobs: -DARK_BUILD_NETWORK_TESTS=ON -DASIO_STANDALONE=OFF ${{ matrix.sanitizer == 'address' && '"-DCMAKE_CXX_FLAGS=-fsanitize=address,undefined -fno-omit-frame-pointer" "-DCMAKE_EXE_LINKER_FLAGS=-fsanitize=address,undefined"' || matrix.sanitizer == 'thread' && '"-DCMAKE_CXX_FLAGS=-fsanitize=thread -fno-omit-frame-pointer" "-DCMAKE_EXE_LINKER_FLAGS=-fsanitize=thread"' || '' }} - - run: cmake --build ci-build --parallel 2 - - name: Run network regressions - env: - ASAN_OPTIONS: detect_leaks=1:halt_on_error=1 - UBSAN_OPTIONS: halt_on_error=1:print_stacktrace=1 - TSAN_OPTIONS: halt_on_error=1:second_deadlock_stack=1 - run: cmake --build ci-build --target check-network - name: Save vcpkg package archives if: ${{ success() && steps.vcpkg-cache.outputs.enabled == 'true' && steps.vcpkg-restore.outputs.cache-hit != 'true' }} continue-on-error: true @@ -144,6 +135,13 @@ jobs: with: path: ${{ steps.vcpkg-cache.outputs.directory }} key: ${{ steps.vcpkg-cache.outputs.key }} + - run: cmake --build ci-build --parallel 2 + - name: Run network regressions + env: + ASAN_OPTIONS: detect_leaks=1:halt_on_error=1 + UBSAN_OPTIONS: halt_on_error=1:print_stacktrace=1 + TSAN_OPTIONS: halt_on_error=1:second_deadlock_stack=1 + run: cmake --build ci-build --target check-network applications: name: Applications - ${{ matrix.os }} - C++20 needs: scope @@ -197,9 +195,6 @@ jobs: -DARK_BUILD_CORE_TESTS=ON -DARK_BUILD_EXAMPLES=ON -DASIO_STANDALONE=OFF - - run: cmake --build ci-build --parallel 2 - - name: Run core and Protobuf regressions - run: cmake --build ci-build --target check-core - name: Save vcpkg package archives if: ${{ success() && steps.vcpkg-cache.outputs.enabled == 'true' && steps.vcpkg-restore.outputs.cache-hit != 'true' }} continue-on-error: true @@ -207,6 +202,9 @@ jobs: with: path: ${{ steps.vcpkg-cache.outputs.directory }} key: ${{ steps.vcpkg-cache.outputs.key }} + - run: cmake --build ci-build --parallel 2 + - name: Run core and Protobuf regressions + run: cmake --build ci-build --target check-core database: name: Database - ${{ matrix.os }} - ${{ matrix.asio }} needs: scope @@ -275,12 +273,6 @@ jobs: "-DARKNET_SOURCE_DIR=$env:GITHUB_WORKSPACE/ci-arknet" -DARKNET_ENABLE_SSL=ON -DASIO_STANDALONE=${{ matrix.standalone }} - - name: Compile native services and coroutine tests - run: cmake --build ci-database --parallel 2 - - name: Run local database lifetime regressions - run: cmake --build ci-database --target check-database - - name: Run local database benchmark self-tests - run: cmake --build ci-database --target check-database-benchmark - name: Save vcpkg package archives if: ${{ success() && steps.vcpkg-cache.outputs.enabled == 'true' && steps.vcpkg-restore.outputs.cache-hit != 'true' }} continue-on-error: true @@ -288,6 +280,12 @@ jobs: with: path: ${{ steps.vcpkg-cache.outputs.directory }} key: ${{ steps.vcpkg-cache.outputs.key }} + - name: Compile native services and coroutine tests + run: cmake --build ci-database --parallel 2 + - name: Run local database lifetime regressions + run: cmake --build ci-database --target check-database + - name: Run local database benchmark self-tests + run: cmake --build ci-database --target check-database-benchmark required: name: CI - required if: always() diff --git a/AGENTS.md b/AGENTS.md index bb82e233e..4ee276433 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,6 +38,11 @@ branch or worktree when the existing checkout contains uncommitted changes. ## Verification and delivery +- When runner capacity is shared by several PRs, prioritize the oldest unblocked + PRs and their required queue candidates. Prepare newer work locally and defer + its hosted runs until older ready PRs can finish; do not repeatedly push newer + branches ahead of the backlog. Preserve active acceptance runs. A cancelled + scheduling run still needs fresh successful checks before acceptance. - Run the checks relevant to the change before publishing. Report the exact tested revision, commands, results and material limitations. - Investigate failed CI logs before changing code or retrying. Do not repeatedly diff --git a/README.md b/README.md index fa5f2e8fc..4e53fc459 100755 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@
Compose services. Model entities. Connect processes.
diff --git a/README_CN.md b/README_CN.md index 664932a03..8bd12819f 100755 --- a/README_CN.md +++ b/README_CN.md @@ -12,7 +12,7 @@组织业务服务,描述实体数据,连接应用进程。
diff --git a/tests/ci/README.md b/tests/ci/README.md index 62f303e10..fe1d324c6 100644 --- a/tests/ci/README.md +++ b/tests/ci/README.md @@ -9,7 +9,19 @@ 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. -Main pushes and manual runs always retain the full C++ matrix. +PRs run the checks required by their changed paths. Merge-group candidates and +manual runs retain the full C++ matrix. 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. + +Each existing PR/ref concurrency group permits one active run and only the most +recently queued pending run. A new run waits for the active run to finish instead +of automatically cancelling it. Individual running jobs retain their configured +timeouts; those limits do not include pending time. Merge-group candidates use +their own refs. The required 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). Root README link checks run only when a root README changes. They validate local file targets, including images, HTML `srcset` and reference links. It handles the forms @@ -21,7 +33,9 @@ The vcpkg cache tests cover Windows/Unix archive paths, dependency and runner-im key changes, provider exports, unavailable storage and explicit cold-build fallbacks. The C++ CI jobs restore only local package archives through Actions cache; vcpkg still validates each package ABI. A cache miss/service failure runs the ordinary -dependency build and tests. Archives are saved only after the job's tests succeed. +dependency build and tests. Archives are saved after successful CMake configuration, +before compiling and testing ark; later build or test failures still fail the job. +Workflow checks cover this order and retain fatal configure/build/test steps. Missing runner-image identity or unsupported archive paths disable persistence and export `VCPKG_BINARY_SOURCES=clear`, which prevents run-vcpkg's removed `x-gha` default from being selected. Missing GitHub file-command channels fail explicitly. diff --git a/tests/ci/README_CN.md b/tests/ci/README_CN.md index 261bc56e3..0f0731042 100644 --- a/tests/ci/README_CN.md +++ b/tests/ci/README_CN.md @@ -8,7 +8,15 @@ node --test tests/ci/*.test.mjs 测试使用临时的本地 Git 仓库,不需要 GitHub API token、网络服务、C++ 重编译或数据库。 覆盖包含重命名旧路径的完整文件集合、无法确定范围时执行完整 C++ 矩阵、文档站可用性, -以及各任务结果的严格校验。推送 main 和手动运行始终执行完整 C++ 矩阵。 +以及各任务结果的严格校验。PR 按变更范围执行检查,Merge Queue 候选和手动运行保留完整 C++ 矩阵。 +受保护的 `main` 只接收已通过必需检查的合并候选;合入后的推送不再对同一代码树重复执行 CI。 +独立的文档工作流仍在 main 推送后发布文档。README 徽章展示合并候选的工作流状态。 + +每个既有的 PR/引用并发分组最多执行一轮,只保留最新进入该分组的一轮等待任务。 +新运行等待旧轮结束,不再自动取消正在执行的旧轮。每个已运行的 job 保留原有超时限制, +等待时间不计入这些限制;Merge Queue 候选使用各自的引用分组。required gate 仍拒绝失败、 +取消或缺失的结果。手动取消仍可能使 `always()` 汇总无法完成。 +详见 [GitHub 并发规则](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#concurrency)。 只有根 README 变更时才检查其本地文件链接,包含图片、HTML `srcset` 和引用式链接。 检查器覆盖这些 README 使用的表达形式,不是完整的 Markdown 解析器;不验证远程 URL 或标题锚点。 @@ -17,6 +25,7 @@ Nimbus 检查单独验证生成文档站内的链接与锚点,不包含根 REA vcpkg 缓存测试覆盖 Windows/Unix 归档路径、依赖和执行机镜像变化引起的 key 变化、 缓存配置导出、目录不可用,以及明确的无缓存构建路径。C++ CI 通过 Actions 缓存本地依赖包归档, 每个包的 ABI 仍由 vcpkg 校验。未命中缓存或缓存服务不可用时,依赖构建和测试照常执行; -只有任务中的测试全部通过后才保存归档。缺少执行机镜像信息或归档路径不受支持时, +归档在 CMake 配置成功后、ark 编译和测试之前保存;后续编译或测试失败仍会使任务失败。 +工作流检查覆盖该顺序,并确保配置、编译和测试失败时不会忽略错误。缺少执行机镜像信息或归档路径不受支持时, 关闭持久缓存并导出 `VCPKG_BINARY_SOURCES=clear`,避免 run-vcpkg 再选择已移除的 `x-gha` 默认值。 缺少 GitHub 文件命令通道时明确失败。不缓存项目的 C++ 编译产物、依赖安装目录、凭据或编译器缓存。 diff --git a/tests/ci/cache-workflow.test.mjs b/tests/ci/cache-workflow.test.mjs new file mode 100644 index 000000000..ae5de2829 --- /dev/null +++ b/tests/ci/cache-workflow.test.mjs @@ -0,0 +1,59 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +const workflow = readFileSync(new URL('../../.github/workflows/ci.yml', import.meta.url), 'utf8').replaceAll('\r\n', '\n'); +const profiles = [ + ['network', 'Configure network regressions', 'ci-build', ['check-network']], + ['applications', 'Configure applications', 'ci-build', ['check-core']], + ['database', 'Configure native database development tests', 'ci-database', + ['check-database', 'check-database-benchmark']], +]; + +// Read this workflow's job/step indentation; this is not a general YAML parser. +function jobSteps(name) { + const jobs = workflow.split(/(?=^ [a-z][a-z0-9_-]*:$)/m); + const job = jobs.find(block => block.startsWith(` ${name}:\n`)); + assert.ok(job, `missing job ${name}`); + const [settings, ...steps] = job.split(/(?=^ - )/m); + assert.doesNotMatch(settings, /^ continue-on-error:/m, `${name} must remain fatal`); + assert.ok(steps.length > 0, `${name} must contain steps`); + return steps; +} + +function onlyStep(steps, predicate, description) { + const found = steps.map((step, index) => ({ step, index })).filter(({ step }) => predicate(step)); + assert.equal(found.length, 1, `expected exactly one ${description}`); + return found[0]; +} + +for (const [name, configureName, buildDirectory, checks] of profiles) { + test(`${name} saves dependency archives immediately after successful configure`, () => { + const steps = jobSteps(name); + const configure = onlyStep(steps, step => step.startsWith(` - name: ${configureName}\n`), 'configure step'); + const save = onlyStep(steps, step => step.includes('uses: actions/cache/save@v4\n'), 'archive save'); + const build = onlyStep(steps, step => step.includes(`run: cmake --build ${buildDirectory} --parallel `), 'source build'); + assert.equal(save.index, configure.index + 1, 'save only after configure has completed'); + assert.ok(save.index < build.index, 'save dependencies before compiling project sources'); + assert.match(save.step, /^ if: \$\{\{ success\(\) && steps\.vcpkg-cache\.outputs\.enabled == 'true' && steps\.vcpkg-restore\.outputs\.cache-hit != 'true' \}\}$/m); + assert.match(save.step, /^ continue-on-error: true$/m); + assert.match(save.step, /^ path: \$\{\{ steps\.vcpkg-cache\.outputs\.directory \}\}$/m); + assert.match(save.step, /^ key: \$\{\{ steps\.vcpkg-cache\.outputs\.key \}\}$/m); + }); + + test(`${name} still requires configure, compilation and every regression command`, () => { + const steps = jobSteps(name); + const configure = onlyStep(steps, step => step.startsWith(` - name: ${configureName}\n`), 'configure step'); + const build = onlyStep(steps, step => step.includes(`run: cmake --build ${buildDirectory} --parallel `), 'source build'); + const required = [configure, build, ...checks.map(target => onlyStep(steps, + step => step.includes(`run: cmake --build ${buildDirectory} --target ${target}\n`), target))]; + assert.match(configure.step, /^ cmake -S \. -B /m); + for (const { step } of required) { + assert.doesNotMatch(step, /^ continue-on-error:/m, 'configure/build/test failure must fail the job'); + assert.doesNotMatch(step, /^ if:/m, 'configure/build/tests must retain their default success condition'); + } + for (const check of required.slice(2)) { + assert.ok(build.index < check.index, 'compile before running regressions'); + } + }); +} diff --git a/tests/ci/policy.test.mjs b/tests/ci/policy.test.mjs index 64cfb85d7..90ffa582d 100644 --- a/tests/ci/policy.test.mjs +++ b/tests/ci/policy.test.mjs @@ -68,7 +68,7 @@ test('empty, missing, truncated and invalid UTF-8 path lists never grant a light assert.equal(decision(['docs/a\nname.md']).full_cpp, true); }); -test('manual, main pushes and unsupported events retain full C++ coverage', () => { +test('events outside pull requests cannot grant a lightweight classifier result', () => { for (const eventName of ['push', 'workflow_dispatch', 'merge_group', 'unknown']) { const plan = decision(['docs/guide.md'], { eventName }); assert.equal(plan.full_cpp, true); diff --git a/tests/ci/workflow-concurrency.test.mjs b/tests/ci/workflow-concurrency.test.mjs new file mode 100644 index 000000000..d0e3d9e72 --- /dev/null +++ b/tests/ci/workflow-concurrency.test.mjs @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +const workflow = readFileSync(new URL('../../.github/workflows/ci.yml', import.meta.url), 'utf8').replaceAll('\r\n', '\n'); + +test('PR, merge-group and manual runs retain the bounded per-PR/ref workflow group', () => { + const concurrency = workflow.match(/^concurrency:\n((?: [^\n]+\n)+)/m)?.[1]; + assert.equal(concurrency, + ' group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}\n' + + ' cancel-in-progress: false\n'); + assert.equal([...workflow.matchAll(/^ *concurrency:/gm)].length, 1, 'keep one workflow-level group'); + assert.match(workflow, /^ pull_request:\n branches: \[main\]$/m); + assert.match(workflow, /^ merge_group:\n types: \[checks_requested\]$/m); + assert.match(workflow, /^ workflow_dispatch:$/m); +}); + +test('protected admission is validated before merge without repeating CI on a main push', () => { + assert.doesNotMatch(workflow, /^ push:/m); + const publishing = readFileSync(new URL('../../.github/workflows/docs.yml', import.meta.url), 'utf8').replaceAll('\r\n', '\n'); + assert.match(publishing, /^ push:\n branches: \[main\]$/m, 'keep documentation publication after merge'); + for (const name of ['README.md', 'README_CN.md']) { + const readme = readFileSync(new URL('../../' + name, import.meta.url), 'utf8'); + assert.match(readme, /ci\.yml\/badge\.svg\?event=merge_group/); + assert.doesNotMatch(readme, /ci\.yml\/badge\.svg\?branch=main/); + } +}); + +test('the required gate still runs after failed needs and invokes the fatal result validator', () => { + const required = workflow.split(/(?=^ [a-z][a-z0-9_-]*:$)/m) + .find(block => block.startsWith(' required:\n')); + 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, /^ 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'); +});