Build with Gradle
The CLI syncs your source to a Gradle sandbox, runs the project's own Gradle wrapper remotely, and streams the log back. Install the APK on a cloud emulator, or sign an AAB and publish it to Google Play.
The CLI covers the whole surface. The TypeScript SDK provisions and builds too, but its builds pass signing material explicitly because the escrowed release key (--sign) is CLI-only:
| Surface | CLI | TypeScript | Python | Go |
|---|---|---|---|---|
| Provision a Gradle sandbox | ✓ | ✓ | via REST | via REST |
| Sync source and run Gradle | ✓ | ✓ | not in SDK | not in SDK |
Escrowed release signing (--sign) | ✓ | pass the material yourself | not in SDK | not in SDK |
The SDK capability matrix covers every other surface.
Build a project
One command does everything. It creates a Gradle sandbox (or reuses the one remembered for your git worktree), syncs the current directory, and runs assembleDebug with the project's Gradle wrapper:
lim gradle build .Pick other tasks with --task; repeat it for several:
lim gradle build . --task :app:assembleReleaseBare React Native repositories usually need nothing extra: the sandbox finds android/ on its own. If the Gradle root is nested and auto-discovery is ambiguous, point at it with --project-path:
lim gradle build . --project-path androidThe sync skips paths your .gitignore files exclude. Repeat --ignore '<regex>' to skip more paths, and --include '<regex>' to force-sync paths that a built-in rule or .gitignore would exclude. --env KEY=VALUE passes an environment variable to the build, and --basis-cache-dir sets where the client keeps its sync cache.
To create the sandbox yourself, for example with labels or a jurisdiction, create it first; the build then uses it:
lim gradle create --jurisdiction eu --label env=dev --display-name ci-builder
lim gradle build . --task :app:assembleReleaseimport Limrun from '@limrun/api';
const lim = new Limrun({ apiKey: process.env['LIM_API_KEY'] });
const instance = await lim.gradleInstances.create({
wait: true,
reuseIfExists: true,
metadata: { labels: { session: 'ci-build' } },
});
const gradle = await lim.gradleInstances.createClient({ instance });
await gradle.sync('.');
const build = gradle.gradlebuild({ tasks: [':app:assembleRelease'] });
build.stdout.on('data', (line) => console.log(line));
const { exitCode } = await build;Without --inactivity-timeout, a Gradle sandbox is deleted after 10 minutes of inactivity. In TypeScript, each step is explicit: create the instance, open a client, sync, then build.
For long builds, return as soon as the build is accepted with --detach, read the log later with lim gradle logs, or receive the result on a webhook. Build logs and webhooks covers all three, including the webhook payload's applicationId, versionName, and versionCode.
React Native and Expo projects
Expo managed-workflow projects have no android/ directory. The sandbox detects them, installs dependencies, and runs expo prebuild before Gradle. Two flags tune that pipeline. Setting either one forces the Expo pipeline on, and the build fails when no Expo app is detected:
--expo-app-dir <path>: the app directory inside a monorepo, relative to the synced root.--abi <abi>: which Android ABIs to build (armeabi-v7a,arm64-v8a,x86,x86_64, orall, which keeps the project's own configuration). Repeatable. Without it, Expo-pipeline builds targetx86_64, the ABI Limrun Android instances run natively (arm64-v8aalso works through translation; see Supported ABIs). Release and bundle tasks keep the project's own ABI configuration.
lim gradle build ./my-monorepo --expo-app-dir apps/mobileSelect tools
The sandbox image includes these mise-managed tools:
| Tool | Included compatibility lines | Default |
|---|---|---|
| Node.js (with npm and npx) | 22, 24 | 22 |
| pnpm | 9, 10, 11 | 10 |
| Yarn | 1, 4 | 1 |
| Bun | 1 (stable) | 1 |
| Java | Temurin 17 | Temurin 17 |
| bundletool | 1 | 1 |
Builds use your project's Gradle wrapper. Android SDK, NDK, and CMake packages stay managed by sdkmanager.
Select tool versions, install missing ones, and inspect the result:
lim gradle use node@24 java@temurin-17 pnpm@10
lim gradle tools install
lim gradle toolsWhat each command does:
lim gradle useselects tools in an existing sandbox after the project has synced. Mise installs a requested version if needed.--cwd apps/mobiletargets a nested project and--idchooses the sandbox.lim gradle tools installsyncs the project and runsmise installin the sandbox.--no-syncinstalls the current selections without syncing,--cwd apps/mobiletargets a nested project, and--idchooses an existing sandbox. It never creates or replaces an instance.lim gradle toolsinspects an existing sandbox without syncing or creating an instance.--syncuploads local changes first, and--cwd apps/mobileinspects a nested project. Both forms need an existing sandbox;--idchooses one.
Limrun imports [tools] declarations from synced project mise files. Those declarations override package-manager detection and image defaults. Most numeric project requests keep their major version; Ruby, Python, Go, Flutter, Dart, and pre-1.0 tools keep major.minor. Limrun can update patch and minor releases within those lines on its own, and latest opts out of a fixed line.
Each build or run resolves its mise environment once. The first sandbox operation that reads a project mise file runs mise install before exporting that environment; this happens once per sandbox. Without a project mise file, commands use the image defaults. Later changes need an explicit lim gradle tools install, and so does a failed or cancelled first install, which also stops that operation.
To pin an exact release, run lim gradle run -- mise use --pin [email protected]. Later operations respect that override, and lim gradle use node@24 replaces it with a Node 24 selection.
The selected Node is exposed as NODE_BINARY, and mise sets JAVA_HOME. Sandbox paths such as HOME, PATH, and the Android SDK locations stay managed.
Run commands
lim gradle run syncs the current directory, then runs a one-shot shell command after -- in the remote workspace with the selected tools. Pass environment variables with --env, which lim gradle build accepts too:
lim gradle run -- node --version
lim gradle run --env APP_ENV=staging -- npm run generate
lim gradle build . --env APP_ENV=stagingconst gradle = await lim.gradleInstances.createClient({ instance });
await gradle.sync('.');
const install = await gradle.run('mise install', { cwd: 'apps/mobile' });
if (install.exitCode !== 0) throw new Error('Tool installation failed');
const result = await gradle.gradlebuild({ env: ['APP_ENV=staging'] });In TypeScript, sync the project first, then call run to install tools or run a command. run accepts cwd, env, and timeoutSeconds and returns the same streamed process interface as gradlebuild.
CLI options:
--no-syncuses the existing remote workspace instead of syncing first.- The optional positional directory (
lim gradle run apps/api -- make generate) is relative to the synced workspace. --timeoutaccepts 1 through 21600 seconds and defaults to 3600.--additional-file localPath=remotePathadds a file from outside the source tree, such as~/.netrc=.netrc; the remote path is relative to the workspace. Repeat it for several files.
Commands stream their output.
Commands and builds share one slot per sandbox: starting a new command or build cancels the one that is running.
Keep builds warm
Reuse the same sandbox for later builds. It keeps its dependency caches and temporary files between builds and commands, until the instance is deleted. The image also ships warmed Gradle caches and a pnpm 10 store, so only another pnpm major needs an initial registry download.
Tools you install yourself last for the sandbox's lifetime. A new sandbox starts from the image again: it does not inherit the previous sandbox's workspace or your installed tools.
Dependencies reinstall only when the installed tool versions change. A selected tool that failed to install doesn't trigger reinstalls or block unrelated commands after the first attempt.
Upload the build artifact
There are two ways to get the artifact out of the sandbox.
Upload to Asset Storage. The artifact lives in Limrun's managed storage, and later creates can install it by name. See Asset Storage.
lim gradle build . --upload myapp.apk
lim android create --install-asset=myapp.apkconst build = gradle.gradlebuild({ upload: { assetName: 'myapp.apk' } });
const { exitCode, signedDownloadUrl } = await build;The uploaded asset expires 14 days after the last upload by default. Each build upload pushes the expiry out again, so actively rebuilt assets stay alive. Pass --upload-ttl with a Go duration (ttl in the SDK) to change the window: 720h works, 1d is not valid Go syntax. Assets uploaded with lim asset push are unaffected; they never expire unless given a --ttl.
The build result includes a signed download URL. Anyone with the URL can download the artifact without a Limrun API key. The signature expires after 15 minutes; fetch a fresh one with lim asset list --name <asset-name> --download-url.
Upload to your own bucket. Pass a presigned S3, GCS, or R2 URL and the sandbox PUTs the artifact straight to it, without a round trip through the machine that started the build:
lim gradle build . --signed-upload-url '<presigned-url>'const build = gradle.gradlebuild({ upload: { signedUploadUrl: '<presigned-url>' } });CLI only: --signed-upload-url defaults to the LIM_SIGNED_UPLOAD_URL environment variable.
To build on every push from a Linux runner, see GitHub Actions recipes.
Troubleshooting
| Symptom or message | Cause | Fix |
|---|---|---|
| The build can't find the Gradle root | Auto-discovery found more than one candidate, or the root is nested. | Pass --project-path <dir>, for example --project-path android. |
--expo-app-dir or --abi fails with no Expo app detected | Either flag forces the Expo pipeline on. | Remove the flag for native projects, or point --expo-app-dir at the Expo app. |
| A new tool selection has no effect | The first mise install already ran for this sandbox. | Run lim gradle tools install. |
A running build stops with status CANCELLED | Another build or lim gradle run started on the same sandbox. | Run one operation at a time per sandbox, or use separate sandboxes. |
--upload-ttl 1d is rejected | 1d is not a Go duration. | Use hours, for example 24h. |
| The download URL returns an error after a while | Signed download URLs expire after 15 minutes. | Run lim asset list --name <asset-name> --download-url for a fresh one. |
Next steps
Sign and publish
Sign a release AAB with your organization's upload key and publish it to Google Play.
Run an emulator
Install the APK the build uploaded and drive the device.
Build logs and webhooks
Detach from long builds, read logs later, and receive the result on a webhook.
GitHub Actions recipes
Build and sign on a plain Linux runner.
Was this guide helpful?