Skip to content

Commit c9cdfd1

Browse files
authored
[build-tools] support Gradle build cache for Android builds (#3510)
* gradle cache * cache modules-2 * cache modules-2 * proper path * set properties, rename * rem modules-2 * set org.gradle.cache.cleanup to ALWAYS * set org.gradle.cache.cleanup to ALWAYS * consolidate caches in restore/save step * restore gradle * cleanup nullthrows * always set properties if env set, cleanup * consistent path, clean log * modify gradle props in trycatch * eas-cache-cleanup script * shorter prune
1 parent 8115dc8 commit c9cdfd1

File tree

4 files changed

+229
-2
lines changed

4 files changed

+229
-2
lines changed

packages/build-tools/src/builders/android.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,12 @@ import { eagerBundleAsync, shouldUseEagerBundle } from '../common/eagerBundle';
1414
import { prebuildAsync } from '../common/prebuild';
1515
import { setupAsync } from '../common/setup';
1616
import { Artifacts, BuildContext, SkipNativeBuildError } from '../context';
17-
import { cacheStatsAsync, restoreCcacheAsync } from '../steps/functions/restoreBuildCache';
18-
import { saveCcacheAsync } from '../steps/functions/saveBuildCache';
17+
import {
18+
cacheStatsAsync,
19+
restoreCcacheAsync,
20+
restoreGradleCacheAsync,
21+
} from '../steps/functions/restoreBuildCache';
22+
import { saveCcacheAsync, saveGradleCacheAsync } from '../steps/functions/saveBuildCache';
1923
import {
2024
injectConfigureVersionGradleConfig,
2125
injectCredentialsGradleConfig,
@@ -81,6 +85,12 @@ async function buildAsync(ctx: BuildContext<Android.Job>): Promise<void> {
8185
env: ctx.env,
8286
secrets: ctx.job.secrets,
8387
});
88+
await restoreGradleCacheAsync({
89+
logger: ctx.logger,
90+
workingDirectory,
91+
env: ctx.env,
92+
secrets: ctx.job.secrets,
93+
});
8494
});
8595

8696
await ctx.runBuildPhase(BuildPhase.POST_INSTALL_HOOK, async () => {
@@ -196,6 +206,12 @@ async function buildAsync(ctx: BuildContext<Android.Job>): Promise<void> {
196206
env: ctx.env,
197207
secrets: ctx.job.secrets,
198208
});
209+
await saveGradleCacheAsync({
210+
logger: ctx.logger,
211+
workingDirectory,
212+
env: ctx.env,
213+
secrets: ctx.job.secrets,
214+
});
199215
});
200216

201217
await ctx.runBuildPhase(BuildPhase.CACHE_STATS, async () => {

packages/build-tools/src/steps/functions/restoreBuildCache.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,10 @@ import {
77
BuildStepInputValueTypeName,
88
spawnAsync,
99
} from '@expo/steps';
10+
import fs from 'fs';
1011
import nullthrows from 'nullthrows';
12+
import os from 'os';
13+
import path from 'path';
1114

1215
import { sendCcacheStatsAsync } from './ccacheStats';
1316
import { decompressCacheAsync, downloadCacheAsync, downloadPublicCacheAsync } from './restoreCache';
@@ -16,6 +19,7 @@ import {
1619
generateDefaultBuildCacheKeyAsync,
1720
getCcachePath,
1821
} from '../../utils/cacheKey';
22+
import { GRADLE_CACHE_KEY_PREFIX, generateGradleCacheKeyAsync } from '../../utils/gradleCacheKey';
1923
import { TurtleFetchError } from '../../utils/turtleFetch';
2024

2125
export function createRestoreBuildCacheFunction(): BuildFunction {
@@ -50,6 +54,15 @@ export function createRestoreBuildCacheFunction(): BuildFunction {
5054
env,
5155
secrets: stepCtx.global.staticContext.job.secrets,
5256
});
57+
58+
if (platform === Platform.ANDROID) {
59+
await restoreGradleCacheAsync({
60+
logger,
61+
workingDirectory,
62+
env,
63+
secrets: stepCtx.global.staticContext.job.secrets,
64+
});
65+
}
5366
},
5467
});
5568
}
@@ -176,6 +189,106 @@ export async function restoreCcacheAsync({
176189
}
177190
}
178191

