Llim.run

Automatic PR previews with GitHub Actions

PR previews let reviewers run a pull request's build in their browser. The packaged iOS Action runs on GitHub Actions, and the underlying lim xcode build --upload pattern works on any CI system. For Android, see Android preview action.

A preview link points the Limrun console at an asset in Asset Storage:

https://console.limrun.com/stream?asset=<asset-name>&platform=ios

Opening the link takes the reviewer to Playground with the app and launch settings filled in. Review the configuration, then select Start device to create a simulator and install and launch the build. An Installing <asset name> overlay stays visible until installation finishes. Reviewers need no install and no Mac, but they do need to be members of your Limrun organization.

The simulator is an iPhone unless the link says otherwise. Add model to open the same build on an iPhone Duo or an iPad:

https://console.limrun.com/stream?asset=<asset-name>&platform=ios&model=iphone-duo

model accepts iphone, iphone-duo, ipad, and watch. Reviewers can change the model in Playground before starting the device.

Any build that uploads to Asset Storage produces a previewable asset. From a shell or a coding agent, build with --upload and share the resulting URL:

ASSET_NAME="${PR_NUMBER:-$(date +%s)}.zip"
lim xcode build . --upload "${ASSET_NAME}"
echo "https://console.limrun.com/stream?asset=${ASSET_NAME}&platform=ios"

Bazel workspaces publish the same kind of asset with lim xcode rbe upload <asset-name> or lim xcode rbe --auto-upload <asset-name>; see Build with Bazel.

Existing /preview links redirect to /stream with their settings preserved.

Configure the preview app

For iOS, repeat the env query parameter to set app environment variables before the app's first launch. Each value uses KEY=VALUE syntax and must be URL-encoded:

https://console.limrun.com/stream?asset=my-app.zip&platform=ios&env=FEATURE_FLAG%3D1&env=API_URL%3Dhttps%3A%2F%2Fapi.example.com

Use URLSearchParams when generating links so spaces, &, +, and = are encoded:

const params = new URLSearchParams({ asset: 'my-app.zip', platform: 'ios' });
params.append('env', 'FEATURE_FLAG=1');
params.append('env', 'API_URL=https://api.example.com');
params.set('openUrl', 'myapp://checkout?item=123&source=preview');
const previewUrl = `https://console.limrun.com/stream?${params}`;

The last occurrence of a key wins, and KEY= sets an empty value. Use these links for nonsecret configuration; values are visible to anyone who can read the URL and remain in browser history.

Add openUrl to open a web URL or app deep link after the app launches. This works for both iOS and Android previews, with or without env. URL-encode the entire URL, including its query string:

https://console.limrun.com/stream?asset=my-app.zip&platform=ios&openUrl=myapp%3A%2F%2Fcheckout%3Fitem%3D123%26source%3Dpreview

The stream stays visible while the app installs, launches, and opens the URL. The overlay disappears when these steps finish. If a step fails, the error appears over the stream. Select Resume setup to retry the unfinished steps on the same device.

Add tunnel with the name of a private tunnel when the app needs services in your private network, such as a staging API behind a VPN. The preview instance attaches to the tunnel before the app launches, for both iOS and Android previews, so the tunnel's connector must be running when a reviewer starts the device:

https://console.limrun.com/stream?asset=my-app.zip&platform=ios&tunnel=staging

Keep app state between previews

Add snapshot-key to a preview URL to keep installed apps, their data, keychain, and supported permissions between iOS previews:

https://console.limrun.com/stream?asset=my-app.zip&platform=ios&snapshot-key=myapp-%7Buser%7D

The console replaces {user} with the reviewer's Limrun email address, giving each reviewer a separate snapshot. Use a stable key across pull requests so reviewers can reuse their simulator state on new builds.

Select Start device to restore all saved apps and their state, then install and launch the preview build. A newer build updates the app while keeping its data. Other apps stay installed at their saved versions. With a new key, the simulator starts fresh. For an existing snapshot, select the same iOS major version it was saved on.

When you finish, select Stop. Limrun saves the snapshot as the simulator terminates, including when it terminates because of a timeout. Closing the tab does not stop the device or trigger an immediate save. Each successful save replaces the previous snapshot under that key.

Keep the tab open until restoration finishes. If you refresh during restoration, stop the device and reopen the link. A failed or interrupted restore leaves the previous snapshot untouched. After restoration, Resume setup can retry unfinished app installation or launch steps.

