Llim.run

Automatic PR previews with GitHub Actions

PR previews work like web deploy previews for an iOS app. Previews cover iOS apps only. The packaged Action runs on GitHub Actions, and the underlying lim xcode build --upload pattern works on any CI system.

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

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

Opening it provisions a fresh simulator with that build pre-installed. Reviewers need no install and no Mac, but they do need to be members of your Limrun organization.

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/preview?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.

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

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 }}

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) or ipad.
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.
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 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.

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) fail the run in Bazel mode. Put your Bazel flags in user.limrun.bazelrc at the workspace root instead.

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/preview?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/preview?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.