diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index ef2dbf1c888..f1522dddeeb 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -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 diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 3848e542cf4..9ea37f36248 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -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 diff --git a/config/scripts/pr-auxiliary-preflight-admission.test.mjs b/config/scripts/pr-auxiliary-preflight-admission.test.mjs index 94889330895..bd2b9e9a095 100644 --- a/config/scripts/pr-auxiliary-preflight-admission.test.mjs +++ b/config/scripts/pr-auxiliary-preflight-admission.test.mjs @@ -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') + }) +}) diff --git a/config/scripts/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs index c9d52d9f36d..13e5d6b1e5e 100644 --- a/config/scripts/pr-code-change-scope.mjs +++ b/config/scripts/pr-code-change-scope.mjs @@ -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`) }