mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
[CI] Run focused checks for Mobile CI tool changes (#27532)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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`)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user