[CI] Run focused checks for Mobile CI tool changes (#27532)

This commit is contained in:
Neil
2026-10-11 11:49:05 -07:00
committed by GitHub
parent 3359fd2bfc
commit e7da462dbf
4 changed files with 253 additions and 11 deletions
+29 -1
View File
@@ -39,6 +39,7 @@ jobs:
mobile_web_app: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.mobile_web_app }}
static_analysis: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.static_analysis }}
typecheck: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.typecheck }}
mobile_ci_tools: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.mobile_ci_tools }}
git_compatibility: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.git_compatibility }}
codex_index_heal_contract: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.codex_index_heal_contract }}
xterm_patch_sync: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.xterm_patch_sync }}
@@ -115,7 +116,11 @@ jobs:
CHANGED="$(git diff --name-only --no-renames --diff-filter=ACDMR "$DIFF_BASE" HEAD)"
echo "Changed paths:"
printf '%s\n' "$CHANGED"
printf '%s\n' "$CHANGED" | node config/scripts/pr-code-change-scope.mjs | tee -a "$GITHUB_OUTPUT"
MOBILE_CI_ARGS=()
if git diff --quiet --no-renames --diff-filter=ACDRTUXB "$DIFF_BASE" HEAD; then
MOBILE_CI_ARGS=(--mobile-ci-tools)
fi
printf '%s\n' "$CHANGED" | node config/scripts/pr-code-change-scope.mjs "${MOBILE_CI_ARGS[@]}" | tee -a "$GITHUB_OUTPUT"
# Reuse the path-detector checkout instead of queuing another runner.
- name: Filter changed E2E specs
@@ -461,6 +466,28 @@ jobs:
path: ci-shards/unit-selection.json
retention-days: 14
- name: Set up Bun for Mobile CI tool tests
if: needs.code_paths.outputs.mobile_ci_tools == 'true'
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version-file: config/.bun-version
- name: Test Mobile CI tools and runtime routing
if: needs.code_paths.outputs.mobile_ci_tools == 'true'
env:
ORCA_BACKGROUND_LAUNCH: '1'
ORCA_BALANCE_UNIT_SHARDS: '1'
run: >-
node config/scripts/run-vitest.mjs run --config config/vitest.config.ts
--maxWorkers=4 --isolate --retry=0 --bail=0
config/scripts/mobile-release-check-scope.test.mjs
config/scripts/mobile-test-change-scope.test.mjs
config/scripts/pr-ready-check-reuse.test.mjs
config/scripts/ci-dependency-download-cache.test.mjs
config/scripts/win32-test-lane-registration.test.mjs
config/scripts/vitest-node-runtime-boundary.test.ts
config/scripts/vitest-sqlite-runtime-boundary.test.ts
git_compatibility:
name: Git compatibility
needs: [code_paths, preflight]
@@ -811,6 +838,7 @@ jobs:
node_versions: '["24"]'
runner: ubuntu-24.04-arm
shards: ${{ needs.preflight.outputs.shards }}
run_shards: ${{ needs.code_paths.outputs.mobile_ci_tools != 'true' }}
browser_contract: ${{ needs.code_paths.outputs.orcad_browser == 'true' }}
# Why a sibling and not part of the test workflow: it is advisory, so it must not delay the
+7
View File
@@ -20,6 +20,12 @@ on:
default: false
type: boolean
run_shards:
description: Run the planned root unit shards.
required: false
default: true
type: boolean
shards:
description: JSON array of shard assignments, produced by unit-plan.yml.
required: true
@@ -30,6 +36,7 @@ permissions:
jobs:
test:
if: inputs.run_shards
name: tests node ${{ matrix.node }} ${{ matrix.shard.index }}/${{ matrix.shard.count }}
runs-on: ${{ inputs.runner }}
timeout-minutes: 60
@@ -1,6 +1,17 @@
import { readFileSync } from 'node:fs'
import {
copyFileSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
symlinkSync,
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { runInNewContext } from 'node:vm'
import { describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it } from 'vitest'
import { runProcessSync } from '@orca/process-host'
import { parse } from 'yaml'
import { classifyPrJobs } from './pr-code-change-scope.mjs'
@@ -28,8 +39,8 @@ function evaluate(expression, context) {
)
}
function routedOutputs(files, reused) {
const scope = classifyPrJobs(files)
function routedOutputs(files, reused, options) {
const scope = classifyPrJobs(files, options)
const context = {
steps: {
readiness: { outputs: { reused } },
@@ -41,10 +52,9 @@ function routedOutputs(files, reused) {
}
}
return Object.fromEntries(
['test', 'static_analysis', 'typecheck', ...routes.map(([job]) => job)].map((name) => [
name,
String(evaluate(workflow.jobs.code_paths.outputs[name], context))
])
['test', 'static_analysis', 'typecheck', 'mobile_ci_tools', ...routes.map(([job]) => job)].map(
(name) => [name, String(evaluate(workflow.jobs.code_paths.outputs[name], context))]
)
)
}
@@ -207,3 +217,183 @@ it('guards both browser steps after setup or discovery failure and for default c
expect(evaluate(chrome.if, context)).toBe(false)
expect(evaluate(browser.if, context)).toBe(false)
})
describe('Mobile CI tool preflight routing', () => {
const verified = { allowMobileCiTools: true }
it.each([
{ files: ['config/scripts/run-mobile-ci-tests.mjs', 'config/mobile-ci.vitest.config.mjs'] },
{ files: ['config/scripts/mobile-release-check-scope.test.mjs'] },
{ files: ['config/scripts/mobile-test-change-scope.test.mjs'] }
])('keeps affected controls required without starting root shards for $files', ({ files }) => {
const outputs = routedOutputs(files, 'false', verified)
expect(outputs.mobile_ci_tools).toBe('true')
expect(outputs.test).toBe('true')
const context = { needs: { code_paths: { outputs } } }
expect(evaluate(workflow.jobs.preflight.if, context)).toBe(true)
expect(evaluate(workflow.jobs.test.with.run_shards, context)).toBe(false)
expect(evaluate(unit.jobs.test.if, { inputs: { run_shards: false } })).toBe(false)
const required = workflow.jobs.verify.steps.find(
(step) => step.name === 'Require successful checks'
)
expect(evaluate(required.env.PREFLIGHT_SHOULD_RUN, context)).toBe(true)
})
it('keeps workflow-only changes on their original skipped root route', () => {
const outputs = routedOutputs(['.github/workflows/mobile.yml'], 'false', verified)
expect(outputs.mobile_ci_tools).toBe('false')
expect(outputs.test).toBe('false')
expect(outputs.static_analysis).toBe('false')
expect(outputs.typecheck).toBe('false')
expect(evaluate(workflow.jobs.preflight.if, { needs: { code_paths: { outputs } } })).toBe(false)
})
it('keeps root shard execution enabled for callers that do not opt out', () => {
expect(unit.on.workflow_call.inputs.run_shards.default).toBe(true)
expect(evaluate(unit.jobs.test.if, { inputs: { run_shards: true } })).toBe(true)
expect(unit.jobs.relay_integration.if).toBeUndefined()
expect(unit.jobs.relay_integration.needs).toBeUndefined()
})
it('requires complete modification evidence and an exact path scope', () => {
const tool = 'config/scripts/run-mobile-ci-tests.mjs'
expect(classifyPrJobs([tool]).test).toBe(true)
expect(classifyPrJobs([tool]).mobile_ci_tools).toBe(false)
for (const files of [
[],
[tool, 'src/main/runtime/orca-runtime.ts'],
[tool, 'pnpm-lock.yaml'],
[tool, 'package.json'],
[tool, 'config/vitest.config.ts'],
[tool, 'config/scripts/pr-code-change-scope.mjs'],
[tool, 'config/scripts/new-mobile-tool.mjs'],
[tool, 'README.md'],
[tool, 'mobile/vitest.config.ts']
]) {
const selected = classifyPrJobs(files, verified)
expect(selected.mobile_ci_tools).toBe(false)
expect(selected.test).toBe(true)
}
})
it('reuses only identical successful checks for the focused preflight gate', () => {
const files = ['config/scripts/run-mobile-ci-tests.mjs']
expect(routedOutputs(files, 'true', verified).mobile_ci_tools).toBe('false')
expect(routedOutputs(files, 'false', verified).mobile_ci_tools).toBe('true')
})
})
const mobileCiScopeDirectories = []
afterEach(() => {
for (const directory of mobileCiScopeDirectories.splice(0)) {
rmSync(directory, { recursive: true, force: true })
}
})
function createMobileCiScopeRepository(withTool = true) {
const directory = mkdtempSync(join(tmpdir(), 'orca-mobile-ci-scope-'))
mobileCiScopeDirectories.push(directory)
const root = resolve(import.meta.dirname, '../..')
for (const file of [
'package.json',
'config/scripts/pr-code-change-scope.mjs',
'config/scripts/git-pull-request-diff-base.mjs'
]) {
mkdirSync(dirname(join(directory, file)), { recursive: true })
copyFileSync(join(root, file), join(directory, file))
}
const git = (...args) => {
const result = runProcessSync({ program: 'git', args, cwd: directory })
expect(result.code, result.stderr).toBe(0)
return result.stdout.trim()
}
const commit = () => {
git('add', '-A')
git(
'-c',
'commit.gpgsign=false',
'-c',
'core.hooksPath=/dev/null',
'commit',
'-qm',
'scope input'
)
return git('rev-parse', 'HEAD')
}
git('init', '--quiet')
git('config', 'user.name', 'Scope control')
git('config', 'user.email', 'scope@example.invalid')
const tool = join(directory, 'config/scripts/run-mobile-ci-tests.mjs')
if (withTool) {
writeFileSync(tool, 'export const revision = 1\n')
}
const base = commit()
const detect = () => {
const output = join(directory, 'github-output')
const step = workflow.jobs.code_paths.steps.find((candidate) => candidate.id === 'filter')
const result = runProcessSync({
program: 'bash',
args: ['-e', '-c', step.run],
cwd: directory,
env: {
...process.env,
BASE_SHA: base,
GITHUB_EVENT_NAME: 'pull_request',
GITHUB_OUTPUT: output,
ORCA_BACKGROUND_LAUNCH: '1'
}
})
return { result, output }
}
return { directory, tool, git, commit, detect }
}
describe.skipIf(process.platform === 'win32')('Mobile CI complete Git change evidence', () => {
it('admits an actual modification through the workflow detector', () => {
const repo = createMobileCiScopeRepository()
writeFileSync(repo.tool, 'export const revision = 2\n')
repo.commit()
const { result, output } = repo.detect()
expect(result.code, result.stderr).toBe(0)
expect(readFileSync(output, 'utf8')).toContain('mobile_ci_tools=true\n')
expect(readFileSync(output, 'utf8')).toContain('test=true\n')
})
it.each(['addition', 'deletion', 'rename', 'type change'])(
'retains full root units for an actual %s',
(change) => {
const repo = createMobileCiScopeRepository(change !== 'addition')
if (change === 'addition') {
writeFileSync(repo.tool, 'export const revision = 1\n')
} else if (change === 'deletion') {
rmSync(repo.tool)
} else if (change === 'rename') {
repo.git(
'mv',
'config/scripts/run-mobile-ci-tests.mjs',
'config/mobile-ci.vitest.config.mjs'
)
} else {
rmSync(repo.tool)
symlinkSync('pr-code-change-scope.mjs', repo.tool)
}
repo.commit()
const { result, output } = repo.detect()
expect(result.code, result.stderr).toBe(0)
expect(readFileSync(output, 'utf8')).toContain('mobile_ci_tools=false\n')
expect(readFileSync(output, 'utf8')).toContain('test=true\n')
}
)
it('does not emit a passing classification when the Git evidence is unavailable', () => {
const repo = createMobileCiScopeRepository()
writeFileSync(repo.tool, 'export const revision = 2\n')
repo.commit()
expect(repo.detect().result.code).toBe(0)
rmSync(join(repo.directory, 'github-output'))
rmSync(join(repo.directory, '.git'), { recursive: true, force: true })
const { result } = repo.detect()
expect(result.code).not.toBe(0)
expect(result.stderr).toContain('not a git repository')
})
})
+19 -2
View File
@@ -35,6 +35,14 @@ export const PR_CHECK_JOBS = [
const ALWAYS_ON_CODE_JOBS = new Set(['static_analysis', 'typecheck', 'test'])
const MOBILE_CI_TOOL_FILES = new Set([
'.github/workflows/mobile.yml',
'config/mobile-ci.vitest.config.mjs',
'config/scripts/run-mobile-ci-tests.mjs',
'config/scripts/mobile-release-check-scope.test.mjs',
'config/scripts/mobile-test-change-scope.test.mjs'
])
const GLOBAL_FORCE_PREFIXES = [
'.github/workflows/pr.yml',
'.github/actions/install-node-dependencies/',
@@ -491,7 +499,7 @@ export function needsMobileDependencies(changedFiles) {
return changedFiles.length === 0 || changedFiles.some((file) => file.startsWith('mobile/'))
}
export function classifyPrJobs(changedFiles) {
export function classifyPrJobs(changedFiles, { allowMobileCiTools = false } = {}) {
const emptyDiff = changedFiles.length === 0
const shouldRun = shouldRunPrChecks(changedFiles)
const forceAll = emptyDiff || changedFiles.some(isGlobalForcePath)
@@ -509,8 +517,15 @@ export function classifyPrJobs(changedFiles) {
// that is exactly the diff that changes the page this job builds. Gated on should_run it would
// skip on every PR that can break it and run on none.
jobs.mobile_web_app = jobs.mobile_web_app || changesMobileWebApp(changedFiles)
const mobileCiTools =
allowMobileCiTools &&
!emptyDiff &&
jobs.test &&
(jobs.static_analysis || jobs.typecheck) &&
changedFiles.every((file) => MOBILE_CI_TOOL_FILES.has(file))
return {
should_run: shouldRun,
mobile_ci_tools: mobileCiTools,
native_cache_changed: shouldRun && (emptyDiff || changedFiles.some(isNativeCacheInputPath)),
mobile_dependencies:
(shouldRun || jobs.static_analysis) && needsMobileDependencies(changedFiles),
@@ -618,7 +633,9 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
input += chunk
}
const files = input.split(/\r?\n/).filter(Boolean)
const classification = classifyPrJobs(files)
const classification = classifyPrJobs(files, {
allowMobileCiTools: process.argv.includes('--mobile-ci-tools')
})
for (const [name, value] of Object.entries(classification)) {
process.stdout.write(`${name}=${value ? 'true' : 'false'}\n`)
}