From 53940fcd983bb93d46483e2d3090f2b7c407047f Mon Sep 17 00:00:00 2001 From: Thiago Brezinski Date: Sat, 5 Sep 2026 22:05:06 +0100 Subject: [PATCH 1/2] ci: purge the agent-device apple-runner artifact before iOS retry attempts (#76) The restored runner artifact can fail hard (dyld: libcurl.4.dylib) because the cache key omits the macOS host build. Remove ~/.agent-device/apple-runner before attempt 2 of iOS device-contract jobs so retries rebuild the runner from scratch; first attempts keep the cached fast path and contract failures still never retry. Co-Authored-By: Claude Fable 5 --- scripts/run-device-contract-job.mjs | 12 +++ src/__tests__/issue39-device-contract.test.ts | 88 ++++++++++++++++++- 2 files changed, 98 insertions(+), 2 deletions(-) diff --git a/scripts/run-device-contract-job.mjs b/scripts/run-device-contract-job.mjs index 4065c12..a3a5f06 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]; @@ -109,6 +110,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 f06431a..04c3f72 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'; @@ -896,6 +902,84 @@ console.log(JSON.stringify({ expect(workflow).not.toContain('profile: pixel_7_pro'); }); + 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( From d51a8b2754f34f6eabd5018b70834904f345fca0 Mon Sep 17 00:00:00 2001 From: Thiago Brezinski Date: Sun, 6 Sep 2026 01:11:54 +0100 Subject: [PATCH 2/2] ci: always run the Gemfile-pinned pod install in build-ios Skipping the install step on a Pods cache hit let the React Native CLI reinstall pods mid-build with the runner image's unpinned system CocoaPods, which fails when that executable is older than the one that generated Podfile.lock. The pinned install is nearly a no-op on a warm cache and reconciles a stale cached Pods directory. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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