diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2081fad..20fa1e9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -152,7 +152,11 @@ jobs: ${{ runner.os }}-cocoapods- - name: Install cocoapods - if: env.turbo_cache_hit != 1 && steps.cocoapods-cache.outputs.cache-hit != 'true' + # Always run the Gemfile-pinned pod install, even on a Pods cache hit: + # skipping it lets the React Native CLI reinstall pods mid-build with + # the runner image's unpinned system CocoaPods, which can be older + # than the one that generated Podfile.lock. + if: env.turbo_cache_hit != 1 run: | cd example bundle install diff --git a/scripts/run-device-contract-job.mjs b/scripts/run-device-contract-job.mjs index c2d589f..bcd5504 100644 --- a/scripts/run-device-contract-job.mjs +++ b/scripts/run-device-contract-job.mjs @@ -7,6 +7,7 @@ import { rmSync, writeFileSync, } from 'node:fs'; +import { homedir } from 'node:os'; import { dirname, resolve } from 'node:path'; const configuration = process.argv[2]; @@ -99,6 +100,17 @@ let finalStatus = 1; for (let index = 1; index <= 2; index += 1) { const attempt = `attempt-${index}`; clearAttemptEvidence(attempt); + if (index > 1 && configuration.startsWith('ios')) { + // The restored runner artifact can be broken in ways that fail hard (see + // issue #76): rebuild from scratch on retries instead of restoring it. + const appleRunnerArtifact = resolve( + homedir(), + '.agent-device', + 'apple-runner' + ); + console.log(`Purging ${appleRunnerArtifact} so ${attempt} rebuilds it`); + rmSync(appleRunnerArtifact, { force: true, recursive: true }); + } const startedAt = Date.now(); const resetResult = configuration.startsWith('ios') ? spawnSync( diff --git a/src/__tests__/issue39-device-contract.test.ts b/src/__tests__/issue39-device-contract.test.ts index ed01f9b..ab70795 100644 --- a/src/__tests__/issue39-device-contract.test.ts +++ b/src/__tests__/issue39-device-contract.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it, jest } from '@jest/globals'; -import { execFileSync } from 'node:child_process'; -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; import { tmpdir } from 'node:os'; import { resolve } from 'node:path'; import { reconcileReorder } from '../semantic'; @@ -668,6 +674,84 @@ describe('issue 39 portable device contract', () => { expect(isolatedRunner).toContain('infrastructureFailure ? 75 : 1'); }); + it('purges the apple-runner cache before iOS retry attempts only', () => { + const directory = mkdtempSync(resolve(tmpdir(), 'issue39-retry-')); + try { + const home = resolve(directory, 'home'); + const appleRunner = resolve(home, '.agent-device', 'apple-runner'); + const workspace = resolve(directory, 'workspace'); + mkdirSync(resolve(workspace, 'scripts'), { recursive: true }); + writeFileSync( + resolve(workspace, 'scripts/reset-device-contract-ios-simulator.mjs'), + 'process.exit(0);\n' + ); + writeFileSync( + resolve(workspace, 'scripts/run-device-contract-isolated.mjs'), + [ + "import { appendFileSync, existsSync } from 'node:fs';", + "import { homedir } from 'node:os';", + "import { resolve } from 'node:path';", + 'appendFileSync(', + " 'attempts.log',", + ' `${process.env.ISSUE39_MATRIX_ATTEMPT} runner-cached=${existsSync(', + " resolve(homedir(), '.agent-device', 'apple-runner')", + ' )}\\n`', + ');', + 'process.exit(Number(process.env.ISSUE39_STUB_EXIT));', + '', + ].join('\n') + ); + const runJob = (configuration: string, stubExit: number) => { + mkdirSync(appleRunner, { recursive: true }); + rmSync(resolve(workspace, 'attempts.log'), { force: true }); + const job = spawnSync( + process.execPath, + [resolve(root, 'scripts/run-device-contract-job.mjs'), configuration], + { + cwd: workspace, + encoding: 'utf8', + env: { + ...process.env, + HOME: home, + ISSUE39_STUB_EXIT: String(stubExit), + }, + } + ); + return { + attempts: readFileSync(resolve(workspace, 'attempts.log'), 'utf8'), + output: `${job.stdout}${job.stderr}`, + status: job.status, + }; + }; + + // Infrastructure failure (exit 75) on iOS: the retry attempt must start + // with no restored runner artifact, and the purge must be visible in the + // attempt log. + const iosRetry = runJob('ios27.native', 75); + expect(iosRetry.status).toBe(75); + expect(iosRetry.attempts).toBe( + 'attempt-1 runner-cached=true\nattempt-2 runner-cached=false\n' + ); + expect(iosRetry.output).toContain('apple-runner'); + + // Contract failure on iOS: no retry, so no purge. + const iosContractFailure = runJob('ios27.fallback', 1); + expect(iosContractFailure.status).toBe(1); + expect(iosContractFailure.attempts).toBe( + 'attempt-1 runner-cached=true\n' + ); + + // Android retries never touch the Apple runner artifact. + const androidRetry = runJob('android.fallback', 75); + expect(androidRetry.status).toBe(75); + expect(androidRetry.attempts).toBe( + 'attempt-1 runner-cached=true\nattempt-2 runner-cached=true\n' + ); + } finally { + rmSync(directory, { force: true, recursive: true }); + } + }); + it('keeps the physical iOS 27 VoiceOver record tied to the named public contract', () => { const protocol = read('artifacts/issue-39/PHYSICAL-IOS27-VOICEOVER.md'); const record = read(