diff --git a/.github/workflows/pr-quality.yml b/.github/workflows/pr-quality.yml index 61a6c826..83b8f57c 100644 --- a/.github/workflows/pr-quality.yml +++ b/.github/workflows/pr-quality.yml @@ -245,6 +245,21 @@ jobs: - name: Run desktop native checks run: bun run check:native + macos-swift-checks: + name: macos-swift-checks + needs: scope-plan + if: needs.scope-plan.outputs.desktop_native_checks == 'true' + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: oven-sh/setup-bun@v2 + with: + bun-version-file: package.json + - name: Run macOS Swift checks + run: bun run check:swift + persistence-checks: name: persistence-checks needs: scope-plan @@ -336,6 +351,7 @@ jobs: - agent-flow-checks - adapter-checks - desktop-native-checks + - macos-swift-checks - persistence-checks - docs-checks - coverage-checks @@ -380,6 +396,7 @@ jobs: require_selected "agent-flow-checks" "${{ needs.scope-plan.outputs.agent_flow_checks }}" "${{ needs.agent-flow-checks.result }}" require_selected "adapter-checks" "${{ needs.scope-plan.outputs.adapter_checks }}" "${{ needs.adapter-checks.result }}" require_selected "desktop-native-checks" "${{ needs.scope-plan.outputs.desktop_native_checks }}" "${{ needs.desktop-native-checks.result }}" + require_selected "macos-swift-checks" "${{ needs.scope-plan.outputs.desktop_native_checks }}" "${{ needs.macos-swift-checks.result }}" require_selected "persistence-checks" "${{ needs.scope-plan.outputs.persistence_checks }}" "${{ needs.persistence-checks.result }}" require_selected "docs-checks" "${{ needs.scope-plan.outputs.docs_checks }}" "${{ needs.docs-checks.result }}" require_selected "coverage-checks" "${{ needs.scope-plan.outputs.coverage_checks }}" "${{ needs.coverage-checks.result }}" diff --git a/package.json b/package.json index 57bbd7fd..5307da22 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "perf:local-index:10k": "bun run scripts/perf/local-index-benchmark.ts --sessions 10000 --runs 20", "check:pr": "bun run scripts/pr/check-pr.ts", "check:impact": "bun run scripts/pr/impact-report.ts", - "check:policy": "bun test ./scripts/pr/bun-test-filter.test.ts ./scripts/pr/change-policy.test.ts ./scripts/pr/changed-files.test.ts ./scripts/pr/dead-imports.test.ts ./scripts/pr/module-graph.test.ts ./scripts/pr/pr-triage-workflow.test.ts ./scripts/pr/pr-quality-workflow.test.ts ./scripts/pr/release-workflow.test.ts ./scripts/pr/quality-contract.test.ts ./scripts/pr/test-environment.test.ts ./scripts/release-update-metadata.test.ts ./scripts/git-hooks/install.test.ts ./scripts/quality-gate/quarantine.test.ts ./scripts/quality-gate/coverage.test.ts ./scripts/quality-gate/provider-smoke/execute.test.ts ./scripts/quality-gate/desktop-smoke/execute.test.ts ./scripts/quality-gate/providerTargets.test.ts ./scripts/quality-gate/runner.test.ts ./scripts/quality-gate/sandbox.test.ts ./scripts/quality-gate/agent-flow/scenarios.test.ts ./scripts/quality-gate/agent-flow/live.test.ts ./scripts/quality-gate/desktop-smoke/deterministic.test.ts ./scripts/quality-gate/computer-use-live-smoke.test.ts", + "check:policy": "bun test ./scripts/pr/bun-test-filter.test.ts ./scripts/pr/change-policy.test.ts ./scripts/pr/changed-files.test.ts ./scripts/pr/dead-imports.test.ts ./scripts/pr/module-graph.test.ts ./scripts/pr/pr-triage-workflow.test.ts ./scripts/pr/pr-quality-workflow.test.ts ./scripts/pr/release-workflow.test.ts ./scripts/pr/quality-contract.test.ts ./scripts/pr/test-environment.test.ts ./scripts/pr/run-swift-checks.test.ts ./scripts/release-update-metadata.test.ts ./scripts/git-hooks/install.test.ts ./scripts/quality-gate/quarantine.test.ts ./scripts/quality-gate/coverage.test.ts ./scripts/quality-gate/provider-smoke/execute.test.ts ./scripts/quality-gate/desktop-smoke/execute.test.ts ./scripts/quality-gate/providerTargets.test.ts ./scripts/quality-gate/runner.test.ts ./scripts/quality-gate/sandbox.test.ts ./scripts/quality-gate/agent-flow/scenarios.test.ts ./scripts/quality-gate/agent-flow/live.test.ts ./scripts/quality-gate/desktop-smoke/deterministic.test.ts ./scripts/quality-gate/computer-use-live-smoke.test.ts", "check:server": "bun run scripts/pr/run-server-tests.ts", "check:provider-contract": "bun run scripts/pr/run-provider-contract-tests.ts", "check:chat-contract": "bun run scripts/pr/run-chat-contract-tests.ts", @@ -44,7 +44,7 @@ "docs:build": "npm --prefix site run build", "docs:preview": "npm --prefix site run preview", "check:agent-flow:live": "bun run scripts/quality-gate/agent-flow/live-cli.ts", - "check:swift": "swift test --package-path native/cu-helper --enable-xctest", + "check:swift": "bun run scripts/pr/run-swift-checks.ts", "check:computer-use-live-smoke": "bun run scripts/quality-gate/computer-use-live-smoke.ts" }, "dependencies": { diff --git a/scripts/pr/pr-quality-workflow.test.ts b/scripts/pr/pr-quality-workflow.test.ts index 705e432d..1338de04 100644 --- a/scripts/pr/pr-quality-workflow.test.ts +++ b/scripts/pr/pr-quality-workflow.test.ts @@ -3,6 +3,9 @@ import { readFileSync } from 'node:fs' import { parse } from 'yaml' type WorkflowJob = { + if?: string + 'runs-on'?: string + 'continue-on-error'?: boolean needs?: string | string[] steps?: Array<{ name?: string; run?: string; 'working-directory'?: string }> } @@ -62,6 +65,7 @@ describe('PR quality workflow', () => { 'agent-flow-checks', 'adapter-checks', 'desktop-native-checks', + 'macos-swift-checks', 'persistence-checks', 'docs-checks', 'coverage-checks', @@ -92,6 +96,55 @@ describe('PR quality workflow', () => { } }) + test('requires macOS Swift checks alongside the selected Linux native packaging lane', () => { + const jobs = workflowJobs(readFileSync('.github/workflows/pr-quality.yml', 'utf8')) + const packageJson = JSON.parse(readFileSync('package.json', 'utf8')) as { scripts: Record } + const macos = jobs['macos-swift-checks']! + const linux = jobs['desktop-native-checks']! + const gate = jobs['pr-quality-gate']! + expect(macos['runs-on']).toBe('macos-latest') + expect(linux['runs-on']).toBe('ubuntu-22.04') + for (const job of [macos, linux]) { + expect(job.needs).toBe('scope-plan') + expect(job.if).toBe("needs.scope-plan.outputs.desktop_native_checks == 'true'") + expect(job['continue-on-error']).not.toBe(true) + } + expect(macos.steps?.some(step => step.run === 'bun run check:swift')).toBe(true) + expect(linux.steps?.some(step => step.run === 'bun run check:native')).toBe(true) + expect(gate.needs).toContain('macos-swift-checks') + expect(gate.needs).toContain('desktop-native-checks') + expect(packageJson.scripts['check:swift']).toBe('bun run scripts/pr/run-swift-checks.ts') + expect(packageJson.scripts['check:policy']).toContain('scripts/pr/run-swift-checks.test.ts') + for (const command of ['check:swift', 'build:sidecars', 'test:compiled-sidecar-smoke', 'check:electron', 'electron:package:dir', 'test:package-smoke:current']) { + expect(packageJson.scripts['check:native']).toContain(`bun run ${command}`) + } + }) + + test.each([ + { selected: true, macos: 'success', linux: 'success', exit: 0 }, + { selected: true, macos: 'failure', linux: 'success', exit: 1 }, + { selected: true, macos: 'skipped', linux: 'success', exit: 1 }, + { selected: true, macos: 'success', linux: 'failure', exit: 1 }, + { selected: false, macos: 'skipped', linux: 'skipped', exit: 0 }, + { selected: false, macos: 'success', linux: 'skipped', exit: 1 }, + ])('enforces both native results in the stable gate: %j', scenario => { + const jobs = workflowJobs(readFileSync('.github/workflows/pr-quality.yml', 'utf8')) + const gateScript = jobs['pr-quality-gate']!.steps!.find(step => step.run?.includes('require_selected'))!.run! + const script = gateScript.replace(/\$\{\{\s*([^}]+?)\s*\}\}/g, (_match, expression: string) => { + if (expression === 'needs.scope-plan.outputs.desktop_native_checks') return String(scenario.selected) + if (expression.startsWith('needs.scope-plan.outputs.')) return 'false' + if (expression === 'needs.macos-swift-checks.result') return scenario.macos + if (expression === 'needs.desktop-native-checks.result') return scenario.linux + if (expression === 'needs.scope-plan.result' || expression === 'needs.policy-enforcement.result') return 'success' + return 'skipped' + }) + const result = Bun.spawnSync(['bash', '-c', script], { + env: { PATH: process.env.PATH ?? '' }, + stdout: 'pipe', stderr: 'pipe', + }) + expect(result.exitCode, new TextDecoder().decode(result.stdout)).toBe(scenario.exit) + }) + test('keeps coverage artifacts observable in CI', () => { const workflow = readFileSync('.github/workflows/pr-quality.yml', 'utf8') diff --git a/scripts/pr/run-swift-checks.test.ts b/scripts/pr/run-swift-checks.test.ts new file mode 100644 index 00000000..7b624910 --- /dev/null +++ b/scripts/pr/run-swift-checks.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, mock, test } from 'bun:test' +import { existsSync, writeFileSync } from 'node:fs' +import { isAbsolute, join } from 'node:path' +import { runSwiftChecks } from './run-swift-checks' + +describe('Swift platform checks', () => { + test.each(['linux', 'win32'] as const)('does not invoke the macOS-only package on %s', async platform => { + const run = mock(async () => { throw new Error('Swift cannot run on this fixture platform') }) + expect(await runSwiftChecks({ platform, run })).toBe(0) + expect(run).not.toHaveBeenCalled() + }) + + test.each([0, 7])('runs the full macOS package in isolation and propagates exit %s', async exitCode => { + let home = '' + const run = mock(async (command: string[], options: { cwd: string; env: Record }) => { + home = options.env.HOME! + expect(command.slice(0, 2)).toEqual(['swift', 'test']) + expect(command).toContain('--enable-xctest') + expect(command[command.indexOf('--package-path') + 1]).toBe(join(options.cwd, 'native/cu-helper')) + expect(command[command.indexOf('--scratch-path') + 1]).toBe(join(home, 'build')) + expect(isAbsolute(options.cwd)).toBe(true) + expect(home).not.toBe(process.env.HOME) + expect(options.env.CLAUDE_CONFIG_DIR).toBe(join(home, '.claude')) + expect(options.env.ANTHROPIC_API_KEY).toBeUndefined() + expect(options.env.ANTHROPIC_AUTH_TOKEN).toBeUndefined() + expect(options.env.GH_TOKEN).toBeUndefined() + writeFileSync(join(home, 'cleanup-fixture'), 'disposable Swift test output') + return exitCode + }) + expect(await runSwiftChecks({ platform: 'darwin', run })).toBe(exitCode) + expect(run).toHaveBeenCalledTimes(1) + expect(existsSync(home)).toBe(false) + }) + + test('cleans the sandbox and reports a Swift launch failure', async () => { + let home = '' + await expect(runSwiftChecks({ + platform: 'darwin', + run: async (_command, options) => { + home = options.env.HOME! + throw new Error('swift executable unavailable') + }, + })).rejects.toThrow('swift executable unavailable') + expect(existsSync(home)).toBe(false) + }) +}) diff --git a/scripts/pr/run-swift-checks.ts b/scripts/pr/run-swift-checks.ts new file mode 100644 index 00000000..f0283541 --- /dev/null +++ b/scripts/pr/run-swift-checks.ts @@ -0,0 +1,41 @@ +#!/usr/bin/env bun + +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { createSandboxedTestEnvironment } from './test-environment' + +type SwiftCheckRunner = ( + command: string[], + options: { cwd: string; env: Record }, +) => Promise + +export async function runSwiftChecks(options: { + platform?: NodeJS.Platform + run?: SwiftCheckRunner +} = {}): Promise { + const platform = options.platform ?? process.platform + if (platform !== 'darwin') { + console.log(`[swift-checks] not applicable on ${platform}: cu-helper targets macOS; PR CI requires the macOS Swift job separately`) + return 0 + } + + const root = resolve(import.meta.dir, '../..') + const sandboxHome = mkdtempSync(join(tmpdir(), 'cc-haha-swift-checks-')) + const run = options.run ?? (async (command, spawnOptions) => { + const child = Bun.spawn(command, { ...spawnOptions, stdout: 'inherit', stderr: 'inherit' }) + return await child.exited + }) + try { + return await run([ + 'swift', 'test', + '--package-path', join(root, 'native/cu-helper'), + '--scratch-path', join(sandboxHome, 'build'), + '--enable-xctest', + ], { cwd: root, env: createSandboxedTestEnvironment(sandboxHome) }) + } finally { + rmSync(sandboxHome, { recursive: true, force: true }) + } +} + +if (import.meta.main) process.exit(await runSwiftChecks())