ci: skip unneeded native builds for proven test-only changes (#29169)

* ci: avoid native compatibility builds for proven test-only edits

* ci: recognize existing serializer test diagnostics
This commit is contained in:
Neil
2026-10-15 08:33:51 -07:00
committed by GitHub
parent fca6a945c4
commit f4abedd7ec
7 changed files with 739 additions and 46 deletions
+18 -4
View File
@@ -60,6 +60,7 @@ jobs:
outputs:
should_run: ${{ steps.scope.outputs.should_run || steps.graph.outputs.should_run }}
qualification: ${{ steps.scope.outputs.qualification || steps.graph.outputs.qualification }}
native_compatibility: ${{ steps.scope.outputs.native_compatibility || steps.graph.outputs.native_compatibility }}
runners: ${{ steps.scope.outputs.runners || steps.graph.outputs.runners }}
steps:
- uses: actions/checkout@v6
@@ -76,6 +77,7 @@ jobs:
env:
PUSH_BASE: ${{ github.event.before }}
EVENT_NAME: ${{ github.event_name }}
ALLOW_TEST_ONLY_NATIVE_SKIP: ${{ github.event_name == 'pull_request' && !inputs.build_template && inputs.ref == '' }}
run: |
if [ "$EVENT_NAME" = push ]; then
# Compare the entire push, including multi-commit pushes and removed files.
@@ -88,10 +90,15 @@ jobs:
fi
exit 0
fi
NATIVE_SCOPE_ARGS=()
if [ "$ALLOW_TEST_ONLY_NATIVE_SKIP" = true ]; then
NATIVE_SCOPE_ARGS=(--pull-request --diff-paths "$RUNNER_TEMP/node-server-native-diff")
fi
# Compare the tested merge with its base, retaining both sides of renames.
if git diff --name-only --no-renames -z HEAD^1 HEAD > "$RUNNER_TEMP/node-server-changes" &&
git diff --name-only --no-renames --diff-filter=M -z HEAD^1 HEAD > "$RUNNER_TEMP/node-server-modified"; then
node config/scripts/node-server-change-scope.mjs "$RUNNER_TEMP/node-server-changes" --defer-graph --modified-paths "$RUNNER_TEMP/node-server-modified"
git diff --name-only --no-renames --diff-filter=M -z HEAD^1 HEAD > "$RUNNER_TEMP/node-server-modified" &&
git diff --raw --no-renames --no-abbrev -z HEAD^1 HEAD > "$RUNNER_TEMP/node-server-native-diff"; then
node config/scripts/node-server-change-scope.mjs "$RUNNER_TEMP/node-server-changes" --defer-graph --modified-paths "$RUNNER_TEMP/node-server-modified" "${NATIVE_SCOPE_ARGS[@]}"
else
echo 'should_run=true' >> "$GITHUB_OUTPUT"
fi
@@ -108,11 +115,16 @@ jobs:
shell: bash
env:
EVENT_NAME: ${{ github.event_name }}
ALLOW_TEST_ONLY_NATIVE_SKIP: ${{ github.event_name == 'pull_request' && !inputs.build_template && inputs.ref == '' }}
run: |
if [ "$EVENT_NAME" = push ]; then
node config/scripts/node-server-change-scope.mjs "$RUNNER_TEMP/node-server-changes" --full-qualification --modified-paths "$RUNNER_TEMP/node-server-modified"
else
node config/scripts/node-server-change-scope.mjs "$RUNNER_TEMP/node-server-changes" --modified-paths "$RUNNER_TEMP/node-server-modified"
NATIVE_SCOPE_ARGS=()
if [ "$ALLOW_TEST_ONLY_NATIVE_SKIP" = true ]; then
NATIVE_SCOPE_ARGS=(--pull-request --diff-paths "$RUNNER_TEMP/node-server-native-diff")
fi
node config/scripts/node-server-change-scope.mjs "$RUNNER_TEMP/node-server-changes" --modified-paths "$RUNNER_TEMP/node-server-modified" "${NATIVE_SCOPE_ARGS[@]}"
fi
persistence:
@@ -359,7 +371,9 @@ jobs:
if: >-
${{ !cancelled() && needs.persistence.result == 'success' &&
needs.changes.outputs.should_run != 'false' &&
needs.changes.outputs.qualification != 'false' }}
needs.changes.outputs.qualification != 'false' &&
(github.event_name != 'pull_request' || inputs.build_template || inputs.ref != '' ||
needs.changes.outputs.native_compatibility != 'false') }}
runs-on: ubuntu-22.04
timeout-minutes: 20
env:
@@ -74,6 +74,7 @@ it('activates the compiler and workspace-policy parser and preserves import grap
'node-server-change-scope',
'node-server-test-paths',
'node-server-qualification',
'node-server-native-compat-scope',
'orcad-entry-build',
'workspace-source-exports'
]) {
+20 -3
View File
@@ -3,6 +3,7 @@ import { resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import { nodeServerTestPaths } from './node-server-test-paths.mjs'
import { nodeServerQualification } from './node-server-qualification.mjs'
import { classifyNativeCompatChanges } from './node-server-native-compat-scope.mjs'
const ROOT = resolve(import.meta.dirname, '../..')
const BUILD_SCRIPTS = [
@@ -37,6 +38,7 @@ const ALWAYS_FILES = new Set([
'tsconfig.json',
'.github/workflows/node-server-tests.yml',
'config/scripts/node-server-change-scope.mjs',
'config/scripts/node-server-native-compat-scope.mjs',
...NODE_SERVER_POLICY_TESTS,
'config/scripts/workspace-source-exports.mjs',
'config/scripts/workspace-source-exports.test.mjs',
@@ -216,9 +218,24 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
const policy = nodeServerQualification(changedFiles, result, {
fullQualification: process.argv.includes('--full-qualification')
})
const output = result.graphRequired
? 'graph_required=true\n'
: `should_run=${result.shouldRun}\nqualification=${policy.qualification}\nrunners=${JSON.stringify(policy.runners)}\n`
const diffArgument = process.argv.indexOf('--diff-paths')
const native = result.graphUnavailable
? { nativeCompatibility: true, reason: 'Server dependency graph unavailable' }
: await classifyNativeCompatChanges(changedFiles, collectNodeServerInputs, {
pullRequest: process.argv.includes('--pull-request'),
fullQualification: process.argv.includes('--full-qualification'),
rawDiff:
diffArgument === -1 ? undefined : readFileSync(process.argv[diffArgument + 1], 'utf8'),
deferGraph: process.argv.includes('--defer-graph')
})
console.log(native.reason)
let output = result.graphRequired || native.graphRequired ? 'graph_required=true\n' : ''
if (!result.graphRequired) {
output += `should_run=${result.shouldRun}\nqualification=${policy.qualification}\nrunners=${JSON.stringify(policy.runners)}\n`
}
if (!native.graphRequired) {
output += `native_compatibility=${native.nativeCompatibility}\n`
}
if (process.env.GITHUB_OUTPUT) {
appendFileSync(process.env.GITHUB_OUTPUT, output)
} else {
@@ -158,6 +158,7 @@ it.each([
'tsconfig.json',
'config/tsconfig.node.json',
'config/scripts/node-server-qualification.mjs',
'config/scripts/node-server-native-compat-scope.mjs',
'config/patches/node-pty@1.1.0.patch',
'native/windows-registry/src/addon.cc',
'.github/actions/install-node-dependencies/action.yml',
@@ -297,29 +298,48 @@ it.each([
fullQualification: false,
expected: 'should_run=true'
},
{
files: ['src/main/persistence/profile-state/profile-state-recovery-crash-boundaries.test.ts'],
deferred: true,
fullQualification: false,
pullRequest: true,
nativeGraph: true,
expected: 'should_run=true'
},
{ files: ['src/renderer/src/example.ts'], deferred: true, expected: 'graph_required=true' },
{ files: ['src/renderer/src/example.ts'], deferred: false, expected: 'should_run=true' }
])('fails closed or requests dependencies in an uninstalled checkout: %j', (scenario) => {
const root = moduleTree(
Object.fromEntries(
['node-server-change-scope', 'node-server-test-paths', 'node-server-qualification'].map(
(name) => [
`config/scripts/${name}.mjs`,
readFileSync(new URL(`./${name}.mjs`, import.meta.url), 'utf8')
]
)
[
'node-server-change-scope',
'node-server-test-paths',
'node-server-qualification',
'node-server-native-compat-scope'
].map((name) => [
`config/scripts/${name}.mjs`,
readFileSync(new URL(`./${name}.mjs`, import.meta.url), 'utf8')
])
)
)
const changes = join(root, 'changes')
const stepOutput = join(root, 'step-output')
const statuses = join(root, 'native-diff')
writeFileSync(changes, scenario.files.map((file) => `${file}\0`).join(''))
writeFileSync(
statuses,
scenario.files
.map((file) => `:100644 100644 ${'1'.repeat(40)} ${'2'.repeat(40)} M\0${file}\0`)
.join('')
)
const result = runProcessSync({
program: process.execPath,
args: [
realpathSync(join(root, 'config/scripts/node-server-change-scope.mjs')),
changes,
...(scenario.fullQualification === false ? [] : ['--full-qualification']),
...(scenario.deferred ? ['--defer-graph'] : [])
...(scenario.deferred ? ['--defer-graph'] : []),
...(scenario.pullRequest ? ['--pull-request', '--diff-paths', statuses] : [])
],
cwd: root,
env: { ...process.env, GITHUB_OUTPUT: stepOutput },
@@ -328,6 +348,10 @@ it.each([
expect(result.code).toBe(0)
const output = readFileSync(stepOutput, 'utf8')
expect(output).toContain(scenario.expected)
if (scenario.nativeGraph) {
expect(output).toContain('graph_required=true')
expect(output).not.toContain('native_compatibility=')
}
if (scenario.expected === 'graph_required=true') {
expect(output).not.toContain('should_run=')
expect(output).not.toContain('runners=')
@@ -555,38 +579,6 @@ it('keeps every platform job and runs them when detection is skipped or fails',
}
})
it('builds server glibc slots on glibc 2.28 and the compat slot on glibc 2.17 (design D6)', () => {
const workflow = parse(
readFileSync(new URL('../../.github/workflows/node-server-tests.yml', import.meta.url), 'utf8')
)
const floor = workflow.jobs.linux_glibc_floor
expect(floor.container).toBe('${{ matrix.image }}')
expect(floor.strategy.matrix.include).toEqual([
{
os: 'ubuntu-22.04',
image: expect.stringMatching(/^quay\.io\/pypa\/manylinux_2_28_x86_64@sha256:[0-9a-f]{64}$/)
},
{
os: 'ubuntu-24.04-arm',
image: expect.stringMatching(/^quay\.io\/pypa\/manylinux_2_28_aarch64@sha256:[0-9a-f]{64}$/)
}
])
expect(floor.env).toMatchObject({ CC: 'gcc', CXX: 'g++' })
const compat = workflow.jobs.linux_glibc217_compat
expect(compat.needs).toEqual(['changes', 'persistence'])
expect(compat.if).toBe(floor.if)
expect(compat['runs-on']).toBe('ubuntu-22.04')
const run = compat.steps.map((step) => step.run ?? '').join('\n')
expect(run).toMatch(/quay\.io\/pypa\/manylinux2014_x86_64@sha256:[0-9a-f]{64} /)
expect(run).toContain('--slot=linux-x64-glibc217 --print-runtime')
expect(run).toContain('build-orcad-prebuilds.mjs --slot=linux-x64-glibc217\n')
expect(run).toContain('--require-slots linux-x64-glibc217')
expect(run).toContain(
'env -u LD_LIBRARY_PATH node config/scripts/build-orcad-prebuilds.mjs --slot=linux-x64-glibc217 --smoke'
)
})
it('runs the Bun and Node cross-runtime tests on Linux against pinned inputs', () => {
const workflow = parse(
readFileSync(new URL('../../.github/workflows/node-server-tests.yml', import.meta.url), 'utf8')
File diff suppressed because one or more lines are too long
@@ -0,0 +1,133 @@
// The glibc 2.17 lane builds and smokes native slots; it does not run the server tests.
export const NATIVE_COMPAT_ENTRY_POINTS = [
'config/scripts/build-orcad-prebuilds.mjs',
'config/scripts/orcad-prebuild-smoke.mjs',
'config/scripts/orcad-prebuild-smoke-child.cjs',
// createRequire and the smoke subprocess do not expose these edges to esbuild.
'config/scripts/node-pty-job-ownership.cjs',
'config/scripts/verify-linux-glibc-floor.cjs'
]
// These files configure or support tests; additions/deletions still require qualification.
const MODIFIED_TEST_INPUTS = new Set([
'config/reliability-gates.jsonc',
'src/main/daemon/serialize-grid-variant-scope.ts',
'config/scripts/vitest-sqlite-database-runtime-files.mjs',
'src/main/native-chat/agent-session-journal/journal-host-database-test-support.ts'
])
function isRepositoryPath(file) {
return (
typeof file === 'string' &&
file.length > 0 &&
!file.startsWith('/') &&
!file.includes(':') &&
!/[\\\0\r\n]/.test(file) &&
file.split('/').every((part) => part !== '' && part !== '.' && part !== '..')
)
}
function isOrdinaryChange(header) {
const match =
/^:(\d{6}) (\d{6}) ([a-f0-9]{40}|[a-f0-9]{64}) ([a-f0-9]{40}|[a-f0-9]{64}) ([ADM])$/.exec(
header
)
if (!match) {
return false
}
const [, beforeMode, afterMode, before, after, status] = match
if (before.length !== after.length) {
return false
}
const ordinary = (mode) => mode === '100644' || mode === '100755'
const absent = (mode, hash) => mode === '000000' && /^0+$/.test(hash)
const present = (mode, hash) => ordinary(mode) && !/^0+$/.test(hash)
if (status === 'A') {
return absent(beforeMode, before) && present(afterMode, after)
}
if (status === 'D') {
return present(beforeMode, before) && absent(afterMode, after)
}
return present(beforeMode, before) && present(afterMode, after)
}
export function readNativeCompatDiff(changedFiles, rawDiff) {
if (
changedFiles.length === 0 ||
new Set(changedFiles).size !== changedFiles.length ||
!changedFiles.every(isRepositoryPath) ||
typeof rawDiff !== 'string' ||
!rawDiff.endsWith('\0')
) {
return null
}
const fields = rawDiff.slice(0, -1).split('\0')
if (fields.length !== changedFiles.length * 2) {
return null
}
const entries = []
for (let index = 0; index < fields.length; index += 2) {
const header = fields[index]
const file = fields[index + 1]
if (!isOrdinaryChange(header) || !isRepositoryPath(file)) {
return null
}
entries.push({ status: header.slice(-1), file })
}
const files = new Set(entries.map(({ file }) => file))
return files.size === changedFiles.length && changedFiles.every((file) => files.has(file))
? entries
: null
}
function isTestInput({ status, file }) {
// Build policy and process-host fixtures can be read without a static import.
if (
file.startsWith('src/packages/') ||
/^config\/scripts\/[^/]*(?:orcad|node-server|node-pty|native|glibc|pinned-node|node-runtime|workspace-source-exports|script-module-dependencies)[^/]*$/.test(
file
)
) {
return false
}
return (
(status === 'M' && MODIFIED_TEST_INPUTS.has(file)) ||
/^src\/(?:[^/]+\/)*[^/]+\.(?:test|spec)\.(?:ts|tsx)$/.test(file) ||
/^config\/scripts\/(?:[^/]+\/)*[^/]+\.test\.(?:ts|mjs)$/.test(file) ||
/^tests\/e2e\/(?:[^/]+\/)*[^/]+\.unit\.test\.ts$/.test(file)
)
}
export async function classifyNativeCompatChanges(
changedFiles,
collect,
{ pullRequest = false, fullQualification = false, rawDiff, deferGraph = false } = {}
) {
const required = (reason) => ({ nativeCompatibility: true, reason })
if (!pullRequest || fullQualification) {
return required('Native qualification is unconditional outside an ordinary PR')
}
const entries = readNativeCompatDiff(changedFiles, rawDiff)
if (!entries || !entries.every(isTestInput)) {
return required('The complete diff is not limited to recognized test inputs')
}
if (deferGraph) {
return { graphRequired: true, reason: 'Native build and smoke imports must be checked' }
}
try {
const inputs = await collect({ entryPoints: NATIVE_COMPAT_ENTRY_POINTS })
if (
!(inputs instanceof Set) ||
![...inputs].every(isRepositoryPath) ||
!NATIVE_COMPAT_ENTRY_POINTS.every((file) => inputs.has(file))
) {
return required('The native build and smoke input graph is incomplete')
}
const matched = changedFiles.find((file) => inputs.has(file))
return matched
? required(`Native build or smoke input changed: ${matched}`)
: { nativeCompatibility: false, reason: 'Only unconsumed test inputs changed' }
} catch (error) {
return required(`Native dependency graph unavailable: ${String(error)}`)
}
}
@@ -0,0 +1,404 @@
import { readFileSync } from 'node:fs'
import { expect, it, vi } from 'vitest'
import { parse } from 'yaml'
import { compileAggregateExpression } from './pr-aggregate-gate-context.mjs'
import {
classifyNativeCompatChanges,
NATIVE_COMPAT_ENTRY_POINTS,
readNativeCompatDiff
} from './node-server-native-compat-scope.mjs'
import { classifyNodeServerChanges, collectNodeServerInputs } from './node-server-change-scope.mjs'
import { NODE_SERVER_RUNNERS, nodeServerQualification } from './node-server-qualification.mjs'
const root = 'src/main/persistence/profile-state/profile-state-recovery-crash-boundaries.test.ts'
const support = 'src/main/native-chat/agent-session-journal/journal-host-database-test-support.ts'
const cleanup = JSON.parse(
readFileSync(
new URL('./node-server-native-compat-cleanup.test-fixture.json', import.meta.url),
'utf8'
)
)
const rawHeader = (status, beforeMode, afterMode) => {
const before = (status === 'A' ? '0' : '1').repeat(40)
const after = (status === 'D' ? '0' : '2').repeat(40)
return `:${beforeMode} ${afterMode} ${before} ${after} ${status}`
}
const rawDiff = (entries) =>
entries
.map(([status, file]) => {
const beforeMode = status === 'A' ? '000000' : '100644'
const afterMode = status === 'D' ? '000000' : '100644'
return `${rawHeader(status, beforeMode, afterMode)}\0${file}\0`
})
.join('')
const filesOf = (entries) => entries.map(([, file]) => file)
const nativeInputs = () => new Set(NATIVE_COMPAT_ENTRY_POINTS)
const optionsFor = (entries) => ({ pullRequest: true, rawDiff: rawDiff(entries) })
it('reconciles complete no-rename status evidence including additions and deletions', () => {
const entries = [
['M', root],
['A', 'src/shared/new.spec.ts'],
['D', 'src/shared/old.test.ts']
]
expect(readNativeCompatDiff(filesOf(entries), rawDiff(entries))).toEqual(
entries.map(([status, file]) => ({ status, file }))
)
})
it.each([
[[], ''],
[[root], undefined],
[[root], rawDiff([['M', root]]).slice(0, -1)],
[[root], `${rawDiff([['M', root]])}trailing\0`],
[
[root],
rawDiff([
['M', root],
['M', root]
])
],
[
[root, root],
rawDiff([
['M', root],
['M', root]
])
],
[
[root, 'src/shared/other.test.ts'],
rawDiff([
['M', root],
['M', root]
])
],
[[root], rawDiff([['M', 'src/shared/foreign.test.ts']])],
[[root], rawDiff([['R100', root]])],
[[root], rawDiff([['T', root]])],
...[
'src/../outside.test.ts',
'src/./outside.test.ts',
'src//outside.test.ts',
'/src/outside.test.ts',
'src\\outside.test.ts',
'src/C:/outside.test.ts'
].map((file) => [[file], rawDiff([['M', file]])])
])(
'requires native qualification for incomplete or malformed diff evidence: %j',
async (files, raw) => {
const collect = vi.fn(nativeInputs)
const result = await classifyNativeCompatChanges(files, collect, {
pullRequest: true,
rawDiff: raw
})
expect(result.nativeCompatibility).toBe(true)
expect(collect).not.toHaveBeenCalled()
}
)
it.each([
['120000', '120000', 'M'],
['000000', '120000', 'A'],
['120000', '000000', 'D'],
['160000', '160000', 'M'],
['100644', '120000', 'T'],
['100644', '100644', 'A'],
['000000', '000000', 'D'],
['100644', '000000', 'M']
])('rejects nonordinary or inconsistent modes: %s %s %s', async (beforeMode, afterMode, status) => {
const raw = `${rawHeader(status, beforeMode, afterMode)}\0${root}\0`
const collect = vi.fn(nativeInputs)
const result = await classifyNativeCompatChanges([root], collect, {
pullRequest: true,
rawDiff: raw
})
expect(result.nativeCompatibility).toBe(true)
expect(collect).not.toHaveBeenCalled()
})
it('checks the native closure before skipping a selected server test', async () => {
const entries = [['M', root]]
const collect = vi.fn(nativeInputs)
const options = optionsFor(entries)
const deferred = await classifyNativeCompatChanges([root], collect, {
...options,
deferGraph: true
})
expect(deferred.graphRequired).toBe(true)
expect(deferred.nativeCompatibility).toBeUndefined()
expect(collect).not.toHaveBeenCalled()
expect(await classifyNativeCompatChanges([root], collect, options)).toMatchObject({
nativeCompatibility: false
})
expect(collect).toHaveBeenCalledWith({ entryPoints: NATIVE_COMPAT_ENTRY_POINTS })
})
it('retains native qualification when a recognized test is consumed by its builder or smoke', async () => {
const result = await classifyNativeCompatChanges(
[root],
async () => new Set([...NATIVE_COMPAT_ENTRY_POINTS, root]),
optionsFor([['M', root]])
)
expect(result.nativeCompatibility).toBe(true)
})
it.each(['missing-root', 'malformed-input', 'unavailable'])(
'retains native qualification when closure evidence is %s',
async (evidence) => {
const collect = async () => {
if (evidence === 'unavailable') {
throw new Error('missing imported native input')
}
return evidence === 'missing-root'
? new Set()
: new Set([...NATIVE_COMPAT_ENTRY_POINTS, '../bad'])
}
const result = await classifyNativeCompatChanges([root], collect, optionsFor([['M', root]]))
expect(result.nativeCompatibility).toBe(true)
}
)
it.each([
'package.json',
'pnpm-lock.yaml',
'.npmrc',
'src/shared/node-runtime-pin.ts',
'src/shared/product-source.ts',
'src/packages/process-host/src/run-process.test.ts',
'native/example/fixture.test.ts',
'config/patches/node-pty@1.1.0.patch',
'config/scripts/build-orcad-prebuilds.mjs',
'config/scripts/orcad-prebuild-smoke.test.mjs',
'config/scripts/node-server-native-compat-scope.mjs',
'config/scripts/node-server-native-compat-scope.test.mjs',
'config/scripts/node-server-native-compat-cleanup.test-fixture.json',
'config/scripts/node-server-change-scope.mjs',
'config/scripts/verify-linux-glibc-floor.cjs',
'.github/actions/prepare-native-runtime/action.yml',
'.github/actions/prepare-headless-compiler/action.yml',
'.github/workflows/node-server-tests.yml',
'src/main/persistence/worker.test-fixture.ts',
'src/main/native-chat/new-test-support.ts',
'docs/ci.md'
])('keeps an unknown or protected input even when a test changes too: %s', async (file) => {
const entries = [
['M', root],
['M', file]
]
const collect = vi.fn(nativeInputs)
const result = await classifyNativeCompatChanges(filesOf(entries), collect, optionsFor(entries))
expect(result.nativeCompatibility).toBe(true)
expect(collect).not.toHaveBeenCalled()
})
it.each(['A', 'D'])(
'retains qualification for %s of a named test-support input',
async (status) => {
const entries = [[status, support]]
const result = await classifyNativeCompatChanges([support], nativeInputs, optionsFor(entries))
expect(result.nativeCompatibility).toBe(true)
}
)
it.each([
[
['D', 'src/shared/source.ts'],
['A', 'src/shared/source.test.ts']
],
[
['D', 'src/shared/source.test.ts'],
['A', 'src/shared/source.ts']
]
])(
'retains qualification for a rename crossing production and test paths: %j',
async (...entries) => {
const result = await classifyNativeCompatChanges(
filesOf(entries),
nativeInputs,
optionsFor(entries)
)
expect(result.nativeCompatibility).toBe(true)
}
)
it.each([{}, { pullRequest: false }, { pullRequest: true, fullQualification: true }])(
'keeps native qualification without ordinary PR authorization: %j',
async (options) => {
const collect = vi.fn(nativeInputs)
const result = await classifyNativeCompatChanges([root], collect, {
rawDiff: rawDiff([['M', root]]),
...options
})
expect(result.nativeCompatibility).toBe(true)
expect(collect).not.toHaveBeenCalled()
}
)
it('qualifies a standalone change to the executable native routing policy', async () => {
const file = 'config/scripts/node-server-native-compat-scope.mjs'
const collect = vi.fn(nativeInputs)
const scope = await classifyNodeServerChanges([file], collect, { modifiedFiles: [file] })
expect(scope.shouldRun).toBe(true)
expect(collect).not.toHaveBeenCalled()
expect(nodeServerQualification([file], scope)).toEqual({
qualification: true,
runners: NODE_SERVER_RUNNERS
})
const native = await classifyNativeCompatChanges([file], collect, optionsFor([['M', file]]))
expect(native.nativeCompatibility).toBe(true)
expect(collect).not.toHaveBeenCalled()
})
it('recognizes the complete 119-path cleanup while retaining all runtime platform qualification', async () => {
const entries = cleanup.paths
expect(entries).toHaveLength(119)
const files = filesOf(entries)
const native = await classifyNativeCompatChanges(files, nativeInputs, {
pullRequest: true,
rawDiff: cleanup.rawDiff
})
expect(native.nativeCompatibility).toBe(false)
const scope = await classifyNodeServerChanges(files, async () => {
throw new Error('the selected profile-state test forces runtime qualification')
})
expect(scope.shouldRun).toBe(true)
expect(scope.graphUnavailable).toBeUndefined()
expect(nodeServerQualification(files, scope)).toEqual({
qualification: true,
runners: NODE_SERVER_RUNNERS
})
})
it('checks the cleanup against the actual native build and smoke closure', async () => {
const files = filesOf(cleanup.paths)
const result = await classifyNativeCompatChanges(files, collectNodeServerInputs, {
pullRequest: true,
rawDiff: cleanup.rawDiff
})
expect(result.nativeCompatibility).toBe(false)
}, 60_000)
it('limits the extra job guard to glibc 2.17 and preserves unconditional callers', () => {
const workflow = parse(
readFileSync(new URL('../../.github/workflows/node-server-tests.yml', import.meta.url), 'utf8')
)
const compat = workflow.jobs.linux_glibc217_compat
expect(compat.if).toContain("needs.changes.outputs.native_compatibility != 'false'")
expect(compat.if).toContain(
"github.event_name != 'pull_request' || inputs.build_template || inputs.ref != ''"
)
for (const job of [
workflow.jobs.persistence,
workflow.jobs.linux_glibc_floor,
workflow.jobs.linux_musl
]) {
expect(job.if).not.toContain('native_compatibility')
}
const scope = workflow.jobs.changes.steps.find((step) => step.id === 'scope')
const graph = workflow.jobs.changes.steps.find((step) => step.id === 'graph')
expect(scope.run).toContain('git diff --raw --no-renames --no-abbrev -z HEAD^1 HEAD')
for (const step of [scope, graph]) {
expect(step.env.ALLOW_TEST_ONLY_NATIVE_SKIP).toBe(
"${{ github.event_name == 'pull_request' && !inputs.build_template && inputs.ref == '' }}"
)
expect(step.run).toContain('--pull-request --diff-paths "$RUNNER_TEMP/node-server-native-diff"')
}
expect(workflow.on).toHaveProperty('workflow_dispatch')
expect(workflow.on).toHaveProperty('workflow_call')
expect(workflow.on.schedule).toHaveLength(1)
})
it('builds server glibc slots on glibc 2.28 and the compat slot on glibc 2.17 (design D6)', () => {
const workflow = parse(
readFileSync(new URL('../../.github/workflows/node-server-tests.yml', import.meta.url), 'utf8')
)
const floor = workflow.jobs.linux_glibc_floor
expect(floor.container).toBe('${{ matrix.image }}')
expect(floor.strategy.matrix.include).toEqual([
{
os: 'ubuntu-22.04',
image: expect.stringMatching(/^quay\.io\/pypa\/manylinux_2_28_x86_64@sha256:[0-9a-f]{64}$/)
},
{
os: 'ubuntu-24.04-arm',
image: expect.stringMatching(/^quay\.io\/pypa\/manylinux_2_28_aarch64@sha256:[0-9a-f]{64}$/)
}
])
expect(floor.env).toMatchObject({ CC: 'gcc', CXX: 'g++' })
const compat = workflow.jobs.linux_glibc217_compat
expect(compat.needs).toEqual(['changes', 'persistence'])
expect(compat.if).toContain(floor.if.slice(0, -3))
expect(compat.if).toContain("needs.changes.outputs.native_compatibility != 'false'")
expect(compat.if).toContain(
"github.event_name != 'pull_request' || inputs.build_template || inputs.ref != ''"
)
expect(compat['runs-on']).toBe('ubuntu-22.04')
const run = compat.steps.map((step) => step.run ?? '').join('\n')
expect(run).toMatch(/quay\.io\/pypa\/manylinux2014_x86_64@sha256:[0-9a-f]{64} /)
expect(run).toContain('--slot=linux-x64-glibc217 --print-runtime')
expect(run).toContain('build-orcad-prebuilds.mjs --slot=linux-x64-glibc217\n')
expect(run).toContain('--require-slots linux-x64-glibc217')
expect(run).toContain(
'env -u LD_LIBRARY_PATH node config/scripts/build-orcad-prebuilds.mjs --slot=linux-x64-glibc217 --smoke'
)
})
const nativeWorkflow = parse(
readFileSync(new URL('../../.github/workflows/node-server-tests.yml', import.meta.url), 'utf8')
)
const nativeJob = compileAggregateExpression(nativeWorkflow.jobs.linux_glibc217_compat.if, [
'needs',
'github',
'inputs'
])
it.each([
{ expected: false, native: 'false' },
{ expected: true, native: 'true' },
{ expected: true, native: '' },
{ expected: true },
{ expected: true, native: 'false', event: 'push' },
{ expected: true, native: 'false', event: 'schedule' },
{ expected: true, native: 'false', event: 'workflow_dispatch' },
{ expected: true, native: 'false', template: true },
{ expected: true, native: 'false', ref: 'other-commit' },
{ expected: false, native: 'true', persistence: 'failure' },
{ expected: false, native: 'true', persistence: 'cancelled' },
{ expected: false, native: 'true', persistence: 'skipped' },
{ expected: false, native: 'true', qualification: 'false' },
{ expected: false, native: 'true', shouldRun: 'false' },
{ expected: false, native: 'true', cancelled: true }
])('evaluates the hosted native job guard with %j', (scenario) => {
const context = {
github: { event_name: scenario.event ?? 'pull_request' },
inputs: { build_template: scenario.template ?? false, ref: scenario.ref ?? '' },
needs: {
persistence: { result: scenario.persistence ?? 'success' },
changes: {
outputs: {
should_run: scenario.shouldRun ?? 'true',
qualification: scenario.qualification ?? 'true',
...(scenario.native === undefined ? {} : { native_compatibility: scenario.native })
}
}
}
}
expect(nativeJob(context, scenario.cancelled ?? false).coerceString()).toBe(
String(scenario.expected)
)
})
it.each(['M', 'A', 'D'])(
'checks %s of the existing serializer test diagnostics against the native closure',
async (status) => {
const file = 'src/main/daemon/serialize-grid-variant-scope.ts'
const result = await classifyNativeCompatChanges(
[file],
collectNodeServerInputs,
optionsFor([[status, file]])
)
expect(result.nativeCompatibility).toBe(status !== 'M')
},
60_000
)