192+
export async function restoreGradleCacheAsync({
193+
logger,
194+
workingDirectory,
195+
env,
196+
secrets,
197+
}: {
198+
logger: bunyan;
199+
workingDirectory: string;
200+
env: Record<string, string | undefined>;
201+
secrets?: { robotAccessToken?: string };
202+
}): Promise<void> {
203+
if (env.EXPERIMENTAL_EAS_GRADLE_CACHE !== '1') {
204+
return;
205+
}
206+
207+
try {
208+
const gradlePropertiesPath = path.join(workingDirectory, 'android', 'gradle.properties');
209+
const gradlePropertiesContent = await fs.promises.readFile(gradlePropertiesPath, 'utf-8');
210+
await fs.promises.writeFile(
211+
gradlePropertiesPath,
212+
`${gradlePropertiesContent}\n\norg.gradle.caching=true\n`
213+
);
214+
215+
// Configure cache cleanup via init script (works with both Gradle 8 and 9,
216+
// org.gradle.cache.cleanup property was removed in Gradle 9)
217+
const initScriptDir = path.join(os.homedir(), '.gradle', 'init.d');
218+
await fs.promises.mkdir(initScriptDir, { recursive: true });
219+
await fs.promises.writeFile(
220+
path.join(initScriptDir, 'eas-cache-cleanup.gradle'),
221+
[
222+
'def cacheDir = new File(System.getProperty("user.home"), ".gradle/caches/build-cache-1")',
223+
'def countBefore = cacheDir.exists() ? cacheDir.listFiles()?.length ?: 0 : 0',
224+
'println "[EAS] Gradle build cache entries before cleanup: ${countBefore}"',
225+
'',
226+
'beforeSettings { settings ->',
227+
' try {',
228+
' settings.caches {',
229+
' cleanup = Cleanup.ALWAYS',
230+
' buildCache {',
231+
' setRemoveUnusedEntriesAfterDays(3)',
232+
' }',
233+
' }',
234+
' println "[EAS] Configured Gradle cache cleanup via init script"',
235+
' } catch (Exception e) {',
236+
' println "[EAS] Failed to configure cache cleanup: ${e.message}"',
237+
' }',
238+
'}',
239+
'',
240+
'gradle.buildFinished {',
241+
' def countAfter = cacheDir.exists() ? cacheDir.listFiles()?.length ?: 0 : 0',
242+
' println "[EAS] Gradle build cache entries after build: ${countAfter} (was ${countBefore})"',
243+
'}',
244+
'',
245+
].join('\n')
246+
);
247+
248+
const robotAccessToken = nullthrows(
249+
secrets?.robotAccessToken,
250+
'Robot access token is required for cache operations'
251+
);
252+
const expoApiServerURL = nullthrows(env.__API_SERVER_URL, '__API_SERVER_URL is not set');
253+
const jobId = nullthrows(env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set');
254+
const cacheKey = await generateGradleCacheKeyAsync(workingDirectory);
255+
logger.info(`Restoring Gradle cache key: ${cacheKey}`);
256+
257+
const gradleCachesPath = path.join(os.homedir(), '.gradle', 'caches');
258+
259+
const buildCachePath = path.join(gradleCachesPath, 'build-cache-1');
260+
261+
const { archivePath, matchedKey } = await downloadCacheAsync({
262+
logger,
263+
jobId,
264+
expoApiServerURL,
265+
robotAccessToken,
266+
paths: [buildCachePath],
267+
key: cacheKey,
268+
keyPrefixes: [GRADLE_CACHE_KEY_PREFIX],
269+
platform: Platform.ANDROID,
270+
});
271+
272+
await fs.promises.mkdir(gradleCachesPath, { recursive: true });
273+
await decompressCacheAsync({
274+
archivePath,
275+
workingDirectory: gradleCachesPath,
276+
verbose: env.EXPO_DEBUG === '1',
277+
logger,
278+
});
279+
280+
logger.info(
281+
`Gradle cache restored to ${gradleCachesPath} ${matchedKey === cacheKey ? '(direct hit)' : '(prefix match)'}`
282+
);
283+
} catch (err: unknown) {
284+
if (err instanceof TurtleFetchError && err.response?.status === 404) {
285+
logger.info('No Gradle cache found for this key');
286+
} else {
287+
logger.warn('Failed to restore Gradle cache: ', err);
288+
}
289+
}
290+
}
291+
179292
export async function cacheStatsAsync({
180293
logger,
181294
env,

packages/build-tools/src/steps/functions/saveBuildCache.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,13 @@ import {
1010
import fs from 'fs';
1111
import nullthrows from 'nullthrows';
1212

13+
import os from 'os';
14+
import path from 'path';
15+
1316
import { compressCacheAsync, uploadCacheAsync } from './saveCache';
17+
import { formatBytes } from '../../utils/artifacts';
1418
import { generateDefaultBuildCacheKeyAsync, getCcachePath } from '../../utils/cacheKey';
19+
import { generateGradleCacheKeyAsync } from '../../utils/gradleCacheKey';
1520

1621
export function createSaveBuildCacheFunction(evictUsedBefore: Date): BuildFunction {
1722
return new BuildFunction({
@@ -46,6 +51,15 @@ export function createSaveBuildCacheFunction(evictUsedBefore: Date): BuildFuncti
4651
env,
4752
secrets: stepCtx.global.staticContext.job.secrets,
4853
});
54+
55+
if (platform === Platform.ANDROID) {
56+
await saveGradleCacheAsync({
57+
logger,
58+
workingDirectory,
59+
env,
60+
secrets: stepCtx.global.staticContext.job.secrets,
61+
});
62+
}
4963
},
5064
});
5165
}
@@ -135,3 +149,66 @@ export async function saveCcacheAsync({
135149
logger.error({ err }, 'Failed to save cache');
136150
}
137151
}
152+
153+
export async function saveGradleCacheAsync({
154+
logger,
155+
workingDirectory,
156+
env,
157+
secrets,
158+
}: {
159+
logger: bunyan;
160+
workingDirectory: string;
161+
env: Record<string, string | undefined>;
162+
secrets?: { robotAccessToken?: string };
163+
}): Promise<void> {
164+
if (env.EXPERIMENTAL_EAS_GRADLE_CACHE !== '1') {
165+
return;
166+
}
167+
168+
const gradleCachesPath = path.join(os.homedir(), '.gradle', 'caches');
169+
const buildCachePath = path.join(gradleCachesPath, 'build-cache-1');
170+
171+
try {
172+
await fs.promises.access(buildCachePath);
173+
} catch {
174+
logger.warn('No Gradle build cache found, skipping save');
175+
return;
176+
}
177+
178+
try {
179+
const cacheKey = await generateGradleCacheKeyAsync(workingDirectory);
180+
logger.info(`Saving Gradle cache key: ${cacheKey}`);
181+
182+
const jobId = nullthrows(env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set');
183+
const robotAccessToken = nullthrows(
184+
secrets?.robotAccessToken,
185+
'Robot access token is required for cache operations'
186+
);
187+
const expoApiServerURL = nullthrows(env.__API_SERVER_URL, '__API_SERVER_URL is not set');
188+
189+
logger.info('Compressing Gradle build cache...');
190+
const { archivePath } = await compressCacheAsync({
191+
paths: [buildCachePath],
192+
workingDirectory: gradleCachesPath,
193+
verbose: env.EXPO_DEBUG === '1',
194+
logger,
195+
});
196+
197+
const { size } = await fs.promises.stat(archivePath);
198+
logger.info(`Gradle cache archive size: ${formatBytes(size)}`);
199+
200+
await uploadCacheAsync({
201+
logger,
202+
jobId,
203+
expoApiServerURL,
204+
robotAccessToken,
205+
archivePath,
206+
key: cacheKey,
207+
paths: [buildCachePath],
208+
size,
209+
platform: Platform.ANDROID,
210+
});
211+
} catch (err) {
212+
logger.error({ err }, 'Failed to save Gradle cache');
213+
}
214+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import * as PackageManagerUtils from '@expo/package-manager';
2+
import { hashFiles } from '@expo/steps';
3+
import path from 'path';
4+
5+
import { findPackagerRootDir } from './packageManager';
6+
7+
export const GRADLE_CACHE_KEY_PREFIX = 'android-gradle-cache-';
8+
9+
export async function generateGradleCacheKeyAsync(workingDirectory: string): Promise<string> {
10+
const packagerRunDir = findPackagerRootDir(workingDirectory);
11+
const manager = PackageManagerUtils.createForProject(packagerRunDir);
12+
const lockPath = path.join(packagerRunDir, manager.lockFile);
13+
14+
try {
15+
return `${GRADLE_CACHE_KEY_PREFIX}${hashFiles([lockPath])}`;
16+
} catch (err: unknown) {
17+
throw new Error(
18+
`Failed to read lockfile for Gradle cache key generation: ${err instanceof Error ? err.message : err}`
19+
);
20+
}
21+
}

0 commit comments

Comments
 (0)