Keys belong to your organization. To share one snapshot across reviewers, omit {user}, for example snapshot-key=myapp-shared. Everyone using that key restores the same state, including its login. Use a shared test account. If multiple devices use one key at the same time, the last successful save wins; their changes are not combined. The key appears in the preview URL, so do not put secrets in it.

To find saved keys, open Snapshots and select iOS. Open a key to check Last saved, size, and iOS version, or copy a preview link. Wait for Last saved to update before starting another device with that state. The link opens Playground with the snapshot's organization and recorded iOS version selected. Select Start device to restore it. Not saved yet means the key exists but has no saved archive.

The simulatorSnapshot and deviceState URL parameters remain supported as aliases. If you supply more than one, their values must match.

With the packaged Action, set simulator-snapshot-key. You can also set xcode-snapshot-key to reuse the Xcode build workspace:

with:
  api-key: ${{ secrets.LIM_API_KEY }}
  simulator-snapshot-key: myapp-{user}
  xcode-snapshot-key: ios-${{ github.repository_id }}-pr-${{ github.event.pull_request.number }}-workspace

The Action puts the simulator key in the preview URL as snapshot-key. Each input can be used on its own.

See Simulator snapshots for what is saved, size limits, and version compatibility.

Use the packaged Action

limrun-inc/ios-preview-action wraps the create, build, upload, and PR comment steps into one. Its post: step deletes the Xcode sandbox after every run, so no sandbox outlives the job.

Add LIM_API_KEY under Settings, Secrets and variables, Actions in the GitHub repository, then add this workflow:

.github/workflows/ios-preview.yml
name: iOS Preview

on:
  pull_request:
    types: [opened, synchronize, reopened, closed]

permissions:
  contents: read
  pull-requests: write

concurrency:
  group: ios-preview-${{ github.event.pull_request.number }}
  cancel-in-progress: true

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: limrun-inc/ios-preview-action@main
        with:
          project-path: .
          api-key: ${{ secrets.LIM_API_KEY }}
          xcode-snapshot-key: ios-${{ github.repository_id }}-pr-${{ github.event.pull_request.number }}-workspace

The Action posts a comment titled Limrun Preview with a table of the platform, the short commit SHA, and the preview link, and updates that same comment on later pushes. With closed in on.pull_request.types, the run on merge or close deletes the PR's Xcode sandboxes and changes the comment to say the preview was removed. The preview asset itself stays until its TTL expires, 14 days after the last upload by default.

InputWhat it does
api-keyRequired. Your Limrun API key, stored as a repository secret.
project-pathDirectory to sync to the Xcode sandbox. Defaults to the repository root. A Bazel workspace root here switches to remote build execution.
projectPath to the .xcodeproj to build. Derived from the workspace file when omitted.
workspacePath to the .xcworkspace to build. Derived from the project file when omitted.
schemeScheme to build. Derived from the project file when omitted.
sdkSDK to build. Defaults to iphonesimulator.
modelSimulator model for previews: iphone (default), iphone-duo, or ipad.
envNewline-delimited KEY=VALUE app environment variables for preview launches. Use nonsecret values because they appear in the preview URL.
open-urlURL or app deep link to open after the preview app launches. Pass it without URL encoding.
tunnelName of a private tunnel the preview simulator attaches to before the app launches. Its connector must be running when a reviewer starts the device.
simulator-snapshot-keySaves and restores installed simulator apps, their data, keychain, and supported permissions. Include {user} for a separate snapshot per reviewer. See Keep app state between previews.
xcode-versionXcode major to build with, such as 27. Switching majors invalidates the other version's build state, so the next build starts cold. Defaults to the sandbox default.
prepareRemote shell script after source sync and before xcodebuild. Failure stops the build.
xcode-snapshot-keyDisk snapshot publication key and default restore key.
xcode-snapshot-restore-keysOne snapshot restore key per line, tried in order.
xcode-snapshot-pathsOne project-relative path per line. Omit to save the whole workspace.
build-settingsNewline-delimited KEY=VALUE Xcode build settings. Keys follow the same rules as --build-setting; see Pass build settings. Use $(inherited) to append.
bazel-targetBazel label of the app target, such as //App. Inferred for single-app workspaces.
github-tokenToken for posting the PR comment. Defaults to github.token.
mediaNewline-delimited image or video paths to show in the PR body. Add #alt text after an image path. Requires media-token and gh 2.99.0 or newer on the runner.
media-tokenUser token (personal access token or OAuth) with write access to the repository, used only to upload media. GitHub rejects github.token for attachments.

The older device-state-key input remains an alias for simulator-snapshot-key. The older snapshot-key, snapshot-restore-keys, and snapshot-paths inputs remain aliases for their xcode- equivalents. If you set an input and its alias, their values must match.

