From ef645f2d923b6a7cd596af0b37d31f86d939c66c Mon Sep 17 00:00:00 2001 From: nick Date: Thu, 5 Nov 2026 13:31:28 +0800 Subject: [PATCH 1/6] ci(build): bound parallel jobs by runner resources --- .github/scripts/build-parallelism.mjs | 77 +++++++++++++ tests/ci/build-parallelism.test.mjs | 150 ++++++++++++++++++++++++++ 2 files changed, 227 insertions(+) create mode 100644 .github/scripts/build-parallelism.mjs create mode 100644 tests/ci/build-parallelism.test.mjs diff --git a/.github/scripts/build-parallelism.mjs b/.github/scripts/build-parallelism.mjs new file mode 100644 index 000000000..9910b7ddc --- /dev/null +++ b/.github/scripts/build-parallelism.mjs @@ -0,0 +1,77 @@ +import { appendFileSync } from 'node:fs'; +import { availableParallelism, totalmem } from 'node:os'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +const gibibyte = 1024 ** 3; +const maxBuildJobs = 4; + +function positiveInteger(value) { + return Number.isSafeInteger(value) && value > 0 ? value : null; +} + +function detected(read) { + try { + return positiveInteger(read()); + } catch { + return null; + } +} + +export function detectBuildResources({ + readCpuCount = availableParallelism, + readTotalMemory = totalmem, + readConstrainedMemory = () => process.constrainedMemory(), +} = {}) { + const memories = [detected(readTotalMemory), detected(readConstrainedMemory)].filter(value => value !== null); + return { + cpuCount: detected(readCpuCount), + // constrainedMemory() is zero when there is no known process/cgroup limit. + memoryBytes: memories.length ? Math.min(...memories) : null, + }; +} + +function applyOverride(value, maximum, fallback, limit) { + if (value === undefined || value === '') return maximum; + return typeof value === 'string' && new RegExp(`^[1-${limit}]$`).test(value) + ? Math.min(maximum, Number(value)) : Math.min(maximum, fallback); +} + +export function buildParallelismPlan({ cpuCount, memoryBytes, buildJobsOverride, linkJobsOverride } = {}) { + const cpu = positiveInteger(cpuCount); + const memory = positiveInteger(memoryBytes); + // Initial CI budget: reserve 1 GiB and allow 3 GiB per compiler process. + // Keep the existing two-job ceiling when either resource is unknown. This + // leaves 7-8 GiB runners at two jobs and never exceeds four on larger hosts. + const memoryJobs = memory === null ? 2 : Math.max(1, Math.floor((memory - gibibyte) / (3 * gibibyte))); + const resourceJobs = Math.min(maxBuildJobs, cpu ?? 2, memoryJobs); + const buildJobs = applyOverride(buildJobsOverride, resourceJobs, 2, maxBuildJobs); + // Links have a separate six-GiB budget; Ninja's overall --parallel bound still + // applies when compile and link jobs overlap. This is a scheduling limit, + // not a guarantee about an individual translation unit's peak memory. + const memoryLinks = memory === null ? 1 : Math.max(1, Math.floor((memory - gibibyte) / (6 * gibibyte))); + const resourceLinks = buildJobs === maxBuildJobs ? Math.min(2, memoryLinks) : 1; + const linkJobs = applyOverride(linkJobsOverride, resourceLinks, 1, 2); + return { cpuCount: cpu, memoryBytes: memory, buildJobs, linkJobs, + jobPools: `ark_compile=${buildJobs};ark_link=${linkJobs}` }; +} + +export function exportBuildParallelism({ env, resources = detectBuildResources() }) { + if (!env.GITHUB_ENV || !env.GITHUB_OUTPUT) throw new Error('GitHub file-command channels are required'); + const plan = buildParallelismPlan({ ...resources, + buildJobsOverride: env.ARK_BUILD_JOBS_OVERRIDE, linkJobsOverride: env.ARK_LINK_JOBS_OVERRIDE }); + appendFileSync(env.GITHUB_ENV, `ARK_BUILD_JOBS=${plan.buildJobs}\nARK_LINK_JOBS=${plan.linkJobs}\nARK_CMAKE_JOB_POOLS=${plan.jobPools}\n`); + appendFileSync(env.GITHUB_OUTPUT, `build_jobs=${plan.buildJobs}\nlink_jobs=${plan.linkJobs}\njob_pools=${plan.jobPools}\ncpu_count=${plan.cpuCount ?? 'unknown'}\nmemory_bytes=${plan.memoryBytes ?? 'unknown'}\n`); + return plan; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + try { + const plan = exportBuildParallelism({ env: process.env }); + const memory = plan.memoryBytes === null ? 'unknown' : `${(plan.memoryBytes / gibibyte).toFixed(2)} GiB`; + console.log(`Build resources: CPUs=${plan.cpuCount ?? 'unknown'}, memory=${memory}, compile jobs=${plan.buildJobs}, link jobs=${plan.linkJobs}`); + } catch (error) { + console.error(`Unable to export build parallelism: ${error.message}`); + process.exitCode = 1; + } +} diff --git a/tests/ci/build-parallelism.test.mjs b/tests/ci/build-parallelism.test.mjs new file mode 100644 index 000000000..12f8a7b8c --- /dev/null +++ b/tests/ci/build-parallelism.test.mjs @@ -0,0 +1,150 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { buildParallelismPlan, detectBuildResources, exportBuildParallelism } from '../../.github/scripts/build-parallelism.mjs'; + +const gibibyte = 1024 ** 3; + +function fixture(t) { + const root = mkdtempSync(join(tmpdir(), 'ark-build-pools-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + return root; +} + +test('CPU and memory jointly bound compilation while small runners retain two jobs', () => { + for (const [cpuCount, gib, buildJobs, linkJobs] of [ + [4, 16, 4, 2], [64, 256, 4, 2], [3, 7, 2, 1], [8, 8, 2, 1], + [8, 10, 3, 1], [8, 4, 1, 1], [1, 16, 1, 1], [4, 0.5, 1, 1], + ]) { + const plan = buildParallelismPlan({ cpuCount, memoryBytes: gib * gibibyte }); + assert.equal(plan.buildJobs, buildJobs, `${cpuCount} CPUs / ${gib} GiB`); + assert.equal(plan.linkJobs, linkJobs, `${cpuCount} CPUs / ${gib} GiB`); + assert.equal(plan.jobPools, `ark_compile=${buildJobs};ark_link=${linkJobs}`); + } +}); + +test('missing, malformed and failed resource readings fall back conservatively', () => { + for (const invalid of [undefined, null, 0, -1, 1.5, NaN, Infinity, '16', {}, Number.MAX_SAFE_INTEGER + 1]) { + const missingCpu = buildParallelismPlan({ cpuCount: invalid, memoryBytes: 16 * gibibyte }); + assert.equal(missingCpu.buildJobs, 2); + assert.equal(missingCpu.linkJobs, 1); + const plan = buildParallelismPlan({ cpuCount: 16, memoryBytes: invalid }); + assert.equal(plan.buildJobs, 2); + assert.equal(plan.linkJobs, 1); + } + assert.equal(buildParallelismPlan({}).buildJobs, 2); + assert.equal(buildParallelismPlan({ memoryBytes: 4 * gibibyte }).buildJobs, 1); + const fail = () => { throw new Error('resource unavailable'); }; + assert.deepEqual(detectBuildResources({ readCpuCount: fail, readTotalMemory: fail, readConstrainedMemory: fail }), + { cpuCount: null, memoryBytes: null }); +}); + +test('detection respects process memory limits and treats an unconstrained zero as unknown', () => { + const readCpuCount = () => 8; + const readTotalMemory = () => 64 * gibibyte; + const limited = detectBuildResources({ readCpuCount, readTotalMemory, readConstrainedMemory: () => 7 * gibibyte }); + assert.deepEqual(limited, { cpuCount: 8, memoryBytes: 7 * gibibyte }); + assert.equal(buildParallelismPlan(limited).buildJobs, 2); + for (const limit of [0, -1, NaN, Infinity, undefined]) { + assert.equal(detectBuildResources({ readCpuCount, readTotalMemory, readConstrainedMemory: () => limit }).memoryBytes, 64 * gibibyte); + } + assert.equal(detectBuildResources({ readCpuCount, readTotalMemory: () => 8 * gibibyte, + readConstrainedMemory: () => 64 * gibibyte }).memoryBytes, 8 * gibibyte); +}); + +test('overrides only reduce resource budgets and malformed overrides use conservative limits', () => { + const resources = { cpuCount: 16, memoryBytes: 64 * gibibyte }; + for (const value of ['1', '2', '3', '4']) { + assert.equal(buildParallelismPlan({ ...resources, buildJobsOverride: value }).buildJobs, Number(value)); + } + assert.equal(buildParallelismPlan({ cpuCount: 2, memoryBytes: 7 * gibibyte, buildJobsOverride: '4' }).buildJobs, 2); + assert.equal(buildParallelismPlan({ ...resources, buildJobsOverride: '1', linkJobsOverride: '2' }).linkJobs, 1); + assert.equal(buildParallelismPlan({ ...resources, buildJobsOverride: '2', linkJobsOverride: '2' }).linkJobs, 1); + assert.equal(buildParallelismPlan({ ...resources, linkJobsOverride: '1' }).linkJobs, 1); + assert.equal(buildParallelismPlan({ ...resources, linkJobsOverride: '2' }).linkJobs, 2); + assert.equal(buildParallelismPlan({ ...resources, buildJobsOverride: '' }).buildJobs, 4); + for (const value of ['0', '-1', '5', 'Infinity', '2.5', '02', ' 2 ', '2\nINJECTED=value', 4, null]) { + assert.equal(buildParallelismPlan({ ...resources, buildJobsOverride: value }).buildJobs, 2, String(value)); + assert.equal(buildParallelismPlan({ ...resources, linkJobsOverride: value }).linkJobs, 1, String(value)); + } +}); + +test('GitHub channels export only validated integers and fixed Ninja pool names', t => { + const root = fixture(t); + const env = { GITHUB_ENV: join(root, 'env'), GITHUB_OUTPUT: join(root, 'output'), + ARK_BUILD_JOBS_OVERRIDE: '4\nINJECTED=value', ARK_LINK_JOBS_OVERRIDE: '2\nINJECTED=value' }; + const plan = exportBuildParallelism({ env, resources: { cpuCount: 4, memoryBytes: 16 * gibibyte } }); + assert.equal(plan.buildJobs, 2); + assert.equal(plan.linkJobs, 1); + assert.equal(readFileSync(env.GITHUB_ENV, 'utf8'), 'ARK_BUILD_JOBS=2\nARK_LINK_JOBS=1\nARK_CMAKE_JOB_POOLS=ark_compile=2;ark_link=1\n'); + assert.equal(readFileSync(env.GITHUB_OUTPUT, 'utf8'), + `build_jobs=2\nlink_jobs=1\njob_pools=ark_compile=2;ark_link=1\ncpu_count=4\nmemory_bytes=${16 * gibibyte}\n`); + for (const key of ['GITHUB_ENV', 'GITHUB_OUTPUT']) { + assert.throws(() => exportBuildParallelism({ env: { ...env, [key]: '' }, resources: {} }), /file-command/); + } +}); + +test('Ninja applies compile and link pools to real C++ builds and preserves compiler failures', { skip: process.platform === 'win32' }, t => { + const root = fixture(t); + const launcher = join(root, 'record jobs.mjs'); + const trace = join(root, 'trace'); + writeFileSync(launcher, `import { spawnSync } from 'node:child_process'; +import { appendFileSync } from 'node:fs'; +const [kind, command, ...args] = process.argv.slice(2); +const record = phase => appendFileSync(${JSON.stringify(trace)}, JSON.stringify({ kind, phase }) + '\\n'); +record('start'); +Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100); +const result = spawnSync(command, args, { stdio: 'inherit' }); +record('end'); +process.exit(result.status ?? 1); +`); + writeFileSync(join(root, 'CMakeLists.txt'), `cmake_minimum_required(VERSION 3.21) +project(build_pools LANGUAGES CXX) +foreach(n RANGE 1 4) + add_executable(check_\${n} main.cpp) +endforeach() +`); + writeFileSync(join(root, 'main.cpp'), 'int main() { return 0; }\n'); + const run = args => spawnSync('cmake', args, { encoding: 'utf8', timeout: 30_000 }); + for (const gib of [8, 16]) { + const plan = buildParallelismPlan({ cpuCount: 4, memoryBytes: gib * gibibyte }); + const build = join(root, `build-${gib}`); + const configured = run(['-S', root, '-B', build, '-G', 'Ninja', + `-DCMAKE_JOB_POOLS=${plan.jobPools}`, '-DCMAKE_JOB_POOL_COMPILE=ark_compile', '-DCMAKE_JOB_POOL_LINK=ark_link', + `-DCMAKE_CXX_COMPILER_LAUNCHER=${process.execPath};${launcher};compile`, + `-DCMAKE_CXX_LINKER_LAUNCHER=${process.execPath};${launcher};link`]); + assert.equal(configured.status, 0, configured.stdout + configured.stderr); + const ninja = readFileSync(join(build, 'build.ninja'), 'utf8'); + const rules = readFileSync(join(build, 'CMakeFiles', 'rules.ninja'), 'utf8'); + assert.match(rules, new RegExp(`pool ark_compile\\n depth = ${plan.buildJobs}\\n`)); + assert.match(rules, new RegExp(`pool ark_link\\n depth = ${plan.linkJobs}\\n`)); + assert.equal((ninja.match(/pool = ark_compile/g) ?? []).length, 4); + assert.equal((ninja.match(/pool = ark_link/g) ?? []).length, 4); + writeFileSync(trace, ''); + const compiled = run(['--build', build, '--parallel', String(plan.buildJobs)]); + assert.equal(compiled.status, 0, compiled.stdout + compiled.stderr); + const active = { compile: 0, link: 0 }; + const peaks = { compile: 0, link: 0 }; + const starts = { compile: 0, link: 0 }; + for (const line of readFileSync(trace, 'utf8').trim().split('\n')) { + const { kind, phase } = JSON.parse(line); + active[kind] += phase === 'start' ? 1 : -1; + if (phase === 'start') starts[kind]++; + peaks[kind] = Math.max(peaks[kind], active[kind]); + assert.ok(active.compile + active.link <= plan.buildJobs, 'overall build parallelism is bounded'); + } + assert.deepEqual(starts, { compile: 4, link: 4 }); + assert.deepEqual(active, { compile: 0, link: 0 }); + assert.ok(peaks.compile >= 2 && peaks.compile <= plan.buildJobs); + assert.ok(peaks.link >= 1 && peaks.link <= plan.linkJobs); + const executable = spawnSync(join(build, 'check_1'), [], { encoding: 'utf8', timeout: 5_000 }); + assert.equal(executable.status, 0, executable.stdout + executable.stderr); + } + writeFileSync(join(root, 'main.cpp'), '#error intentional compiler failure\n'); + const failed = run(['--build', join(root, 'build-16'), '--parallel', '4']); + assert.notEqual(failed.status, 0); + assert.match(failed.stdout + failed.stderr, /intentional compiler failure/); +}); From a7daad21f2f59fff6a755293ebdcad529bfa9d95 Mon Sep 17 00:00:00 2001 From: nick Date: Thu, 5 Nov 2026 13:33:34 +0800 Subject: [PATCH 2/6] ci(applications): select regression target closures for source PRs --- .github/scripts/application-build-plan.mjs | 150 +++++++++++++++++++ .github/scripts/ci-policy.mjs | 6 +- tests/ci/application-build-plan.test.mjs | 160 +++++++++++++++++++++ 3 files changed, 315 insertions(+), 1 deletion(-) create mode 100644 .github/scripts/application-build-plan.mjs create mode 100644 tests/ci/application-build-plan.test.mjs diff --git a/.github/scripts/application-build-plan.mjs b/.github/scripts/application-build-plan.mjs new file mode 100644 index 000000000..0f833264b --- /dev/null +++ b/.github/scripts/application-build-plan.mjs @@ -0,0 +1,150 @@ +// Keep the existing configuration for every plan. Narrow PRs save compilation +// by invoking check targets, whose dependencies remain owned by CMake. The full +// build still compiles ALL, including archived integration compatibility inputs. +export const applicationConfigureFlags = Object.freeze([ + '-DARK_BUILD_APPLICATIONS=ON', + '-DARK_BUILD_BENCHMARKS=ON', + '-DARK_BUILD_PLUGIN_HTTP=ON', + '-DARK_BUILD_PLUGIN_CONSUL=ON', + '-DARK_BUILD_PLUGIN_ETCD_CLIENT=ON', + '-DARK_BUILD_PROTO=ON', + '-DARK_BUILD_TESTS=ON', + '-DARK_BUILD_CORE_TESTS=ON', + '-DARK_BUILD_COMPRESSION_TESTS=ON', + '-DARK_BUILD_EXAMPLES=ON', + '-DARK_BUILD_GUEST_ROOM_EXAMPLE=ON', + '-DARK_BUILD_CONFIG_TOOL=ON', + '-DARK_BUILD_CONFIG_TOOL_TESTS=ON', + '-DASIO_STANDALONE=OFF', +]); + +const targetsByPath = new Map(); +function register(targets, paths) { + for (const file of paths) { + if (targetsByPath.has(file)) throw new Error(`duplicate application path: ${file}`); + targetsByPath.set(file, Object.freeze([...targets])); + } +} + +// Exact reviewed fixtures only. A future file cannot silently inherit a plan +// that omits a newly introduced consumer. Test CMake scripts are runtime inputs; +// CMakeLists.txt and configure/build scripts deliberately remain unregistered. +register(['check-app-shutdown-deadlines'], ['tests/app/shutdown_deadlines.cmake']); +register(['check-app-business-runtime'], ['tests/app/business_runtime.cmake']); +register(['check-app-business-runtime', 'check-app-shutdown-deadlines'], ['tests/app/business_runtime_plugin.cpp']); +register(['check-app-shutdown'], ['tests/app/shutdown_request.cpp', 'tests/app/shutdown_request.cmake']); +register(['check-app-startup-failures'], [ + 'tests/app/startup_failure_plugin.cpp', 'tests/app/missing_entry_plugin.cpp', 'tests/app/startup_failures.cmake', +]); +register(['check-startup-cleanup'], [ + 'tests/app/startup_cleanup_allocation.cpp', 'tests/app/startup_cleanup_allocation.cmake', + 'tests/core/startup_cleanup.cpp', 'tests/core/headers/startup_cleanup.cpp', +]); +register(['check-app-startup-cleanup'], ['tests/app/startup_cleanup_plugin.cpp', 'tests/app/startup_cleanup.cmake']); +register(['check-app-manifest-failures'], ['tests/app/manifest_failures.cmake']); +register(['check-app-module-dependencies'], ['tests/app/module_dependencies.cmake']); +register(['check-app-presets'], [ + 'tests/app/application_presets.cmake', 'tests/app/application_preset_stop.cpp', 'tests/app/application_preset_stop.sh', +]); +register(['check-app-bus-configuration'], ['tests/app/bus_configuration.cmake']); +register(['check-bus-plugin'], ['tests/app/bus_plugin.cpp', 'tests/app/bus_plugin.cmake']); +register(['check-net-plugin'], ['tests/app/net_plugin.cpp', 'tests/app/net_plugin.cmake']); +register(['check-net-http-drain'], ['tests/app/http_client_drain.cpp', 'tests/app/http_client_drain.cmake']); +register(['check-http-plugin'], ['tests/app/http_plugin.cpp', 'tests/app/http_plugin.cmake']); +register(['check-http-examples'], ['tests/app/http_examples.cpp', 'tests/app/http_examples.cmake']); +register(['check-kernel-plugin'], ['tests/app/kernel_plugin.cpp', 'tests/app/kernel_plugin.cmake']); +register(['check-typed-examples'], ['tests/app/typed_examples.cpp', 'tests/app/typed_examples.cmake']); +register(['check-static-services', 'check-system-composition'], ['tests/app/static_services.cpp']); +register([ + 'check-bus-plugin', 'check-net-plugin', 'check-net-http-drain', 'check-kernel-plugin', + 'check-typed-examples', 'check-static-services', 'check-system-composition', +], ['tests/app/owner_drain.hpp']); + +register(['check-business-runtime'], [ + 'tests/core/business_runtime.cpp', 'tests/core/business_runtime_contract.cpp', + 'tests/core/business_runtime_contract.cmake', 'tests/core/headers/business_runtime.cpp', +]); +register(['check-task-sdk'], [ + 'tests/core/tasks.cpp', 'tests/core/headers/task.cpp', + 'tests/core/headers/task_scheduler.cpp', 'tests/core/headers/scheduler_task.cpp', +]); +register(['check-scheduler-drain'], [ + 'tests/core/scheduler_drain.cpp', 'tests/core/scheduler_failure.cpp', 'tests/core/scheduler_drain.cmake', +]); +register(['check-scheduler-startup-cleanup'], [ + 'tests/core/scheduler_startup_cleanup.cpp', 'tests/core/scheduler_startup_cleanup.cmake', +]); +register(['check-scheduler-construction'], ['tests/core/thread_pool_construction.cpp']); +register(['check-native-unload'], ['tests/core/dynamic_library.cpp', 'tests/core/plugin_allocation_entry.cpp']); + +register(['check-kernel-initialization'], [ + 'tests/core/module_initialization.cpp', 'tests/core/kernel_callbacks.cpp', + 'tests/core/kernel_resource_ownership.cpp', 'tests/core/table_row_allocation.cpp', + 'tests/core/entity_config_copy.cpp', 'tests/core/entity_json_loading.cpp', + 'tests/core/kernel_delayed_sync.cpp', 'tests/core/entity_nested_snapshot.cpp', + 'tests/core/entity_snapshot.cpp', 'tests/core/entity_snapshot_interface.cpp', + 'tests/core/kernel_common_class_callbacks.cpp', 'tests/core/kernel_metadata_files.hpp', + 'tests/core/kernel_snapshot_test_access.hpp', +]); +register(['check-entity-stream'], ['tests/core/entity_stream.cpp', 'tests/core/entity_nested_stream.cpp']); +register(['check-scalar-stream-allocation'], ['tests/core/entity_scalar_stream_allocation.cpp']); +register(['check-nested-stream-allocation'], ['tests/core/entity_nested_stream_allocation.cpp']); +register(['check-nested-wire'], [ + 'tests/core/nested_wire.cpp', 'tests/core/nested_wire_macro.cpp', 'tests/core/nested_wire_macro_first.cpp', +]); +register(['check-proto-naming'], ['tests/proto/naming.cpp']); +register(['check-proto-configuration'], ['tests/proto/check_configuration.cmake']); + +register(['check-guest-room'], [ + 'tests/guest_room/codec.cpp', 'tests/guest_room/server.cpp', + 'examples/guest_room/guest_room_module.cpp', 'examples/guest_room/guest_room_module.hpp', + 'examples/guest_room/guest_room_plugin.cpp', 'examples/guest_room/guest_room_plugin.hpp', + 'examples/guest_room/guest_room_protocol.hpp', 'examples/guest_room/guest_room_wire.hpp', + 'examples/guest_room/wire-vectors.json', 'examples/guest_room/stage.cmake', +]); +// The composed-services fixture also consumes this example's schema. +register(['check-guest-room', 'check-system-composition'], ['examples/guest_room/resource/meta/entity_schema.json']); + +export const applicationTargetPaths = Object.freeze([...targetsByPath.keys()]); +export const applicationCheckTargets = Object.freeze([...new Set([ + 'check-core', 'check-entity-benchmark-smoke', ...[...targetsByPath.values()].flat(), +])].sort()); + +export function applicationTargetsForPath(file) { + const targets = targetsByPath.get(file); + return targets ? [...targets] : null; +} + +export function fullApplicationBuildPlan(reason = 'full-fallback') { + return { + version: 1, mode: 'full', reason, buildAll: true, + configureFlags: [...applicationConfigureFlags], + checkTargets: ['check-core', 'check-entity-benchmark-smoke'], + }; +} + +export function selectApplicationBuildPlan({ eventName, files, scope }) { + // Never reduce queue/manual/unknown events, even if their supplied metadata + // resembles a source PR. Invalid or uncertain scope also retains full work. + if (eventName !== 'pull_request') return fullApplicationBuildPlan('full-event'); + if (scope?.event_name !== eventName || scope.files_known !== true || scope.site_available !== true || + !['documentation', 'modules'].includes(scope.validation_stage) || + !['documentation-only', 'module-paths'].includes(scope.reason) || !Array.isArray(files)) { + return fullApplicationBuildPlan('uncertain-scope'); + } + if (scope.applications_check === false) { + return { version: 1, mode: 'skip', reason: 'applications-not-selected', buildAll: false, + configureFlags: [], checkTargets: [] }; + } + if (scope.applications_check !== true || files.length === 0) return fullApplicationBuildPlan('empty-or-invalid-application-scope'); + const targets = new Set(); + for (const file of files) { + const selected = applicationTargetsForPath(file); + if (!selected) return fullApplicationBuildPlan('unrecognized-application-path'); + for (const target of selected) targets.add(target); + } + return { + version: 1, mode: 'targets', reason: 'registered-application-paths', buildAll: false, + configureFlags: [...applicationConfigureFlags], checkTargets: [...targets].sort(), + }; +} diff --git a/.github/scripts/ci-policy.mjs b/.github/scripts/ci-policy.mjs index 88c7e61f7..be8388688 100644 --- a/.github/scripts/ci-policy.mjs +++ b/.github/scripts/ci-policy.mjs @@ -2,6 +2,7 @@ import { execFileSync } from 'node:child_process'; import { appendFileSync, existsSync, readFileSync, statSync } from 'node:fs'; import { posix, resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; +import { applicationTargetsForPath, selectApplicationBuildPlan } from './application-build-plan.mjs'; const shaPattern = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/; const cppPath = /\.(?:c|cc|cpp|cxx|c\+\+|h|hh|hpp|hxx|inl|ipp|tpp|proto)$/i; @@ -79,6 +80,7 @@ const nativeJobs = ['network', 'applications', 'database']; function modulesForPath(file) { if (isDocumentationPath(file)) return []; if (!isRepositoryPath(file)) return nativeJobs; + if (applicationTargetsForPath(file)) return ['applications']; 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']; @@ -114,7 +116,7 @@ export function selectScope({ eventName, files, site, enumerationFailed = false if (native && (!narrow || unknown)) { for (const job of nativeJobs) selected.add(job); } - return { + const decision = { event_name: eventName, validation_stage: !native ? 'documentation' : narrow ? 'modules' : 'full', native_check: native, @@ -131,6 +133,8 @@ export function selectScope({ eventName, files, site, enumerationFailed = false !site.available ? 'site-unavailable' : eventName === 'merge_group' ? 'code-build-or-unknown-path' : unknown ? 'unknown-path' : 'module-paths', }; + const applicationFiles = known ? paths.filter(file => modulesForPath(file).includes('applications')) : undefined; + return { ...decision, app_plan_json: JSON.stringify(selectApplicationBuildPlan({ eventName, files: applicationFiles, scope: decision })) }; } export function admissionState(eventName, event) { diff --git a/tests/ci/application-build-plan.test.mjs b/tests/ci/application-build-plan.test.mjs new file mode 100644 index 000000000..414a39a3c --- /dev/null +++ b/tests/ci/application-build-plan.test.mjs @@ -0,0 +1,160 @@ +import assert from 'node:assert/strict'; +import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import test from 'node:test'; +import { + applicationCheckTargets, applicationConfigureFlags, applicationTargetPaths, applicationTargetsForPath, selectApplicationBuildPlan, +} from '../../.github/scripts/application-build-plan.mjs'; +import { selectScope } from '../../.github/scripts/ci-policy.mjs'; + +const site = { present: true, available: true }; +const scope = (files, eventName = 'pull_request', extra = {}) => selectScope({ files, eventName, site, ...extra }); +const plan = (files, eventName = 'pull_request', extra = {}) => JSON.parse(scope(files, eventName, extra).app_plan_json); +const checkFull = actual => { + assert.equal(actual.mode, 'full'); + assert.equal(actual.buildAll, true); + assert.deepEqual(actual.configureFlags, applicationConfigureFlags); + assert.deepEqual(actual.checkTargets, ['check-core', 'check-entity-benchmark-smoke']); +}; + +test('full plans retain all configuration including archived integration compatibility and benchmarks', () => { + assert.deepEqual(applicationConfigureFlags, [ + '-DARK_BUILD_APPLICATIONS=ON', '-DARK_BUILD_BENCHMARKS=ON', '-DARK_BUILD_PLUGIN_HTTP=ON', + '-DARK_BUILD_PLUGIN_CONSUL=ON', '-DARK_BUILD_PLUGIN_ETCD_CLIENT=ON', '-DARK_BUILD_PROTO=ON', + '-DARK_BUILD_TESTS=ON', '-DARK_BUILD_CORE_TESTS=ON', '-DARK_BUILD_COMPRESSION_TESTS=ON', + '-DARK_BUILD_EXAMPLES=ON', '-DARK_BUILD_GUEST_ROOM_EXAMPLE=ON', '-DARK_BUILD_CONFIG_TOOL=ON', + '-DARK_BUILD_CONFIG_TOOL_TESTS=ON', '-DASIO_STANDALONE=OFF', + ]); + for (const eventName of ['merge_group', 'workflow_dispatch', 'push', '', 'unknown']) { + for (const files of [undefined, [], ['tests/app/shutdown_deadlines.cmake'], ['README.md']]) { + checkFull(plan(files, eventName)); + } + } + checkFull(JSON.parse(selectScope({ eventName: undefined, files: ['tests/app/shutdown_deadlines.cmake'], site }).app_plan_json)); +}); + +test('specific application fixture changes run their actual regression dependencies without ALL', () => { + const cases = [ + ['tests/app/shutdown_deadlines.cmake', ['check-app-shutdown-deadlines']], + ['tests/app/business_runtime_plugin.cpp', ['check-app-business-runtime', 'check-app-shutdown-deadlines']], + ['tests/app/startup_failure_plugin.cpp', ['check-app-startup-failures']], + ['tests/app/startup_cleanup_allocation.cpp', ['check-startup-cleanup']], + ['tests/app/application_preset_stop.sh', ['check-app-presets']], + ['tests/app/static_services.cpp', ['check-static-services', 'check-system-composition']], + ['tests/core/business_runtime.cpp', ['check-business-runtime']], + ['tests/core/scheduler_drain.cmake', ['check-scheduler-drain']], + ['tests/core/entity_snapshot.cpp', ['check-kernel-initialization']], + ['tests/core/nested_wire_macro_first.cpp', ['check-nested-wire']], + ['tests/proto/check_configuration.cmake', ['check-proto-configuration']], + ['tests/proto/naming.cpp', ['check-proto-naming']], + ['examples/guest_room/guest_room_wire.hpp', ['check-guest-room']], + ['examples/guest_room/wire-vectors.json', ['check-guest-room']], + ['examples/guest_room/stage.cmake', ['check-guest-room']], + ['tests/guest_room/server.cpp', ['check-guest-room']], + ['examples/guest_room/resource/meta/entity_schema.json', ['check-guest-room', 'check-system-composition']], + ]; + for (const [file, expected] of cases) { + const selected = scope([file]); + assert.equal(selected.validation_stage, 'modules', file); + assert.equal(selected.applications_check, true, file); + assert.equal(selected.network_check, false, file); + assert.equal(selected.database_check, false, file); + const actual = JSON.parse(selected.app_plan_json); + assert.equal(actual.mode, 'targets', file); + assert.equal(actual.buildAll, false, file); + assert.deepEqual(actual.checkTargets, expected, file); + assert.deepEqual(actual.configureFlags, applicationConfigureFlags, file); + } +}); + +test('mixed fixture changes union and sort targets while unrelated modules and docs do not enlarge Applications', () => { + const files = ['tests/app/shutdown_deadlines.cmake', 'tests/app/business_runtime_plugin.cpp', + 'tests/guest_room/server.cpp', 'tests/network/rudp.cpp', 'tests/database/redis_service.cpp', 'docs/guide.md']; + const actual = plan(files); + assert.equal(actual.mode, 'targets'); + assert.deepEqual(actual.checkTargets, ['check-app-business-runtime', 'check-app-shutdown-deadlines', 'check-guest-room']); + assert.equal(new Set(actual.checkTargets).size, actual.checkTargets.length); + assert.deepEqual(plan([...files].reverse()), actual); + assert.deepEqual(plan([...files, ...files]), actual); +}); + +test('public interfaces, production code, build, dependency, CI and unreviewed neighbors fail closed', () => { + const files = [ + 'src/core/plugin_manager.hpp', 'include/ark/kernel/entity.hpp', 'src/plugins/kernel/entity_snapshot.hpp', + 'src/plugins/kernel/kernel_module_impl.cpp', 'src/proto/proto/entity_sync.proto', + 'src/application/main.cpp', 'src/config/application_manifest.cpp', + 'CMakeLists.txt', 'src/plugins/kernel/CMakeLists.txt', 'tests/core/CMakeLists.txt', + 'tests/app/CMakeLists.txt', 'examples/guest_room/CMakeLists.txt', 'cmake/helpers.cmake', + 'vcpkg.json', 'vcpkg-configuration.json', 'package-lock.json', + '.github/workflows/ci.yml', '.github/scripts/application-build-plan.mjs', 'tests/ci/application-build-plan.test.mjs', + 'tests/app/future_regression.cpp', 'tests/app/shutdown_deadlines.cmake.bak', + 'tests/app/nested/shutdown_deadlines.cmake', 'tests/core/main.cpp', 'tests/core/protobuf_messages.cpp', + 'examples/guest_room/new_runtime_input.json', 'tests/guest_room/new_fixture.hpp', 'unknown.bin', + ]; + for (const file of files) { + checkFull(plan([file])); + checkFull(plan(['tests/app/shutdown_deadlines.cmake', file])); + } +}); + +test('unavailable or malformed enumeration and scope cannot authorize target selection', () => { + for (const files of [undefined, null, [], [''], ['/tests/app/shutdown_deadlines.cmake'], + ['tests/app/../app/shutdown_deadlines.cmake'], ['tests\\app\\shutdown_deadlines.cmake'], + ['tests/app/shutdown_deadlines.cmake\n'], [42], [null]]) checkFull(plan(files)); + checkFull(plan(['tests/app/shutdown_deadlines.cmake'], 'pull_request', { enumerationFailed: true })); + checkFull(plan(['tests/app/shutdown_deadlines.cmake'], 'pull_request', { site: { present: false, available: false } })); + const valid = scope(['tests/app/shutdown_deadlines.cmake']); + for (const patch of [ + { event_name: 'merge_group' }, { files_known: false }, { files_known: 'true' }, + { site_available: false }, { validation_stage: 'full' }, { reason: 'unknown-path' }, + { applications_check: undefined }, { applications_check: 'true' }, + ]) { + checkFull(selectApplicationBuildPlan({ eventName: 'pull_request', files: ['tests/app/shutdown_deadlines.cmake'], + scope: { ...valid, ...patch } })); + } +}); + +test('genuine documentation and unrelated-module PRs expose an explicit empty Applications plan', () => { + for (const files of [['README.md'], ['tests/network/rudp.cpp'], ['tests/database/redis_service.cpp']]) { + const actual = plan(files); + assert.equal(actual.mode, 'skip'); + assert.equal(actual.buildAll, false); + assert.deepEqual(actual.configureFlags, []); + assert.deepEqual(actual.checkTargets, []); + } +}); + +test('registered paths are existing files and every current app fixture has a reviewed plan', () => { + for (const file of applicationTargetPaths) { + assert.equal(existsSync(new URL(`../../${file}`, import.meta.url)), true, file); + const targets = applicationTargetsForPath(file); + assert.ok(targets.length > 0, file); + for (const target of targets) assert.match(target, /^check-[a-z][a-z0-9-]*$/, file); + } + for (const entry of readdirSync(new URL('../app/', import.meta.url), { withFileTypes: true })) { + if (entry.isFile()) assert.ok(applicationTargetsForPath(`tests/app/${entry.name}`), entry.name); + } + const helper = plan(['tests/app/owner_drain.hpp']); + assert.deepEqual(helper.checkTargets, ['check-bus-plugin', 'check-kernel-plugin', 'check-net-http-drain', + 'check-net-plugin', 'check-static-services', 'check-system-composition', 'check-typed-examples']); +}); + +test('registered check targets are defined by CMake and returned arrays cannot mutate later plans', () => { + const cmake = ['tests/core/CMakeLists.txt', 'examples/guest_room/CMakeLists.txt', 'tests/core/native_loader/CMakeLists.txt'] + .map(file => readFileSync(new URL(`../../${file}`, import.meta.url), 'utf8')).join('\n'); + const defined = new Set([...cmake.matchAll(/add_custom_target\((check-[a-z0-9-]+)/g)].map(match => match[1])); + for (const file of applicationTargetPaths) { + for (const target of applicationTargetsForPath(file)) { + assert.ok(defined.has(target), `${file}: ${target}`); + assert.ok(applicationCheckTargets.includes(target), `${file}: ${target}`); + } + } + assert.equal(new Set(applicationCheckTargets).size, applicationCheckTargets.length); + assert.ok(applicationCheckTargets.includes('check-core')); + assert.ok(applicationCheckTargets.includes('check-entity-benchmark-smoke')); + const original = plan(['tests/app/business_runtime_plugin.cpp']); + applicationTargetsForPath('tests/app/business_runtime_plugin.cpp').push('check-injected'); + const mutable = plan(['tests/app/business_runtime_plugin.cpp']); + mutable.configureFlags.push('-DARK_BUILD_TESTS=OFF'); + mutable.checkTargets.push('check-injected'); + assert.deepEqual(plan(['tests/app/business_runtime_plugin.cpp']), original); +}); From 48b55e515be05b339ed5caf4939fbf2fc3c2a6d2 Mon Sep 17 00:00:00 2001 From: nick Date: Thu, 5 Nov 2026 13:34:18 +0800 Subject: [PATCH 3/6] fix(ci): account for overlapping compile and link jobs --- .github/scripts/build-parallelism.mjs | 11 ++++++----- tests/ci/build-parallelism.test.mjs | 6 ++++-- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/.github/scripts/build-parallelism.mjs b/.github/scripts/build-parallelism.mjs index 9910b7ddc..36af5a088 100644 --- a/.github/scripts/build-parallelism.mjs +++ b/.github/scripts/build-parallelism.mjs @@ -43,14 +43,15 @@ export function buildParallelismPlan({ cpuCount, memoryBytes, buildJobsOverride, // Initial CI budget: reserve 1 GiB and allow 3 GiB per compiler process. // Keep the existing two-job ceiling when either resource is unknown. This // leaves 7-8 GiB runners at two jobs and never exceeds four on larger hosts. + // These are initial scheduling estimates, not peak-memory guarantees. const memoryJobs = memory === null ? 2 : Math.max(1, Math.floor((memory - gibibyte) / (3 * gibibyte))); const resourceJobs = Math.min(maxBuildJobs, cpu ?? 2, memoryJobs); const buildJobs = applyOverride(buildJobsOverride, resourceJobs, 2, maxBuildJobs); - // Links have a separate six-GiB budget; Ninja's overall --parallel bound still - // applies when compile and link jobs overlap. This is a scheduling limit, - // not a guarantee about an individual translation unit's peak memory. - const memoryLinks = memory === null ? 1 : Math.max(1, Math.floor((memory - gibibyte) / (6 * gibibyte))); - const resourceLinks = buildJobs === maxBuildJobs ? Math.min(2, memoryLinks) : 1; + // Two six-GiB links can overlap with two three-GiB compilers at four jobs: + // reserve 1 + 2*6 + 2*3 = 19 GiB before admitting both links. Smaller hosts + // retain a single link alongside the existing compile-job heuristic. + const twoLinkMemory = (1 + 2 * 6 + (maxBuildJobs - 2) * 3) * gibibyte; + const resourceLinks = buildJobs === maxBuildJobs && memory !== null && memory >= twoLinkMemory ? 2 : 1; const linkJobs = applyOverride(linkJobsOverride, resourceLinks, 1, 2); return { cpuCount: cpu, memoryBytes: memory, buildJobs, linkJobs, jobPools: `ark_compile=${buildJobs};ark_link=${linkJobs}` }; diff --git a/tests/ci/build-parallelism.test.mjs b/tests/ci/build-parallelism.test.mjs index 12f8a7b8c..3fbf5dedd 100644 --- a/tests/ci/build-parallelism.test.mjs +++ b/tests/ci/build-parallelism.test.mjs @@ -16,7 +16,8 @@ function fixture(t) { test('CPU and memory jointly bound compilation while small runners retain two jobs', () => { for (const [cpuCount, gib, buildJobs, linkJobs] of [ - [4, 16, 4, 2], [64, 256, 4, 2], [3, 7, 2, 1], [8, 8, 2, 1], + [4, 16, 4, 1], [4, 13, 4, 1], [4, 19 - 1 / gibibyte, 4, 1], + [4, 19, 4, 2], [4, 48, 4, 2], [64, 256, 4, 2], [3, 7, 2, 1], [8, 8, 2, 1], [8, 10, 3, 1], [8, 4, 1, 1], [1, 16, 1, 1], [4, 0.5, 1, 1], ]) { const plan = buildParallelismPlan({ cpuCount, memoryBytes: gib * gibibyte }); @@ -65,6 +66,7 @@ test('overrides only reduce resource budgets and malformed overrides use conserv assert.equal(buildParallelismPlan({ ...resources, buildJobsOverride: '2', linkJobsOverride: '2' }).linkJobs, 1); assert.equal(buildParallelismPlan({ ...resources, linkJobsOverride: '1' }).linkJobs, 1); assert.equal(buildParallelismPlan({ ...resources, linkJobsOverride: '2' }).linkJobs, 2); + assert.equal(buildParallelismPlan({ cpuCount: 4, memoryBytes: 16 * gibibyte, linkJobsOverride: '2' }).linkJobs, 1); assert.equal(buildParallelismPlan({ ...resources, buildJobsOverride: '' }).buildJobs, 4); for (const value of ['0', '-1', '5', 'Infinity', '2.5', '02', ' 2 ', '2\nINJECTED=value', 4, null]) { assert.equal(buildParallelismPlan({ ...resources, buildJobsOverride: value }).buildJobs, 2, String(value)); @@ -109,7 +111,7 @@ endforeach() `); writeFileSync(join(root, 'main.cpp'), 'int main() { return 0; }\n'); const run = args => spawnSync('cmake', args, { encoding: 'utf8', timeout: 30_000 }); - for (const gib of [8, 16]) { + for (const gib of [8, 16, 19]) { const plan = buildParallelismPlan({ cpuCount: 4, memoryBytes: gib * gibibyte }); const build = join(root, `build-${gib}`); const configured = run(['-S', root, '-B', build, '-G', 'Ninja', From 2bb4fb8f07a4252e955f2deb8c123db60e1b924c Mon Sep 17 00:00:00 2001 From: nick Date: Thu, 5 Nov 2026 13:38:46 +0800 Subject: [PATCH 4/6] ci(cache): reuse compatible weekly compiler snapshots --- .github/scripts/cache-stats.mjs | 75 +++++++++++------ .github/scripts/compiler-cache.mjs | 98 +++++++++++++++++----- tests/ci/cache-stats.test.mjs | 63 +++++++++++++++ tests/ci/compiler-cache.test.mjs | 126 ++++++++++++++++++++++++++--- 4 files changed, 306 insertions(+), 56 deletions(-) create mode 100644 tests/ci/cache-stats.test.mjs diff --git a/.github/scripts/cache-stats.mjs b/.github/scripts/cache-stats.mjs index 3c1539331..215664d01 100644 --- a/.github/scripts/cache-stats.mjs +++ b/.github/scripts/cache-stats.mjs @@ -1,29 +1,56 @@ import { spawnSync } from 'node:child_process'; import { appendFileSync, readdirSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; -let archives = 'unavailable'; -if (process.env.VCPKG_ARCHIVE_DIRECTORY) { - try { - archives = readdirSync(process.env.VCPKG_ARCHIVE_DIRECTORY, { recursive: true }) - .filter(file => file.endsWith('.zip')).length; - } catch { - // Cache diagnostics do not change the build or test result. +export function reportCacheStats({ env = process.env, run = spawnSync, append = appendFileSync, log = console.log } = {}) { + let archives = 'unavailable'; + if (env.VCPKG_ARCHIVE_DIRECTORY) { + try { + archives = readdirSync(env.VCPKG_ARCHIVE_DIRECTORY, { recursive: true }) + .filter(file => file.endsWith('.zip')).length; + } catch { + // Cache diagnostics do not change the build or test result. + } } + const cacheSize = /^(?:[1-9][0-9]*)M$/.test(env.SCCACHE_CACHE_SIZE ?? '') ? env.SCCACHE_CACHE_SIZE : 'unavailable'; + const scope = ['trusted', 'untrusted'].includes(env.ARK_COMPILER_CACHE_ARCHIVE_SCOPE) ? env.ARK_COMPILER_CACHE_ARCHIVE_SCOPE : 'unavailable'; + const epoch = /^[0-9]{4}-W[0-9]{2}$/.test(env.ARK_COMPILER_CACHE_EPOCH ?? '') ? env.ARK_COMPILER_CACHE_EPOCH : 'unavailable'; + const lines = [ + `Dependency archive cache exact hit: ${env.VCPKG_ARCHIVE_HIT || 'false/unavailable'}`, + `Dependency archives after configure/build: ${archives}`, + 'vcpkg package restore/build counts are in the Configure step output.', + `Compiler archive cache exact hit: ${env.COMPILER_ARCHIVE_HIT || 'false/unavailable'} (a prefix restore can still provide compiler hits)`, + `Compiler local cache budget: ${cacheSize}`, + `Compiler archive publication allowed: ${env.ARK_COMPILER_CACHE_SAVE_ENABLED === 'true'} (scope=${scope}, epoch=${epoch})`, + ]; + if (env.ARK_COMPILER_LAUNCHER) { + let statistics = 'sccache statistics unavailable'; + try { + const result = run(env.ARK_COMPILER_LAUNCHER, ['--show-stats'], { + env, encoding: 'utf8', timeout: 30_000, + }); + if (result?.status === 0 && typeof result.stdout === 'string' && result.stdout.trim()) { + const output = result.stdout.trim(); + statistics = output.slice(0, 16 * 1024) + (output.length > 16 * 1024 ? '\n[sccache output truncated]' : ''); + } + } catch { + // An unavailable optional tool cannot turn successful native checks red. + } + lines.push(statistics); + } else { + lines.push('Compiler cache disabled; compilation uses the compiler directly.'); + } + const report = lines.join('\n'); + log(report); + if (env.GITHUB_STEP_SUMMARY) { + try { + append(env.GITHUB_STEP_SUMMARY, `\n### Native build cache\n\n\`\`\`text\n${report}\n\`\`\`\n`); + } catch { + // The console still contains the report when a summary is unavailable. + } + } + return report; } -const lines = [ - `Dependency archive cache exact hit: ${process.env.VCPKG_ARCHIVE_HIT || 'false/unavailable'}`, - `Dependency archives after configure/build: ${archives}`, - 'vcpkg package restore/build counts are in the Configure step output.', - `Compiler archive cache exact hit: ${process.env.COMPILER_ARCHIVE_HIT || 'false/unavailable'} (a prefix restore can still provide compiler hits)`, -]; -if (process.env.ARK_COMPILER_LAUNCHER) { - const result = spawnSync(process.env.ARK_COMPILER_LAUNCHER, ['--show-stats'], { - encoding: 'utf8', timeout: 30_000, - }); - lines.push(result.status === 0 ? result.stdout.trim() : 'sccache statistics unavailable'); -} else { - lines.push('Compiler cache disabled; compilation uses the compiler directly.'); -} -const report = lines.join('\n'); -console.log(report); -if (process.env.GITHUB_STEP_SUMMARY) appendFileSync(process.env.GITHUB_STEP_SUMMARY, `\n### Native build cache\n\n\`\`\`text\n${report}\n\`\`\`\n`); + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) reportCacheStats(); diff --git a/.github/scripts/compiler-cache.mjs b/.github/scripts/compiler-cache.mjs index 180cff5b1..5a2133f89 100644 --- a/.github/scripts/compiler-cache.mjs +++ b/.github/scripts/compiler-cache.mjs @@ -4,33 +4,81 @@ import { posix, resolve, win32 } from 'node:path'; import { pathToFileURL } from 'node:url'; export const sccacheVersion = '0.16.0'; +const singleLine = value => typeof value === 'string' && !/[\x00-\x1f\x7f]/.test(value); +const cold = reason => ({ compatible: false, reason, directory: '', key: '', archiveKey: '', restoreKeys: '', + cacheSize: '512M', epoch: '', scope: '', saveEnabled: false }); -export function prepareCompilerCache({ env, run = spawnSync, start = true }) { +function weeklyEpoch(now) { + const date = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())); + date.setUTCDate(date.getUTCDate() + 4 - (date.getUTCDay() || 7)); + const year = date.getUTCFullYear(); + const week = Math.ceil(((date - Date.UTC(year, 0, 1)) / 86400000 + 1) / 7); + return `${year}-W${String(week).padStart(2, '0')}`; +} + +export function compilerCachePlan({ env, now = new Date() }) { + if (!singleLine(env.COMPILER_CACHE_IDENTITY) || !singleLine(env.GITHUB_SHA)) { + return cold('missing or invalid compatibility identity'); + } + const identity = env.COMPILER_CACHE_IDENTITY.match( + /^ark-vcpkg-files-v2-(Linux|macOS|Windows)-(X64|ARM64|ARM)-([a-f0-9]{64})-([a-f0-9]{64})$/); + if (!identity || identity[1] !== env.RUNNER_OS || identity[2] !== env.RUNNER_ARCH || + !/^[a-f0-9]{40}$/.test(env.GITHUB_SHA ?? '')) return cold('missing or invalid compatibility identity'); + // Keep /Zi and the deliberately uncached Windows Debug configuration unchanged. + if (env.RUNNER_OS === 'Windows') return cold('Windows compilation uses the compiler directly'); + const paths = env.RUNNER_OS === 'Windows' ? win32 : posix; + if (!paths.isAbsolute(env.RUNNER_TEMP ?? '') || /[\x00-\x1f\x7f;,`*?\[\]{}!]/.test(env.RUNNER_TEMP ?? '')) { + return cold('unsupported compiler cache directory'); + } + const profile = env.COMPILER_CACHE_PROFILE ?? ''; + if (!singleLine(profile) || !/^(applications|network-(none|address|thread)|database-(Boost|Standalone))$/.test(profile)) { + return cold('missing or invalid compiler cache profile'); + } + const capacity = env.COMPILER_CACHE_MAX_MIB ?? '512'; + if (!singleLine(capacity) || !/^[1-9][0-9]*$/.test(capacity) || Number(capacity) < 64 || Number(capacity) > 1024) { + return cold('compiler cache capacity must be an integer from 64 through 1024 MiB'); + } + const epoch = env.COMPILER_CACHE_EPOCH ?? weeklyEpoch(now); + if (!singleLine(epoch) || !/^[0-9]{4}-W(0[1-9]|[1-4][0-9]|5[0-3])$/.test(epoch)) return cold('invalid compiler cache epoch'); + if (env.COMPILER_CACHE_WRITE !== undefined && !['true', 'false'].includes(env.COMPILER_CACHE_WRITE)) { + return cold('invalid compiler cache write policy'); + } + const trustedMain = env.GITHUB_REF === 'refs/heads/main' && ['push', 'workflow_dispatch'].includes(env.GITHUB_EVENT_NAME); + const sourcePullRequest = env.GITHUB_EVENT_NAME === 'pull_request' && singleLine(env.GITHUB_REF) && + /^refs\/pull\/[1-9][0-9]*\/merge$/.test(env.GITHUB_REF); + const scope = sourcePullRequest ? 'untrusted' : 'trusted'; + // The dependency family retains OS/imageOS, architecture, compiler environment, + // manifest/overlay, ABI triplets and module/sanitizer profile. Only ImageVersion + // is omitted here; sccache still hashes the actual compiler, flags and inputs. + const key = `ark-sccache-${sccacheVersion}-v2-${identity[1]}-${identity[2]}-${identity[3]}`; + const prefix = `${key}-${scope}-`; + const restoreKeys = scope === 'untrusted' ? `${prefix}\n${key}-trusted-` : prefix; + // Applications are seeded only by trusted main. Other source-PR profiles retain + // one immutable snapshot per ref/family/week; queue jobs never multiply saves. + // The workflow must independently enforce its trusted checkout/event boundary. + const saveEnabled = env.COMPILER_CACHE_WRITE !== 'false' && + ((trustedMain && env.COMPILER_CACHE_WRITE === 'true') || (sourcePullRequest && profile !== 'applications')); + return { compatible: true, reason: '', directory: paths.join(env.RUNNER_TEMP, 'ark-sccache'), key, + archiveKey: `${prefix}${epoch}`, restoreKeys, cacheSize: `${capacity}M`, epoch, scope, saveEnabled }; +} + +export function prepareCompilerCache({ env, run = spawnSync, start = true, now }) { if (!env.GITHUB_ENV || !env.GITHUB_OUTPUT) throw new Error('GitHub file-command channels are required'); - // The dependency step already fingerprints the runner, profile and dependency inputs. - const identity = env.COMPILER_CACHE_IDENTITY ?? ''; - const validIdentity = /^ark-vcpkg-files-v2-(Linux|macOS|Windows)-(X64|ARM64|ARM)-[a-f0-9]{64}-[a-f0-9]{64}$/.test(identity) && - identity.startsWith(`ark-vcpkg-files-v2-${env.RUNNER_OS}-${env.RUNNER_ARCH}-`); - const validSource = /^[a-f0-9]{40}$/.test(env.GITHUB_SHA ?? ''); + const selection = compilerCachePlan({ env, now }); const paths = env.RUNNER_OS === 'Windows' ? win32 : posix; - const compatible = validIdentity && validSource && env.RUNNER_OS !== 'Windows' && - paths.isAbsolute(env.RUNNER_TEMP ?? '') && !/[\x00-\x1f\x7f;,`*?\[\]{}!]/.test(env.RUNNER_TEMP ?? ''); - const directory = compatible ? paths.join(env.RUNNER_TEMP, 'ark-sccache') : ''; - const key = compatible ? identity.replace('vcpkg-files-v2', `sccache-${sccacheVersion}-v1`) : ''; - // Immutable snapshots can grow across source revisions without invalidating object compatibility. - const selection = { archiveKey: key ? `${key}-${env.GITHUB_SHA}` : '', restoreKeys: key ? `${key}-` : '' }; let enabled = false; - if (directory && (!start || env.SCCACHE_RESTORE_OUTCOME === 'success') && + if (selection.compatible && (!start || env.SCCACHE_RESTORE_OUTCOME === 'success') && env.SCCACHE_SETUP_OUTCOME === 'success' && env.SCCACHE_PATH && paths.isAbsolute(env.SCCACHE_PATH) && !/[\x00-\x1f\x7f;]/.test(env.SCCACHE_PATH)) { try { - mkdirSync(directory, { recursive: true }); - if (!lstatSync(directory).isDirectory()) throw new Error('cache path is not a directory'); - const cacheEnv = { ...env, SCCACHE_DIR: directory, SCCACHE_GHA_ENABLED: 'false', - SCCACHE_IGNORE_SERVER_IO_ERROR: '1', SCCACHE_CACHE_SIZE: '512M', SCCACHE_C_CUSTOM_CACHE_BUSTER: key }; + mkdirSync(selection.directory, { recursive: true }); + if (!lstatSync(selection.directory).isDirectory()) throw new Error('cache path is not a directory'); + const cacheEnv = { ...env, SCCACHE_DIR: selection.directory, SCCACHE_GHA_ENABLED: 'false', + SCCACHE_IGNORE_SERVER_IO_ERROR: '1', SCCACHE_CACHE_SIZE: selection.cacheSize, + SCCACHE_C_CUSTOM_CACHE_BUSTER: selection.key }; const options = { env: cacheEnv, encoding: 'utf8', timeout: 30_000 }; const version = run(env.SCCACHE_PATH, ['--version'], options); - if (version.status === 0 && version.stdout.trim() === `sccache ${sccacheVersion}`) { + if (version.status === 0 && version.stdout?.trim() === `sccache ${sccacheVersion}`) { enabled = !start || run(env.SCCACHE_PATH, ['--start-server'], options).status === 0; } } catch { @@ -38,15 +86,21 @@ export function prepareCompilerCache({ env, run = spawnSync, start = true }) { } } const launcher = enabled ? env.SCCACHE_PATH.replaceAll('\\', '/') : ''; - appendFileSync(env.GITHUB_ENV, `ARK_COMPILER_LAUNCHER=${launcher}\nSCCACHE_DIR=${enabled ? directory : ''}\nSCCACHE_GHA_ENABLED=false\nSCCACHE_IGNORE_SERVER_IO_ERROR=1\nSCCACHE_CACHE_SIZE=512M\nSCCACHE_C_CUSTOM_CACHE_BUSTER=${key}\n`); - appendFileSync(env.GITHUB_OUTPUT, `enabled=${enabled}\ndirectory=${enabled ? directory : ''}\nkey=${enabled ? key : ''}\narchive_key=${enabled ? selection.archiveKey : ''}\nrestore_keys=${enabled ? selection.restoreKeys : ''}\n`); - return { enabled, directory, key, launcher, ...selection }; + const saveEnabled = enabled && selection.saveEnabled; + appendFileSync(env.GITHUB_ENV, `ARK_COMPILER_LAUNCHER=${launcher}\nSCCACHE_DIR=${enabled ? selection.directory : ''}\nSCCACHE_GHA_ENABLED=false\nSCCACHE_IGNORE_SERVER_IO_ERROR=1\nSCCACHE_CACHE_SIZE=${selection.cacheSize}\nSCCACHE_C_CUSTOM_CACHE_BUSTER=${enabled ? selection.key : ''}\nARK_COMPILER_CACHE_SAVE_ENABLED=${saveEnabled}\nARK_COMPILER_CACHE_EPOCH=${enabled ? selection.epoch : ''}\nARK_COMPILER_CACHE_ARCHIVE_SCOPE=${enabled ? selection.scope : ''}\n`); + const output = `enabled=${enabled}\ndirectory=${enabled ? selection.directory : ''}\nkey=${enabled ? selection.key : ''}\narchive_key=${enabled ? selection.archiveKey : ''}\nsave_enabled=${saveEnabled}\ncache_size=${selection.cacheSize}\nepoch=${enabled ? selection.epoch : ''}\narchive_scope=${enabled ? selection.scope : ''}\n`; + // The keys contain only validated fixed-format characters, so this delimiter + // cannot be injected through a path, profile, source revision or epoch. + appendFileSync(env.GITHUB_OUTPUT, output + (enabled ? + `restore_keys< { + const root = mkdtempSync(join(tmpdir(), 'ark-cache-stats-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + mkdirSync(join(root, 'nested')); + writeFileSync(join(root, 'one.zip'), 'fixture'); + writeFileSync(join(root, 'nested', 'two.zip'), 'fixture'); + const summary = join(root, 'summary'); + const env = { VCPKG_ARCHIVE_DIRECTORY: root, VCPKG_ARCHIVE_HIT: 'true', COMPILER_ARCHIVE_HIT: 'false', + ARK_COMPILER_LAUNCHER: '/fixture/sccache', SCCACHE_CACHE_SIZE: '768M', + ARK_COMPILER_CACHE_SAVE_ENABLED: 'false', ARK_COMPILER_CACHE_ARCHIVE_SCOPE: 'trusted', + ARK_COMPILER_CACHE_EPOCH: '2026-W45', GITHUB_STEP_SUMMARY: summary }; + const messages = []; + const report = reportCacheStats({ env, log: text => messages.push(text), run: (command, args, options) => { + assert.equal(command, env.ARK_COMPILER_LAUNCHER); + assert.deepEqual(args, ['--show-stats']); + assert.equal(options.env, env); + assert.equal(options.timeout, 30_000); + return { status: 0, stdout: 'Compile requests 409\nCache hits 29\n' }; + } }); + assert.match(report, /Dependency archives after configure\/build: 2/); + assert.match(report, /Compiler archive cache exact hit: false/); + assert.match(report, /Compiler local cache budget: 768M/); + assert.match(report, /publication allowed: false \(scope=trusted, epoch=2026-W45\)/); + assert.match(report, /Compile requests 409\nCache hits 29/); + assert.deepEqual(messages, [report]); + assert.ok(readFileSync(summary, 'utf8').includes(report)); +}); + +test('unavailable statistics, missing storage and summary errors never change the native result', () => { + for (const run of [ + () => { throw new Error('tool unavailable'); }, + () => ({ status: 1, stdout: '' }), + () => ({ status: null, error: new Error('timeout') }), + () => ({ status: 0, stdout: null }), + () => null, + ]) { + const report = reportCacheStats({ env: { ARK_COMPILER_LAUNCHER: '/missing/sccache', + VCPKG_ARCHIVE_DIRECTORY: '/missing/archives', GITHUB_STEP_SUMMARY: '/missing/summary' }, + run, append: () => { throw new Error('summary unavailable'); }, log: () => {} }); + assert.match(report, /sccache statistics unavailable/); + assert.match(report, /Dependency archives after configure\/build: unavailable/); + } +}); + +test('disabled caching reports direct compilation without launching tools', () => { + const report = reportCacheStats({ env: {}, log: () => {}, run: () => assert.fail('disabled cache must not invoke a tool') }); + assert.match(report, /compilation uses the compiler directly/); + assert.match(report, /Compiler local cache budget: unavailable/); +}); + +test('optional compiler diagnostics remain bounded', () => { + const report = reportCacheStats({ env: { ARK_COMPILER_LAUNCHER: '/fixture/sccache' }, log: () => {}, + run: () => ({ status: 0, stdout: 'x'.repeat(32 * 1024) }) }); + assert.ok(report.length < 17 * 1024); + assert.match(report, /\[sccache output truncated\]/); +}); diff --git a/tests/ci/compiler-cache.test.mjs b/tests/ci/compiler-cache.test.mjs index b0ea500dd..f2f29b916 100644 --- a/tests/ci/compiler-cache.test.mjs +++ b/tests/ci/compiler-cache.test.mjs @@ -5,7 +5,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import test from 'node:test'; import { cachePlan } from '../../.github/scripts/vcpkg-cache.mjs'; -import { prepareCompilerCache, sccacheVersion } from '../../.github/scripts/compiler-cache.mjs'; +import { compilerCachePlan, prepareCompilerCache, sccacheVersion } from '../../.github/scripts/compiler-cache.mjs'; function fixture(t) { const root = mkdtempSync(join(tmpdir(), 'ark-compiler-cache-')); @@ -15,10 +15,12 @@ function fixture(t) { writeFileSync(join(root, 'vcpkg-configuration.json'), '{"overlay-ports":["ports"]}'); const env = { GITHUB_SHA: 'b'.repeat(40), GITHUB_RUN_ID: '1001', GITHUB_RUN_ATTEMPT: '1', + GITHUB_EVENT_NAME: 'pull_request', GITHUB_REF: 'refs/pull/7/merge', + COMPILER_CACHE_PROFILE: 'applications', COMPILER_CACHE_EPOCH: '2026-W45', RUNNER_OS: 'Linux', RUNNER_ARCH: 'X64', RUNNER_TEMP: join(root, 'runner temp'), ImageOS: 'ubuntu24', ImageVersion: '20261020.1', VCPKG_CACHE_REVISION: 'a'.repeat(40), VCPKG_CACHE_FEATURES: 'boost;ssl', VCPKG_CACHE_DEFAULT_FEATURES: 'true', - VCPKG_CACHE_PROFILE: 'ON-ON-OFF-Release', VCPKG_DEFAULT_TRIPLET: 'x64-linux', + VCPKG_CACHE_PROFILE: 'applications', VCPKG_DEFAULT_TRIPLET: 'x64-linux', VCPKG_DEFAULT_HOST_TRIPLET: 'x64-linux', GITHUB_ENV: join(root, 'env'), GITHUB_OUTPUT: join(root, 'output'), SCCACHE_SETUP_OUTCOME: 'success', SCCACHE_PATH: '/tool cache/sccache', SCCACHE_RESTORE_OUTCOME: 'success', @@ -34,7 +36,7 @@ test('compiler launcher uses only the verified pinned version with cache IO fall calls.push([command, args]); assert.equal(options.env.SCCACHE_GHA_ENABLED, 'false'); assert.equal(options.env.SCCACHE_IGNORE_SERVER_IO_ERROR, '1'); - assert.match(options.env.SCCACHE_C_CUSTOM_CACHE_BUSTER, /ark-sccache-0.16.0-v1/); + assert.match(options.env.SCCACHE_C_CUSTOM_CACHE_BUSTER, /ark-sccache-0.16.0-v2/); return { status: 0, stdout: `sccache ${sccacheVersion}\n` }; } }); assert.equal(plan.enabled, true); @@ -111,25 +113,26 @@ test('CMake passes a launcher path with spaces, keeps the compiler and propagate assert.notEqual(run(['--build', direct, '--parallel', '1']).status, 0, 'cold builds must preserve compiler errors'); }); -test('source commits and run attempts share object compatibility while immutable snapshots differ', t => { +test('source commits and run attempts share one weekly immutable snapshot', t => { const f = fixture(t); const run = () => ({ status: 0, stdout: `sccache ${sccacheVersion}` }); const first = prepareCompilerCache({ ...f, run }); const next = prepareCompilerCache({ ...f, env: { ...f.env, GITHUB_SHA: 'c'.repeat(40), GITHUB_RUN_ID: '2002', GITHUB_RUN_ATTEMPT: '2' }, run }); assert.equal(first.key, next.key); assert.equal(first.restoreKeys, next.restoreKeys); - assert.notEqual(first.archiveKey, next.archiveKey); - assert.ok(first.archiveKey.startsWith(next.restoreKeys)); + assert.equal(first.archiveKey, next.archiveKey); + assert.ok(first.archiveKey.startsWith(next.restoreKeys.split('\n')[0])); assert.equal(first.key.includes(f.env.GITHUB_SHA), false); assert.equal(first.directory, next.directory); }); -test('runner image, compiler flags, dependency contents and profiles isolate compiler restore prefixes', t => { +test('image OS, compiler flags, dependency contents and profiles isolate compiler restore prefixes', t => { const f = fixture(t); const run = () => ({ status: 0, stdout: `sccache ${sccacheVersion}` }); const baseline = prepareCompilerCache({ ...f, run }); - for (const extra of [{ ImageVersion: 'different-image' }, { CXXFLAGS: '-fsanitize=thread' }, - { CXX: '/other/compiler' }, { VCPKG_CACHE_PROFILE: 'database-Standalone' }, + for (const extra of [{ ImageOS: 'ubuntu26' }, { CXXFLAGS: '-fsanitize=thread' }, { CFLAGS: '-O3' }, { LDFLAGS: '-flto' }, + { VCPKG_DEFAULT_TRIPLET: 'arm64-linux' }, + { CXX: '/other/compiler' }, { VCPKG_CACHE_PROFILE: 'database-Standalone' }, { VCPKG_CACHE_PROFILE: 'network-address' }, { VCPKG_CACHE_FEATURES: 'database' }, { VCPKG_CACHE_REVISION: 'd'.repeat(40) }]) { const identity = cachePlan({ root: f.root, env: { ...f.env, ...extra } }).key; const plan = prepareCompilerCache({ ...f, env: { ...f.env, COMPILER_CACHE_IDENTITY: identity }, run }); @@ -165,6 +168,109 @@ test('failed restore or startup clears the previously prepared launcher before C assert.equal(prepareCompilerCache({ env, run }).enabled, false); const launchers = readFileSync(f.env.GITHUB_ENV, 'utf8').split('\n').filter(line => line.startsWith('ARK_COMPILER_LAUNCHER=')); assert.deepEqual(launchers, [`ARK_COMPILER_LAUNCHER=${f.env.SCCACHE_PATH}`, 'ARK_COMPILER_LAUNCHER=']); - assert.ok(readFileSync(f.env.GITHUB_OUTPUT, 'utf8').endsWith('enabled=false\ndirectory=\nkey=\narchive_key=\nrestore_keys=\n')); + assert.match(readFileSync(f.env.GITHUB_OUTPUT, 'utf8'), /enabled=false\ndirectory=\nkey=\narchive_key=\nsave_enabled=false\ncache_size=512M\nepoch=\narchive_scope=\nrestore_keys=\n$/); + } +}); + +test('compatible image versions retain the same object buster and archive family', t => { + const f = fixture(t); + const run = () => ({ status: 0, stdout: `sccache ${sccacheVersion}` }); + const first = prepareCompilerCache({ ...f, run }); + const identity = cachePlan({ root: f.root, env: { ...f.env, ImageVersion: '20261108.2' } }).key; + assert.notEqual(identity, f.env.COMPILER_CACHE_IDENTITY); + const next = prepareCompilerCache({ env: { ...f.env, COMPILER_CACHE_IDENTITY: identity }, run }); + assert.equal(first.key, next.key); + assert.equal(first.archiveKey, next.archiveKey); + assert.equal(first.restoreKeys, next.restoreKeys); + assert.match(readFileSync(f.env.GITHUB_OUTPUT, 'utf8'), /restore_keys< { + const f = fixture(t); + delete f.env.COMPILER_CACHE_EPOCH; + const plan = date => compilerCachePlan({ env: f.env, now: new Date(date) }); + const first = plan('2026-11-02T00:00:00Z'); + const sameWeek = plan('2026-11-08T23:59:59Z'); + const nextWeek = plan('2026-11-09T00:00:00Z'); + assert.equal(first.epoch, '2026-W45'); + assert.equal(first.archiveKey, sameWeek.archiveKey); + assert.notEqual(first.archiveKey, nextWeek.archiveKey); + assert.equal(first.key, nextWeek.key); + assert.equal(first.restoreKeys, nextWeek.restoreKeys); + assert.equal(plan('2021-01-01T00:00:00Z').epoch, '2020-W53'); +}); + +test('trusted main archives are isolated from untrusted PR snapshots and queue writes', t => { + const f = fixture(t); + const plan = extra => compilerCachePlan({ env: { ...f.env, ...extra } }); + const main = plan({ GITHUB_EVENT_NAME: 'push', GITHUB_REF: 'refs/heads/main', COMPILER_CACHE_WRITE: 'true' }); + const pr = plan({}); + const queue = plan({ GITHUB_EVENT_NAME: 'merge_group', GITHUB_REF: 'refs/heads/gh-readonly-queue/main/pr-7' }); + assert.equal(main.saveEnabled, true); + assert.equal(main.scope, 'trusted'); + assert.doesNotMatch(main.restoreKeys, /untrusted/); + assert.equal(pr.saveEnabled, false, 'Applications PRs consume the main seed'); + assert.match(pr.restoreKeys, /-untrusted-\n.*-trusted-$/); + assert.equal(pr.key, main.key, 'archive trust boundaries do not prevent object reuse from a trusted seed'); + assert.equal(queue.saveEnabled, false); + assert.doesNotMatch(queue.restoreKeys, /untrusted/); + for (const extra of [ + { COMPILER_CACHE_WRITE: 'true' }, // An untrusted request cannot opt Applications into saving. + { GITHUB_EVENT_NAME: 'pull_request_target', GITHUB_REF: 'refs/heads/main', COMPILER_CACHE_WRITE: 'true' }, + { GITHUB_EVENT_NAME: 'push', GITHUB_REF: 'refs/heads/topic', COMPILER_CACHE_WRITE: 'true' }, + { GITHUB_EVENT_NAME: 'workflow_dispatch', GITHUB_REF: 'refs/heads/topic', COMPILER_CACHE_WRITE: 'true' }, + { GITHUB_EVENT_NAME: 'push', GITHUB_REF: 'refs/heads/main' }, + { GITHUB_EVENT_NAME: 'push', GITHUB_REF: 'refs/heads/main', COMPILER_CACHE_WRITE: 'false' }, + ]) assert.equal(plan(extra).saveEnabled, false, JSON.stringify(extra)); + assert.equal(plan({ GITHUB_EVENT_NAME: 'workflow_dispatch', GITHUB_REF: 'refs/heads/main', COMPILER_CACHE_WRITE: 'true' }).saveEnabled, true); +}); + +test('network and database source PRs retain bounded per-ref weekly snapshots', t => { + const f = fixture(t); + for (const profile of ['network-none', 'network-address', 'network-thread', 'database-Boost', 'database-Standalone']) { + const env = { ...f.env, COMPILER_CACHE_PROFILE: profile, VCPKG_CACHE_PROFILE: profile }; + env.COMPILER_CACHE_IDENTITY = cachePlan({ root: f.root, env }).key; + const first = compilerCachePlan({ env }); + assert.equal(first.saveEnabled, true, profile); + assert.equal(first.scope, 'untrusted'); + assert.equal(first.cacheSize, '512M'); + assert.equal(first.archiveKey, compilerCachePlan({ env: { ...env, GITHUB_SHA: 'e'.repeat(40) } }).archiveKey); + assert.equal(compilerCachePlan({ env: { ...env, COMPILER_CACHE_WRITE: 'false' } }).saveEnabled, false); + assert.equal(compilerCachePlan({ env: { ...env, GITHUB_REF: 'refs/pull/7/merge\n' } }).saveEnabled, false); + assert.equal(compilerCachePlan({ env: { ...env, GITHUB_EVENT_NAME: 'merge_group' } }).saveEnabled, false); + } +}); + +test('cache capacity is bounded, optional, and exported without changing compatibility', t => { + const f = fixture(t); + const original = compilerCachePlan(f); + for (const capacity of ['64', '512', '768', '1024']) { + const env = { ...f.env, COMPILER_CACHE_MAX_MIB: capacity }; + const seen = []; + const plan = prepareCompilerCache({ env, run: (_command, _args, options) => { + seen.push(options.env.SCCACHE_CACHE_SIZE); + return { status: 0, stdout: `sccache ${sccacheVersion}` }; + } }); + assert.equal(plan.enabled, true); + assert.equal(plan.cacheSize, `${capacity}M`); + assert.equal(plan.key, original.key); + assert.deepEqual(seen, [`${capacity}M`, `${capacity}M`]); + assert.match(readFileSync(env.GITHUB_ENV, 'utf8'), new RegExp(`SCCACHE_CACHE_SIZE=${capacity}M\\n`)); + } +}); + +test('invalid capacity, profile, epoch or publication settings disable optional caching safely', t => { + for (const extra of [ + ...['', '0', '63', '1025', '2048', '-1', '512M', '0.5', '0512', '512\n', '512;bad'].map(COMPILER_CACHE_MAX_MIB => ({ COMPILER_CACHE_MAX_MIB })), + ...['', '2026-W00', '2026-W54', '2026-W45\n', 'bad'].map(COMPILER_CACHE_EPOCH => ({ COMPILER_CACHE_EPOCH })), + ...['', 'applications\n', 'unknown'].map(COMPILER_CACHE_PROFILE => ({ COMPILER_CACHE_PROFILE })), + { COMPILER_CACHE_WRITE: 'maybe' }, { GITHUB_SHA: 'b'.repeat(40) + '\n' }, + ]) { + const f = fixture(t); + const plan = prepareCompilerCache({ env: { ...f.env, ...extra }, run: () => assert.fail('invalid cache settings must not launch a tool') }); + assert.equal(plan.enabled, false, JSON.stringify(extra)); + assert.equal(plan.saveEnabled, false); + assert.equal(plan.launcher, ''); + assert.match(readFileSync(f.env.GITHUB_ENV, 'utf8'), /^ARK_COMPILER_LAUNCHER=\n/); } }); From 19e0736a68f5cb8e767da5fe7f5987abc9e9fe98 Mon Sep 17 00:00:00 2001 From: nick Date: Thu, 5 Nov 2026 13:53:49 +0800 Subject: [PATCH 5/6] ci(build): integrate scoped builds and trusted cache seeding --- .github/scripts/cache-stats.mjs | 9 + .github/scripts/ci-applications.mjs | 77 +++++++ .github/scripts/compiler-seed-plan.mjs | 43 ++++ .github/workflows/ci.yml | 69 +++--- .github/workflows/compiler-cache-seed.yml | 193 ++++++++++++++++ AGENTS.md | 10 + docs/project/contributing.md | 183 +++------------ docs/project/contributing_CN.md | 130 +++-------- tests/ci/cache-stats.test.mjs | 12 + tests/ci/cache-workflow.test.mjs | 267 ++++++++++++++++++++-- tests/ci/ci-applications.test.mjs | 115 ++++++++++ tests/ci/entity-example.test.mjs | 10 +- tests/ci/http-examples.test.mjs | 13 +- tests/ci/module-validation.test.mjs | 16 +- tests/ci/package-dependencies.test.mjs | 83 ++++--- tests/ci/vcpkg-seed-workflow.test.mjs | 32 ++- tests/ci/windows-runtime.test.mjs | 15 +- 17 files changed, 940 insertions(+), 337 deletions(-) create mode 100644 .github/scripts/ci-applications.mjs create mode 100644 .github/scripts/compiler-seed-plan.mjs create mode 100644 .github/workflows/compiler-cache-seed.yml create mode 100644 tests/ci/ci-applications.test.mjs diff --git a/.github/scripts/cache-stats.mjs b/.github/scripts/cache-stats.mjs index 215664d01..f8ee615b1 100644 --- a/.github/scripts/cache-stats.mjs +++ b/.github/scripts/cache-stats.mjs @@ -24,6 +24,15 @@ export function reportCacheStats({ env = process.env, run = spawnSync, append = `Compiler local cache budget: ${cacheSize}`, `Compiler archive publication allowed: ${env.ARK_COMPILER_CACHE_SAVE_ENABLED === 'true'} (scope=${scope}, epoch=${epoch})`, ]; + if (['controlled-cold', 'controlled-warm', 'seed-or-restored-snapshot'].includes(env.ARK_CACHE_SAMPLE)) { + const source = /^[a-f0-9]{40}$/.test(env.GITHUB_SHA ?? '') ? env.GITHUB_SHA : 'unavailable'; + const key = /^ark-sccache-[a-zA-Z0-9.-]{1,240}$/.test(env.ARK_CACHE_ARCHIVE_KEY ?? '') + ? env.ARK_CACHE_ARCHIVE_KEY : 'unavailable'; + lines.unshift(`Cache measurement sample: ${env.ARK_CACHE_SAMPLE}`, `Source revision: ${source}`, + `Requested archive key: ${key}`, + `Project build executed: ${['true', 'false'].includes(env.ARK_CACHE_BUILD_EXECUTED) + ? env.ARK_CACHE_BUILD_EXECUTED : 'unavailable'}`); + } if (env.ARK_COMPILER_LAUNCHER) { let statistics = 'sccache statistics unavailable'; try { diff --git a/.github/scripts/ci-applications.mjs b/.github/scripts/ci-applications.mjs new file mode 100644 index 000000000..2aaf0cc12 --- /dev/null +++ b/.github/scripts/ci-applications.mjs @@ -0,0 +1,77 @@ +import { spawnSync } from 'node:child_process'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { + applicationCheckTargets, applicationConfigureFlags, + fullApplicationBuildPlan, +} from './application-build-plan.mjs'; + +export function applicationPlan(env) { + const fallback = () => fullApplicationBuildPlan('runner-full-fallback'); + if (env.GITHUB_EVENT_NAME !== 'pull_request' || env.ARK_APPLICATION_SCOPE_RESULT !== 'success' || + env.ARK_APPLICATION_VALIDATION_STAGE !== 'modules' || + env.ARK_APPLICATION_FILES_KNOWN !== 'true') return fallback(); + try { + const plan = JSON.parse(env.ARK_APPLICATION_PLAN ?? ''); + const knownTargets = new Set(applicationCheckTargets); + if (plan.version !== 1 || !['full', 'targets'].includes(plan.mode) || + typeof plan.reason !== 'string' || plan.buildAll !== (plan.mode === 'full') || + !Array.isArray(plan.configureFlags) || + JSON.stringify(plan.configureFlags) !== JSON.stringify(applicationConfigureFlags) || + !Array.isArray(plan.checkTargets) || plan.checkTargets.length === 0 || + new Set(plan.checkTargets).size !== plan.checkTargets.length || + plan.checkTargets.some(target => !knownTargets.has(target)) || + (plan.buildAll && JSON.stringify(plan.checkTargets) !== + JSON.stringify(['check-core', 'check-entity-benchmark-smoke']))) return fallback(); + return plan; + } catch { + return fallback(); + } +} + +export function applicationCommands(action, env) { + const plan = applicationPlan(env); + const jobs = /^[1-4]$/.test(env.ARK_BUILD_JOBS ?? '') ? env.ARK_BUILD_JOBS : '2'; + if (action === 'configure') { + if (!env.VCPKG_ROOT) throw new Error('VCPKG_ROOT is required'); + const args = ['-S', '.', '-B', 'ci-build', '-G', 'Ninja', + `-DCMAKE_TOOLCHAIN_FILE=${resolve(env.VCPKG_ROOT, 'scripts/buildsystems/vcpkg.cmake')}`, + '-DVCPKG_MANIFEST_FEATURES=application;boost;compression-tests;etcd-client;guest-room;config-tool', + '-DCMAKE_BUILD_TYPE=Debug', + `-DCMAKE_C_COMPILER_LAUNCHER=${env.ARK_COMPILER_LAUNCHER ?? ''}`, + `-DCMAKE_CXX_COMPILER_LAUNCHER=${env.ARK_COMPILER_LAUNCHER ?? ''}`, + `-DCMAKE_JOB_POOLS=${env.ARK_CMAKE_JOB_POOLS ?? 'ark_compile=2;ark_link=1'}`, + '-DCMAKE_JOB_POOL_COMPILE=ark_compile', '-DCMAKE_JOB_POOL_LINK=ark_link']; + if (env.RUNNER_OS === 'Windows') args.push('-DVCPKG_APPLOCAL_DEPS=OFF'); + return [[...args, ...plan.configureFlags]]; + } + if (action === 'build') { + return plan.buildAll ? [['--build', 'ci-build', '--parallel', jobs]] : []; + } + if (action === 'check') { + return plan.checkTargets.map(target => ['--build', 'ci-build', '--target', target, '--parallel', jobs]); + } + throw new Error(`Unknown applications phase: ${action}`); +} + +export function runApplications(action, env, run = spawnSync) { + const plan = applicationPlan(env); + console.log(`Applications plan: ${plan.mode}; ${plan.reason}; ${plan.checkTargets.join(', ')}`); + const started = performance.now(); + for (const args of applicationCommands(action, env)) { + const result = run('cmake', args, { env, stdio: 'inherit', shell: false }); + if (result.error) throw result.error; + if (result.status !== 0) return Number.isInteger(result.status) ? result.status : 1; + } + console.log(`Applications ${action} completed in ${((performance.now() - started) / 1000).toFixed(2)} seconds`); + return 0; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + try { + process.exitCode = runApplications(process.argv[2], process.env); + } catch (error) { + console.error(error.message); + process.exitCode = 1; + } +} diff --git a/.github/scripts/compiler-seed-plan.mjs b/.github/scripts/compiler-seed-plan.mjs new file mode 100644 index 000000000..e7132e1b8 --- /dev/null +++ b/.github/scripts/compiler-seed-plan.mjs @@ -0,0 +1,43 @@ +import { execFileSync } from 'node:child_process'; +import { appendFileSync, readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { changedPaths } from './ci-policy.mjs'; + +export function compilerSeedPlan({ env, event, latestMain, files }) { + if (env.GITHUB_REF !== 'refs/heads/main' || !['push', 'workflow_dispatch'].includes(env.GITHUB_EVENT_NAME)) { + return { build: false, reason: 'trusted-main-only' }; + } + if (env.GITHUB_EVENT_NAME === 'workflow_dispatch') return { build: true, reason: 'manual-main-seed' }; + if (latestMain && latestMain !== env.GITHUB_SHA) return { build: false, reason: 'newer-main-available' }; + if (!Array.isArray(files) || files.length === 0) return { build: true, reason: 'unknown-inputs' }; + const native = files.some(file => /\.(?:c|cc|cpp|cxx|h|hpp|inl|ipp|tpp|proto|cmake)$/i.test(file) || + /(?:^|\/)CMakeLists\.txt$/.test(file) || /^(?:ports\/|vcpkg(?:-configuration)?\.json$)/.test(file) || + /^\.github\/(?:workflows|scripts)\//.test(file)); + return { build: native, reason: native ? 'accepted-native-inputs' : 'no-compiler-inputs' }; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + try { + const env = process.env; + const event = JSON.parse(readFileSync(env.GITHUB_EVENT_PATH, 'utf8')); + let files, latestMain; + if (env.GITHUB_EVENT_NAME === 'push') { + // Fetching records the current accepted main without changing the checkout. + // Cache-only work may skip superseded inputs; acceptance jobs never do. + execFileSync('git', ['fetch', '--no-tags', 'origin', 'main'], { stdio: 'pipe' }); + latestMain = execFileSync('git', ['rev-parse', 'FETCH_HEAD'], { encoding: 'utf8' }).trim(); + try { + files = changedPaths({ root: process.cwd(), base: event.before, head: event.after, pullRequest: false }); + } catch { + // Unknown changes warm accepted code conservatively. + } + } + const plan = compilerSeedPlan({ env, event, latestMain, files }); + appendFileSync(env.GITHUB_OUTPUT, `build=${plan.build}\nreason=${plan.reason}\n`); + console.log(`Compiler seed: ${plan.reason}`); + } catch (error) { + console.error(error.message); + process.exitCode = 1; + } +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2ef35aa5e..b1b06563c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,6 +28,7 @@ jobs: native_check: ${{ steps.scope.outputs.native_check }} network_check: ${{ steps.scope.outputs.network_check }} applications_check: ${{ steps.scope.outputs.applications_check }} + app_plan_json: ${{ steps.scope.outputs.app_plan_json }} database_check: ${{ steps.scope.outputs.database_check }} format_check: ${{ steps.scope.outputs.format_check }} docs_check: ${{ steps.scope.outputs.docs_check }} @@ -105,6 +106,9 @@ jobs: - uses: actions/setup-node@v4 with: node-version: 26 + - name: Select bounded build parallelism + id: build-parallelism + run: node .github/scripts/build-parallelism.mjs - name: Prepare vcpkg binary cache id: vcpkg-cache env: @@ -134,6 +138,7 @@ jobs: id: compiler-cache env: COMPILER_CACHE_IDENTITY: ${{ steps.vcpkg-cache.outputs.key }} + COMPILER_CACHE_PROFILE: network-${{ matrix.sanitizer }} SCCACHE_SETUP_OUTCOME: ${{ steps.sccache-setup.outcome }} run: node .github/scripts/compiler-cache.mjs --prepare - name: Restore compiler cache @@ -150,6 +155,7 @@ jobs: if: steps.compiler-cache.outputs.enabled == 'true' env: COMPILER_CACHE_IDENTITY: ${{ steps.vcpkg-cache.outputs.key }} + COMPILER_CACHE_PROFILE: network-${{ matrix.sanitizer }} SCCACHE_SETUP_OUTCOME: ${{ steps.sccache-setup.outcome }} SCCACHE_RESTORE_OUTCOME: ${{ steps.compiler-restore.outcome }} run: node .github/scripts/compiler-cache.mjs @@ -170,6 +176,9 @@ jobs: -DCMAKE_BUILD_TYPE=Debug "-DCMAKE_C_COMPILER_LAUNCHER=$env:ARK_COMPILER_LAUNCHER" "-DCMAKE_CXX_COMPILER_LAUNCHER=$env:ARK_COMPILER_LAUNCHER" + "-DCMAKE_JOB_POOLS=$env:ARK_CMAKE_JOB_POOLS" + -DCMAKE_JOB_POOL_COMPILE=ark_compile + -DCMAKE_JOB_POOL_LINK=ark_link -DARK_BUILD_APPLICATIONS=OFF -DARK_BUILD_NETWORK_TESTS=ON -DASIO_STANDALONE=OFF @@ -181,7 +190,7 @@ jobs: with: path: ${{ steps.vcpkg-cache.outputs.directory }} key: ${{ steps.vcpkg-cache.outputs.key }} - - run: cmake --build ci-build --parallel 2 + - run: cmake --build ci-build --parallel ${{ steps.build-parallelism.outputs.build_jobs }} - name: Run network regressions env: ASAN_OPTIONS: detect_leaks=1:halt_on_error=1 @@ -197,7 +206,7 @@ jobs: COMPILER_ARCHIVE_HIT: ${{ steps.compiler-restore.outputs.cache-hit }} run: node .github/scripts/cache-stats.mjs - name: Save compiler cache - if: ${{ !cancelled() && steps.compiler-start.outcome == 'success' && steps.compiler-start.outputs.enabled == 'true' && steps.compiler-restore.outputs.cache-hit != 'true' }} + if: ${{ !cancelled() && steps.compiler-start.outcome == 'success' && steps.compiler-start.outputs.enabled == 'true' && steps.compiler-restore.outputs.cache-hit != 'true' && steps.compiler-start.outputs.save_enabled == 'true' && (github.event_name == 'pull_request' || ((github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.ref == 'refs/heads/main')) }} continue-on-error: true uses: actions/cache/save@v4 with: @@ -211,6 +220,12 @@ jobs: # Cold Windows dependency configuration and the full C++ build share this # budget. Individual regression timeouts and required checks are unchanged. timeout-minutes: 60 + env: + ARK_APPLICATION_PLAN: ${{ needs.scope.outputs.app_plan_json }} + ARK_APPLICATION_SCOPE_RESULT: ${{ needs.scope.result }} + ARK_APPLICATION_VALIDATION_STAGE: ${{ needs.scope.outputs.validation_stage }} + ARK_APPLICATION_FILES_KNOWN: ${{ needs.scope.outputs.files_known }} + COMPILER_CACHE_MAX_MIB: 1024 strategy: fail-fast: false 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"}]}') }} @@ -221,6 +236,9 @@ jobs: - uses: actions/setup-node@v4 with: node-version: 26 + - name: Select bounded build parallelism + id: build-parallelism + run: node .github/scripts/build-parallelism.mjs - name: Prepare vcpkg binary cache id: vcpkg-cache env: @@ -250,6 +268,7 @@ jobs: id: compiler-cache env: COMPILER_CACHE_IDENTITY: ${{ steps.vcpkg-cache.outputs.key }} + COMPILER_CACHE_PROFILE: applications SCCACHE_SETUP_OUTCOME: ${{ steps.sccache-setup.outcome }} run: node .github/scripts/compiler-cache.mjs --prepare - name: Restore compiler cache @@ -266,6 +285,7 @@ jobs: if: steps.compiler-cache.outputs.enabled == 'true' env: COMPILER_CACHE_IDENTITY: ${{ steps.vcpkg-cache.outputs.key }} + COMPILER_CACHE_PROFILE: applications SCCACHE_SETUP_OUTCOME: ${{ steps.sccache-setup.outcome }} SCCACHE_RESTORE_OUTCOME: ${{ steps.compiler-restore.outcome }} run: node .github/scripts/compiler-cache.mjs @@ -275,29 +295,7 @@ jobs: vcpkgGitCommitId: 0699a19d0c6386247ce50d4dedbb8217d484d536 vcpkgJsonGlob: vcpkg.json - name: Configure applications - shell: pwsh - run: >- - cmake -S . -B ci-build -G Ninja - "-DCMAKE_TOOLCHAIN_FILE=$env:VCPKG_ROOT/scripts/buildsystems/vcpkg.cmake" - "-DVCPKG_MANIFEST_FEATURES=application;boost;compression-tests;etcd-client;guest-room;config-tool" - -DCMAKE_BUILD_TYPE=Debug - "-DCMAKE_C_COMPILER_LAUNCHER=$env:ARK_COMPILER_LAUNCHER" - "-DCMAKE_CXX_COMPILER_LAUNCHER=$env:ARK_COMPILER_LAUNCHER" - ${{ runner.os == 'Windows' && '-DVCPKG_APPLOCAL_DEPS=OFF' || '' }} - -DARK_BUILD_APPLICATIONS=ON - -DARK_BUILD_BENCHMARKS=ON - -DARK_BUILD_PLUGIN_HTTP=ON - -DARK_BUILD_PLUGIN_CONSUL=ON - -DARK_BUILD_PLUGIN_ETCD_CLIENT=ON - -DARK_BUILD_PROTO=ON - -DARK_BUILD_TESTS=ON - -DARK_BUILD_CORE_TESTS=ON - -DARK_BUILD_COMPRESSION_TESTS=ON - -DARK_BUILD_EXAMPLES=ON - -DARK_BUILD_GUEST_ROOM_EXAMPLE=ON - -DARK_BUILD_CONFIG_TOOL=ON - -DARK_BUILD_CONFIG_TOOL_TESTS=ON - -DASIO_STANDALONE=OFF + run: node .github/scripts/ci-applications.mjs configure - name: Save vcpkg package archives if: ${{ success() && steps.vcpkg-cache.outputs.enabled == 'true' && steps.vcpkg-restore.outputs.cache-hit != 'true' }} continue-on-error: true @@ -312,11 +310,10 @@ jobs: - name: Set Windows dependency runtime PATH if: runner.os == 'Windows' run: cmake -DBUILD_DIR=ci-build -P .github/scripts/windows-runtime.cmake - - run: cmake --build ci-build --parallel 2 + - name: Build selected application inputs + run: node .github/scripts/ci-applications.mjs build - name: Run core and Protobuf regressions - run: cmake --build ci-build --target check-core - - name: Check entity benchmark CLI without measurements - run: cmake --build ci-build --target check-entity-benchmark-smoke + run: node .github/scripts/ci-applications.mjs check - name: Report native cache statistics if: ${{ !cancelled() }} @@ -327,7 +324,7 @@ jobs: COMPILER_ARCHIVE_HIT: ${{ steps.compiler-restore.outputs.cache-hit }} run: node .github/scripts/cache-stats.mjs - name: Save compiler cache - if: ${{ !cancelled() && steps.compiler-start.outcome == 'success' && steps.compiler-start.outputs.enabled == 'true' && steps.compiler-restore.outputs.cache-hit != 'true' }} + if: ${{ !cancelled() && steps.compiler-start.outcome == 'success' && steps.compiler-start.outputs.enabled == 'true' && steps.compiler-restore.outputs.cache-hit != 'true' && steps.compiler-start.outputs.save_enabled == 'true' && (github.event_name == 'pull_request' || ((github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.ref == 'refs/heads/main')) }} continue-on-error: true uses: actions/cache/save@v4 with: @@ -355,6 +352,9 @@ jobs: - uses: actions/setup-node@v4 with: node-version: 26 + - name: Select bounded build parallelism + id: build-parallelism + run: node .github/scripts/build-parallelism.mjs - name: Prepare vcpkg binary cache id: vcpkg-cache env: @@ -384,6 +384,7 @@ jobs: id: compiler-cache env: COMPILER_CACHE_IDENTITY: ${{ steps.vcpkg-cache.outputs.key }} + COMPILER_CACHE_PROFILE: database-${{ matrix.asio }} SCCACHE_SETUP_OUTCOME: ${{ steps.sccache-setup.outcome }} run: node .github/scripts/compiler-cache.mjs --prepare - name: Restore compiler cache @@ -400,6 +401,7 @@ jobs: if: steps.compiler-cache.outputs.enabled == 'true' env: COMPILER_CACHE_IDENTITY: ${{ steps.vcpkg-cache.outputs.key }} + COMPILER_CACHE_PROFILE: database-${{ matrix.asio }} SCCACHE_SETUP_OUTCOME: ${{ steps.sccache-setup.outcome }} SCCACHE_RESTORE_OUTCOME: ${{ steps.compiler-restore.outcome }} run: node .github/scripts/compiler-cache.mjs @@ -418,6 +420,9 @@ jobs: -DCMAKE_BUILD_TYPE=Debug "-DCMAKE_C_COMPILER_LAUNCHER=$env:ARK_COMPILER_LAUNCHER" "-DCMAKE_CXX_COMPILER_LAUNCHER=$env:ARK_COMPILER_LAUNCHER" + "-DCMAKE_JOB_POOLS=$env:ARK_CMAKE_JOB_POOLS" + -DCMAKE_JOB_POOL_COMPILE=ark_compile + -DCMAKE_JOB_POOL_LINK=ark_link -DARK_BUILD_APPLICATIONS=OFF -DARK_BUILD_PROTO=OFF -DARK_BUILD_TESTS=OFF @@ -436,7 +441,7 @@ jobs: 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 + run: cmake --build ci-database --parallel ${{ steps.build-parallelism.outputs.build_jobs }} - name: Run local database lifetime regressions run: cmake --build ci-database --target check-database - name: Run local database benchmark self-tests @@ -450,7 +455,7 @@ jobs: COMPILER_ARCHIVE_HIT: ${{ steps.compiler-restore.outputs.cache-hit }} run: node .github/scripts/cache-stats.mjs - name: Save compiler cache - if: ${{ !cancelled() && steps.compiler-start.outcome == 'success' && steps.compiler-start.outputs.enabled == 'true' && steps.compiler-restore.outputs.cache-hit != 'true' }} + if: ${{ !cancelled() && steps.compiler-start.outcome == 'success' && steps.compiler-start.outputs.enabled == 'true' && steps.compiler-restore.outputs.cache-hit != 'true' && steps.compiler-start.outputs.save_enabled == 'true' && (github.event_name == 'pull_request' || ((github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.ref == 'refs/heads/main')) }} continue-on-error: true uses: actions/cache/save@v4 with: diff --git a/.github/workflows/compiler-cache-seed.yml b/.github/workflows/compiler-cache-seed.yml new file mode 100644 index 000000000..fe6bf66b1 --- /dev/null +++ b/.github/workflows/compiler-cache-seed.yml @@ -0,0 +1,193 @@ +name: Compiler cache seed +run-name: Compiler cache seed - ${{ github.event_name }} - ${{ github.ref_name }} + +on: + push: + branches: [main] + paths: + - 'src/**' + - 'examples/**' + - 'tests/**' + - 'benchmarks/**' + - 'cmake/**' + - 'ports/**' + - 'vcpkg*.json' + - CMakeLists.txt + - .github/workflows/ci.yml + - .github/workflows/compiler-cache-seed.yml + - .github/scripts/compiler-cache.mjs + - .github/scripts/application-build-plan.mjs + - .github/scripts/ci-applications.mjs + - .github/scripts/build-parallelism.mjs + workflow_dispatch: + inputs: + verify_reuse: + description: Clean and rebuild once to measure compiler-cache reuse + type: boolean + default: false + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-main + cancel-in-progress: false + +jobs: + applications: + name: Seed ${{ matrix.os }} application compiler cache + if: ${{ github.ref == 'refs/heads/main' }} + runs-on: ${{ matrix.os }} + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + cache_mib: 1024 + - os: macos-latest + cache_mib: 512 + env: + COMPILER_CACHE_WRITE: 'true' + COMPILER_CACHE_PROFILE: applications + COMPILER_CACHE_MAX_MIB: ${{ matrix.cache_mib }} + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + fetch-depth: 0 + - uses: actions/setup-node@v4 + with: + node-version: 26 + - name: Select current main inputs + id: current + env: + CACHE_VERIFY_REUSE: ${{ inputs.verify_reuse == true && 'true' || 'false' }} + run: node .github/scripts/compiler-seed-plan.mjs + - name: Select bounded build parallelism + id: build-parallelism + if: steps.current.outputs.build == 'true' + run: node .github/scripts/build-parallelism.mjs + - name: Prepare vcpkg binary cache + id: vcpkg-cache + if: steps.current.outputs.build == 'true' + env: + VCPKG_CACHE_REVISION: 0699a19d0c6386247ce50d4dedbb8217d484d536 + VCPKG_CACHE_FEATURES: 'application;boost;compression-tests;etcd-client;guest-room;config-tool' + VCPKG_CACHE_DEFAULT_FEATURES: 'true' + VCPKG_CACHE_PROFILE: applications + run: node .github/scripts/vcpkg-cache.mjs + - name: Restore vcpkg package archives + id: vcpkg-restore + if: steps.current.outputs.build == 'true' && steps.vcpkg-cache.outputs.enabled == 'true' + continue-on-error: true + uses: actions/cache/restore@v4 + with: + path: ${{ steps.vcpkg-cache.outputs.directory }} + key: ${{ steps.vcpkg-cache.outputs.key }} + restore-keys: ${{ steps.vcpkg-cache.outputs.restore-key }} + - name: Install compiler cache + id: sccache-setup + if: steps.current.outputs.build == 'true' + uses: Mozilla-Actions/sccache-action@v0.0.11 + with: + version: v0.16.0 + disable_annotations: true + - name: Prepare compiler launcher + id: compiler-cache + if: steps.current.outputs.build == 'true' + env: + COMPILER_CACHE_IDENTITY: ${{ steps.vcpkg-cache.outputs.key }} + SCCACHE_SETUP_OUTCOME: ${{ steps.sccache-setup.outcome }} + run: node .github/scripts/compiler-cache.mjs --prepare + - name: Restore compiler cache + id: compiler-restore + if: steps.current.outputs.build == 'true' && steps.compiler-cache.outputs.enabled == 'true' + continue-on-error: true + uses: actions/cache/restore@v4 + with: + path: ${{ steps.compiler-cache.outputs.directory }} + key: ${{ steps.compiler-cache.outputs.archive_key }} + restore-keys: ${{ steps.compiler-cache.outputs.restore_keys }} + - name: Start compiler cache after archive restore + id: compiler-start + if: steps.current.outputs.build == 'true' && steps.compiler-cache.outputs.enabled == 'true' + env: + COMPILER_CACHE_IDENTITY: ${{ steps.vcpkg-cache.outputs.key }} + SCCACHE_SETUP_OUTCOME: ${{ steps.sccache-setup.outcome }} + SCCACHE_RESTORE_OUTCOME: ${{ steps.compiler-restore.outcome }} + run: node .github/scripts/compiler-cache.mjs + - name: Require trusted compiler cache inputs + if: steps.current.outputs.build == 'true' + env: + CACHE_ENABLED: ${{ steps.compiler-start.outputs.enabled }} + CACHE_WRITE_ENABLED: ${{ steps.compiler-start.outputs.save_enabled }} + run: | + test "$CACHE_ENABLED" = true + test "$CACHE_WRITE_ENABLED" = true + - uses: lukka/get-cmake@latest + if: steps.current.outputs.build == 'true' + - uses: lukka/run-vcpkg@v11 + if: steps.current.outputs.build == 'true' + with: + vcpkgGitCommitId: 0699a19d0c6386247ce50d4dedbb8217d484d536 + vcpkgJsonGlob: vcpkg.json + - name: Configure applications + if: steps.current.outputs.build == 'true' + run: node .github/scripts/ci-applications.mjs configure + - name: Prepare controlled cold-cache measurement + if: steps.current.outputs.build == 'true' && inputs.verify_reuse == true + shell: pwsh + run: | + & $env:SCCACHE_PATH --stop-server + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + Remove-Item -LiteralPath $env:SCCACHE_DIR -Recurse -Force + New-Item -ItemType Directory -Path $env:SCCACHE_DIR | Out-Null + & $env:SCCACHE_PATH --start-server + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + & $env:SCCACHE_PATH --zero-stats + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + Write-Output 'Controlled cold build: restored local compiler objects were cleared; remote cache is unchanged.' + - name: Compile accepted main inputs + if: steps.current.outputs.build == 'true' && (steps.compiler-restore.outputs.cache-hit != 'true' || inputs.verify_reuse == true) + run: node .github/scripts/ci-applications.mjs build + - name: Report first build cache statistics + if: ${{ !cancelled() && steps.current.outputs.build == 'true' }} + continue-on-error: true + env: + COMPILER_ARCHIVE_HIT: ${{ steps.compiler-restore.outputs.cache-hit }} + VCPKG_ARCHIVE_HIT: ${{ steps.vcpkg-restore.outputs.cache-hit }} + ARK_CACHE_SAMPLE: ${{ inputs.verify_reuse == true && 'controlled-cold' || 'seed-or-restored-snapshot' }} + ARK_CACHE_BUILD_EXECUTED: ${{ (steps.compiler-restore.outputs.cache-hit != 'true' || inputs.verify_reuse == true) && 'true' || 'false' }} + ARK_CACHE_ARCHIVE_KEY: ${{ steps.compiler-cache.outputs.archive_key }} + run: node .github/scripts/cache-stats.mjs + - name: Measure reuse after cleaning project outputs + if: steps.current.outputs.build == 'true' && inputs.verify_reuse == true + shell: pwsh + env: + COMPILER_ARCHIVE_HIT: ${{ steps.compiler-restore.outputs.cache-hit }} + ARK_CACHE_SAMPLE: controlled-warm + ARK_CACHE_BUILD_EXECUTED: 'true' + ARK_CACHE_ARCHIVE_KEY: ${{ steps.compiler-cache.outputs.archive_key }} + run: | + cmake --build ci-build --target clean + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + & $env:SCCACHE_PATH --zero-stats + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + node .github/scripts/ci-applications.mjs build + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + node .github/scripts/cache-stats.mjs + - name: Save compiler cache + if: ${{ success() && github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && steps.compiler-start.outputs.enabled == 'true' && steps.compiler-start.outputs.save_enabled == 'true' && steps.compiler-restore.outputs.cache-hit != 'true' }} + uses: actions/cache/save@v4 + with: + path: ${{ steps.compiler-cache.outputs.directory }} + key: ${{ steps.compiler-cache.outputs.archive_key }} + - name: Verify trusted main cache availability + if: steps.current.outputs.build == 'true' && steps.compiler-start.outputs.enabled == 'true' && steps.compiler-start.outputs.save_enabled == 'true' + uses: actions/cache/restore@v4 + with: + path: ${{ steps.compiler-cache.outputs.directory }} + key: ${{ steps.compiler-cache.outputs.archive_key }} + lookup-only: true + fail-on-cache-miss: true diff --git a/AGENTS.md b/AGENTS.md index e469cb005..070dfbcdd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -100,6 +100,16 @@ branch or worktree when the existing checkout contains uncommitted changes. flags, matrix coverage and failure propagation; use an empty launcher when cache setup is unavailable. Windows Debug remains uncached without changing `/Zi`. Record hosted cold/warm cache evidence before claiming a speed improvement. +- Source application plans may compile reviewed check-target dependency closures; + failed/missing scope, public interfaces, unknown inputs and queue/manual runs + retain complete application coverage. Never infer acceptance from cache warmup. +- Select CI compile/link parallelism from actual CPU and memory with conservative + limits; preserve compiler flags and use separate Ninja pools. Overrides only + lower these limits. Measure hosted time and memory before claiming improvement. +- Compiler snapshots use compatible families and bounded weekly epochs. Application + seeds run only on trusted main; PR/queue jobs cannot publish trusted snapshots. + Cache-only jobs may skip superseded main inputs without cancelling acceptance. + Do not increase paid cache quotas or delete remote caches as an implicit tuning step. - CI policy tests: `node --test tests/ci/*.test.mjs`. Nimbus documentation: `npm ci --prefix docs/site` followed by `npm run check --prefix docs/site`. - Keep official plugins in the ark repository and application business plugins diff --git a/docs/project/contributing.md b/docs/project/contributing.md index 2e9791c8d..7525dc7ee 100644 --- a/docs/project/contributing.md +++ b/docs/project/contributing.md @@ -17,159 +17,50 @@ The site provides language switching, search, code highlighting and plain Markdo ## 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. - -The Applications job has a 60-minute aggregate budget for dependency setup, -compilation and regressions. Cold Windows dependency configuration alone can -take about 26 minutes. Individual test timeouts and all required checks remain -unchanged; a cancelled job does not establish acceptance and needs a fresh -successful merge candidate. +| Change | Source PR | Merge queue / manual | +| --- | --- | --- | +| Recognized documentation only | Nimbus and relevant README links | Same documentation checks | +| Reviewed application inputs | Related check targets and their CMake dependencies | Complete platform matrix and application build | +| Public interfaces, build, dependencies, CI or unknown inputs | Full affected application coverage; uncertain scope keeps all modules | Complete platform matrix | + +All PRs require `CI - required`. Missing or failed classification falls back to full work. Target selection never reduces queue/manual coverage, archived integration construction checks, sanitizer coverage or test timeouts. Draft previews do not establish acceptance. + +## CI build resources + +Compile jobs are selected from detected CPU and memory, capped at four. A 7–8 GiB runner keeps two compile jobs and one link; a 16 GiB runner uses four compile jobs and one link. Two concurrent links require at least 19 GiB. These are scheduling estimates, not a guarantee about a translation unit's peak memory. + +Ninja uses separate compile/link pools. `ARK_BUILD_JOBS_OVERRIDE` and `ARK_LINK_JOBS_OVERRIDE` may only lower the computed limits. Missing resource data falls back conservatively. ## CI dependency archive cache -CI restores vcpkg package archives from the runner temporary directory with -`actions/cache`. A versioned v2 key contains a compatibility prefix and an exact -runner-image suffix. The prefix includes the runner OS and architecture, -`ImageOS`, pinned vcpkg revision, manifest and configuration bytes (including the -baseline), local port overlay, selected/default features, build profile, compiler -environment flags and target/host triplet overrides. Only `ImageVersion` is moved -to the suffix. Within each ref searched by Actions, lookup uses the exact key -first, then that single prefix; there is no OS-only or cross-profile fallback. -Both image fields remain required. - -An archive restore only makes candidates available to the vcpkg `files` provider. -It does not install an existing dependency tree. vcpkg computes each requested -package's full ABI hash, including compiler tracking, and retrieves only matching -archives; mismatched packages build from source. The workflow does not disable -compiler tracking, override ABI hashes or rename package archives. See the -[vcpkg ABI mismatch guide](https://learn.microsoft.com/en-us/vcpkg/users/binarycaching-troubleshooting#cause-3-differences-between-local-and-remote-build-environments). -A rolling image can therefore share an archive cache without treating its -packages as compatible in advance. - -After successful configuration, a miss or prefix restore saves only the exact -key. There is no second save under the compatibility prefix. Image-specific -snapshots can still overlap and use cache capacity. The v1 keys cannot be safely -searched by this prefix and are deliberately excluded: v2 starts cold until an -accessible run has saved a v2 cache for the same dependency configuration. -Actions cache branch/ref visibility still applies; see the [Actions cache reference](https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching#cache-key-matching). -This change does not make caches from another merge-queue ref readable. A compatible v2 archive on the default branch is needed to seed queue -runs that cannot access each other's entries. - -`Dependency cache seed` (`.github/workflows/vcpkg-seed.yml`) creates that baseline -for Windows Applications on `main`. It runs after main pushes change `vcpkg.json`, -`vcpkg-configuration.json`, `ports/**`, `.gitattributes`, the cache-key helper or -the seed workflow itself. Its first deployment also starts the seed job. -For a new runner image or a missing baseline, explicitly run -`gh workflow run vcpkg-seed.yml --ref main`; manual runs on other refs skip the job. -There is no schedule. Policy tests require the seed's cache identity, tool versions -and complete Windows configuration to match the required Applications job; -changes to those inputs must update both workflows. - -The seed runs CMake configuration and dependency installation, then saves archives -in main's cache scope. It does not build ark targets, run the native regression -matrix or enable Windows compiler caching, and it does not replace required CI. -The seed fails if configuration fails, the files provider/key is unavailable, -package archives are empty, or a required save fails to produce a verifiable exact -cache entry. An existing exact hit skips saving. After saving, a metadata-only -exact-key lookup and an explicit hit check also reject warning-only upload failures. -Restore and diagnostics remain best effort; required CI's caches remain optional. -Seed success confirms current archive availability, not reuse by a later candidate. -Confirm that candidate's archive restore and matching vcpkg package reuse before -claiming a warm build or timing improvement. Image/ABI changes and cache eviction -can still cause cold builds. - -In required CI, missing identity, unsupported dependency layout or archive paths keep persistence -disabled with `VCPKG_BINARY_SOURCES=clear`. Cache-service failures retain ordinary -source configuration, compilation and regressions. The cache covers archives only, -not installed trees. Project object caching is configured separately below. Local Node tests validate the -lookup boundaries, exports and workflow wiring; hosted cache reuse and any timing -improvement require separate CI evidence. +vcpkg package archives and compiler objects use separate caches. Dependency keys retain OS/architecture, dependency manifests and overlays, features, profiles and compiler environment. Image-version fallback still requires each package's actual vcpkg ABI hash; compiler tracking is never disabled. +The existing **Dependency cache seed** workflow prepares Windows application dependencies on trusted `main`. It configures dependencies without compiling ark or repeating native tests. Seed availability is verified with an exact cache lookup. Required CI treats cache failures as optional and builds normally. ### Windows ABI diagnostics -After dependency configuration and the existing archive-save step, Windows -Applications logs a bounded JSON report for Asio, spdlog and Protobuf. The -helper reads only `ci-build/vcpkg_installed/x64-windows/share//vcpkg_abi_info.txt`, -without searching directories or invoking build tools. It rejects symbolic links, -non-regular files, malformed records, unexpected triplets and files over 64 KiB. -Absent or rejected metadata is explicitly `unavailable`; missing optional fields -also remain `unavailable` rather than being treated as equal. - -`packageAbi` is the SHA256 of the exact ABI metadata bytes. Field values are -reported only as SHA256 fingerprints under fixed names: `triplet`, `triplet_abi`, -`features`, `cmake`, `powershell`, `compiler_hash`, `portfile.cmake`, `ports.cmake`, -`vcpkg.json`, `post_build_checks`, `sbom_info`, `vcpkg-cmake`, `vcpkg-cmake-config`, -`abseil` and `utf8-range`. Unknown field names and all raw values are omitted; -no full environment, absolute paths or credentials are logged. Metadata files -and artifacts are not uploaded. -A separate `compiler_hash` record may be absent; the report does not infer one -from a compiler version string or split the opaque `triplet_abi` value. - -The diagnostic also runs after an earlier step fails, unless the job is cancelled. -Its availability status describes metadata only. It cannot turn failed -configuration, compilation or tests into success, and diagnostic errors do not -become a new native acceptance requirement. Cache keys/providers, compiler -tracking and build settings remain unchanged. Compare the fingerprints across -runs to narrow an ABI difference; equal reported fields do not establish equal -unreported inputs. This report is neither a cache-hit verdict nor a performance -measurement. - +Diagnostics report available vcpkg ABI metadata after configuration, including failed configurations. They do not infer missing hashes, change dependency selection or convert a failed build into a pass. ## CI compiler object cache -Linux and macOS native jobs optionally use `Mozilla-Actions/sccache-action@v0.0.11` -with sccache `v0.16.0`. Preparation verifies the exact executable version before -exporting both C and C++ CMake compiler launchers. The compiler remains selected -by CMake; all platforms, dependency profiles, sanitizer flags, build targets, -parallelism, regressions and timeouts retain their existing settings. Windows -Debug jobs compile directly: their existing `/Zi` shared PDB configuration is not -changed to force compiler-cache hits. - -The compiler archive has its own namespace, separate from the vcpkg archive. -Its compatibility prefix uses the complete existing dependency key, including -its exact image version, manifest/overlay fingerprint, compiler environment and -module/sanitizer/dependency profile. The source commit is only an immutable -snapshot suffix. There is no run ID or source commit in the compatibility prefix -or `SCCACHE_C_CUSTOM_CACHE_BUSTER`, so another commit with the same identity can -restore objects. There is no cross-image or cross-profile compiler restore. -sccache still validates compiler identity, compile arguments and source/header -inputs for every invocation; an archive restore alone does not establish an -object hit or bypass compilation validation. - -Each job sets `SCCACHE_CACHE_SIZE=512M` under the runner temporary directory. -The full matrix has nine eligible Linux/macOS jobs, giving nine such local cache -budgets per source snapshot; Windows adds no compiler cache. Actions stores -immutable snapshots, which may overlap across commits and refs, so this is not a -repository-wide storage cap. Compression, metadata, dependency archives and cache -service retention are separate. Actions branch/ref visibility still applies; -queue refs cannot read each other's entries merely because their keys match. - -Installation, version validation, cache restoration or server startup failures -leave an empty launcher and normal compilation. Cache service I/O failures use -`SCCACHE_IGNORE_SERVER_IO_ERROR=1`. Compiler and test failures still fail the job; -the workflow never retries a compiler failure without the launcher. After successful -launcher startup, non-cancelled jobs save new compiler snapshots on an exact-key -miss even if compilation or a regression fails. A best-effort job summary reports -dependency archive availability, compiler archive exact hits and sccache statistics. -Prefix restoration and object hits are different observations. - -Run `node --test tests/ci/*.test.mjs` for the cache identity/fallback and workflow -contracts. The permanent CMake/Ninja fixture compiles C and C++ through a launcher -path containing spaces and checks that intentional compiler errors propagate -with and without a launcher. It uses a transparent fixture launcher, not sccache, -and does not prove hosted cache hits. Hosted acceptance must compare a cold job -and a subsequent job with the same identity, record the source revisions, -archive lookup, sccache hits/misses and setup/configure/build/test durations, -and retain all required regressions. No build-time improvement is claimed before -that evidence is available. Native CI also needs separate Linux/macOS acceptance; -Windows remains uncached. +| Setting | Behavior | +| --- | --- | +| Linux/macOS | Pinned sccache 0.16.0; CMake retains the actual compiler | +| Windows Debug | Direct compilation; existing `/Zi` settings remain | +| Compatibility | Dependency/profile family retained; sccache validates actual compiler, flags, sources and headers | +| Snapshots | One immutable snapshot per family, trust scope and ISO week | +| Application snapshots | Written only by trusted `main`; PRs and queue restore them | +| Other source-PR profiles | May keep a bounded weekly snapshot within their own PR ref | +| Queue snapshots | Restore only; no duplicate uploads per candidate | + +The **Compiler cache seed** workflow builds accepted Linux/macOS application inputs on `main`, without repeating the regression matrix. Superseded push inputs may skip this cache-only work; active acceptance runs are preserved. Linux application cache capacity is 1 GiB, macOS 512 MiB. Capacity is configurable from 64 to 1024 MiB; it is a local job budget, not a repository storage limit. This change does not increase paid cache storage or remove existing remote caches. + +An exact weekly hit may reuse an earlier accepted snapshot without rebuilding it. Ordinary CI still compiles and validates its actual revision; new inputs can miss until the next snapshot. An archive hit is distinct from an object hit. [GitHub's cache access rules](https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching) still apply. + +## Verify CI changes + + node --test tests/ci/*.test.mjs + +The tests cover scope fallbacks, workflow boundaries, compiler errors and real CMake/Ninja dependency and resource controls. Native and hosted acceptance remain separate. + +To measure reuse, dispatch **Compiler cache seed** on `main` with `verify_reuse=true`. It explicitly clears only the job's local compiler objects, records a controlled cold build, cleans project outputs and records a warm rebuild with the same source and settings. Remote caches stay intact. Summaries identify source, archive lookup, build execution, sample type and object statistics. Compare setup, compilation and regression durations before claiming a speed improvement. diff --git a/docs/project/contributing_CN.md b/docs/project/contributing_CN.md index 3fde545d0..48da0d3c3 100644 --- a/docs/project/contributing_CN.md +++ b/docs/project/contributing_CN.md @@ -17,114 +17,50 @@ npm run check --prefix docs/site ## CI 文档范围 -根目录的精确文件 `AGENTS.md` 归为文档。改动范围已知且非空、仅包含已识别的文档, -并且 Nimbus 文档站可用时,源分支 PR 与合并候选均要求文档检查和 `CI - required`, -原生任务明确跳过。Nimbus 检查文档站;只有根 README 变化时才检查其链接。 +| 改动 | 源分支 PR | 合并队列/手动验收 | +| --- | --- | --- | +| 已识别的纯文档 | Nimbus 与相关 README 链接检查 | 同样的文档检查 | +| 已登记的应用输入 | 相关检查目标及 CMake 依赖 | 完整跨平台矩阵与应用构建 | +| 公开接口、构建、依赖、CI 或未知输入 | 完整应用检查;范围不确定时保留所有模块 | 完整跨平台矩阵 | -嵌套的未知 `AGENTS.md` 路径、名称相近的代码、混合源码变更、范围不可用或文档站不可用时, -仍保留原生检查。手动运行保留完整原生矩阵。CI 策略本身的变更仍须完成原生验收, -这项文档例外不会改变该要求。 +所有 PR 均要求 `CI - required`。范围分类缺失或失败时回退完整检查。按目标构建不减少队列/手动验收、归档集成的构造检查、运行时检测或测试超时约束。草稿预览不代表通过验收。 -Applications 任务的依赖准备、编译与回归共用 60 分钟总预算。Windows 未命中缓存时, -仅依赖配置就可能耗时约 26 分钟。各测试自身的限时和全部必需检查保持不变; -取消的任务不算通过,需要重新完成一个成功的合并候选检查。 +## CI 构建资源 + +根据实际 CPU 和内存选择编译并行度,最多四路。7–8 GiB 执行机保持两路编译、一路链接;16 GiB 使用四路编译、一路链接;至少 19 GiB 才允许两路链接。这是调度估算,不保证单个编译单元的内存峰值。 + +Ninja 分别限制编译与链接任务。`ARK_BUILD_JOBS_OVERRIDE`、`ARK_LINK_JOBS_OVERRIDE` 只能降低计算出的上限。资源信息缺失时采用保守值。 ## CI 依赖包归档缓存 -CI 使用 `actions/cache` 恢复执行机临时目录中的 vcpkg 包归档。 -带版本号的 v2 key 由兼容前缀和精确执行机镜像后缀组成。 -前缀包含执行机操作系统与架构、`ImageOS`、固定的 vcpkg 版本、manifest 和配置文件内容 -(含 baseline)、本地 port overlay、选中和默认 feature、构建 profile、编译器环境参数, -以及 target/host triplet 覆盖项。只有 `ImageVersion` 移到后缀。 -在 Actions 搜索的每个引用内,先查找精确 key,再使用这一个前缀,不提供仅按操作系统或跨 profile 的回退。 -两个镜像字段仍为必填项。 - -恢复归档只为 vcpkg 的 `files` provider 提供候选包,不直接恢复依赖安装目录。 -vcpkg 为每个请求的包计算完整 ABI hash,其中保留编译器跟踪;只有 hash 匹配时才取用归档, -不匹配的包仍从源码构建。工作流不会关闭编译器跟踪、覆盖 ABI hash 或重命名包归档。 -具体规则见 [vcpkg ABI 不匹配说明](https://learn.microsoft.com/en-us/vcpkg/users/binarycaching-troubleshooting#cause-3-differences-between-local-and-remote-build-environments)。 -因此,滚动更新的镜像可以共享归档缓存,但恢复成功不代表其中每个包都兼容。 - -配置成功后,未命中或通过前缀恢复的运行只按精确 key 保存一次,不另外按兼容前缀保存第二份。 -各镜像的快照仍可能包含重复归档并占用缓存空间。v1 key 无法通过这个前缀安全查找,因此不参与回退; -可访问的运行尚未保存相同依赖配置的 v2 缓存时,v2 仍需冷构建。 -Actions 缓存的分支/引用可见性规则继续生效,详见 [Actions 缓存规则](https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching#cache-key-matching)。 -本次改动不能让另一个合并队列引用的缓存变得可读。 -相互无法读取缓存的队列运行需要默认分支中兼容的 v2 归档作为初始来源。 - -`Dependency cache seed`(`.github/workflows/vcpkg-seed.yml`)为 `main` 建立 -Windows Applications 基线。仅在 main 推送修改 `vcpkg.json`、`vcpkg-configuration.json`、 -`ports/**`、`.gitattributes`、缓存 key 脚本或种子工作流自身时自动执行;首次部署也会触发。 -遇到新执行机镜像或基线缺失时,可显式运行 `gh workflow run vcpkg-seed.yml --ref main`; -其他引用上的手动运行会跳过任务,不设置定时执行。 -策略测试要求种子与必需 Applications 任务的缓存身份、工具版本和完整 Windows 配置一致, -因此这些输入变化时必须同步两个工作流。 - -种子只执行 CMake 配置和依赖安装,随后将归档保存到 main 的缓存作用域; -不构建 ark 目标、不重复原生回归矩阵、不启用 Windows 编译缓存,也不替代必需 CI。 -配置失败、files provider/key 不可用、包归档为空,或应执行的保存未产生可验证的精确缓存条目时, -种子任务均明确失败。已命中精确 key 时跳过保存;保存后只查询精确条目的元数据并显式检查命中, -避免上传失败仅报告 warning 却使任务成功。恢复与诊断仍尽力执行,必需 CI 中的缓存继续可选。 -种子成功只确认当前归档可用;还须确认后续候选恢复归档并由 vcpkg 复用匹配包, -才能声明热构建或耗时改善。镜像/ABI 变化和缓存驱逐仍可能导致冷构建。 - -必需 CI 中,身份信息缺失、依赖布局或归档路径不受支持时,继续关闭持久缓存并导出 `VCPKG_BINARY_SOURCES=clear`。 -缓存服务失败不影响正常的源码配置、编译和回归检查。依赖缓存仅覆盖包归档,不包含依赖安装目录。项目目标文件缓存单独配置,见下文。 -本地 Node 测试验证查找边界、输出与工作流连接;托管环境中的缓存复用和耗时变化仍需独立 CI 证据。 +vcpkg 包归档与项目编译对象分开缓存。依赖 key 保留系统/架构、manifest 与 overlay、feature、profile 和编译器环境。跨镜像小版本恢复后,仍由 vcpkg 的真实 ABI hash 决定能否使用包,不关闭编译器跟踪。 +现有 **Dependency cache seed** 工作流在受信任的 `main` 准备 Windows 应用依赖,只配置依赖,不编译 ark 或重复原生回归。种子必须通过精确缓存查找验证。必需 CI 中缓存始终可选,缓存故障时正常编译。 ### Windows ABI 诊断 -依赖配置及原有的归档保存步骤之后,Windows Applications 为 Asio、spdlog 和 Protobuf -记录有界的 JSON 诊断。脚本只读取 -`ci-build/vcpkg_installed/x64-windows/share//vcpkg_abi_info.txt`, -不搜索目录,也不调用构建工具。符号链接、非普通文件、格式错误、triplet 不符或超过 64 KiB 的文件均被拒绝。 -元数据缺失或被拒绝时明确报告 `unavailable`;可选字段缺失时同样报告 `unavailable`,不将缺失视为相等。 +诊断输出配置阶段可用的 vcpkg ABI 元数据,包括配置失败的情况。不推断缺失 hash、不改变依赖选择,也不让失败构建变为成功。 -`packageAbi` 是 ABI 元数据原始字节的 SHA256。字段值仅以 SHA256 指纹输出,字段名固定为: -`triplet`、`triplet_abi`、`features`、`cmake`、`powershell`、`compiler_hash`、`portfile.cmake`、 -`ports.cmake`、`vcpkg.json`、`post_build_checks`、`sbom_info`、`vcpkg-cmake`、 -`vcpkg-cmake-config`、`abseil` 和 `utf8-range`。 -未知字段名和所有原始值均不输出,不记录完整环境、绝对路径或凭据,也不上传元数据文件或 artifact。 -独立的 `compiler_hash` 字段可能不存在;诊断不会根据编译器版本字符串推断其值,也不拆解不透明的 `triplet_abi` 值。 +## CI 编译目标文件缓存 -此前步骤失败后,诊断仍会执行,任务取消时除外。状态只表示元数据是否可用,不能让配置、编译或测试的失败变为成功。 -诊断错误也不会成为新增的原生验收条件。缓存 key/provider、编译器跟踪和构建设置均保持不变。 -对比不同运行的指纹可以缩小 ABI 差异范围;已输出字段相同不能证明未输出的输入相同。 -诊断不判定缓存命中,也不构成性能测量。 +| 配置 | 行为 | +| --- | --- | +| Linux/macOS | 固定 sccache 0.16.0;实际编译器仍由 CMake 选择 | +| Windows Debug | 直接编译,保留原有 `/Zi` 配置 | +| 兼容性 | 保留依赖/profile 族;sccache 校验实际编译器、参数、源码和头文件 | +| 快照 | 每个兼容族、信任范围与 ISO 周保留一份不可变快照 | +| 应用快照 | 只由受信任的 `main` 写入,PR 和队列恢复使用 | +| 其他源 PR profile | 可在自身 PR 引用中保留一份每周快照 | +| 队列快照 | 只恢复,不按候选重复上传 | +**Compiler cache seed** 工作流编译 `main` 上已接受的 Linux/macOS 应用输入,不重复回归矩阵。已被新 main 替代的推送可跳过这项缓存工作;活动验收继续保留。Linux 应用缓存为 1 GiB,macOS 为 512 MiB,可配置范围为 64–1024 MiB。这是单任务本地预算,不是仓库总额度;不增加付费缓存空间、不删除现有远端缓存。 -## CI 编译目标文件缓存 +精确命中每周快照时,可以使用此前已接受版本的对象而不重新生成快照。普通 CI 仍编译并验证实际版本;新增输入可能未命中,直到下一份快照。归档命中不等于目标文件命中。[GitHub 缓存访问规则](https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching)仍生效。 + +## 验证 CI 改动 + + node --test tests/ci/*.test.mjs + +测试覆盖范围回退、工作流边界、编译错误传播,以及真实 CMake/Ninja 的依赖和资源控制。原生验收与托管环境验收单独完成。 -Linux 和 macOS 原生任务可选使用 `Mozilla-Actions/sccache-action@v0.0.11`, -固定 sccache 版本为 `v0.16.0`。准备步骤先验证可执行文件的精确版本,再导出 C 和 C++ 的 -CMake 编译器 launcher。编译器仍由 CMake 选择;平台、依赖 profile、sanitizer 参数、构建目标、 -并行度、回归检查和超时设置保持不变。Windows Debug 任务直接编译;保留现有 `/Zi` 共享 PDB -配置,不为获得缓存命中而修改参数。 - -编译归档使用独立命名空间,与 vcpkg 包归档分开。兼容前缀采用现有依赖 key 的完整身份信息, -包括精确镜像版本、manifest/overlay 指纹、编译器环境,以及模块、sanitizer 和依赖 profile。 -源码 commit 只作为不可变快照的后缀。兼容前缀和 `SCCACHE_C_CUSTOM_CACHE_BUSTER` 不包含运行 ID -或源码 commit,因此相同身份的后续 commit 可以恢复目标文件。编译缓存不跨镜像或 profile 回退。 -sccache 仍逐次验证编译器身份、编译参数及源码和头文件输入;恢复归档不代表目标文件命中, -也不会绕过编译输入校验。 - -每个任务在执行机临时目录中设置 `SCCACHE_CACHE_SIZE=512M`。完整矩阵有九个可用的 Linux/macOS -任务,每个源码快照对应九份这样的本地缓存预算;Windows 不增加编译缓存。 -Actions 保存不可变快照,不同 commit 和引用的快照可能重复占用空间,因此这不是仓库总存储上限。 -压缩、元数据、依赖包归档和缓存服务保留策略需要单独计算。Actions 的分支/引用可见性规则仍然适用; -key 相同不能让不同合并队列引用相互读取缓存。 - -安装、版本验证、缓存恢复或服务启动失败时,launcher 为空并正常编译。 -缓存服务 I/O 失败通过 `SCCACHE_IGNORE_SERVER_IO_ERROR=1` 回退。 -编译或测试失败仍使任务失败;工作流不会在编译器报错后移除 launcher 自动重试。 -launcher 成功启动且精确 key 未命中时,未取消的任务会保存新的编译快照,包括编译或回归失败的任务。 -尽力生成的任务摘要报告依赖归档状态、编译归档精确命中状态,以及 sccache 统计。 -前缀恢复与目标文件命中是两类不同证据。 - -运行 `node --test tests/ci/*.test.mjs` 验证缓存身份、回退行为和工作流契约。 -永久保留的 CMake/Ninja fixture 使用含空格路径的 launcher 编译 C 和 C++,并验证启用和关闭 -launcher 时,故意引入的编译错误均会传播。fixture 使用透明转发脚本,不使用 sccache, -不能证明托管环境命中。托管验收需要对比冷任务与随后身份相同的任务,记录源码版本、归档查找结果、 -sccache 命中/未命中,以及准备、配置、构建和测试耗时,并保留全部必要回归。 -这些证据齐备之前不宣称构建提速。Linux/macOS 原生 CI 仍需分别验收;Windows 继续直接编译。 +测量复用时,在 `main` 手动运行 **Compiler cache seed**,设置 `verify_reuse=true`。工作流仅清空该任务的本地编译对象,记录受控冷构建,再清理项目输出,以相同源码和设置记录热构建;远端缓存不变。摘要标注源码、归档查找、是否执行构建、样本类型和目标文件统计。比较依赖准备、编译与回归耗时后,才能声明提速。 diff --git a/tests/ci/cache-stats.test.mjs b/tests/ci/cache-stats.test.mjs index b63ce7f7c..ced8b43a1 100644 --- a/tests/ci/cache-stats.test.mjs +++ b/tests/ci/cache-stats.test.mjs @@ -5,6 +5,18 @@ import { join } from 'node:path'; import test from 'node:test'; import { reportCacheStats } from '../../.github/scripts/cache-stats.mjs'; +test('seed diagnostics distinguish a restored snapshot from whether this revision was built', () => { + const env = { COMPILER_ARCHIVE_HIT: 'true', ARK_CACHE_SAMPLE: 'seed-or-restored-snapshot', + ARK_CACHE_BUILD_EXECUTED: 'false', GITHUB_SHA: 'a'.repeat(40), + ARK_CACHE_ARCHIVE_KEY: 'ark-sccache-0.16.0-v2-Linux-trusted-2026-W45' }; + const report = reportCacheStats({ env, log: () => {} }); + assert.match(report, /Compiler archive cache exact hit: true/); + assert.match(report, /Cache measurement sample: seed-or-restored-snapshot/); + assert.match(report, /Project build executed: false/); + assert.ok(report.includes(`Source revision: ${env.GITHUB_SHA}`)); + assert.ok(report.includes(`Requested archive key: ${env.ARK_CACHE_ARCHIVE_KEY}`)); +}); + test('cache diagnostics distinguish archive restoration, configured budget, policy and actual object statistics', t => { const root = mkdtempSync(join(tmpdir(), 'ark-cache-stats-')); t.after(() => rmSync(root, { recursive: true, force: true })); diff --git a/tests/ci/cache-workflow.test.mjs b/tests/ci/cache-workflow.test.mjs index 6d128d887..c4448686f 100644 --- a/tests/ci/cache-workflow.test.mjs +++ b/tests/ci/cache-workflow.test.mjs @@ -2,18 +2,21 @@ import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { runInNewContext } from 'node:vm'; import test from 'node:test'; +import { applicationCommands, runApplications } from '../../.github/scripts/ci-applications.mjs'; +import { selectScope } from '../../.github/scripts/ci-policy.mjs'; const workflow = readFileSync(new URL('../../.github/workflows/ci.yml', import.meta.url), 'utf8').replaceAll('\r\n', '\n'); +const compilerSeed = readFileSync(new URL('../../.github/workflows/compiler-cache-seed.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']], + ['applications', 'Configure applications', 'ci-build', ['check-core', 'check-entity-benchmark-smoke']], ['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); +function jobSteps(name, source = workflow) { + const jobs = source.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); @@ -28,20 +31,32 @@ function onlyStep(steps, predicate, description) { return found[0]; } +function sourceBuild(steps, name, buildDirectory) { + return onlyStep(steps, step => name === 'applications' + ? step.includes('run: node .github/scripts/ci-applications.mjs build\n') + : step.includes(`run: cmake --build ${buildDirectory} --parallel `), 'source build'); +} + +function conditionExpression(step) { + const raw = step.match(/^ if: (.+)$/m)?.[1]; + const expression = raw?.startsWith('${{') ? raw.match(/^\$\{\{ (.+) \}\}$/)?.[1] : raw; + assert.ok(expression, 'cache save must have an explicit condition'); + return expression.replace(/steps\.([a-z][a-z0-9_-]*)/g, (_, id) => `steps[${JSON.stringify(id)}]`) + .replace(/\.cache-hit\b/g, "['cache-hit']"); +} + // Evaluate the checked-in save guard, including its explicit status function; // otherwise Actions would add success() and skip this step after a failure. -function compilerSaveRuns(step, { previous = ['success'], outcome = 'success', enabled = 'true', cacheHit = 'false' } = {}) { - const expression = step.match(/^ if: \$\{\{ (.+) \}\}$/m)?.[1]; - assert.ok(expression, 'compiler save must have an explicit condition'); +function compilerSaveRuns(step, { previous = ['success'], outcome = 'success', enabled = 'true', cacheHit = 'false', + saveEnabled = 'true', eventName = 'pull_request', ref = 'refs/pull/1/merge' } = {}) { + const expression = conditionExpression(step); assert.match(expression, /\bcancelled\(\)/, 'save must override implicit success without running after cancellation'); - const javascript = expression - .replace(/steps\.([a-z][a-z0-9_-]*)/g, (_, id) => `steps[${JSON.stringify(id)}]`) - .replace(/\.cache-hit\b/g, "['cache-hit']"); - return runInNewContext(javascript, { + return runInNewContext(expression, { cancelled: () => previous.includes('cancelled'), success: () => !previous.includes('failure') && !previous.includes('cancelled'), + github: { event_name: eventName, ref }, steps: { - 'compiler-start': { outcome, outputs: { enabled } }, + 'compiler-start': { outcome, outputs: { enabled, save_enabled: saveEnabled } }, 'compiler-restore': { outputs: { 'cache-hit': cacheHit } }, }, }); @@ -52,7 +67,7 @@ for (const [name, configureName, buildDirectory, checks] of profiles) { const steps = jobSteps(name); const configure = onlyStep(steps, step => step.startsWith(` - name: ${configureName}\n`), 'configure step'); const save = onlyStep(steps, step => step.includes('name: Save vcpkg package archives\n'), 'archive save'); - const build = onlyStep(steps, step => step.includes(`run: cmake --build ${buildDirectory} --parallel `), 'source build'); + const build = sourceBuild(steps, name, buildDirectory); 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); @@ -86,10 +101,17 @@ for (const [name, configureName, buildDirectory, checks] of profiles) { 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); + const build = sourceBuild(steps, name, buildDirectory); + const regressions = name === 'applications' + ? [onlyStep(steps, step => step.includes('run: node .github/scripts/ci-applications.mjs check\n'), 'application checks')] + : checks.map(target => onlyStep(steps, step => step.includes(`run: cmake --build ${buildDirectory} --target ${target}\n`), target)); + const required = [configure, build, ...regressions]; + if (name === 'applications') { + assert.match(configure.step, /^ run: node \.github\/scripts\/ci-applications\.mjs configure$/m); + assert.deepEqual(applicationCommands('check', { GITHUB_EVENT_NAME: 'merge_group' }).map(args => args[3]), checks); + } else { + 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'); @@ -98,6 +120,25 @@ for (const [name, configureName, buildDirectory, checks] of profiles) { assert.ok(build.index < check.index, 'compile before running regressions'); } }); + + test(`${name} applies detected compile and link budgets before configuring Ninja`, () => { + const steps = jobSteps(name); + const resources = onlyStep(steps, step => step.includes('id: build-parallelism\n'), 'resource selection'); + const configure = onlyStep(steps, step => step.startsWith(` - name: ${configureName}\n`), 'configure step'); + assert.ok(resources.index < configure.index); + assert.match(resources.step, /^ run: node \.github\/scripts\/build-parallelism\.mjs$/m); + assert.doesNotMatch(resources.step, /^ (?:continue-on-error|if):/m); + if (name === 'applications') { + const [args] = applicationCommands('configure', { VCPKG_ROOT: '/vcpkg', ARK_CMAKE_JOB_POOLS: 'ark_compile=4;ark_link=1' }); + for (const option of ['-DCMAKE_JOB_POOLS=ark_compile=4;ark_link=1', + '-DCMAKE_JOB_POOL_COMPILE=ark_compile', '-DCMAKE_JOB_POOL_LINK=ark_link']) assert.ok(args.includes(option)); + } else { + assert.ok(configure.step.includes('"-DCMAKE_JOB_POOLS=$env:ARK_CMAKE_JOB_POOLS"')); + assert.match(configure.step, /-DCMAKE_JOB_POOL_COMPILE=ark_compile/); + assert.match(configure.step, /-DCMAKE_JOB_POOL_LINK=ark_link/); + assert.ok(sourceBuild(steps, name, buildDirectory).step.includes('--parallel ${{ steps.build-parallelism.outputs.build_jobs }}')); + } + }); } for (const [name, configureName] of profiles) { @@ -125,9 +166,14 @@ for (const [name, configureName] of profiles) { assert.match(restore.step, /key: \$\{\{ steps\.compiler-cache\.outputs\.archive_key \}\}/); assert.match(restore.step, /restore-keys: \$\{\{ steps\.compiler-cache\.outputs\.restore_keys \}\}/); for (const language of ['C', 'CXX']) { - assert.ok(configure.step.includes(`"-DCMAKE_${language}_COMPILER_LAUNCHER=$env:ARK_COMPILER_LAUNCHER"`)); + if (name === 'applications') { + const [args] = applicationCommands('configure', { VCPKG_ROOT: '/vcpkg', ARK_COMPILER_LAUNCHER: '/tool cache/sccache' }); + assert.ok(args.includes(`-DCMAKE_${language}_COMPILER_LAUNCHER=/tool cache/sccache`)); + } else { + assert.ok(configure.step.includes(`"-DCMAKE_${language}_COMPILER_LAUNCHER=$env:ARK_COMPILER_LAUNCHER"`)); + } } - assert.match(save.step, /if: \$\{\{ !cancelled\(\) && steps\.compiler-start\.outcome == 'success' && steps\.compiler-start\.outputs\.enabled == 'true' && steps\.compiler-restore\.outputs\.cache-hit != 'true' \}\}/); + assert.match(save.step, /steps\.compiler-start\.outputs\.save_enabled == 'true'/); assert.match(save.step, /path: \$\{\{ steps\.compiler-cache\.outputs\.directory \}\}/); assert.match(save.step, /key: \$\{\{ steps\.compiler-cache\.outputs\.archive_key \}\}/); assert.equal(save.index, steps.length - 1, 'save only after all source builds and checks'); @@ -152,6 +198,8 @@ for (const [name, configureName] of profiles) { { previous: ['failure', 'cancelled'] }, { enabled: 'false' }, { enabled: '' }, + { saveEnabled: 'false' }, + { saveEnabled: '' }, ...['failure', 'cancelled', 'skipped', ''].map(outcome => ({ outcome })), { cacheHit: 'true' }, { previous: ['success', 'failure'], cacheHit: 'true' }, @@ -160,4 +208,187 @@ for (const [name, configureName] of profiles) { } assert.equal(compilerSaveRuns(step, { cacheHit: '' }), true, 'a successful restore miss may omit cache-hit'); }); + + test(`${name} restricts compiler writes to authorized PR snapshots or trusted main`, () => { + const { step } = onlyStep(jobSteps(name), step => step.includes('name: Save compiler cache\n'), 'compiler save'); + for (const eventName of ['push', 'workflow_dispatch']) { + assert.equal(compilerSaveRuns(step, { eventName, ref: 'refs/heads/main' }), true); + for (const ref of ['refs/heads/feature', 'refs/tags/main', 'refs/pull/1/merge', '']) { + assert.equal(compilerSaveRuns(step, { eventName, ref }), false, `${eventName}: ${ref}`); + } + assert.equal(compilerSaveRuns(step, { eventName, ref: 'refs/heads/main', saveEnabled: 'false' }), false); + } + for (const eventName of ['merge_group', 'pull_request_target', 'schedule', 'workflow_run', '']) { + assert.equal(compilerSaveRuns(step, { eventName, ref: 'refs/heads/main' }), false, eventName); + } + assert.equal(compilerSaveRuns(step, { saveEnabled: 'false' }), false, 'PR writes also require helper authorization'); + }); } + +test('scope plans reach the application runner while queue and manual runs retain ALL and both full checks', () => { + assert.match(workflow, /^ app_plan_json: \$\{\{ steps\.scope\.outputs\.app_plan_json \}\}$/m); + const applicationJob = workflow.split(/(?=^ [a-z][a-z0-9_-]*:$)/m).find(block => block.startsWith(' applications:\n')); + for (const [variable, output] of [['ARK_APPLICATION_PLAN', 'app_plan_json'], + ['ARK_APPLICATION_VALIDATION_STAGE', 'validation_stage'], ['ARK_APPLICATION_FILES_KNOWN', 'files_known']]) { + assert.ok(applicationJob.includes(`${variable}: \${{ needs.scope.outputs.${output} }}`)); + } + assert.ok(applicationJob.includes('ARK_APPLICATION_SCOPE_RESULT: ${{ needs.scope.result }}')); + for (const eventName of ['pull_request', 'merge_group', 'workflow_dispatch']) { + const scope = selectScope({ eventName, files: ['tests/app/shutdown_deadlines.cmake'], site: { present: true, available: true } }); + const env = { GITHUB_EVENT_NAME: eventName, ARK_APPLICATION_PLAN: scope.app_plan_json, ARK_APPLICATION_SCOPE_RESULT: 'success', + ARK_APPLICATION_VALIDATION_STAGE: scope.validation_stage, ARK_APPLICATION_FILES_KNOWN: String(scope.files_known), ARK_BUILD_JOBS: '4' }; + if (eventName === 'pull_request') { + assert.deepEqual(applicationCommands('build', env), []); + assert.deepEqual(applicationCommands('check', env), [ + ['--build', 'ci-build', '--target', 'check-app-shutdown-deadlines', '--parallel', '4'], + ]); + } else { + assert.deepEqual(applicationCommands('build', env), [['--build', 'ci-build', '--parallel', '4']]); + assert.deepEqual(applicationCommands('check', env), [ + ['--build', 'ci-build', '--target', 'check-core', '--parallel', '4'], + ['--build', 'ci-build', '--target', 'check-entity-benchmark-smoke', '--parallel', '4'], + ]); + } + } +}); + +test('optional application caching preserves direct compilers, Windows Debug settings and fatal native results', () => { + for (const runner of ['Linux', 'macOS', 'Windows']) { + const env = { GITHUB_EVENT_NAME: 'merge_group', RUNNER_OS: runner, VCPKG_ROOT: '/vcpkg', ARK_COMPILER_LAUNCHER: '' }; + const [args] = applicationCommands('configure', env); + assert.ok(args.includes('-DCMAKE_BUILD_TYPE=Debug')); + for (const language of ['C', 'CXX']) assert.ok(args.includes(`-DCMAKE_${language}_COMPILER_LAUNCHER=`)); + assert.equal(args.includes('-DVCPKG_APPLOCAL_DEPS=OFF'), runner === 'Windows'); + assert.ok(!args.some(arg => /CMAKE_(?:C|CXX)_FLAGS|\/(?:Zi|Z7)\b/.test(arg))); + for (const action of ['configure', 'build', 'check']) { + const calls = []; + const status = runApplications(action, env, (command, commandArgs, options) => { + calls.push({ command, commandArgs, options }); + return { status: 37 }; + }); + assert.equal(status, 37, `${runner} ${action}`); + assert.equal(calls.length, 1, 'stop immediately after a failed native command'); + assert.equal(calls[0].command, 'cmake'); + assert.equal(calls[0].options.shell, false); + } + } +}); + +test('compiler seeding accepts only main pushes or manual main runs with read-only repository access', () => { + const triggers = compilerSeed.match(/^on:\n([\s\S]*?)(?=^permissions:)/m)?.[1]; + assert.ok(triggers); + assert.deepEqual([...triggers.matchAll(/^ (\w+):/gm)].map(match => match[1]), ['push', 'workflow_dispatch']); + assert.match(triggers, /^ push:\n branches: \[main\]\n paths:\n/m); + assert.doesNotMatch(triggers, /schedule:|pull_request|merge_group|workflow_run|repository_dispatch/); + assert.match(compilerSeed, /^permissions:\n contents: read\n/m); + assert.equal([...compilerSeed.matchAll(/^ *permissions:/gm)].length, 1); + assert.match(compilerSeed, /persist-credentials: false/); + assert.doesNotMatch(compilerSeed, /secrets\.|secrets:|pull_request_target|upload-artifact|gh api|curl /); + assert.match(compilerSeed, /COMPILER_CACHE_WRITE: 'true'/); + assert.match(compilerSeed, /COMPILER_CACHE_PROFILE: applications/); + assert.deepEqual([...compilerSeed.matchAll(/^ - os: (.+)$/gm)].map(match => match[1]), ['ubuntu-latest', 'macos-latest']); + const guard = compilerSeed.match(/^ if: \$\{\{ (.+) \}\}$/m)?.[1]; + assert.ok(guard); + for (const ref of ['refs/heads/main', 'refs/heads/feature', 'refs/pull/1/merge', 'refs/tags/main', '']) { + assert.equal(runInNewContext(guard, { github: { ref } }), ref === 'refs/heads/main', ref); + } +}); + +test('compiler seeding requires trusted cache inputs, successful native compilation and exact saved availability', () => { + const steps = jobSteps('applications', compilerSeed); + const current = onlyStep(steps, step => step.includes('id: current\n'), 'current main selection'); + assert.match(current.step, /run: node \.github\/scripts\/compiler-seed-plan\.mjs/); + const trusted = onlyStep(steps, step => step.includes('name: Require trusted compiler cache inputs\n'), 'trusted inputs'); + assert.match(trusted.step, /CACHE_ENABLED: \$\{\{ steps\.compiler-start\.outputs\.enabled \}\}/); + assert.match(trusted.step, /CACHE_WRITE_ENABLED: \$\{\{ steps\.compiler-start\.outputs\.save_enabled \}\}/); + assert.match(trusted.step, /test "\$CACHE_ENABLED" = true\n test "\$CACHE_WRITE_ENABLED" = true/); + const configure = onlyStep(steps, step => step.includes('run: node .github/scripts/ci-applications.mjs configure\n'), 'seed configure'); + const build = sourceBuild(steps, 'applications', 'ci-build'); + assert.ok(current.index < trusted.index && trusted.index < configure.index && configure.index < build.index); + for (const { step } of [trusted, configure, build]) assert.doesNotMatch(step, /continue-on-error:/); + assert.match(build.step, /steps\.current\.outputs\.build == 'true'/); + assert.deepEqual(applicationCommands('build', { GITHUB_EVENT_NAME: 'push', ARK_BUILD_JOBS: '4' }), + [['--build', 'ci-build', '--parallel', '4']]); + const save = onlyStep(steps, step => step.includes('name: Save compiler cache\n'), 'seed save'); + const verify = onlyStep(steps, step => step.includes('name: Verify trusted main cache availability\n'), 'exact verification'); + assert.ok(build.index < save.index && save.index < verify.index); + for (const { step } of [save, verify]) { + assert.doesNotMatch(step, /continue-on-error:/); + assert.match(step, /path: \$\{\{ steps\.compiler-cache\.outputs\.directory \}\}/); + assert.match(step, /key: \$\{\{ steps\.compiler-cache\.outputs\.archive_key \}\}/); + assert.doesNotMatch(step, /restore-keys:/); + assert.match(step, /steps\.compiler-start\.outputs\.enabled == 'true'/); + assert.match(step, /steps\.compiler-start\.outputs\.save_enabled == 'true'/); + } + assert.match(verify.step, /lookup-only: true\n fail-on-cache-miss: true/); + assert.match(verify.step, /steps\.current\.outputs\.build == 'true'/); + for (const eventName of ['push', 'workflow_dispatch', 'pull_request', 'merge_group', 'schedule']) { + for (const ref of ['refs/heads/main', 'refs/heads/feature']) { + for (const saveEnabled of ['true', 'false', '']) { + for (const enabled of ['true', 'false', '']) { + for (const success of [true, false]) { + const saved = runInNewContext(conditionExpression(save.step), { + success: () => success, github: { event_name: eventName, ref }, + steps: { 'compiler-start': { outputs: { enabled, save_enabled: saveEnabled } }, + 'compiler-restore': { outputs: { 'cache-hit': 'false' } } }, + }); + assert.equal(saved, success && ref === 'refs/heads/main' && ['push', 'workflow_dispatch'].includes(eventName) && + enabled === 'true' && saveEnabled === 'true'); + } + } + } + } + } + assert.equal(runInNewContext(conditionExpression(save.step), { + success: () => true, github: { event_name: 'push', ref: 'refs/heads/main' }, + steps: { 'compiler-start': { outputs: { enabled: 'true', save_enabled: 'true' } }, + 'compiler-restore': { outputs: { 'cache-hit': 'true' } } }, + }), false, 'an immutable exact hit cannot be replaced'); + for (const build of ['true', 'false']) { + for (const enabled of ['true', 'false', '']) { + for (const saveEnabled of ['true', 'false', '']) { + assert.equal(runInNewContext(conditionExpression(verify.step), { + steps: { current: { outputs: { build } }, + 'compiler-start': { outputs: { enabled, save_enabled: saveEnabled } } }, + }), build === 'true' && enabled === 'true' && saveEnabled === 'true'); + } + } + } +}); + +test('optional compiler reuse measurements preserve clean, cache-reset and rebuild failures', () => { + const steps = jobSteps('applications', compilerSeed); + const configure = onlyStep(steps, step => step.includes('run: node .github/scripts/ci-applications.mjs configure\n'), 'seed configure'); + const trusted = onlyStep(steps, step => step.includes('name: Require trusted compiler cache inputs\n'), 'trusted inputs'); + const cold = onlyStep(steps, step => step.includes('name: Prepare controlled cold-cache measurement\n'), 'controlled cold measurement'); + const build = sourceBuild(steps, 'applications', 'ci-build'); + const reuse = onlyStep(steps, + step => step.includes('name: Measure reuse after cleaning project outputs\n'), 'reuse measurement'); + const { step } = reuse; + assert.ok(trusted.index < configure.index && configure.index < cold.index && cold.index < build.index && build.index < reuse.index); + assert.match(cold.step, /& \$env:SCCACHE_PATH --stop-server\n if \(\$LASTEXITCODE -ne 0\) \{ exit \$LASTEXITCODE \}/); + assert.match(cold.step, /^ Remove-Item -LiteralPath \$env:SCCACHE_DIR -Recurse -Force$/m); + assert.equal((cold.step.match(/Remove-Item/g) ?? []).length, 1, 'clear only the validated local cache directory'); + assert.match(cold.step, /& \$env:SCCACHE_PATH --start-server\n if \(\$LASTEXITCODE -ne 0\) \{ exit \$LASTEXITCODE \}/); + assert.match(cold.step, /& \$env:SCCACHE_PATH --zero-stats\n if \(\$LASTEXITCODE -ne 0\) \{ exit \$LASTEXITCODE \}/); + for (const [eventName, verifyReuse] of [['push', undefined], ['workflow_dispatch', false], ['workflow_dispatch', true]]) { + for (const buildCurrent of ['true', 'false']) { + const context = { github: { event_name: eventName }, inputs: { verify_reuse: verifyReuse }, + steps: { current: { outputs: { build: buildCurrent } } } }; + const expected = eventName === 'workflow_dispatch' && verifyReuse === true && buildCurrent === 'true'; + assert.equal(runInNewContext(conditionExpression(cold.step), context), expected); + assert.equal(runInNewContext(conditionExpression(step), context), expected); + } + } + assert.match(step, /inputs\.verify_reuse == true/); + assert.match(step, /cmake --build ci-build --target clean\n if \(\$LASTEXITCODE -ne 0\) \{ exit \$LASTEXITCODE \}/); + assert.match(step, /& \$env:SCCACHE_PATH --zero-stats\n if \(\$LASTEXITCODE -ne 0\) \{ exit \$LASTEXITCODE \}/); + assert.match(step, /node \.github\/scripts\/ci-applications\.mjs build\n if \(\$LASTEXITCODE -ne 0\) \{ exit \$LASTEXITCODE \}/); + for (const value of [cold.step, step]) { + assert.doesNotMatch(value, /continue-on-error:|ci-applications\.mjs configure|cmake -S|gh |curl |cache.*delete/); + } + assert.doesNotMatch(step, /Remove-Item|--stop-server|--start-server/); + assert.equal((compilerSeed.match(/run: node \.github\/scripts\/ci-applications\.mjs configure/g) ?? []).length, 1); + assert.equal((compilerSeed.match(/node \.github\/scripts\/ci-applications\.mjs build/g) ?? []).length, 2, + 'cold and warm builds share one configure and one runner implementation'); +}); diff --git a/tests/ci/ci-applications.test.mjs b/tests/ci/ci-applications.test.mjs new file mode 100644 index 000000000..885f41978 --- /dev/null +++ b/tests/ci/ci-applications.test.mjs @@ -0,0 +1,115 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { applicationCommands, applicationPlan, runApplications } from '../../.github/scripts/ci-applications.mjs'; +import { applicationConfigureFlags, fullApplicationBuildPlan } from '../../.github/scripts/application-build-plan.mjs'; +import { compilerSeedPlan } from '../../.github/scripts/compiler-seed-plan.mjs'; + +const full = fullApplicationBuildPlan(); +const narrow = { ...full, mode: 'targets', buildAll: false, reason: 'reviewed-inputs', + checkTargets: ['check-app-shutdown-deadlines'] }; +const source = { GITHUB_EVENT_NAME: 'pull_request', ARK_APPLICATION_VALIDATION_STAGE: 'modules', + ARK_APPLICATION_SCOPE_RESULT: 'success', + ARK_APPLICATION_FILES_KNOWN: 'true', ARK_APPLICATION_PLAN: JSON.stringify(narrow), + VCPKG_ROOT: '/tool cache/vcpkg', ARK_COMPILER_LAUNCHER: '/tool cache/sccache', + ARK_BUILD_JOBS: '4', ARK_CMAKE_JOB_POOLS: 'ark_compile=4;ark_link=1' }; + +test('reviewed source plans build check-target dependency closures instead of ALL', () => { + assert.equal(applicationPlan(source).mode, 'targets'); + assert.deepEqual(applicationCommands('build', source), []); + assert.deepEqual(applicationCommands('check', source), [ + ['--build', 'ci-build', '--target', 'check-app-shutdown-deadlines', '--parallel', '4'], + ]); +}); + +test('queue, manual and missing or malformed scope always restore full application coverage', () => { + const corruptions = [ + { GITHUB_EVENT_NAME: 'merge_group' }, { GITHUB_EVENT_NAME: 'workflow_dispatch' }, + ...['failure', 'cancelled', 'skipped', undefined].map(result => ({ ARK_APPLICATION_SCOPE_RESULT: result })), + { ARK_APPLICATION_VALIDATION_STAGE: '' }, { ARK_APPLICATION_FILES_KNOWN: '' }, + { ARK_APPLICATION_PLAN: '' }, { ARK_APPLICATION_PLAN: '{}' }, { ARK_APPLICATION_PLAN: 'null' }, + ...[ + { mode: 'skip' }, { version: 2 }, { buildAll: true }, { checkTargets: [] }, + { checkTargets: ['install'] }, { checkTargets: ['--clean-first'] }, + { checkTargets: [narrow.checkTargets[0], narrow.checkTargets[0]] }, + { configureFlags: [...applicationConfigureFlags, '-DARK_BUILD_CORE_TESTS=OFF'] }, + ].map(change => ({ ARK_APPLICATION_PLAN: JSON.stringify({ ...narrow, ...change }) })), + ]; + for (const change of corruptions) { + const env = { ...source, ...change }; + assert.equal(applicationPlan(env).mode, 'full', JSON.stringify(change)); + assert.equal(applicationCommands('build', env).length, 1); + assert.deepEqual(applicationCommands('check', env).map(args => args[3]), + ['check-core', 'check-entity-benchmark-smoke']); + } +}); + +test('configuration preserves complete flags, Windows debug settings and literal paths', () => { + const [args] = applicationCommands('configure', { ...source, RUNNER_OS: 'Windows' }); + assert.ok(args.includes('-DCMAKE_CXX_COMPILER_LAUNCHER=/tool cache/sccache')); + assert.ok(args.includes('-DCMAKE_JOB_POOLS=ark_compile=4;ark_link=1')); + assert.ok(args.includes('-DCMAKE_JOB_POOL_LINK=ark_link')); + assert.ok(args.includes('-DCMAKE_BUILD_TYPE=Debug')); + assert.ok(args.includes('-DVCPKG_APPLOCAL_DEPS=OFF')); + assert.deepEqual(args.slice(-applicationConfigureFlags.length), [...applicationConfigureFlags]); + assert.ok(args.includes('-DARK_BUILD_PLUGIN_CONSUL=ON')); + assert.ok(args.includes('-DARK_BUILD_PLUGIN_ETCD_CLIENT=ON')); + assert.ok(!args.some(arg => /\/(?:Zi|Z7)/.test(arg))); +}); + +test('a failed CMake command ends the phase without a shell, retry or later check', () => { + const calls = []; + const env = { ...source, GITHUB_EVENT_NAME: 'merge_group' }; + assert.equal(runApplications('check', env, (command, args, options) => { + calls.push({ command, args, options }); + return { status: 27 }; + }), 27); + assert.equal(calls.length, 1); + assert.equal(calls[0].command, 'cmake'); + assert.equal(calls[0].options.shell, false); + assert.equal(runApplications('build', env, () => ({ status: null })), 1); + assert.throws(() => runApplications('build', env, () => ({ error: new Error('cannot spawn') })), /cannot spawn/); + assert.throws(() => applicationCommands('unexpected', source), /Unknown applications phase/); +}); + +test('cache warmup only accepts current trusted main and skips documentation or superseded pushes', () => { + const env = { GITHUB_EVENT_NAME: 'push', GITHUB_REF: 'refs/heads/main', GITHUB_SHA: 'a'.repeat(40) }; + assert.equal(compilerSeedPlan({ env, latestMain: env.GITHUB_SHA, files: ['src/core/module.hpp'] }).build, true); + assert.equal(compilerSeedPlan({ env, latestMain: env.GITHUB_SHA, files: ['examples/guest_room/README.md'] }).build, false); + assert.equal(compilerSeedPlan({ env, latestMain: 'b'.repeat(40), files: ['src/core/module.hpp'] }).build, false); + assert.equal(compilerSeedPlan({ env }).build, true); + assert.equal(compilerSeedPlan({ env: { ...env, GITHUB_EVENT_NAME: 'pull_request' } }).build, false); + assert.equal(compilerSeedPlan({ env: { ...env, GITHUB_REF: 'refs/pull/1/merge' } }).build, false); + assert.equal(compilerSeedPlan({ env: { ...env, GITHUB_EVENT_NAME: 'workflow_dispatch' } }).build, true); +}); + +test('real CMake target closures omit unrelated sources while full acceptance still builds them', t => { + const root = mkdtempSync(join(tmpdir(), 'ark applications plan ')); + t.after(() => rmSync(root, { recursive: true, force: true })); + const toolchain = join(root, 'tool cache', 'scripts', 'buildsystems'); + mkdirSync(toolchain, { recursive: true }); + writeFileSync(join(toolchain, 'vcpkg.cmake'), '# Isolated CMake fixture: no package installation.\n'); + writeFileSync(join(root, 'CMakeLists.txt'), [ + 'cmake_minimum_required(VERSION 3.21)', 'project(application_plan LANGUAGES CXX)', + 'add_executable(required required.cpp)', 'add_library(unrelated STATIC broken.cpp)', + 'add_custom_target(check-app-shutdown-deadlines COMMAND $ DEPENDS required)', + ].join('\n') + '\n'); + writeFileSync(join(root, 'required.cpp'), 'int main() { return 0; }\n'); + writeFileSync(join(root, 'broken.cpp'), '#error unrelated input must still fail full acceptance\n'); + const env = { ...process.env, ...source, VCPKG_ROOT: join(root, 'tool cache'), + ARK_COMPILER_LAUNCHER: '', ARK_BUILD_JOBS: '2' }; + let diagnostic = ''; + const run = (command, args, options) => { + const result = spawnSync(command, args, { ...options, cwd: root, stdio: 'pipe', encoding: 'utf8', timeout: 30_000 }); + diagnostic = (result.stdout ?? '') + (result.stderr ?? ''); + return result; + }; + assert.equal(runApplications('configure', env, run), 0, diagnostic); + assert.equal(runApplications('build', env, run), 0, diagnostic); + assert.equal(runApplications('check', env, run), 0, diagnostic); + assert.notEqual(runApplications('build', { ...env, GITHUB_EVENT_NAME: 'merge_group' }, run), 0); + assert.match(diagnostic, /unrelated input must still fail full acceptance/); +}); diff --git a/tests/ci/entity-example.test.mjs b/tests/ci/entity-example.test.mjs index 384c8aeaf..984326ff9 100644 --- a/tests/ci/entity-example.test.mjs +++ b/tests/ci/entity-example.test.mjs @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import test from 'node:test'; +import { applicationCommands } from '../../.github/scripts/ci-applications.mjs'; const read = path => readFileSync(new URL(`../../${path}`, import.meta.url), 'utf8'); test('entity benchmark stays optional with an independent CLI smoke entry', () => { @@ -17,9 +18,14 @@ test('Applications CI compiles the optional entity benchmark and runs only its c const apps = workflow.split(/(?=^ [a-z][a-z0-9_-]*:$)/m).find(block => block.startsWith(' applications:\n')); assert.ok(apps); const configure = apps.match(/ - name: Configure applications[\s\S]*?(?=\n - )/)?.[0]; - assert.match(configure ?? '', /(?:^|\s)-DARK_BUILD_BENCHMARKS=ON(?:\s|$)/); + assert.match(configure ?? '', /run: node \.github\/scripts\/ci-applications\.mjs configure$/m); + const env = { GITHUB_EVENT_NAME: 'merge_group', VCPKG_ROOT: '/vcpkg' }; + assert.ok(applicationCommands('configure', env)[0].includes('-DARK_BUILD_BENCHMARKS=ON')); const steps = apps.split(/(?=^ - )/m).slice(1); - const smoke = steps.filter(step => step.includes('run: cmake --build ci-build --target check-entity-benchmark-smoke')); + const smoke = steps.filter(step => step.includes('run: node .github/scripts/ci-applications.mjs check\n')); assert.equal(smoke.length, 1); assert.doesNotMatch(smoke[0], /continue-on-error:|if:|--iterations/); + const commands = applicationCommands('check', env).filter(args => args[3] === 'check-entity-benchmark-smoke'); + assert.equal(commands.length, 1, 'the full check phase must include the independent benchmark smoke'); + assert.doesNotMatch(commands[0].join(' '), /--iterations/); }); diff --git a/tests/ci/http-examples.test.mjs b/tests/ci/http-examples.test.mjs index 05fe240e2..6115d7019 100644 --- a/tests/ci/http-examples.test.mjs +++ b/tests/ci/http-examples.test.mjs @@ -5,6 +5,7 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import test from 'node:test'; +import { applicationCommands } from '../../.github/scripts/ci-applications.mjs'; const read = path => readFileSync(new URL(`../../${path}`, import.meta.url), 'utf8'); test('HTTP example regressions build the real DSO sources without enabling application examples', () => { @@ -62,8 +63,12 @@ test('existing Applications CI reaches HTTP example regressions through core Boo .find(block => block.startsWith(' applications:\n')); assert.ok(applications); const configure = applications.match(/ - name: Configure applications[\s\S]*?(?=\n - )/)?.[0]; - assert.match(configure ?? '', /-DARK_BUILD_CORE_TESTS=ON/); - assert.match(configure ?? '', /-DASIO_STANDALONE=OFF/); - assert.doesNotMatch(configure ?? '', /-DARK_BUILD_HTTP_EXAMPLE_TESTS=OFF/); - assert.match(applications, /run: cmake --build ci-build --target check-core/); + assert.match(configure ?? '', /run: node \.github\/scripts\/ci-applications\.mjs configure$/m); + const env = { GITHUB_EVENT_NAME: 'merge_group', VCPKG_ROOT: '/vcpkg' }; + const [args] = applicationCommands('configure', env); + assert.ok(args.includes('-DARK_BUILD_CORE_TESTS=ON')); + assert.ok(args.includes('-DASIO_STANDALONE=OFF')); + assert.ok(!args.includes('-DARK_BUILD_HTTP_EXAMPLE_TESTS=OFF')); + assert.match(applications, /run: node \.github\/scripts\/ci-applications\.mjs check\n/); + assert.ok(applicationCommands('check', env).some(command => command[3] === 'check-core')); }); diff --git a/tests/ci/module-validation.test.mjs b/tests/ci/module-validation.test.mjs index b1b2d490d..99f44794a 100644 --- a/tests/ci/module-validation.test.mjs +++ b/tests/ci/module-validation.test.mjs @@ -3,6 +3,7 @@ import { readFileSync } from 'node:fs'; import { runInNewContext } from 'node:vm'; import test from 'node:test'; import { selectScope, validateGate } from '../../.github/scripts/ci-policy.mjs'; +import { applicationCommands } from '../../.github/scripts/ci-applications.mjs'; const site = { present: true, available: true }; const jobs = ['network', 'applications', 'database']; @@ -249,7 +250,13 @@ test('workflow source jobs reuse Linux Boost and full acceptance retains exactly 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('applications'), /run: node \.github\/scripts\/ci-applications\.mjs build\n/); + assert.match(jobBlock('applications'), /run: node \.github\/scripts\/ci-applications\.mjs check\n/); + for (const eventName of ['merge_group', 'workflow_dispatch']) { + const env = { GITHUB_EVENT_NAME: eventName, ARK_BUILD_JOBS: '4' }; + assert.deepEqual(applicationCommands('build', env), [['--build', 'ci-build', '--parallel', '4']]); + assert.deepEqual(applicationCommands('check', env).map(command => command[3]), ['check-core', 'check-entity-benchmark-smoke']); + } 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/); }); @@ -272,8 +279,11 @@ test('Applications CI reaches the actual HTTP DSO lifecycle through check-core', const applications = jobBlock('applications'); const configure = applications.match(/ - name: Configure applications[\s\S]*?(?=\n - )/)?.[0]; assert.ok(configure, 'Applications must configure the native runtime'); - assert.match(configure, /(?:^|\s)-DARK_BUILD_PLUGIN_HTTP=ON(?:\s|$)/); - assert.match(applications, /run: cmake --build ci-build --target check-core(?:\s|$)/); + assert.match(configure, /run: node \.github\/scripts\/ci-applications\.mjs configure$/m); + const env = { GITHUB_EVENT_NAME: 'merge_group', VCPKG_ROOT: '/vcpkg' }; + assert.ok(applicationCommands('configure', env)[0].includes('-DARK_BUILD_PLUGIN_HTTP=ON')); + assert.match(applications, /run: node \.github\/scripts\/ci-applications\.mjs check\n/); + assert.ok(applicationCommands('check', env).some(command => command[3] === 'check-core')); const core = readFileSync(new URL('../core/CMakeLists.txt', import.meta.url), 'utf8').replaceAll('\r\n', '\n'); const http = core.match(/if\(TARGET ark_http_plugin\)\n([\s\S]*?)\nendif\(\)/)?.[1]; assert.ok(http, 'the optional HTTP target must expose its real-DSO check'); diff --git a/tests/ci/package-dependencies.test.mjs b/tests/ci/package-dependencies.test.mjs index 20609ad6d..fc32e6847 100644 --- a/tests/ci/package-dependencies.test.mjs +++ b/tests/ci/package-dependencies.test.mjs @@ -2,10 +2,30 @@ import assert from 'node:assert/strict'; import { existsSync, readFileSync } from 'node:fs'; import test from 'node:test'; +import { applicationCommands } from '../../.github/scripts/ci-applications.mjs'; const read = path => readFileSync(new URL(`../../${path}`, import.meta.url), 'utf8'); const manifest = JSON.parse(read('vcpkg.json')); const dependencies = values => values.map(value => typeof value === 'string' ? value : value.name); +const fullApplicationEnv = { GITHUB_EVENT_NAME: 'merge_group', VCPKG_ROOT: '/vcpkg', ARK_BUILD_JOBS: '4' }; +const applicationConfigureArgs = () => applicationCommands('configure', fullApplicationEnv)[0]; + +function applicationWorkflow(workflow) { + const applications = workflow.replaceAll('\r\n', '\n').split(/(?=^ [a-z][a-z0-9_-]*:$)/m) + .find(block => block.startsWith(' applications:\n')); + assert.ok(applications, 'Applications CI must remain configured'); + const steps = applications.split(/(?=^ - )/m).slice(1); + const positions = ['configure', 'build', 'check'].map(action => { + const matches = steps.map((step, index) => ({ step, index })) + .filter(({ step }) => step.includes(`run: node .github/scripts/ci-applications.mjs ${action}\n`)); + assert.equal(matches.length, 1, `Applications must invoke one ${action} phase`); + assert.doesNotMatch(matches[0].step, /^ (?:continue-on-error|if):/m); + return matches[0].index; + }); + assert.ok(positions[0] < positions[1] && positions[1] < positions[2], 'configure, build and checks retain their required order'); + assert.ok(applicationCommands('check', fullApplicationEnv).some(command => command[3] === 'check-core')); + return applications; +} test('core packages are shared while application, configuration and JSON dependencies retain separate features', () => { const core = dependencies(manifest.dependencies); @@ -35,10 +55,12 @@ test('the public core target propagates package usage requirements without a bun assert.doesNotMatch(read('src/plugins/net/net_service_manager_module_impl.cpp'), /json\.hpp/); }); -function applicationFeatures(workflow) { - const applications = workflow.split(/(?=^ [a-z][a-z0-9_-]*:$)/m).find(block => block.startsWith(' applications:\n')); +function applicationFeatures(workflow, args = applicationConfigureArgs()) { + const applications = applicationWorkflow(workflow); const cache = applications.match(/VCPKG_CACHE_FEATURES: '([^']+)'/)[1].split(';').sort(); - const configure = applications.match(/-DVCPKG_MANIFEST_FEATURES=([^"\s]+)/)[1].split(';').sort(); + const features = args.filter(arg => arg.startsWith('-DVCPKG_MANIFEST_FEATURES=')); + assert.equal(features.length, 1, 'the runner must select one complete feature set'); + const configure = features[0].slice('-DVCPKG_MANIFEST_FEATURES='.length).split(';').sort(); assert.deepEqual(configure, cache, 'cache metadata must describe the actual package features'); assert.ok(configure.includes('etcd-client'), 'default etcd plugin requires its package feature'); assert.ok(configure.includes('guest-room'), 'guest-room example requires its package feature'); @@ -50,37 +72,46 @@ test('CI caches exactly the configured application and offline-tool package feat applicationFeatures(workflow); assert.throws(() => applicationFeatures(workflow.replace('VCPKG_CACHE_FEATURES: \'application;boost;compression-tests;etcd-client;guest-room;config-tool\'', 'VCPKG_CACHE_FEATURES: \'application;boost\'')), /cache metadata/); - assert.throws(() => applicationFeatures(workflow.replaceAll('application;boost;compression-tests;etcd-client;guest-room;config-tool', 'application;boost')), /requires its package feature/); - assert.throws(() => applicationFeatures(workflow.replaceAll(';guest-room', '')), /guest-room example requires its package feature/); - assert.throws(() => applicationFeatures(workflow.replaceAll(';config-tool', '')), /native exporter requires its package feature/); + const reduced = applicationConfigureArgs().map(arg => arg.replace('application;boost;compression-tests;etcd-client;guest-room;config-tool', 'application;boost')); + assert.throws(() => applicationFeatures(workflow.replaceAll('application;boost;compression-tests;etcd-client;guest-room;config-tool', 'application;boost'), reduced), + /requires its package feature/); + for (const [feature, message] of [['guest-room', /guest-room example requires its package feature/], + ['config-tool', /native exporter requires its package feature/]]) { + const args = applicationConfigureArgs().map(arg => arg.replace(`;${feature}`, '')); + assert.throws(() => applicationFeatures(workflow.replaceAll(`;${feature}`, ''), args), message); + } assert.doesNotMatch(workflow, /git submodule|Initialize pinned dependencies/); assert.equal(existsSync(new URL('../../.gitmodules', import.meta.url)), false); }); test('Applications builds and executes the native configuration tool within existing jobs', () => { - const application = read('.github/workflows/ci.yml').split(' applications:\n')[1].split('\n database:')[0]; - assert.match(application, /-DARK_BUILD_CONFIG_TOOL=ON/); - assert.match(application, /-DARK_BUILD_CONFIG_TOOL_TESTS=ON/); + applicationWorkflow(read('.github/workflows/ci.yml')); + const args = applicationConfigureArgs(); + assert.ok(args.includes('-DARK_BUILD_CONFIG_TOOL=ON')); + assert.ok(args.includes('-DARK_BUILD_CONFIG_TOOL_TESTS=ON')); const tools = read('tools/entity_config/CMakeLists.txt'); assert.match(tools, /if\(TARGET check-core\)\s+add_dependencies\(check-core check-config-tool\)/); assert.match(tools, /find_package\(OpenXLSX 0\.5\.1 CONFIG REQUIRED\)/); assert.doesNotMatch(tools, /python|ruby|add_test\(/i); }); -function guestRoomApplications(workflow) { - const applications = workflow.split(' applications:\n')[1].split('\n database:')[0]; - assert.match(applications, /^ -DARK_BUILD_GUEST_ROOM_EXAMPLE=ON$/m, +function guestRoomApplications(workflow, args = applicationConfigureArgs()) { + applicationWorkflow(workflow); + assert.ok(args.includes('-DARK_BUILD_GUEST_ROOM_EXAMPLE=ON'), 'the shared Applications configure must enable guest-room on every selected platform'); - const build = applications.indexOf('run: cmake --build ci-build --parallel 2'); - const check = applications.indexOf('run: cmake --build ci-build --target check-core'); - assert.ok(build >= 0 && check > build, 'build all runtime plugins before running check-core'); + for (const runner of ['Linux', 'macOS', 'Windows']) { + assert.ok(applicationCommands('configure', { ...fullApplicationEnv, RUNNER_OS: runner })[0] + .includes('-DARK_BUILD_GUEST_ROOM_EXAMPLE=ON'), `${runner} must enable guest-room`); + } + assert.deepEqual(applicationCommands('build', fullApplicationEnv), [['--build', 'ci-build', '--parallel', '4']], + 'full acceptance must build all runtime plugins before running check-core'); } test('Applications builds guest-room on every platform and runs platform-appropriate checks through check-core', () => { const workflow = read('.github/workflows/ci.yml'); guestRoomApplications(workflow); - assert.throws(() => guestRoomApplications(workflow.replace('-DARK_BUILD_GUEST_ROOM_EXAMPLE=ON', - '-DARK_BUILD_GUEST_ROOM_EXAMPLE=OFF')), /must enable guest-room/); + assert.throws(() => guestRoomApplications(workflow, applicationConfigureArgs().map(arg => + arg.replace('-DARK_BUILD_GUEST_ROOM_EXAMPLE=ON', '-DARK_BUILD_GUEST_ROOM_EXAMPLE=OFF'))), /must enable guest-room/); const examples = read('examples/CMakeLists.txt'); assert.match(examples, /option\(ARK_BUILD_GUEST_ROOM_EXAMPLE[^\n]* OFF\)/); assert.match(examples, /if\(ARK_BUILD_GUEST_ROOM_EXAMPLE\)\s+add_subdirectory\(guest_room\)/); @@ -107,10 +138,10 @@ test('compression checks are opt-in and Applications CI runs all three codecs th const checks = read('tests/compression/CMakeLists.txt'); for (const dependency of ['ZLIB', 'Zstd', 'LZ4']) assert.match(checks, new RegExp(`find_package\\(${dependency} REQUIRED\\)`)); assert.match(checks, /add_custom_target\(check-compression\s+COMMAND \$/); - const applications = read('.github/workflows/ci.yml').split(' applications:\n')[1].split('\n database:')[0]; - assert.match(applications, /-DVCPKG_MANIFEST_FEATURES=[^\n]*compression-tests/); - assert.match(applications, /-DARK_BUILD_COMPRESSION_TESTS=ON/); - assert.match(applications, /run: cmake --build ci-build --target check-core/); + applicationWorkflow(read('.github/workflows/ci.yml')); + const args = applicationConfigureArgs(); + assert.ok(args.some(arg => arg.startsWith('-DVCPKG_MANIFEST_FEATURES=') && arg.split('=')[1].split(';').includes('compression-tests'))); + assert.ok(args.includes('-DARK_BUILD_COMPRESSION_TESTS=ON')); }); test('unsupported PhysX and simulate integrations fail explicitly instead of searching absent bundled SDKs', () => { @@ -123,9 +154,8 @@ test('unsupported PhysX and simulate integrations fail explicitly instead of sea }); test('Applications CI enables Consul construction and production export checks without a service', () => { - const applications = read('.github/workflows/ci.yml').split(' applications:\n')[1].split('\n database:')[0]; - assert.match(applications, /-DARK_BUILD_PLUGIN_CONSUL=ON/); - assert.match(applications, /run: cmake --build ci-build --target check-core/); + applicationWorkflow(read('.github/workflows/ci.yml')); + assert.ok(applicationConfigureArgs().includes('-DARK_BUILD_PLUGIN_CONSUL=ON')); const checks = read('tests/consul/CMakeLists.txt'); assert.match(checks, /if\(TARGET ark_consul_plugin\)/); assert.match(checks, /add_dependencies\(check-core check-consul-plugin\)/); @@ -133,9 +163,8 @@ test('Applications CI enables Consul construction and production export checks w }); test('Applications CI explicitly enables Etcd Client ownership and production DSO checks', () => { - const applications = read('.github/workflows/ci.yml').split(' applications:\n')[1].split('\n database:')[0]; - assert.match(applications, /-DARK_BUILD_PLUGIN_ETCD_CLIENT=ON/); - assert.match(applications, /run: cmake --build ci-build --target check-core/); + applicationWorkflow(read('.github/workflows/ci.yml')); + assert.ok(applicationConfigureArgs().includes('-DARK_BUILD_PLUGIN_ETCD_CLIENT=ON')); const core = read('tests/core/CMakeLists.txt'); assert.match(core, /if\(TARGET ark_etcd_client_plugin\)[\s\S]*add_subdirectory\(\.\.\/etcd_client/); const checks = read('tests/etcd_client/CMakeLists.txt'); diff --git a/tests/ci/vcpkg-seed-workflow.test.mjs b/tests/ci/vcpkg-seed-workflow.test.mjs index 2991d5387..995c076c9 100644 --- a/tests/ci/vcpkg-seed-workflow.test.mjs +++ b/tests/ci/vcpkg-seed-workflow.test.mjs @@ -6,6 +6,7 @@ import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { runInNewContext } from 'node:vm'; import test from 'node:test'; +import { applicationCommands } from '../../.github/scripts/ci-applications.mjs'; import { requireArchives, requireAvailableSeed } from '../../.github/scripts/vcpkg-seed-check.mjs'; const read = file => readFileSync(new URL(`../../${file}`, import.meta.url), 'utf8').replaceAll('\r\n', '\n'); @@ -55,7 +56,13 @@ test('manual runs on another branch cannot write a seed and only read-only check test('seed identity, package provider and tools exactly match Windows Applications CI', () => { // New inherited compiler/triplet overrides also need an explicit seed review. assert.doesNotMatch(ci.split(/^jobs:/m)[0], /^env:/m); - assert.doesNotMatch(applications.split(/^ steps:/m)[0], /^ env:/m); + const settings = applications.split(/^ steps:/m)[0]; + const inherited = settings.match(/^ env:\n((?: .+\n)+)/m)?.[1]; + assert.ok(inherited); + assert.deepEqual([...inherited.matchAll(/^ (\w+):/gm)].map(match => match[1]), [ + 'ARK_APPLICATION_PLAN', 'ARK_APPLICATION_SCOPE_RESULT', 'ARK_APPLICATION_VALIDATION_STAGE', + 'ARK_APPLICATION_FILES_KNOWN', 'COMPILER_CACHE_MAX_MIB', + ], 'only reviewed scheduling/cache-size inputs may differ from the dependency-only seed'); for (const match of ['id: vcpkg-cache\n', 'id: vcpkg-restore\n', 'uses: lukka/get-cmake@', 'uses: lukka/run-vcpkg@']) { assert.equal(onlyStep(seed, match).trimEnd(), onlyStep(applications, match).trimEnd(), match); @@ -75,10 +82,25 @@ test('seed identity, package provider and tools exactly match Windows Applicatio test('the complete Windows configuration stays equal and fatal without building ark targets', () => { const configure = onlyStep(seed, 'name: Configure applications\n'); - const windowsConfigure = onlyStep(applications, 'name: Configure applications\n') - .replace("${{ runner.os == 'Windows' && '-DVCPKG_APPLOCAL_DEPS=OFF' || '' }}", '-DVCPKG_APPLOCAL_DEPS=OFF'); - assert.equal(configure.trimEnd(), windowsConfigure.trimEnd()); - assert.doesNotMatch(configure, /^ (?:if|continue-on-error):/m); + const windowsConfigure = onlyStep(applications, 'name: Configure applications\n'); + assert.match(windowsConfigure, /^ run: node \.github\/scripts\/ci-applications\.mjs configure$/m); + for (const step of [configure, windowsConfigure]) assert.doesNotMatch(step, /^ (?:if|continue-on-error):/m); + const root = join(tmpdir(), 'tool cache', 'vcpkg'); + const lines = configure.trimEnd().split('\n'); + const command = lines.findIndex(line => line.startsWith(' cmake ')); + assert.ok(command >= 0); + assert.equal(lines[command].trim(), 'cmake -S . -B ci-build -G Ninja'); + const seedArgs = ['-S', '.', '-B', 'ci-build', '-G', 'Ninja', ...lines.slice(command + 1).map(line => { + assert.match(line, /^ (?:"-D[^"\n]+"|-D[^\s]+)$/); + return line.trim().replace(/^"|"$/g, '').replaceAll('$env:VCPKG_ROOT', root).replaceAll('$env:ARK_COMPILER_LAUNCHER', ''); + })]; + const pools = new Set(['-DCMAKE_JOB_POOLS=ark_compile=2;ark_link=1', + '-DCMAKE_JOB_POOL_COMPILE=ark_compile', '-DCMAKE_JOB_POOL_LINK=ark_link']); + const [runnerArgs] = applicationCommands('configure', { RUNNER_OS: 'Windows', VCPKG_ROOT: root, + GITHUB_EVENT_NAME: 'merge_group', ARK_COMPILER_LAUNCHER: '' }); + const normalizeToolchain = arg => arg.startsWith('-DCMAKE_TOOLCHAIN_FILE=') ? arg.replaceAll('\\', '/') : arg; + assert.deepEqual(runnerArgs.filter(arg => !pools.has(arg)).map(normalizeToolchain), seedArgs.map(normalizeToolchain), + 'dependency features, Debug flags and every application build option remain identical'); assert.match(seed, /^ env:\n ARK_COMPILER_LAUNCHER: ''$/m); assert.doesNotMatch(seed, /cmake --build|ctest|sccache|compiler-cache\.mjs|CMAKE_(?:C|CXX)_FLAGS|\/(?:Zi|Z7)\b/); assert.doesNotMatch(seed, /VCPKG_DISABLE_COMPILER_TRACKING|VCPKG_ENV_PASSTHROUGH_UNTRACKED|--editable|upload-artifact/); diff --git a/tests/ci/windows-runtime.test.mjs b/tests/ci/windows-runtime.test.mjs index 4dc6559ff..b2e22a519 100644 --- a/tests/ci/windows-runtime.test.mjs +++ b/tests/ci/windows-runtime.test.mjs @@ -6,6 +6,7 @@ import { tmpdir } from 'node:os'; import { delimiter, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import test from 'node:test'; +import { applicationCommands } from '../../.github/scripts/ci-applications.mjs'; const exporter = fileURLToPath(new URL('../../.github/scripts/windows-runtime.cmake', import.meta.url)); const workflow = readFileSync(new URL('../../.github/workflows/ci.yml', import.meta.url), 'utf8'); @@ -41,14 +42,22 @@ function fixture(t, overrides = {}) { test('only Windows Applications disables per-target staging and exports its configured dependency directory before build', () => { const applications = job('applications'); - assert.match(applications, /\$\{\{ runner.os == 'Windows' && '-DVCPKG_APPLOCAL_DEPS=OFF' \|\| '' \}\}/); + assert.match(applications, /run: node \.github\/scripts\/ci-applications\.mjs configure\n/); + for (const runner of ['Windows', 'Linux', 'macOS']) { + const [args] = applicationCommands('configure', { GITHUB_EVENT_NAME: 'merge_group', RUNNER_OS: runner, VCPKG_ROOT: '/vcpkg' }); + assert.equal(args.includes('-DVCPKG_APPLOCAL_DEPS=OFF'), runner === 'Windows', runner); + } const runtime = applications.split(/(?=^ - )/m).find(step => step.includes('Set Windows dependency runtime PATH')); assert.match(runtime, /^ if: runner.os == 'Windows'$/m); assert.match(runtime, /run: cmake -DBUILD_DIR=ci-build -P .github\/scripts\/windows-runtime.cmake/); assert.doesNotMatch(runtime, /continue-on-error:|always\(\)|\|\| true/); assert.ok(applications.indexOf('Configure applications') < applications.indexOf(runtime)); - assert.ok(applications.indexOf(runtime) < applications.indexOf('run: cmake --build ci-build --parallel 2')); - assert.ok(applications.indexOf('run: cmake --build ci-build --parallel 2') < applications.indexOf('run: cmake --build ci-build --target check-core')); + const build = applications.indexOf('run: node .github/scripts/ci-applications.mjs build\n'); + const check = applications.indexOf('run: node .github/scripts/ci-applications.mjs check\n'); + assert.ok(applications.indexOf(runtime) < build); + assert.ok(build < check); + assert.ok(applicationCommands('check', { GITHUB_EVENT_NAME: 'merge_group', RUNNER_OS: 'Windows' }) + .some(command => command[3] === 'check-core')); assert.doesNotMatch(job('network') + job('database'), /VCPKG_APPLOCAL_DEPS|windows-runtime.cmake/); const core = readFileSync(new URL('../core/CMakeLists.txt', import.meta.url), 'utf8'); for (const check of ['check-app-business-runtime', 'check-app-startup-failures', 'check-app-manifest-failures']) { From 071840ac84ca7b7b16d0d05772159ef79d71685c Mon Sep 17 00:00:00 2001 From: nick Date: Thu, 5 Nov 2026 14:30:25 +0800 Subject: [PATCH 6/6] ci(cache): publish Unix application dependency seeds --- .github/workflows/compiler-cache-seed.yml | 10 ++++++++++ docs/project/contributing.md | 2 +- docs/project/contributing_CN.md | 2 +- tests/ci/cache-workflow.test.mjs | 13 +++++++++++++ 4 files changed, 25 insertions(+), 2 deletions(-) diff --git a/.github/workflows/compiler-cache-seed.yml b/.github/workflows/compiler-cache-seed.yml index fe6bf66b1..46b962601 100644 --- a/.github/workflows/compiler-cache-seed.yml +++ b/.github/workflows/compiler-cache-seed.yml @@ -135,6 +135,13 @@ jobs: - name: Configure applications if: steps.current.outputs.build == 'true' run: node .github/scripts/ci-applications.mjs configure + - name: Save application dependency archives + if: ${{ success() && steps.current.outputs.build == 'true' && steps.vcpkg-cache.outputs.enabled == 'true' && steps.vcpkg-restore.outputs.cache-hit != 'true' }} + continue-on-error: true + uses: actions/cache/save@v4 + with: + path: ${{ steps.vcpkg-cache.outputs.directory }} + key: ${{ steps.vcpkg-cache.outputs.key }} - name: Prepare controlled cold-cache measurement if: steps.current.outputs.build == 'true' && inputs.verify_reuse == true shell: pwsh @@ -157,6 +164,7 @@ jobs: env: COMPILER_ARCHIVE_HIT: ${{ steps.compiler-restore.outputs.cache-hit }} VCPKG_ARCHIVE_HIT: ${{ steps.vcpkg-restore.outputs.cache-hit }} + VCPKG_ARCHIVE_DIRECTORY: ${{ steps.vcpkg-cache.outputs.directory }} ARK_CACHE_SAMPLE: ${{ inputs.verify_reuse == true && 'controlled-cold' || 'seed-or-restored-snapshot' }} ARK_CACHE_BUILD_EXECUTED: ${{ (steps.compiler-restore.outputs.cache-hit != 'true' || inputs.verify_reuse == true) && 'true' || 'false' }} ARK_CACHE_ARCHIVE_KEY: ${{ steps.compiler-cache.outputs.archive_key }} @@ -167,6 +175,8 @@ jobs: env: COMPILER_ARCHIVE_HIT: ${{ steps.compiler-restore.outputs.cache-hit }} ARK_CACHE_SAMPLE: controlled-warm + VCPKG_ARCHIVE_HIT: ${{ steps.vcpkg-restore.outputs.cache-hit }} + VCPKG_ARCHIVE_DIRECTORY: ${{ steps.vcpkg-cache.outputs.directory }} ARK_CACHE_BUILD_EXECUTED: 'true' ARK_CACHE_ARCHIVE_KEY: ${{ steps.compiler-cache.outputs.archive_key }} run: | diff --git a/docs/project/contributing.md b/docs/project/contributing.md index 7525dc7ee..c13e0d5d4 100644 --- a/docs/project/contributing.md +++ b/docs/project/contributing.md @@ -53,7 +53,7 @@ Diagnostics report available vcpkg ABI metadata after configuration, including f | Other source-PR profiles | May keep a bounded weekly snapshot within their own PR ref | | Queue snapshots | Restore only; no duplicate uploads per candidate | -The **Compiler cache seed** workflow builds accepted Linux/macOS application inputs on `main`, without repeating the regression matrix. Superseded push inputs may skip this cache-only work; active acceptance runs are preserved. Linux application cache capacity is 1 GiB, macOS 512 MiB. Capacity is configurable from 64 to 1024 MiB; it is a local job budget, not a repository storage limit. This change does not increase paid cache storage or remove existing remote caches. +The **Compiler cache seed** workflow builds accepted Linux/macOS application inputs on `main`, without repeating the regression matrix. It also publishes matching dependency archives so later Unix configurations can reuse them. Superseded push inputs may skip this cache-only work; active acceptance runs are preserved. Linux application cache capacity is 1 GiB, macOS 512 MiB. Capacity is configurable from 64 to 1024 MiB; it is a local job budget, not a repository storage limit. This change does not increase paid cache storage or remove existing remote caches. An exact weekly hit may reuse an earlier accepted snapshot without rebuilding it. Ordinary CI still compiles and validates its actual revision; new inputs can miss until the next snapshot. An archive hit is distinct from an object hit. [GitHub's cache access rules](https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching) still apply. diff --git a/docs/project/contributing_CN.md b/docs/project/contributing_CN.md index 48da0d3c3..63c9a48f0 100644 --- a/docs/project/contributing_CN.md +++ b/docs/project/contributing_CN.md @@ -53,7 +53,7 @@ vcpkg 包归档与项目编译对象分开缓存。依赖 key 保留系统/架 | 其他源 PR profile | 可在自身 PR 引用中保留一份每周快照 | | 队列快照 | 只恢复,不按候选重复上传 | -**Compiler cache seed** 工作流编译 `main` 上已接受的 Linux/macOS 应用输入,不重复回归矩阵。已被新 main 替代的推送可跳过这项缓存工作;活动验收继续保留。Linux 应用缓存为 1 GiB,macOS 为 512 MiB,可配置范围为 64–1024 MiB。这是单任务本地预算,不是仓库总额度;不增加付费缓存空间、不删除现有远端缓存。 +**Compiler cache seed** 工作流编译 `main` 上已接受的 Linux/macOS 应用输入,不重复回归矩阵。同时保存匹配的依赖包归档,供后续 Unix 配置复用。已被新 main 替代的推送可跳过这项缓存工作;活动验收继续保留。Linux 应用缓存为 1 GiB,macOS 为 512 MiB,可配置范围为 64–1024 MiB。这是单任务本地预算,不是仓库总额度;不增加付费缓存空间、不删除现有远端缓存。 精确命中每周快照时,可以使用此前已接受版本的对象而不重新生成快照。普通 CI 仍编译并验证实际版本;新增输入可能未命中,直到下一份快照。归档命中不等于目标文件命中。[GitHub 缓存访问规则](https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching)仍生效。 diff --git a/tests/ci/cache-workflow.test.mjs b/tests/ci/cache-workflow.test.mjs index c4448686f..976c58b20 100644 --- a/tests/ci/cache-workflow.test.mjs +++ b/tests/ci/cache-workflow.test.mjs @@ -7,6 +7,19 @@ import { selectScope } from '../../.github/scripts/ci-policy.mjs'; const workflow = readFileSync(new URL('../../.github/workflows/ci.yml', import.meta.url), 'utf8').replaceAll('\r\n', '\n'); const compilerSeed = readFileSync(new URL('../../.github/workflows/compiler-cache-seed.yml', import.meta.url), 'utf8').replaceAll('\r\n', '\n'); + +test('trusted Unix application warmup publishes dependencies after configure and before source compilation', () => { + const steps = jobSteps('applications', compilerSeed); + const configure = onlyStep(steps, step => step.includes('name: Configure applications\n'), 'seed configure'); + const save = onlyStep(steps, step => step.includes('name: Save application dependency archives\n'), 'dependency seed'); + const compile = onlyStep(steps, step => step.includes('name: Compile accepted main inputs\n'), 'seed build'); + assert.ok(configure.index < save.index && save.index < compile.index); + assert.match(save.step, /success\(\).*current\.outputs\.build == 'true'.*vcpkg-cache\.outputs\.enabled == 'true'/); + assert.match(save.step, /steps\.vcpkg-restore\.outputs\.cache-hit != 'true'/); + assert.match(save.step, /continue-on-error: true/); + assert.match(save.step, /path: \$\{\{ steps\.vcpkg-cache\.outputs\.directory \}\}/); + assert.match(save.step, /key: \$\{\{ steps\.vcpkg-cache\.outputs\.key \}\}/); +}); const profiles = [ ['network', 'Configure network regressions', 'ci-build', ['check-network']], ['applications', 'Configure applications', 'ci-build', ['check-core', 'check-entity-benchmark-smoke']],