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.
How a preview link works
A preview link points the Limrun console at an asset in Asset Storage:
https://console.limrun.com/stream?asset=<asset-name>&platform=iosOpening 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-duomodel 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.comUse 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%3DpreviewThe 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=stagingKeep 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%7DThe 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 }}-workspaceThe 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:
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 }}-workspaceThe 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.
| Input | What it does |
|---|---|
api-key | Required. Your Limrun API key, stored as a repository secret. |
project-path | Directory to sync to the Xcode sandbox. Defaults to the repository root. A Bazel workspace root here switches to remote build execution. |
project | Path to the .xcodeproj to build. Derived from the workspace file when omitted. |
workspace | Path to the .xcworkspace to build. Derived from the project file when omitted. |
scheme | Scheme to build. Derived from the project file when omitted. |
sdk | SDK to build. Defaults to iphonesimulator. |
model | Simulator model for previews: iphone (default), iphone-duo, or ipad. |
env | Newline-delimited KEY=VALUE app environment variables for preview launches. Use nonsecret values because they appear in the preview URL. |
open-url | URL or app deep link to open after the preview app launches. Pass it without URL encoding. |
tunnel | Name 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-key | Saves 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-version | Xcode 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. |
prepare | Remote shell script after source sync and before xcodebuild. Failure stops the build. |
xcode-snapshot-key | Disk snapshot publication key and default restore key. |
xcode-snapshot-restore-keys | One snapshot restore key per line, tried in order. |
xcode-snapshot-paths | One project-relative path per line. Omit to save the whole workspace. |
build-settings | Newline-delimited KEY=VALUE Xcode build settings. Keys follow the same rules as --build-setting; see Pass build settings. Use $(inherited) to append. |
bazel-target | Bazel label of the app target, such as //App. Inferred for single-app workspaces. |
github-token | Token for posting the PR comment. Defaults to github.token. |
media | Newline-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-token | User 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 installThis 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:
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 generateThe 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=iosThe workflow:
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:
- Checkout. The source tree is what gets synced to the Xcode sandbox.
- Install Limrun CLI. The build runs on Limrun's Macs, so the job stays on a Linux runner.
- Build on Limrun.
lim xcode create --reuse-if-existscreates 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, runsxcodebuild, 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>. - 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"
fiWith the build job guarded the same way (if: github.event.action != 'closed'), one workflow file handles both.
Next steps
Build with Xcode
Every lim xcode build flag and artifact upload option.
GitHub Actions recipes
Signed IPAs, Bazel builds, and signed AABs from Linux runners.
Asset Storage
How preview assets are stored, named, and expired.
XCTest
Gate previews on your test suite with lim xcode test.
Give every pull request an iOS build that reviewers open in the browser from a link on the PR.
Was this guide helpful?