mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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'
|
||||
]) {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
Reference in New Issue
Block a user