Llim.run

Publish to App Store & Play Store

Why?

Connecting the developer account once gives your platform the signing material and store authorization needed to support the app after the code is written.

With those pieces, your platform can:

The runnable reference is typescript-sdk/examples/publish-to-stores. It includes a React frontend, an Express backend, both store flows, and an authenticated build-completion webhook.

What your platform needs

Before implementing either store flow, prepare:

The example uses JSON files for secrets and an in-memory map for publish status. Those two parts are demo implementations. Replace them with your secret manager and database.

1. Build the shared backend

Both stores use the same backend responsibilities.

Create the Limrun client only in trusted server code. The frontend never needs the organization API key.

import Limrun from '@limrun/api';

const limrun = new Limrun({
  apiKey: process.env.LIM_API_KEY,
});

For Apple connection, mint a short-lived token scoped to the Apple relay and return that token to the browser:

const session = await limrun.scopedTokens.create({
  scopes: ['applerelay:*:connect'],
});

res.json({
  token: session.token,
  expiresAt: session.expiresAt,
  registryUrl: 'https://registry.limrun.com',
});

See the example's POST /session route.

@limrun/apple-auth and @limrun/play-auth both accept the same SigningSecretStore shape:

interface SigningSecretStore {
  put(type: string, name: string, data: Record<string, string>): Promise<SigningSecret>;
  get(type: string, name: string): Promise<SigningSecret | undefined>;
  list(): Promise<SigningSecretMetadata[]>;
  delete(type: string, name: string): Promise<void>;
}

Back this interface with your existing database, KMS, or secret manager. Keep the names stable because later publish requests use them to resolve credentials:

  • Apple distribution certificate: <teamId>/DISTRIBUTION
  • App Store Connect API key: <teamId>/APP_STORE_CONNECT_API_KEY
  • Apple provisioning profiles: metadata includes the team ID and bundle ID
  • Android upload keystore: <packageName>/UPLOAD

The example's SigningSecretStore adapter and file-backed implementation show the complete request and response shapes.

A detached build finishes after the initial CLI process exits. Give each publish a random token and pass it as a custom webhook header:

const publishId = crypto.randomUUID();
const webhookToken = crypto.randomBytes(32).toString('hex');

const callbackArgs = [
  '--webhook-url', publicWebhookUrl,
  '--webhook-header', `X-Publish-Token=${webhookToken}`,
  '--detach',
  '--json',
];

Store the token with the publish record. When Limrun calls the webhook, compare X-Publish-Token, persist the payload, and mark the publish as succeeded or failed. The callback URL must be publicly reachable over HTTPS. Do not expose your secret-store or token-minting routes with it.

The example runs the webhook receiver on a separate port and performs constant-time token matching.

2. Add App Store connection

The user needs an Apple Developer Program account with permission to create App Store Connect API keys. Let them choose who maintains the signing certificate:

Both modes prepare an App Store Connect app record and API key for the upload.

In the browser, initialize useAppleIDLogin with the scoped token returned by your backend:

import { useAppleIDLogin } from '@limrun/apple-auth/react';

const appleLogin = useAppleIDLogin({
  registryApiUrl: session.registryUrl,
  token: session.token,
});

const result = await appleLogin.startLogin({
  accountName,
  password,
});

If result.requiresTwoFactor is true, collect the verification code and call appleLogin.submitTwoFactorCode(code). After authentication, call finalize() and list the account's teams with listAppleTeams.

The example implements this state machine in useConnect.ts.

List the selected team's explicit bundle IDs with listAppleBundleIDs. Let the user select an existing ID or register one with createAppleBundleID.

Ask for the app's display name at the same time. It is required when you create the App Store Connect app record.

Run these actions after the user confirms the team, bundle ID, and signing mode:

  1. Switch to the team's App Store Connect provider when the team has a provider ID.
  2. Call ensureAppStoreConnectApp for the bundle ID and app name.
  3. Call ensureAppStoreConnectApiKeySecret and save the result.
  4. For manual signing, call ensureAppleCertificateSecret, create or refresh an App Store provisioning profile that references that certificate, and store the profile.

Cloud signing requires the App Store Connect team key to be an Admin key or have Access to cloud-managed distribution certificates enabled. Limrun uses the key for cloud signing and upload; manual mode uses it for upload. Reuse valid stored resources when the user reconnects.

3. Publish the iOS app

When the user clicks publish, send the project path, team ID, bundle ID, signing mode, optional scheme, and webhook URL to your backend.

The backend then:

  1. Loads the App Store Connect API key from the secret store.
  2. For manual mode, also loads the distribution certificate and App Store provisioning profile.
  3. Writes the selected credentials to a temporary directory with owner-only permissions.
  4. Starts a release build for the iphoneos SDK with either cloud or manual signing flags.
  5. Requests automatic build-number selection, upload to App Store Connect, a completion webhook, and detached JSON output.
  6. Deletes the temporary files after the CLI exits.

The cloud-signing invocation has this shape:

lim xcode build <project-path> \
  --sdk iphoneos \
  --configuration Release \
  --signing-method app-store-connect \
  --team-id <team-id> \
  --upload-to-appstore \
  --auto-build-number \
  --asc-key-id <key-id> \
  --asc-issuer-id <issuer-id> \
  --asc-key <AuthKey.p8> \
  --webhook-url <public-https-url> \
  --webhook-header "X-Publish-Token=<random-token>" \
  --inactivity-timeout 3s \
  --detach \
  --json

Manual mode replaces --signing-method and --team-id with:

--certificate-p12 <certificate.p12> \
--certificate-password <password> \
--provisioning-profile <profile.mobileprovision>

The upload still uses the --asc-* flags. Add --scheme <scheme> when the project needs one. Cloud signing requires a team API key and its issuer ID. Spawn the CLI with an argument array instead of constructing a shell command, and redact the certificate password from logs.

The complete implementation is in startPublish. A successful upload appears in App Store Connect and TestFlight. The user still attaches the processed build to an App Store version and submits that version for review in App Store Connect.

4. Add Google Play connection

Google requires the Play Console app listing to exist before an API client can publish to it. Ask the user to create the listing with the exact Android package name and grant their Google account release permission.

Create a Google OAuth Web application client whose authorized JavaScript origins include your frontend origin. Pass its client ID to requestGoogleAccessToken:

import { requestGoogleAccessToken } from '@limrun/play-auth';

const accessToken = await requestGoogleAccessToken({
  clientId: GOOGLE_OAUTH_CLIENT_ID,
});

Keep the access token in browser memory. Send it to your backend only with a publish request, and ask the user to sign in again after it expires.

Read expo.android.package from Expo projects or applicationId from app/build.gradle and app/build.gradle.kts. Let the user correct the detected value.

Use the Google token to verify that the listing exists and that the account can access it. If the listing is missing, link the user to Play Console and retry after they create it. The example contains both package detection and the browser connection state.

First check your secret store for <packageName>/UPLOAD.

  • For an app that already has an upload key, import that keystore and its alias and passwords.
  • For a new app, call generateAndroidUploadKeystore(packageName) and store the returned values.
  • Never replace an existing upload keystore without an explicit key-reset process. Future uploads signed by a different key will be rejected.

The stored data contains keystoreBase64, keystorePassword, keyAlias, and keyPassword. The example's usePlay hook checks again immediately before storing so concurrent requests cannot overwrite a key.

5. Publish the Android app

Send the project path, package name, Google access token, and webhook URL to your backend. The reference flow publishes to the internal track.

The backend then:

  1. Loads <packageName>/UPLOAD from the secret store.
  2. Writes the keystore to a temporary owner-only file.
  3. Passes keystore and key passwords through environment variables.
  4. Starts a release AAB build with automatic versionCode selection.
  5. Publishes the AAB to Google Play's internal track.
  6. Deletes the temporary file after the CLI exits.

The invocation has this shape:

LIM_KEYSTORE_PASSWORD=<keystore-password> \
LIM_KEY_PASSWORD=<key-password> \
LIM_PLAYSTORE_ACCESS_TOKEN=<google-access-token> \
lim gradle build <project-path> \
  --keystore <upload-keystore> \
  --key-alias <alias> \
  --upload-to-playstore \
  --playstore-package <package-name> \
  --playstore-track internal \
  --auto-version-code \
  --webhook-url <public-https-url> \
  --webhook-header "X-Publish-Token=<random-token>" \
  --inactivity-timeout 3s \
  --detach \
  --json

The reference UI supports only the internal track. Its backend accepts another track value, but it does not expose the release-status handling required for a complete production-track flow. Add that handling before offering other tracks in your product. The full internal-track implementation is in startAndroidPublish.

6. Return progress to the frontend

The initial publish endpoint should return as soon as the detached build is accepted:

{
  "publishId": "01k..."
}

Store at least publishId, state, start time, webhook token, and the Limrun Console URL returned by the CLI's detached JSON output. Your frontend can poll a status endpoint or subscribe to your own event stream while the build runs.

When the authenticated webhook arrives:

  1. Match it to the publish using the webhook token.
  2. Store the payload and its receipt time.
  3. Treat status: "SUCCEEDED" as success and other terminal statuses as failure.
  4. Surface consoleUrl and logsUrl when present. Use the persisted log for failure diagnostics.

The example's usePublish and usePlay poll every three seconds. Its backend map is process-local, so restarting the example loses active publish records. Persist these records in your platform.

Production checklist

Run the reference implementation

The example requires Node.js and Yarn 1. Clone the TypeScript SDK repository, then install and start both halves:

export LIM_API_KEY="lim_..."

yarn --cwd examples/publish-to-stores/backend install
yarn --cwd examples/publish-to-stores/frontend install
yarn --cwd examples/publish-to-stores/backend dev

In another terminal:

yarn --cwd examples/publish-to-stores/frontend dev

Run a public HTTPS tunnel to the webhook receiver on port 3001, paste that URL into the UI, and open http://localhost:5173. The example README covers its local ports, webhook behavior, secret-directory sharing, and both store tabs.