diff --git a/CHANGELOG.md b/CHANGELOG.md index 85106fbfce..376708f209 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ This is the log of notable changes to EAS CLI and related packages. ### ๐ŸŽ‰ New features +- [build-tools] Cache CocoaPods dependencies between iOS builds. ([#4266](https://github.com/expo/eas-cli/pull/4266) by [@AbbanMustafa](https://github.com/AbbanMustafa)) + ### ๐Ÿ› Bug fixes ### ๐Ÿงน Chores diff --git a/packages/build-tools/src/builders/ios.ts b/packages/build-tools/src/builders/ios.ts index ecab7c087b..7d0f7cb65b 100644 --- a/packages/build-tools/src/builders/ios.ts +++ b/packages/build-tools/src/builders/ios.ts @@ -18,8 +18,12 @@ import { downloadApplicationArchiveAsync } from '../ios/resign'; import { resolveArtifactPath, resolveBuildConfiguration, resolveScheme } from '../ios/resolve'; import { Sentry } from '../sentry'; import { parseAndReportXcactivitylog } from '../steps/utils/ios/xcactivitylog'; -import { cacheStatsAsync, restoreCcacheAsync } from '../steps/functions/restoreBuildCache'; -import { saveCcacheAsync } from '../steps/functions/saveBuildCache'; +import { + cacheStatsAsync, + restoreCcacheAsync, + restoreCocoapodsCacheAsync, +} from '../steps/functions/restoreBuildCache'; +import { saveCcacheAsync, saveCocoapodsCacheAsync } from '../steps/functions/saveBuildCache'; import { uploadApplicationArchive } from '../utils/artifacts'; import { configureExpoUpdatesIfInstalledAsync, @@ -107,6 +111,12 @@ async function buildInnerAsync( env: ctx.env, secrets: ctx.job.secrets, }); + await restoreCocoapodsCacheAsync({ + logger: ctx.logger, + workingDirectory, + env: ctx.env, + secrets: ctx.job.secrets, + }); }); await ctx.runBuildPhase(BuildPhase.INSTALL_PODS, async () => { @@ -250,6 +260,12 @@ async function buildInnerAsync( env: ctx.env, secrets: ctx.job.secrets, }); + await saveCocoapodsCacheAsync({ + logger: ctx.logger, + workingDirectory, + env: ctx.env, + secrets: ctx.job.secrets, + }); }); await ctx.runBuildPhase(BuildPhase.CACHE_STATS, async () => { diff --git a/packages/build-tools/src/steps/functionGroups/__tests__/build.test.ts b/packages/build-tools/src/steps/functionGroups/__tests__/build.test.ts index 87df151263..01a4d49501 100644 --- a/packages/build-tools/src/steps/functionGroups/__tests__/build.test.ts +++ b/packages/build-tools/src/steps/functionGroups/__tests__/build.test.ts @@ -162,6 +162,28 @@ describe(createEasBuildBuildFunctionGroup, () => { expect(installPodsStep!.ctx.relativeWorkingDirectory).toBe('./ios'); }); + it('restores and saves caches around an iOS simulator build', () => { + const buildToolsContext = createMockBuildToolsContext({ + platform: Platform.IOS, + simulator: true, + }); + const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext); + const globalCtx = createGlobalContextMock({ logger: createMockLogger() }); + + const steps = functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx); + const stepNames = steps.map(step => step.displayName); + const restoreCacheIndex = stepNames.indexOf('Restore Cache'); + const installPodsIndex = stepNames.indexOf('Install Pods'); + const saveCacheIndex = stepNames.indexOf('Save Cache'); + + expect(restoreCacheIndex).toBeGreaterThan(-1); + expect(restoreCacheIndex).toBeLessThan(installPodsIndex); + expect(saveCacheIndex).toBeGreaterThan(installPodsIndex); + + const restoreCacheStep = steps[restoreCacheIndex]; + expect(restoreCacheStep.inputs?.find(input => input.id === 'simulator')?.rawValue).toBe(true); + }); + it('sets working directory on all steps except checkout (Android with credentials)', () => { const buildToolsContext = createMockBuildToolsContext({ platform: Platform.ANDROID, diff --git a/packages/build-tools/src/steps/functionGroups/build.ts b/packages/build-tools/src/steps/functionGroups/build.ts index 389f073aba..416a91887c 100644 --- a/packages/build-tools/src/steps/functionGroups/build.ts +++ b/packages/build-tools/src/steps/functionGroups/build.ts @@ -98,6 +98,8 @@ function createStepsForIosSimulatorBuild({ buildToolsContext, workingDirectory, }: HelperFunctionsInput): BuildStep[] { + const evictUsedBefore = new Date(); + const calculateEASUpdateRuntimeVersion = calculateEASUpdateRuntimeVersionFunction().createBuildStepFromFunctionCall(globalCtx, { id: 'calculate_eas_update_runtime_version', @@ -106,6 +108,16 @@ function createStepsForIosSimulatorBuild({ const installPods = createInstallPodsBuildFunction().createBuildStepFromFunctionCall(globalCtx, { workingDirectory: workingDirectory ? path.join(workingDirectory, './ios') : './ios', }); + const restoreCache = createRestoreBuildCacheFunction().createBuildStepFromFunctionCall( + globalCtx, + { + workingDirectory, + callInputs: { + platform: Platform.IOS, + simulator: true, + }, + } + ); const configureEASUpdate = configureEASUpdateIfInstalledFunction().createBuildStepFromFunctionCall(globalCtx, { workingDirectory, @@ -123,6 +135,16 @@ function createStepsForIosSimulatorBuild({ '${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }', }, }); + const saveCache = createSaveBuildCacheFunction(evictUsedBefore).createBuildStepFromFunctionCall( + globalCtx, + { + workingDirectory, + callInputs: { + platform: Platform.IOS, + simulator: true, + }, + } + ); return [ createCheckoutBuildFunction().createBuildStepFromFunctionCall(globalCtx), createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx, { @@ -138,6 +160,7 @@ function createStepsForIosSimulatorBuild({ createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx, { workingDirectory, }), + restoreCache, calculateEASUpdateRuntimeVersion, installPods, configureEASUpdate, @@ -159,6 +182,10 @@ function createStepsForIosSimulatorBuild({ createFindAndUploadBuildArtifactsBuildFunction( buildToolsContext ).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }), + saveCache, + createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, { + workingDirectory, + }), ]; } diff --git a/packages/build-tools/src/steps/functions/__tests__/cocoapodsBuildCache.test.ts b/packages/build-tools/src/steps/functions/__tests__/cocoapodsBuildCache.test.ts new file mode 100644 index 0000000000..98186b8d87 --- /dev/null +++ b/packages/build-tools/src/steps/functions/__tests__/cocoapodsBuildCache.test.ts @@ -0,0 +1,136 @@ +import { spawnAsync } from '@expo/steps'; +import { vol } from 'memfs'; + +import { createMockLogger } from '../../../__tests__/utils/logger'; +import { Datadog } from '../../../datadog'; +import { + compressCocoapodsCacheAsync, + getCocoapodsCachePaths, + resolveCocoapodsCacheKeyAsync, + restoreCocoapodsCacheArchiveAsync, +} from '../../../utils/cocoapodsCache'; +import { downloadCacheAsync } from '../restoreCache'; +import { restoreCocoapodsCacheAsync } from '../restoreBuildCache'; +import { uploadCacheAsync } from '../saveCache'; +import { saveCocoapodsCacheAsync } from '../saveBuildCache'; + +jest.mock('@expo/steps', () => ({ + ...jest.requireActual('@expo/steps'), + spawnAsync: jest.fn(), +})); +jest.mock('../restoreCache', () => ({ + decompressCacheAsync: jest.fn(), + downloadCacheAsync: jest.fn(), + downloadPublicCacheAsync: jest.fn(), +})); +jest.mock('../saveCache', () => ({ + compressCacheAsync: jest.fn(), + uploadCacheAsync: jest.fn(), +})); +jest.mock('../../../utils/cocoapodsCache', () => ({ + compressCocoapodsCacheAsync: jest.fn(), + getCocoapodsCachePaths: jest.fn(), + resolveCocoapodsCacheKeyAsync: jest.fn(), + restoreCocoapodsCacheArchiveAsync: jest.fn(), +})); + +const logger = createMockLogger(); +const env = { + EAS_PODS_CACHE: '1', + EAS_BUILD_ID: 'build-id', + __API_SERVER_URL: 'https://api.expo.test', +}; +const secrets = { robotAccessToken: 'robot-token' }; + +describe('CocoaPods build cache', () => { + beforeEach(() => { + jest.clearAllMocks(); + vol.fromJSON( + { + '/workingdir/ios/Pods/Manifest.lock': 'manifest', + '/workingdir/ios/Podfile.lock': 'lockfile', + '/tmp/cocoapods-cache.tar.gz': 'archive', + }, + '/' + ); + jest.mocked(getCocoapodsCachePaths).mockReturnValue({ + iosDirectory: '/workingdir/ios', + podsDirectory: '/workingdir/ios/Pods', + podfileLockPath: '/workingdir/ios/Podfile.lock', + }); + jest.mocked(resolveCocoapodsCacheKeyAsync).mockResolvedValue({ + key: 'ios-pods-1.16.2-lock-hash', + keyPrefix: 'ios-pods-1.16.2-', + }); + jest.mocked(spawnAsync).mockResolvedValue({ stdout: '1.16.2\n' } as any); + }); + + it('does nothing when the cache is disabled', async () => { + await restoreCocoapodsCacheAsync({ + logger, + workingDirectory: '/workingdir', + env: {}, + secrets, + }); + await saveCocoapodsCacheAsync({ + logger, + workingDirectory: '/workingdir', + env: {}, + secrets, + }); + + expect(spawnAsync).not.toHaveBeenCalled(); + }); + + it('restores the newest matching CocoaPods cache', async () => { + jest.mocked(downloadCacheAsync).mockResolvedValue({ + archivePath: '/tmp/cocoapods-cache.tar.gz', + matchedKey: 'ios-pods-1.16.2-older-lock-hash', + }); + const datadogLogSpy = jest.spyOn(Datadog, 'log'); + + await restoreCocoapodsCacheAsync({ + logger, + workingDirectory: '/workingdir', + env, + secrets, + }); + + expect(downloadCacheAsync).toHaveBeenCalledWith( + expect.objectContaining({ + key: 'ios-pods-1.16.2-lock-hash', + keyPrefixes: ['ios-pods-1.16.2-'], + paths: ['/workingdir/ios/Pods'], + }) + ); + expect(restoreCocoapodsCacheArchiveAsync).toHaveBeenCalledWith({ + archivePath: '/tmp/cocoapods-cache.tar.gz', + workingDirectory: '/workingdir', + }); + expect(datadogLogSpy).toHaveBeenCalledWith('CocoaPods cache restored (prefix_match)', { + event: 'cocoapods_cache_restored', + cache_hit_type: 'prefix_match', + }); + }); + + it('compresses and saves the installed Pods directory', async () => { + jest.mocked(compressCocoapodsCacheAsync).mockResolvedValue({ + archivePath: '/tmp/cocoapods-cache.tar.gz', + }); + + await saveCocoapodsCacheAsync({ + logger, + workingDirectory: '/workingdir', + env, + secrets, + }); + + expect(uploadCacheAsync).toHaveBeenCalledWith( + expect.objectContaining({ + archivePath: '/tmp/cocoapods-cache.tar.gz', + key: 'ios-pods-1.16.2-lock-hash', + paths: ['/workingdir/ios/Pods'], + }) + ); + }); +}); diff --git a/packages/build-tools/src/steps/functions/restoreBuildCache.ts b/packages/build-tools/src/steps/functions/restoreBuildCache.ts index bbf07aff79..6424620ee5 100644 --- a/packages/build-tools/src/steps/functions/restoreBuildCache.ts +++ b/packages/build-tools/src/steps/functions/restoreBuildCache.ts @@ -20,6 +20,11 @@ import { getCcacheKeyPrefix, getCcachePath, } from '../../utils/cacheKey'; +import { + getCocoapodsCachePaths, + resolveCocoapodsCacheKeyAsync, + restoreCocoapodsCacheArchiveAsync, +} from '../../utils/cocoapodsCache'; import { Datadog } from '../../datadog'; import { GRADLE_CACHE_KEY_PREFIX, generateGradleCacheKeyAsync } from '../../utils/gradleCacheKey'; import { TurtleFetchError, turtleFetch } from '../../utils/turtleFetch'; @@ -79,6 +84,13 @@ export function createRestoreBuildCacheFunction(): BuildFunction { env, secrets: stepCtx.global.staticContext.job.secrets, }); + } else { + await restoreCocoapodsCacheAsync({ + logger, + workingDirectory, + env, + secrets: stepCtx.global.staticContext.job.secrets, + }); } }, }); @@ -206,6 +218,67 @@ export async function restoreCcacheAsync({ } } +export async function restoreCocoapodsCacheAsync({ + logger, + workingDirectory, + env, + secrets, +}: { + logger: bunyan; + workingDirectory: string; + env: Record; + secrets?: { robotAccessToken?: string }; +}): Promise { + if (env.EAS_PODS_CACHE !== '1') { + return; + } + + try { + const { stdout } = await spawnAsync('pod', ['--version'], { + env, + stdio: 'pipe', + }); + const { key, keyPrefix } = await resolveCocoapodsCacheKeyAsync(workingDirectory, stdout); + logger.info(`Restoring CocoaPods cache key: ${key}`); + + const jobId = nullthrows(env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set'); + const robotAccessToken = nullthrows( + secrets?.robotAccessToken, + 'Robot access token is required for cache operations' + ); + const expoApiServerURL = nullthrows(env.__API_SERVER_URL, '__API_SERVER_URL is not set'); + const { podsDirectory } = getCocoapodsCachePaths(workingDirectory); + + const { archivePath, matchedKey } = await downloadCacheAsync({ + logger, + jobId, + expoApiServerURL, + robotAccessToken, + paths: [podsDirectory], + key, + keyPrefixes: [keyPrefix], + platform: Platform.IOS, + }); + + await restoreCocoapodsCacheArchiveAsync({ archivePath, workingDirectory }); + + const hitType = matchedKey === key ? 'direct_hit' : 'prefix_match'; + logger.info( + `CocoaPods cache restored to ${podsDirectory} (${hitType === 'direct_hit' ? 'direct hit' : 'prefix match'})` + ); + Datadog.log(`CocoaPods cache restored (${hitType})`, { + event: 'cocoapods_cache_restored', + cache_hit_type: hitType, + }); + } catch (err: unknown) { + if (err instanceof TurtleFetchError && err.response?.status === 404) { + logger.info('No CocoaPods cache found for this key'); + } else { + logger.warn('Failed to restore CocoaPods cache: ', err); + } + } +} + export async function restoreGradleCacheAsync({ logger, workingDirectory, diff --git a/packages/build-tools/src/steps/functions/saveBuildCache.ts b/packages/build-tools/src/steps/functions/saveBuildCache.ts index 613df31bfd..9d72937a36 100644 --- a/packages/build-tools/src/steps/functions/saveBuildCache.ts +++ b/packages/build-tools/src/steps/functions/saveBuildCache.ts @@ -20,6 +20,11 @@ import { generateDefaultBuildCacheKeyAsync, getCcachePath, } from '../../utils/cacheKey'; +import { + compressCocoapodsCacheAsync, + getCocoapodsCachePaths, + resolveCocoapodsCacheKeyAsync, +} from '../../utils/cocoapodsCache'; import { generateGradleCacheKeyAsync } from '../../utils/gradleCacheKey'; export function createSaveBuildCacheFunction(evictUsedBefore: Date): BuildFunction { @@ -78,11 +83,77 @@ export function createSaveBuildCacheFunction(evictUsedBefore: Date): BuildFuncti env, secrets: stepCtx.global.staticContext.job.secrets, }); + } else { + await saveCocoapodsCacheAsync({ + logger, + workingDirectory, + env, + secrets: stepCtx.global.staticContext.job.secrets, + }); } }, }); } +export async function saveCocoapodsCacheAsync({ + logger, + workingDirectory, + env, + secrets, +}: { + logger: bunyan; + workingDirectory: string; + env: Record; + secrets?: { robotAccessToken?: string }; +}): Promise { + if (env.EAS_PODS_CACHE !== '1') { + return; + } + + const { podsDirectory, podfileLockPath } = getCocoapodsCachePaths(workingDirectory); + try { + await Promise.all([fs.promises.access(podsDirectory), fs.promises.access(podfileLockPath)]); + } catch { + logger.warn('No CocoaPods installation found, skipping cache save'); + return; + } + + try { + const { stdout } = await spawnAsync('pod', ['--version'], { + env, + stdio: 'pipe', + }); + const { key } = await resolveCocoapodsCacheKeyAsync(workingDirectory, stdout); + logger.info(`Saving CocoaPods cache key: ${key}`); + + const jobId = nullthrows(env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set'); + const robotAccessToken = nullthrows( + secrets?.robotAccessToken, + 'Robot access token is required for cache operations' + ); + const expoApiServerURL = nullthrows(env.__API_SERVER_URL, '__API_SERVER_URL is not set'); + + logger.info('Compressing CocoaPods cache...'); + const { archivePath } = await compressCocoapodsCacheAsync({ workingDirectory }); + const { size } = await fs.promises.stat(archivePath); + logger.info(`CocoaPods cache archive size: ${formatBytes(size)}`); + + await uploadCacheAsync({ + logger, + jobId, + expoApiServerURL, + robotAccessToken, + archivePath, + key, + paths: [podsDirectory], + size, + platform: Platform.IOS, + }); + } catch (err) { + logger.error({ err }, 'Failed to save CocoaPods cache'); + } +} + export async function saveCcacheAsync({ logger, workingDirectory, diff --git a/packages/build-tools/src/utils/__tests__/cocoapodsCache.test.ts b/packages/build-tools/src/utils/__tests__/cocoapodsCache.test.ts new file mode 100644 index 0000000000..bb321d9e70 --- /dev/null +++ b/packages/build-tools/src/utils/__tests__/cocoapodsCache.test.ts @@ -0,0 +1,78 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { + compressCocoapodsCacheAsync, + getCocoapodsCachePaths, + resolveCocoapodsCacheKeyAsync, + restoreCocoapodsCacheArchiveAsync, +} from '../cocoapodsCache'; + +jest.unmock('fs'); +jest.unmock('node:fs'); +jest.unmock('fs/promises'); +jest.unmock('node:fs/promises'); + +describe('CocoaPods cache utilities', () => { + let workingDirectory: string; + const cocoapodsVersion = '1.16.2'; + + beforeEach(async () => { + workingDirectory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'cocoapods-cache-test-')); + await fs.promises.mkdir(path.join(workingDirectory, 'ios'), { recursive: true }); + }); + + afterEach(async () => { + await fs.promises.rm(workingDirectory, { recursive: true, force: true }); + }); + + it('uses a CocoaPods version prefix when no Podfile.lock exists', async () => { + await expect( + resolveCocoapodsCacheKeyAsync(workingDirectory, cocoapodsVersion) + ).resolves.toEqual({ + key: 'ios-pods-1.16.2-', + keyPrefix: 'ios-pods-1.16.2-', + }); + }); + + it('includes the Podfile.lock hash when it exists', async () => { + const { podfileLockPath } = getCocoapodsCachePaths(workingDirectory); + await fs.promises.writeFile(podfileLockPath, 'PODS:\n - Expo (55.0.0)\n'); + + const firstResult = await resolveCocoapodsCacheKeyAsync(workingDirectory, cocoapodsVersion); + expect(firstResult.key).toMatch(/^ios-pods-1\.16\.2-[a-f0-9]+$/); + expect(firstResult.keyPrefix).toBe('ios-pods-1.16.2-'); + + await fs.promises.writeFile(podfileLockPath, 'PODS:\n - Expo (56.0.0)\n'); + const secondResult = await resolveCocoapodsCacheKeyAsync(workingDirectory, cocoapodsVersion); + expect(secondResult.key).not.toBe(firstResult.key); + }); + + it('preserves symlinks and executable permissions through an archive round trip', async () => { + const { podsDirectory } = getCocoapodsCachePaths(workingDirectory); + const frameworkVersionsDirectory = path.join(podsDirectory, 'Example.framework', 'Versions'); + const frameworkVersionDirectory = path.join(frameworkVersionsDirectory, 'A'); + await fs.promises.mkdir(frameworkVersionDirectory, { recursive: true }); + await fs.promises.writeFile(path.join(frameworkVersionDirectory, 'Example'), 'binary'); + await fs.promises.symlink('A', path.join(frameworkVersionsDirectory, 'Current')); + + const scriptPath = path.join(podsDirectory, 'Target Support Files', 'script.sh'); + await fs.promises.mkdir(path.dirname(scriptPath), { recursive: true }); + await fs.promises.writeFile(scriptPath, '#!/bin/sh\n'); + await fs.promises.chmod(scriptPath, 0o755); + + const { archivePath } = await compressCocoapodsCacheAsync({ workingDirectory }); + await fs.promises.rm(podsDirectory, { recursive: true, force: true }); + await restoreCocoapodsCacheArchiveAsync({ archivePath, workingDirectory }); + + const restoredSymlinkPath = path.join(frameworkVersionsDirectory, 'Current'); + expect((await fs.promises.lstat(restoredSymlinkPath)).isSymbolicLink()).toBe(true); + await expect(fs.promises.readlink(restoredSymlinkPath)).resolves.toBe('A'); + + const restoredScriptStat = await fs.promises.stat(scriptPath); + expect(restoredScriptStat.mode & 0o111).toBe(0o111); + + await fs.promises.rm(path.dirname(archivePath), { recursive: true, force: true }); + }); +}); diff --git a/packages/build-tools/src/utils/cocoapodsCache.ts b/packages/build-tools/src/utils/cocoapodsCache.ts new file mode 100644 index 0000000000..34d6e0d295 --- /dev/null +++ b/packages/build-tools/src/utils/cocoapodsCache.ts @@ -0,0 +1,106 @@ +import { hashFiles } from '@expo/steps'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import * as tar from 'tar'; + +export const COCOAPODS_CACHE_KEY_PREFIX = 'ios-pods-'; + +const PODS_DIRECTORY_NAME = 'Pods'; +const PODFILE_LOCK_NAME = 'Podfile.lock'; + +export function getCocoapodsCachePaths(workingDirectory: string): { + iosDirectory: string; + podsDirectory: string; + podfileLockPath: string; +} { + const iosDirectory = path.join(workingDirectory, 'ios'); + return { + iosDirectory, + podsDirectory: path.join(iosDirectory, PODS_DIRECTORY_NAME), + podfileLockPath: path.join(iosDirectory, PODFILE_LOCK_NAME), + }; +} + +export async function resolveCocoapodsCacheKeyAsync( + workingDirectory: string, + cocoapodsVersion: string +): Promise<{ key: string; keyPrefix: string }> { + const normalizedVersion = cocoapodsVersion.trim(); + if (!normalizedVersion) { + throw new Error('Failed to determine CocoaPods version'); + } + + const keyPrefix = `${COCOAPODS_CACHE_KEY_PREFIX}${normalizedVersion}-`; + const { podfileLockPath } = getCocoapodsCachePaths(workingDirectory); + + try { + await fs.promises.access(podfileLockPath); + } catch { + return { key: keyPrefix, keyPrefix }; + } + + return { + key: `${keyPrefix}${hashFiles([podfileLockPath])}`, + keyPrefix, + }; +} + +export async function compressCocoapodsCacheAsync({ + workingDirectory, +}: { + workingDirectory: string; +}): Promise<{ archivePath: string }> { + const { iosDirectory, podsDirectory } = getCocoapodsCachePaths(workingDirectory); + await fs.promises.access(podsDirectory); + + const archiveDestinationDirectory = await fs.promises.mkdtemp( + path.join(os.tmpdir(), 'save-cocoapods-cache-') + ); + const archivePath = path.join(archiveDestinationDirectory, 'cache.tar.gz'); + + await tar.create( + { + file: archivePath, + cwd: iosDirectory, + gzip: true, + }, + [PODS_DIRECTORY_NAME] + ); + + return { archivePath }; +} + +export async function restoreCocoapodsCacheArchiveAsync({ + archivePath, + workingDirectory, +}: { + archivePath: string; + workingDirectory: string; +}): Promise { + const { iosDirectory, podsDirectory } = getCocoapodsCachePaths(workingDirectory); + await fs.promises.mkdir(iosDirectory, { recursive: true }); + + const temporaryRestoreDirectory = await fs.promises.mkdtemp( + path.join(iosDirectory, '.eas-pods-cache-') + ); + try { + await tar.extract({ + file: archivePath, + cwd: temporaryRestoreDirectory, + filter: entryPath => + entryPath === PODS_DIRECTORY_NAME || entryPath.startsWith(`${PODS_DIRECTORY_NAME}/`), + }); + + const restoredPodsDirectory = path.join(temporaryRestoreDirectory, PODS_DIRECTORY_NAME); + const restoredPodsStat = await fs.promises.stat(restoredPodsDirectory); + if (!restoredPodsStat.isDirectory()) { + throw new Error('CocoaPods cache archive does not contain a Pods directory'); + } + + await fs.promises.rm(podsDirectory, { recursive: true, force: true }); + await fs.promises.rename(restoredPodsDirectory, podsDirectory); + } finally { + await fs.promises.rm(temporaryRestoreDirectory, { recursive: true, force: true }); + } +}