The Action sets two outputs: preview-url and asset-name. Its action.yml also declares asset-id, but the Action never sets it, so that output is always empty.

Prepare and reuse the workspace

The example uses a snapshot key scoped to the repository and PR. The first build starts cold; subsequent builds restore that workspace before source sync. Cleanup waits for snapshot publication and reports its outcome. A publication failure warns without discarding a successful preview. Use separate keys for different Xcode versions or build configurations.

Use prepare for commands that need to run remotely after sync and before xcodebuild. For a CocoaPods project:

with:
  api-key: ${{ secrets.LIM_API_KEY }}
  xcode-snapshot-key: ios-${{ github.repository_id }}-pr-${{ github.event.pull_request.number }}-workspace
  prepare: |
    bundle install
    bundle exec pod install

This uses the same command API as lim xcode run, with /bin/sh and set -e, from the synced project root. A failed preparation stops the build and still cleans up the sandbox. App-launch env is separate from preparation.

xcode-snapshot-restore-keys accepts an ordered multiline list of fallback keys; include the destination key to try it first. xcode-snapshot-paths accepts one project-relative path per line. Leave it unset to preserve the whole workspace, including DerivedData and source sync state. See Disk snapshots for publication conditions and restore matching.

App launch options

Set env and open-url under with to configure the app when a reviewer opens the preview. Both inputs work with Xcode and Bazel builds:

- uses: limrun-inc/ios-preview-action@main
  with:
    api-key: ${{ secrets.LIM_API_KEY }}
    env: |
      API_URL=https://api.example.com
      FEATURE_FLAG=1
    open-url: 'myapp://checkout?item=123&source=preview'

The Action encodes each environment entry and the URL automatically. KEY= sets an empty value; duplicate keys use the last value. Use nonsecret values: the preview URL appears in the PR comment, action output, logs, and browser history.

Set tunnel to the name of a private tunnel when the app needs services in your private network.

Bazel projects

The same Action handles Bazel workspaces. When project-path is a Bazel workspace root (it contains MODULE.bazel, WORKSPACE, or WORKSPACE.bazel directly), the Action builds with remote build execution instead of xcodebuild and uploads the built app as the preview asset. The runner needs bazelisk on the PATH; GitHub-hosted runners have it preinstalled. Set bazel-target, for example //App, when the workspace has more than one app target; a single-app workspace is inferred:

- uses: limrun-inc/ios-preview-action@main
  with:
    project-path: .
    bazel-target: //App
    api-key: ${{ secrets.LIM_API_KEY }}

The xcodebuild-only inputs (project, workspace, scheme, build-settings, prepare, and Xcode snapshot inputs) fail the run in Bazel mode. Put your Bazel flags in user.limrun.bazelrc at the workspace root instead.

Android preview action

limrun-inc/android-preview-action builds an APK on a temporary Gradle sandbox and posts a link to a streamed Android emulator. Add LIM_API_KEY as a repository secret, then use:

.github/workflows/android-preview.yml
name: Android preview
on:
  pull_request:
    types: [opened, synchronize, reopened, closed]
permissions:
  contents: read
  pull-requests: write
concurrency:
  group: android-preview-${{ github.event.pull_request.number }}
  cancel-in-progress: true
jobs:
  preview:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: limrun-inc/android-preview-action@main
        with:
          api-key: ${{ secrets.LIM_API_KEY }}
          project-path: .
          tasks: assembleDebug
          build-env: |
            APP_ENV=preview
            API_URL=https://api.example.com
          open-url: 'myapp://checkout?item=123&source=preview'

project-path selects the directory to sync. Set gradle-project-path to the Gradle root within that directory when auto-discovery is ambiguous. tasks accepts one task per line and defaults to assembleDebug; choose tasks that produce an installable APK. build-env accepts one KEY=VALUE entry per line for the remote build. It does not set app-launch environment variables.

open-url is encoded automatically and opens after the app launches. Use nonsecret URLs because the link appears in the PR comment, logs, and browser history. tunnel names a private tunnel the preview emulator attaches to before the app launches. Pass sensitive build values through GitHub secrets; those values may be embedded in the app artifact.

Use prepare for remote setup or code generation before Gradle builds. It runs after source sync from the synced project root, with build-env, using the same command API as lim gradle run. For example, a JavaScript project can use:

prepare: |
  npm ci
  npm run generate

The script runs with /bin/sh and set -e; failures stop the build and trigger cleanup. Android builds do not currently support disk snapshots.

