Guides

Distribution

Get a build onto testers' devices with a link they can tap — and register the release and upload its symbols in the same step, so crashes from that build are readable from the moment it's installed.

Over-the-air install links

When you upload an .ipa, Pharen returns an install link — an itms-services URL that installs the app over the air. Open it in Safari on the iPhone and the app installs directly, no App Store, no cable:

text
itms-services://?action=download-manifest&url=https://install.pharen.ai/install/<opaque>/manifest.plist

Install links live at an unguessable path — they're shareable and bookmarkable, but not enumerable, so one organization's builds can't be discovered from another's. The link is what you send a tester; installing needs no login.

The build itself is never parked at a public address. When a tester opens the link, Pharen mints a short-lived signed URL for that one download and renders the manifest around it on the spot — so an install link that leaks doesn't hand anyone a permanent copy of your unreleased binary.

The whole release in one command

The canonical release lane is a single shell script. From your app directory:

bash
bash release/release-lane.sh

It runs the entire chain and prints the install link at the end:

  • archive the app and export an IPA;
  • symbols upload — upload every dSYM the archive produced, so the release's crashes symbolicate. This runs first, so no later failure can skip it;
  • releases create --commit — register the release with the commit it was built from;
  • builds upload — upload the IPA and mint the OTA install link.

A build is one commit, so the lane refuses a working tree with uncommitted or untracked changes (ignored files don't count): such a file can reach the archive, and then no commit is the build's source. It checks again after generating the project and after the archive, and stops before registering or distributing if the tree changed while it ran. If your CI writes files it doesn't ignore, set PHAREN_ALLOW_DIRTY=1: the lane builds and distributes as usual but registers no commit. Outside a git checkout (with PHAREN_BUILD_NUMBER set) there is no commit to record, and the lane says so.

Nothing app-specific is baked into the script: version, build number, and bundle id are read from the archived Info.plist (so uploaded metadata can never drift from what was built), and your org/app come from a committed .pharen.yml. On your Mac, sign in once with pharen auth login; for CI, see Running it in CI. No credential is ever written into your repo.

Getting the lane into your repo

pharen init ios writes the lane (and a .pharen.yml) into your project — no network calls, no prompts:

bash
pharen init ios --org your-org --app your-app-id

It writes .pharen.yml at the repo root and copies release/release-lane.sh plus release/exportOptions.plist into your app, filling in your Apple team id when it can find one. The release lane and init use the pharen CLI (npm install -g @pharen-ai/cli); your credentials come with an approved application. See the CLI reference.

Before the first run

  • Add a Staging build configuration. The lane builds it by default: a copy of Release that carries your test ingest key, so testers' sessions stay out of your live numbers. Named yours differently? Set PHAREN_CONFIGURATION.
  • Register your testers' devices. An install link carries an ad-hoc build, which installs only on devices in its provisioning profile. pharen device-requests list prints the devices waiting to be added, ready to paste into the Apple Developer portal.

Running it in CI

The lane runs unattended on a macOS runner. It needs:

  • A Pharen credential. On GitHub Actions and GitLab CI, the job signs in with its own identity, so there is no secret to store. An organization owner binds the repository once (below). On other CI, export PHAREN_AUTH_TOKEN as a secret.
  • Full git history, since the build number is the commit count — or set PHAREN_BUILD_NUMBER.
  • Code signing. Install your signing certificate on the runner. To let Xcode update provisioning profiles unattended, also set PHAREN_ASC_KEY_PATH, PHAREN_ASC_KEY_ID and PHAREN_ASC_ISSUER_ID to an App Store Connect API key.
  • XcodeGen, if your project is generated from a project.yml.

Bind a GitHub repository once, as an organization owner:

bash
gh api repos/OWNER/REPO --jq '.id, .owner.id'   # the two numeric ids
pharen workload federations add --kind github --project <repo-id> --project-owner <owner-id> --environment test

Then a workflow like this ships a tester build on demand:

yaml.github/workflows/beta.yml
name: beta
on: workflow_dispatch

permissions:
  contents: read
  id-token: write   # lets the job sign in to Pharen as itself

jobs:
  beta:
    runs-on: macos-15
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm install -g @pharen-ai/cli
      # Install your signing certificate here.
      - run: bash MyApp/release/release-lane.sh

On GitLab CI, bind the exact group/project path instead, and run the job on a protected branch. A GitLab ID token signs in once and the lane runs up to five pharen commands, so the job declares five tokens and the lane hands each command the next one. It checks they are all there before it builds.

bash
pharen workload federations add --kind gitlab --project acme/app --environment test
yaml.gitlab-ci.yml
beta:
  tags: [macos]   # your macOS runner's tag
  when: manual
  id_tokens:
    PHAREN_ID_TOKEN_1: { aud: 'https://api.pharen.ai' }
    PHAREN_ID_TOKEN_2: { aud: 'https://api.pharen.ai' }
    PHAREN_ID_TOKEN_3: { aud: 'https://api.pharen.ai' }
    PHAREN_ID_TOKEN_4: { aud: 'https://api.pharen.ai' }
    PHAREN_ID_TOKEN_5: { aud: 'https://api.pharen.ai' }
  variables:
    GIT_DEPTH: 0   # full history: the build number is the commit count
  script:
    - npm install -g @pharen-ai/cli   # the runner needs Node 20 or later
    # Install your signing certificate here.
    - bash MyApp/release/release-lane.sh

A binding that reaches live must also prove the job runs on a branch not everyone can push: on GitHub, a deployment environment (--job-environment) that the job declares as its environment:; on GitLab, an exact protected ref (--ref). That way a branch anyone can push never ships to your customers. The other options are in the CLI reference.

TestFlight and the App Store

A store build is its own Release archive, carrying your live key:

bash
bash release/release-lane.sh --app-store

The lane refuses an archive that carries a test key, uploads its dSYMs and registers the release, then stops and prints the archive's path. Upload that archive to App Store Connect — from Xcode's Organizer (Distribute App) or your CI's own upload step — so the symbols Pharen holds match the build your users run. Store builds get no install link.

How TestFlight stays out of your customer metrics is on Environments & release tracks.