The action outputs preview-url and asset-name, updates its Android comment on each push, and deletes its Gradle sandbox after success or failure. Its post hook retries cleanup after cancellation. Closing the PR cleans up remaining builders and marks the comment as closed; the APK asset expires 14 days after the latest upload by default. Reviewers must belong to the asset's Limrun organization. Fork pull requests normally lack the required repository secrets; use this action on trusted pull_request workflows.

Write a custom workflow

Write the workflow yourself when you need a different comment format, your own asset naming, or a CI system other than GitHub Actions. The CLI runs anywhere Node runs, so the same lim xcode build --upload pattern works on GitLab CI, CircleCI, Buildkite, and Jenkins.

This workflow builds on every PR open or push, uploads under a PR-scoped asset name, and comments with the link. Replace MyApp with your scheme. Each run adds a new comment like this one:

📱 **iOS preview ready**

Preview: https://console.limrun.com/stream?asset=my-app-pr-42.zip&platform=ios

The workflow:

.github/workflows/ios-preview.yml
name: iOS Preview

on:
  pull_request:
    types: [opened, synchronize, reopened]

permissions:
  pull-requests: write
  contents: read

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Install Limrun CLI
        run: npm install --global lim

      - name: Build on Limrun
        env:
          LIM_API_KEY: ${{ secrets.LIM_API_KEY }}
          PR: ${{ github.event.number }}
          REPO: ${{ github.event.repository.name }}
        run: |
          ASSET_NAME="${REPO}-pr-${PR}.zip"
          echo "ASSET_NAME=${ASSET_NAME}" >> $GITHUB_ENV

          lim xcode create --reuse-if-exists \
            --label pr=${PR} \
            --label repo=${REPO}

          lim xcode build . \
            --scheme MyApp \
            --upload "${ASSET_NAME}"

      - name: Comment with preview link
        uses: actions/github-script@v7
        with:
          script: |
            const assetName = process.env.ASSET_NAME;
            const url = `https://console.limrun.com/stream?asset=${encodeURIComponent(assetName)}&platform=ios`;
            const body = [
              '📱 **iOS preview ready**',
              '',
              `Preview: ${url}`,
            ].join('\n');
            await github.rest.issues.createComment({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: context.issue.number,
              body,
            });

What each step does:

  1. Checkout. The source tree is what gets synced to the Xcode sandbox.
  2. Install Limrun CLI. The build runs on Limrun's Macs, so the job stays on a Linux runner.
  3. Build on Limrun. lim xcode create --reuse-if-exists creates or reuses a sandbox labelled with the PR number and repository, so later pushes to the same PR land on the same sandbox. lim xcode build . --upload <name> syncs the source, runs xcodebuild, and uploads the artifact. Naming the asset after the PR means each push overwrites it, so the link always shows the latest commit. Build uploads expire 14 days after the latest upload by default, so the preview outlives the PR's last activity by 14 days; change the window with --upload-ttl <duration>.
  4. Comment with preview link. The script builds the preview URL and posts it on the PR.

Clean up when a PR closes

The packaged Action deletes the sandboxes on its own, but leaves the preview asset to expire by TTL. For the custom workflow, add closed to on.pull_request.types and a job that deletes everything labelled with the closed PR:

  cleanup:
    if: github.event.action == 'closed'
    runs-on: ubuntu-latest
    steps:
      - run: npm install --global lim
      - name: Delete PR instances and assets
        env:
          LIM_API_KEY: ${{ secrets.LIM_API_KEY }}
          PR: ${{ github.event.number }}
          REPO: ${{ github.event.repository.name }}
        run: |
          # Delete Xcode sandboxes for this PR in any state (list shows only ready ones by default)
          lim xcode list --all --label-selector "pr=${PR},repo=${REPO}" --json \
            | jq -r '.[].metadata.id' \
            | xargs -r -n1 lim xcode delete

          # Delete the preview asset
          ASSET_NAME="${REPO}-pr-${PR}.zip"
          ASSET_ID=$(lim asset list --name "${ASSET_NAME}" --json | jq -r '.[0].id // empty')
          if [ -n "$ASSET_ID" ]; then
            lim asset delete "$ASSET_ID"
          fi

With the build job guarded the same way (if: github.event.action != 'closed'), one workflow file handles both.

Next steps

hammer

Build with Xcode

Every lim xcode build flag and artifact upload option.

workflow

GitHub Actions recipes

Signed IPAs, Bazel builds, and signed AABs from Linux runners.

package

Asset Storage

How preview assets are stored, named, and expired.

flask-conical

XCTest

Gate previews on your test suite with lim xcode test.