Patch documentation

Ship Swift over the air.

Patch compiles the safe parts of your native iOS app — logic, async/await, and SwiftUI views — to WebAssembly so you can update them remotely, with no App Store review. Write ordinary Swift, run patchcli release, and the fix lands on installed apps within minutes. Anything that can't be made WASM-safe stays native, so an update can never break your app.

Introduction

Patch delivers over-the-air (OTA) code updates to native iOS apps. You write ordinary Swift; Patch's build engine analyzes your code, compiles the parts that are safe to run as WebAssembly, and ships them as a tiny module that runs on-device in the WasmKit runtime. The signed App Store binary never changes — only the interpreted WASM layer updates.

Patch compiles Swift with the official swift.org WebAssembly SDK, not the Apple/Xcode toolchain (Xcode's LLVM can't target WebAssembly). The resulting module is interpreted on-device by WasmKit, maintained under the swiftwasm GitHub org.

If you've used a JavaScript OTA tool like Expo / EAS Update or CodePush on React Native, Patch is the equivalent for native Swift and SwiftUI — no JS bridge, no web views. See plans and pricing on patchrelease.com.

Quick Start

From zero to a live OTA patch. Machine setup is two commands you run once. Per-app setup is one commandpatchcli init registers the app, adds the SDK, and wires the startup code for you. Then you write normal Swift and release.

1

Install the CLI + toolchain (once per machine)

Patch compiles Swift to WebAssembly with the swift.org toolchain plus the WebAssembly Swift SDK. The Apple/Xcode toolchain cannot target WebAssembly.

install — zsh
# 1 · the Patch CLI
$ brew install patch-release/tap/patchcli

# 2 · the Swift→WebAssembly toolchain — ONE command installs + checksum-verifies
#     the swift.org toolchain + WebAssembly SDK (Xcode's can't target wasm)
$ patchcli setup
(downloads the pinned swift.org toolchain + WASM SDK from swift.org)
2

Set up your app — one command

From your app's directory, run patchcli init. One command does the whole setup, skipping anything already done (it's safe to re-run):

  1. Detects the Xcode project, build target, bundle id, and app icon.
  2. Registers the app — your browser opens app.patchrelease.com/cli-connect; sign in (or create a free account) and click Register app. The CLI receives the app key automatically within seconds and writes app_key/app_id/workspace_id into .Patch.yml — no copying credentials.
  3. Adds the PatchSDK Swift package to the Xcode project (exactly what Xcode's "Add Package Dependencies…" would write; a backup is kept at project.pbxproj.patch-backup), then resolves packages.
  4. Proposes a diff inserting the Patch startup code into your @main App struct — applied only after you confirm (y/n).
  5. Makes your SwiftUI views patchable — marks each view body patchable and generates the dynamic-replacement thunks (the same mechanism Xcode Previews uses), so a future OTA patch re-renders your views with no PatchView wrapping and no changes to your views. This runs automatically on every patchcli release / push / build, so views you add later are picked up with no extra step. (You can still run patchcli prepare --check in CI to verify, or --no-prepare to opt out.)
~/MyApp — zsh
$ patchcli init
Patch init
==========
Detected Xcode project:   MyApp.xcodeproj  (target MyApp · com.example.myapp)
Opening browser to register the app…       ✓ app key received
Wrote:                    /Users/you/MyApp/.Patch.yml
Adding PatchSDK package…                   ✓ resolved (backup: project.pbxproj.patch-backup)
Proposed change to MyApp.swift — apply? (y/n) y   ✓ startup code inserted
✓ MyApp is set up — build & run, then ship with: patchcli release

The startup code it inserts (the same integration you'd write by hand — configure once, start() activates the best cached module immediately, offline-safe, then checks for an update in the background). init bakes in your appID and the current native-shell fingerprint; with only the appKey the SDK (≥ 1.0.3) still works — the backend resolves the key, and updates are served best-effort until a fingerprint is reported:

MyApp.swift
import PatchSDK

@main
struct MyApp: App {
  init() {
    Patch.configure(.init(
      appKey: "pak_live_…",       // your key, from .Patch.yml
      appID: "3f2b…-uuid",         // baked in by patchcli init
      fingerprint: "3f2b…"))       // this build's native shell
    Task { await Patch.shared.start() }
  }
  var body: some Scene {
    WindowGroup { ContentView() }
  }
}

Anything patchcli init can't do safely — no bundle id detected, an unusual project layout, a UIKit AppDelegate app, a declined confirmation, or no network — falls back to printing exact manual steps. See Manual setup for the same steps, and the CLI reference for flags like --manual, --no-open, and --yes.

3

Write a patchable Swift function

No annotations needed — just write pure logic. Patch detects that this function is WASM-safe (it only touches Decimal and value types) and lifts it into the module. The call site stays exactly the same.

Pricing.swift
import Foundation

enum Pricing {
  // Pure logic → compiled to WASM, updatable over the air.
  static func orderTotal(items: [LineItem], promo: String?) -> Decimal {
    var subtotal = items.reduce(Decimal(0)) { $0 + $1.price * Decimal($1.qty) }
    if promo == "LAUNCH20" { subtotal *= 0.80 }
    return subtotal.rounded(2, .bankers)
  }
}
4

Register your App Store build's fingerprint

After your next App Store build ships, run this once. Pushes are gated on the native-shell fingerprint, so the backend only ever serves modules compatible with installed apps. Re-run it after each App Store release to re-baseline.

~/MyApp — zsh
$ patchcli fingerprint register
✓ Registered native-shell fingerprint 3f2b… for MyApp
5

Release it

patchcli release builds the module and pushes it to the backend in one command (build → fingerprint gate → upload → activate). Edit Pricing.orderTotal, then release the fix to 10% of devices to start.

~/MyApp — zsh
$ patchcli release --rollout 10 --message "Fix LAUNCH20 promo math"
building…  → .Patch/build/module.wasm  (38.2 KB)
Fingerprint:   ✓ compatible
✓ Pushed module 2026.06.03.142210
  Rollout:     10%
  Active:      true

Re-launch the app: the SDK fetches, verifies, and hot-swaps the new module. Watch adoption with patchcli status, widen the rollout, or patchcli rollback if anything looks wrong. That's the whole loop.

Local vs CI

Running patchcli release from your machine is great for the first end-to-end test. For production, wire it into CI so deploys are reproducible and credentials never live on a laptop — see CI/CD.

Manual setup (the fallback)

Everything patchcli init automates can be done by hand — and init prints these exact steps whenever it can't do something safely (or when you run patchcli init --manual):

  1. Add the SDK package — in Xcode, File → Add Package Dependencies… with https://github.com/patch-release/patch-swift (product PatchSDK, from 1.0.0). See SDK reference for the Package.swift form.
  2. Create the app in the dashboard to get its pak_… app key.
  3. Paste the key — set app_key (and optionally app_id/workspace_id) in .Patch.yml.
  4. Add the startup code — the import PatchSDK + Patch.configure / Patch.shared.start() snippet from step 2 above, in your @main App struct's init().

Set up with AI

Already using an AI coding assistant — Claude Code, Cursor, Windsurf, GitHub Copilot, or similar? Hand it the prompt below and it will set Patch up in your app for you. Under the hood it follows the exact same Quick Start flow: install patchcli, run patchcli init (which registers the app, adds the PatchSDK package, injects Patch.configure(…), and makes your SwiftUI views patchable), then show you how to ship an update with patchcli release.

Open your iOS project first

Point your assistant at the directory that contains your .xcodeproj / .xcworkspace before pasting, so it runs in the right place. patchcli init is safe to re-run — it skips any step that's already done.

Prompt — assistant with terminal access

Use this if your assistant can run shell commands (Claude Code, Cursor agent mode, Windsurf Cascade, etc.). Copy it, paste it into the chat, and let it run the CLI.

paste into your AI assistant
Please set up Patch (OTA over-the-air code updates for native Swift iOS apps)
in this project. The official docs are at https://docs.patchrelease.com
(LLM-readable: https://docs.patchrelease.com/llms.txt and
https://docs.patchrelease.com/llms-full.txt). Follow the real onboarding flow:

1. Install the CLI with Homebrew:
       brew install patch-release/tap/patchcli
   Then install the Swift→WebAssembly toolchain it needs (one command,
   verifies checksums):
       patchcli setup

2. From this project's root directory, run:
       patchcli init
   This single command will: detect the Xcode project/target/bundle id;
   register the app via a browser link flow (it opens
   app.patchrelease.com/cli-connect — I'll sign in and click "Register app",
   and the CLI receives the app key automatically); add the PatchSDK Swift
   package (https://github.com/patch-release/patch-swift); propose a diff that
   inserts Patch.configure(appKey:appID:fingerprint:) plus
   Patch.shared.start() into my @main App struct (ask me before applying);
   and make my SwiftUI views patchable (insert `dynamic` + generate the
   dynamic-replacement thunks). Note: this view-prep step ALSO runs
   automatically on every "patchcli release" / "push" / "build", so views I
   add later are picked up with no extra step — I never have to remember to
   run "patchcli prepare" manually.
   Useful flags: --yes (accept the code diff automatically), --no-open
   (don't auto-open the browser), --skip-package / --skip-code (skip a step),
   --manual (just print the manual steps). Do NOT pass --yes unless I ask.

3. Verify the setup:
   - The PatchSDK package is added to the Xcode project (and the project
     still resolves/builds).
   - "import PatchSDK" and a Patch.configure(...) + Patch.shared.start() call
     exist in my @main App struct's init().
   - my view bodies are marked dynamic and the dynamic-replacement thunks were
     generated. (This re-runs automatically on every "patchcli release" so views
     I add later are picked up; I can run "patchcli prepare --check" in CI to
     verify, or pass "--no-prepare" to opt out.)
   - .Patch.yml exists in the project root with app_key / app_id.

4. After my next App Store build, I should run "patchcli fingerprint register"
   once to baseline the native shell (pushes are gated on this fingerprint).

Then explain, briefly, how I ship an update: I edit my Swift normally, then run
   patchcli release --rollout 10 --message "what changed"
which builds the WASM module, checks the fingerprint gate, uploads, and
activates it to 10% of devices — no App Store review. The on-device SDK fetches
and hot-swaps it on next launch. I widen the rollout with the dashboard or CLI,
and "patchcli rollback" reverts instantly.

If any step can't run (no Homebrew, no network, an unusual project layout, or a
UIKit AppDelegate app), tell me exactly which step failed and fall back to the
manual instructions at https://docs.patchrelease.com/#manual-setup. Don't invent
flags or steps — only use what's in the docs above.

Prompt — assistant without terminal access

Use this if your assistant edits files but can't run a shell (e.g. an in-editor chat with no command execution). It will make the in-project code changes and hand you the two commands to run yourself.

paste into your AI assistant
Help me set up Patch (OTA over-the-air code updates for native Swift iOS apps)
in this project. You can't run shell commands, so do the file edits you can and
give me the exact commands to run myself. The docs are at
https://docs.patchrelease.com (LLM-readable:
https://docs.patchrelease.com/llms.txt). Follow the real flow:

1. Tell me to run these two commands in a terminal myself, in this order:
       brew install patch-release/tap/patchcli
       patchcli init
   Explain that "patchcli init" does most of the setup automatically:
   registers the app in the browser (app.patchrelease.com/cli-connect), adds the
   PatchSDK package, proposes the Patch.configure(...) code diff, and makes my
   SwiftUI views patchable. Tell me this view-prep step also runs automatically
   on every "patchcli release" (so views I add later need no extra step), that
   it's safe to re-run, and that it falls back to printing manual steps if
   anything can't be done automatically.

2. As a fallback (the manual path at
   https://docs.patchrelease.com/#manual-setup), prepare these edits so I'm
   ready either way:
   - In Xcode I'll add the Swift package
     https://github.com/patch-release/patch-swift (product PatchSDK, from 1.0.0)
     via File → Add Package Dependencies… — or add it to my Package.swift if I
     have one; show me the Package.swift dependency + target lines.
   - In my @main App struct, add "import PatchSDK" and, in init(), call:
         Patch.configure(.init(
           appKey: "pak_…",        // from .Patch.yml after I run patchcli init
           appID: "…",             // baked in by patchcli init
           fingerprint: "…"))      // this build's native shell
         Task { await Patch.shared.start() }
     Show me the edit in context against my actual App struct.

3. Tell me that after running "patchcli init" I should confirm a .Patch.yml file
   appears in the project root with app_key / app_id, and that my SwiftUI view
   bodies were marked dynamic. (This view-prep re-runs automatically on every
   "patchcli release", so I never have to remember it when I add new views.)

Then explain how I ship an update: edit Swift normally, then run
   patchcli release --rollout 10 --message "what changed"
which compiles the change to WebAssembly and ships it as an OTA patch (no App
Store review); the SDK hot-swaps it on next launch, and "patchcli rollback"
reverts instantly. Don't invent flags or steps — only use what's in the docs.
Tip

Whichever variant you use, the assistant is just driving the documented patchcli commands — there's nothing AI-specific to install. If you'd rather do it yourself, the Quick Start walks through the same steps by hand, and Manual setup lists the fallback for unusual projects.

How it works

Patch turns one Swift codebase into two cooperating halves: a WASM module with the safe, updatable code, and a native shell that holds everything else and calls into it. You write ordinary Swift — the build engine decides what's safe to ship over the air. At a glance:

Swift source

Your ordinary .swift files. No annotations.

Partition

Patch sorts safe, updatable code from must-stay-native code.

WASM module

Safe code compiled to a real .wasm, kept as small as possible.

Device

WasmKit runs the module; the SDK hot-swaps updates.

What's updatable

Patch updates logic, and your SwiftUI views (structure, state, and interaction). Full async/await concurrency runs in the WASM module, and the SwiftUI lowering supports up to 98.5% of SwiftUI view elements (the declarative view body, state, and user interaction); only the lowest-level platform rendering and must-stay-native APIs remain in the shell. Every function lands in one of four buckets, in safety order:

WASM pure logic → runs OTA BRIDGED uses a host bridge → runs OTA SPLIT mixed → safe part lifted to WASM NATIVE stays in the App Store binary
  • WASM-eligible — pure logic plus WASM-safe Foundation value types (Decimal, Date, UUID, JSONEncoder/Codable, formatters, URL, regex). Ships as WASM.
  • Bridged — touches only APIs Patch has a host bridge for: networking, UserDefaults, notifications, navigation, keychain, date/locale, logging, analytics, file storage, connectivity, biometrics, in-app review, pasteboard, haptics, device info, share sheet, open-URL, location, calendar, contacts, app badge, mail compose, in-app purchase, speech synthesis, document picker, system sound, photo picker, secure random, app-group storage, screen control, audio playback/recording, motion, maps, network/remote images, Spotlight, background tasks, file download, accessibility, app shortcuts, Handoff, Watch connectivity, NFC, process info, video playback, speech recognition, now-playing, image filters, PDF/QR generation, and camera — 51 bridges in all. Runs OTA, calling the native shell for the host part.
  • Mixed — part native, part safe. Patch lifts the safe parts over the air and keeps the rest in the shell, wiring the two together automatically.
  • Native — touches a must-stay-native API (low-level UIKit/AppKit rendering, AVFoundation, CoreLocation, HealthKit, Core Data, file system, threads/locks, ObjC runtime, unsafe pointers). Stays in the App Store binary.

When in doubt, Patch keeps code native — so coverage is the only thing that's ever at stake, never safety. On typical apps — business logic, view code, SDKs, and libraries — OTA coverage usually runs 60–100%.

Safety first

Updates can only ever swap out logic Patch already proved is safe to run as WASM. If a generated module fails to compile, the offending code is auto-demoted to native and the build retries — a broken module is never produced. On-device, if a module is missing, corrupt, or fails to activate, the app runs the native fallback baked into the App Store binary. Updates never break the app.

Compatibility fingerprint & packaging tiers

Patch computes a deterministic SHA-256 native-shell fingerprint over everything that fixes the binary's layout — SDK + WasmKit version, bridge definitions, native .swift files, Info.plist, entitlements, linked frameworks, deployment target, and compiler version. If any of these change, the OTA module is incompatible with installed apps: push/release refuse and tell you to ship through the App Store and re-register. The OTA-updatable code is deliberately excluded from the fingerprint, so a pure-logic patch is always compatible.

Patch also picks the smallest viable packaging tier per module, so OTA patches stay tiny while keeping full Foundation semantics by borrowing the native shell's real Foundation:

T0 · Embedded

Embedded Swift

tens of KB

The default. No Foundation/ICU in the module — Foundation values (Decimal, JSON, Date) are satisfied by host bridges into the shell's real Foundation.

T1 · Stdlib

Stdlib only

~1.1 MB

For code Embedded rejects but that needs no Foundation (e.g. any P existentials). Standard library, no import Foundation.

T2 · Foundation

Full Foundation

~11.7 MB

The fallback when a module needs in-module Foundation no bridge covers (Mirror, in-module Codable synthesis, NSRegularExpression, formatters).

Patch uses the lightest tier a module can run on, only stepping up when the code genuinely needs more. Any heavier base ships once; every update after that is a tiny compressed binary diff against the version already on the device — typically a few hundred bytes to tens of KB.

What Patch can & can't update

The honest, developer-facing map of what an OTA patch can reach in a SwiftUI app — and the small set of things it genuinely can't. The short version: SwiftUI structure is never the wall. An OTA patch can author, restyle, reorder, rebind, and re-flow essentially your entire view hierarchy. The only true ceiling is the App Store's own boundary — a native capability, framework symbol, or privacy entitlement that isn't already compiled and signed into your shipped binary.

The headline

Of the SwiftUI surface that real iOS apps actually use, roughly 99% is OTA-reachable. On a real-app validation corpus, Patch lowered about 78% of view bodies to WebAssembly today — and the views that stay native still render and move via OTA (only their internals are frozen). The gap between "reachable" and "lowered today" is engineering backlog, not a fundamental wall.

What you can update over the air

Within your app's already-linked frameworks and declared entitlements, OTA patches reach virtually the entire SwiftUI surface:

AreaWhat an OTA patch can do
View structureAdd, remove, reorder, and restyle views; change the whole hierarchy. Structure is fully authorable over the air.
Text & imagesText (literals, interpolation, computed strings), Label, Image(systemName:) and bundle assets, AsyncImage.
LayoutStacks, grids, List, Form, Section, ScrollView, Spacer/Divider, ViewThatFits, and the layout modifiers — frame, padding, offset, position, safe-area insets.
Styling, colors & fontsForeground/background/tint, borders, shadows, corner radius, fonts and weights, plus design-system tokens — a custom Theme.Colors.ink / Theme.Font.body(…) / Theme.Radius.lg in a modifier value position lowers and rides the patch (resolved natively, fed to the view).
Controls & stateButton, Toggle, Slider, Stepper, Picker, TextField/SecureField, Link/ShareLink, Gauge/ProgressView, bound to @State / @Binding / @AppStorage / @FocusState.
Navigation & presentationNavigationStack / navigationDestination, sheets, full-screen covers, popovers, alerts, confirmation dialogs, toolbars, context menus, and searchable.
Modifiers & control flowThe vast majority of standard modifiers, plus if / if let / switch control flow inside a view body, gestures (onTapGesture & the gesture algebra), onAppear/onChange/task, and full async/await logic.

For the exhaustive symbol-by-symbol breakdown, see What's updatable and the per-function coverage you get from patchcli build --verbose.

What you can't update — the binary-symbol wall

There is essentially one true wall, with two faces. Both reduce to the same thing: an OTA patch can never introduce a native symbol, framework, or entitlement that isn't already in your shipped, code-signed app, and can never rewrite the compiled machine code of native functions that aren't a patchable View.body. This is not "hard, just not yet" — it's a physical boundary, and it's exactly the line the App Store draws.

1 · A native capability, framework, or entitlement that isn't in the signed binary

An OTA patch can't introduce a privacy-gated capability your app never declared, or call into a framework the app never linked. The OS reads your signed Info.plist and entitlements at launch; WebAssembly can't synthesize a permission key or link a new framework. Examples:

  • Presenting a camera or photo-library picker in an app with no NSCameraUsageDescription / NSPhotoLibraryUsageDescription and no PhotosUI/AVFoundation linked.
  • HealthKit, Contacts, Location, Microphone, Bluetooth, Apple Pay / Wallet presenters when the matching usage string or entitlement was never declared.
  • A UIViewRepresentable wrapping MKMapView / WKWebView / MTKView / ARView in an app that never linked MapKit / WebKit / MetalKit / RealityKit.
  • A custom Metal Shader (.colorEffect / .layerEffect) whose .metal function was never compiled into the app's .metallib.
2 · The internals of non-View.body native code

Patch hot-swaps View.body getters (via dynamic replacement, like Xcode Previews) and data-drives the renderer. It has no mechanism to rewrite arbitrary compiled native functions. So an OTA patch can't change the body of:

  • A UIViewRepresentable's makeUIView / updateUIView.
  • A custom ButtonStyle / ViewModifier's makeBody / body(content:).
  • A @FetchRequest / @Query predicate or sort declaration (you can restyle, reorder, and relabel the resulting rows — you just can't rewrite the predicate to reference an attribute the model lacks).
  • A custom Shape.path(in:), custom Layout geometry, or any private native helper that isn't a thunked View.body.
Important: "frozen internals" ≠ "can't touch it"

Be precise here, because it's the most common misconception. An existing MKMapView, WKWebView, custom view, custom style, or Canvas in your app is fully reachable by an OTA patch — it still renders, gets placed, reordered, resized, shown, and hidden over the air. What's frozen is only its native internals (the makeUIView body, the Metal kernel, the predicate). Likewise, a design-system color/font/number constant patches fine — unless it references a private or body-local member, in which case the view stays native (still rendered, never broken). And things people assume are off-limits — .drawingGroup() (SwiftUI's own Metal), AsyncImage, ShareLink, haptics, built-in format styles, GeometryReader — are all patchable, because they live in the SwiftUI/Foundation runtime your app already links.

The one-line rule

If a change lives entirely inside your app's existing linked frameworks, declared entitlements, and SwiftUI view bodies, an OTA patch can ship it. If it requires a new native symbol, framework, or privacy entitlement that isn't in the signed binary — or it rewrites compiled native code that isn't a View.body — it needs an App Store release. That second set is tiny, and it's the same boundary Apple's review process draws. When Patch isn't sure a change is safe, it keeps the code native and renders the bundled fallback, so an update can never break your app.

New here? Start with the Quick Start or have an AI assistant set Patch up for you.

CLI reference

The full command surface of the patchcli CLI. build and compile drive the real engine and the real WASM toolchain; push, release, status, rollback, and fingerprint talk to the backend over HTTP. Run patchcli <command> --help for full details on any command. Hover any flag below for its description and an example.

Which command compiles vs. uploads? patchcli build compiles your Swift to a real .wasm at .Patch/build/module.wasm but never talks to the backend. patchcli push does not build — it only uploads an already-built .wasm (the one build wrote, or the path you pass with --module), gated on the fingerprint check. patchcli release is the everyday command: it runs the same build pipeline as build, then the same fingerprint-gated upload as push — i.e. release = build + push.

CommandWhat it doesKey flags
patchcli init [path] One-command app setup: detect the Xcode project/target/bundle id, register the app via the browser (app.patchrelease.com/cli-connect — the CLI receives the app key automatically and writes app_key/app_id/workspace_id into .Patch.yml), add the PatchSDK package to the project (backup at project.pbxproj.patch-backup), and propose the startup-code diff, applied after you confirm. Skips anything already done — safe to re-run. Anything it can't do safely falls back to printed manual steps. --manual
--no-open
--yes
--skip-package
--skip-code
--force
--target <name>
--base-url <url>
patchcli doctor [path] new Read-only setup/health preflight — answers "is my app set up correctly for OTA patches?" Runs five checks (each ✓/⚠/✗ with a one-line fix hint), never mutates the project, and exits non-zero on any blocking ✗ so it's CI-usable. Run it after init or whenever a patch isn't behaving. See the doctor section. --json
--offline
--base-url <url>
patchcli prepare [path] Make your SwiftUI views patchable: mark each var body: some View dynamic and generate the @_dynamicReplacement(for: body) thunks (the same mechanism Xcode Previews uses), so a future OTA patch re-renders your views with no PatchView wrapping. Idempotent. You normally never run this yourself — it runs automatically on every build / push / release. This standalone command stays for CI (--check) and debugging. --check
patchcli build [path] Parse → classify → split → compile to a real .wasm module at .Patch/build/module.wasm. Does not upload. Auto-runs prepare first (insert dynamic + thunks for any new views) unless --no-prepare. --verbose
--dry-run
--report <path>
--optimization size|speed
--output <path>
--no-prepare
patchcli push Upload an already-built .wasm to the backend, gated on a fingerprint-compatibility check. Does not build — run build first (or use release). --channel <name>
--rollout 0-100
--message <text>
--version <ver>
--module <path>
--mandatory
--min-app-version <ver>
--max-app-version <ver>
--min-os-version <ver>
--target-cohort <name>
--allow-native-drift
--base-url <url>
--dry-run
--no-prepare
patchcli release [path] new Build then push in one command — the everyday deploy. Runs the same build pipeline as build, then the same fingerprint-gated upload as push; accepts both flag sets (no --module, since it builds the module itself). Auto-runs prepare first so views you add are always patchable (skip with --no-prepare). --channel <name>
--rollout 0-100
--message <text>
--version <ver>
--mandatory
--min-app-version <ver>
--max-app-version <ver>
--min-os-version <ver>
--target-cohort <name>
--allow-native-drift
--optimization size|speed
--output <path>
--base-url <url>
--dry-run
--no-prepare
patchcli status Show the current deployment: version, rollout %, adoption, failure rate. --channel <name>
--base-url <url>
--json
patchcli rollback Roll back to the previous module (or to a specific version). --channel <name>
--to <version>
--base-url <url>
--dry-run
--json
patchcli fingerprint <diff|register> Diff or register the native-shell fingerprint that gates OTA compatibility. diff is the default subcommand. --base-url <url>
--app-version <ver> (register)
--dry-run (register)
--json
patchcli analyze <path> Analysis only, for CI/code review. Reports OTA coverage; with --check-fingerprint it exits 1 if the native shell changed. --format text|json
--verbose
--check-fingerprint
--fingerprint-baseline <hash>
patchcli channels List the deployment channels seen for the configured app, with the active module per channel. Calls GET /apps/{app_id}/channels (falls back to deriving channels from the module list). --base-url <url>
--json
patchcli whoami Print the configured app_id / workspace_id / base URL / channel resolved from .Patch.yml (and env overrides). Handy for confirming which backend a command will hit. --json
patchcli compile <path> Lower-level: compile a directory of generated _wasm.swift to a real .wasm module. --output <path>
--swift-sdk <id>
--exports <symbols>

Build coverage output

patchcli build parses, classifies, splits, and compiles, then prints a coverage report and writes the module to .Patch/build/module.wasm. Use --verbose for the per-function table and --dry-run to skip the WASM compile.

~/MyApp — zsh
$ patchcli build ./Sources
Coverage report:
  WASM-eligible (pure)          7  (50.0%)
  WASM-eligible (bridged)       3  (21.4%)
  Mixed (auto-split)            0  (0.0%)
  Native (stays in shell)       4  (28.6%)
  OTA-updatable (realized): 71.4%
  Packaging tier (start): T0 Embedded + bridges (tens of KB)

  Module: .Patch/build/module.wasm (38.2 KB)
  ✓ Build succeeded — push with: patchcli push

Release in one command

patchcli release runs the build pipeline, confirms the module, then runs the same fingerprint-gated push as patchcli push — so it accepts both sets of flags. This is the command you'll use day to day.

~/MyApp — zsh
# everyday: build + push to a 10% staged rollout on production
$ patchcli release --rollout 10 --message "Tighten refund eligibility"

# release to a named channel, optimizing the module for size
$ patchcli release --channel staging --optimization size

# mark a release mandatory (see Force updates)
$ patchcli release --rollout 100 --mandatory --message "Critical tax fix"

# preflight only — build + run all push checks, but do not upload
$ patchcli release --dry-run

Status & rollback

~/MyApp — zsh
# current deployment + device telemetry on a channel
$ patchcli status --channel production
Active version:   2026.06.03.142210
Rollout:          10%
  Adoption:       96.4% (activations / downloads)
  Failure rate:   0.2% (errors / (activations+errors))

# one-click revert to the previous module (≈60s to propagate)
$ patchcli rollback --channel production

# roll all the way back to a specific version
$ patchcli rollback --to 2026.05.30.090112

Check your setup: patchcli doctor new

patchcli doctor is the "is my setup correct?" command. It runs a read-only preflight — it never edits your project (unlike init / prepare), so it's safe to run anytime, in CI, or on a teammate's machine. Run it right after patchcli init, or whenever a patch isn't behaving the way you expect. It reports five checks, each with a / / and a one-line fix hint:

  1. .Patch.yml present + valid — has an app_key (pak_…); app_id recommended for backend commands.
  2. PatchSDK package added + linked — referenced in your .xcodeproj or Package.swift and linked into your app target.
  3. Patch.configure(...) + Patch.shared.start() in the @main App — plus import PatchSDK (a configure with no start() never fetches updates).
  4. Views are patch-ready — every SwiftUI view body is dynamic and the dynamic-replacement thunks exist. If some aren't yet, this is just a (not a blocker): build / push / release auto-run prepare, so it self-heals on your next release.
  5. Native-shell fingerprint registered + current — the registered fingerprint matches the current shell (needs network; degrades to a ⚠ with --offline).
~/MyApp — zsh
$ patchcli doctor
Patch doctor
============

 .Patch.yml present + valid
    app_key set; app_id 3f2b…-uuid.
 PatchSDK package added to the project
    MyApp.xcodeproj references patch-swift and links the PatchSDK product.
 Patch.configure(...) + start() in the @main App entry
    Patch.configure(...) + Patch.shared.start() + import PatchSDK found (MyAppApp.swift).
 Views are patch-ready (bodies dynamic + thunks)
    12 SwiftUI view(s) are `dynamic` + have generated thunks.
 Native-shell fingerprint registered + current
    Registered fingerprint matches the current native shell (3f2b9c1e7a4d…).

5/5 checks passed.
READY — this project is set up for OTA patches. ✓

It exits non-zero on any blocking (the items are non-fatal but worth fixing), so you can drop it into CI as a setup gate. Add --json for a machine-readable report:

CI — setup gate
# fail the job if the project isn't set up for OTA patches
$ patchcli doctor --json
Fingerprint gate

push and release refuse to upload if the local native-shell fingerprint doesn't match the backend's active fingerprint — the native shell changed since the last App Store release, so the module isn't compatible with installed apps. Run patchcli fingerprint diff to see what changed; after an App Store release, run patchcli fingerprint register to re-baseline.

Native-shell drift: --allow-native-drift new

Sometimes your native shell changes but in ways the OTA patch can't see — you added a Swift package, edited Info.plist, tweaked an entitlement, or bumped the deployment target, without touching the source the patch is built against. The plain fingerprint gate would block the push, even though the view patch is provably compatible. To handle this, patchcli fingerprint diff tags each changed component as either native-only (the patch can't see it) or patch-affecting (it can change the surface the patch is built against), and --allow-native-drift lets you ship a view patch when every change is native-only.

Changed componentClassifiedWhy
Info.plistnative-onlyPure native-shell metadata (display name, permission strings, URL schemes). The WASM module never reads or links it.
Entitlementsnative-onlyNative binary capabilities (keychain groups, App Groups, push) — an OS/codesign concern the WASM ABI is blind to.
Linked frameworksnative-onlyAdding/removing a framework the shell links. (If a patched view had started using it, the view's source would have changed too — that shows up as a patch-affecting native-source change and blocks the skip.)
Deployment targetnative-onlyA native build setting — it changes which OS versions the binary supports, not the OS-version-independent WASM ABI.
Native .swift filespatch-affectingA native function, signature, or file add/remove can change the surface the patch is built against — never skipped.
Bridge definitionspatch-affectingHost bridges are capabilities the module calls; changing them shifts the shell ABI.
SDK / WasmKit / compiler versionpatch-affectingThese govern how the module is marshalled and executed on-device — a rebuild + re-register is required.
~/MyApp — zsh
# see the per-component breakdown (native-only vs patch-affecting)
$ patchcli fingerprint diff

# the native shell drifted, but only natively — ship the view patch anyway
$ patchcli release --allow-native-drift --message "Restyle the paywall"
The safety model

--allow-native-drift only ever bypasses the gate when every changed component is native-only. If any change is patch-affecting, the push is still refused — you must ship that change through the App Store and re-register; --allow-native-drift will not override it. When run interactively on an all-native-only drift, push/release prompt you instead; the flag is just the non-interactive (CI) form of that "yes." Either way, you should still ship the native change through the App Store and re-run patchcli fingerprint register so future devices report the new shell.

Configuration: .Patch.yml

patchcli init writes .Patch.yml at your project root — including the app_key/app_id/workspace_id it receives during browser registration. It's a small, fixed-shape YAML file. Environment variables override the API fields at call time; app_id/workspace_id are only needed for backend commands (push/release/status/rollback).

.Patch.yml
version: 1
app_key: pak_live_…          # per-app API key (written by patchcli init; also in the dashboard)
project: MyApp.xcodeproj
target: MyApp

app_id: 3f2b…-uuid         # required for push/release/status/rollback
workspace_id: 9a01…-uuid
api_base_url: https://api.patchrelease.com
api_key: pak_live_…          # env PATCH_API_KEY overrides this

exclude:                    # paths/globs to skip during analysis
  []

bridges:                    # host bridges available to OTA code
  networking: true
  userDefaults: true
  notifications: true
  navigation: true
  keychain: true
  dateLocale: true
  logging: true

build:
  optimization: size        # size | speed
  stripDebugInfo: true
  swiftui: true         # lower SwiftUI views to WASM (default on)
FieldTypeDescription
versionintConfig schema version. Currently 1.
app_keystringPer-app key (pak_…) issued by Patch. Sent as X-API-Key.
projectstringDetected project file (e.g. MyApp.xcodeproj or Package.swift).
targetstringThe app target whose sources Patch analyzes.
app_iduuid?Backend app UUID. Required for push/release/status/rollback; optional for offline build/analyze.
workspace_iduuid?Backend workspace UUID the app belongs to.
api_base_urlurl?Backend base URL. Overridden by env PATCH_API_URL / the --base-url flag.
api_keystring?API key for backend calls. Overridden by env PATCH_API_KEY.
excludestring[]Paths/globs to skip during analysis. Empty list = analyze everything.
bridges.*boolToggle each host bridge: networking, userDefaults, notifications, navigation, keychain, dateLocale, logging, analytics, fileStorage, connectivity, biometrics, appReview, pasteboard, haptics, deviceInfo, shareSheet, openURL, location, calendar, contacts, appBadge, mailCompose, inAppPurchase, speechSynthesis, documentPicker, systemSound, photoPicker, secureRandom, appGroupStorage, screenControl, audioPlayback, audioRecording, motion, mapsDirections, networkImage, spotlightIndex, backgroundTask, fileDownload, accessibility, appShortcuts, handoff, watchConnectivity, nfcRead, processInfo, videoPlayback, speechRecognition, mediaInfo, imageFilter, pdfGenerate, qrGenerate, camera (51 in total). A disabled bridge means functions using it stay native.
build.optimizationstringsize (default) or speed. Overridden by --optimization.
build.stripDebugInfoboolStrip debug info from the emitted module to shrink it. Default true.
build.swiftuiboolLower SwiftUI View.body to WASM so views ship over the air. Default true (on if omitted); set false to keep views native. Env PATCH_SWIFTUI=0 overrides per-run.

SDK reference

The PatchSDK Swift package loads, verifies, hot-swaps, and runs OTA modules on-device, with native fallback and telemetry. It's pure Swift on top of WasmKit — no extra runtime to bundle beyond the package.

Install with Swift Package Manager

patchcli init adds this package to your Xcode project (or Package.swift) automatically — the steps here are the manual path (and what init prints if it can't edit your project safely). In Xcode: File → Add Package Dependencies… and paste the URL. Or add it to your Package.swift:

Package.swift
dependencies: [
  .package(url: "https://github.com/patch-release/patch-swift", from: "1.0.0"),
],
targets: [
  .target(name: "MyApp", dependencies: [
    .product(name: "PatchSDK", package: "patch-swift"),
  ]),
]

Configure: PatchConfiguration

Configure the SDK once, early in launch. configure sets up the on-disk cache, installs the default host bridges, and prepares the update checker when an apiBaseURL is set. It does no network I/O — that happens in start() / the imperative API.

PatchConfiguration
Patch.configure(.init(
  appKey: "pak_live_…",                 // per-app key from the dashboard
  appID: "3f2b…-uuid",                  // backend app_id for the update check
  apiBaseURL: URL(string: "https://api.patchrelease.com/api/v1"),
  fingerprint: "3f2b…",                    // native-shell fingerprint of THIS build (see note)
  deviceID: UIDevice.current.identifierForVendor?.uuidString,  // stable, anonymous per install
  channel: .production,                  // PatchChannel; .staging/.development or .custom("beta-eu")
  autoApply: true,                       // start() may apply updates automatically
  nativeFallbackEnabled: true))         // fall back to baked-in code if needed

Only appKey is required — the backend resolves the key server-side (SDK ≥ 1.0.3), so the one-liner Patch.configure(.init(appKey: "pak_…")) is a complete integration. patchcli init bakes in appID (keeps older SDKs polling) and the current native-shell fingerprint (exact update gating; a device that reports none is served best-effort). deviceID is any stable, anonymous per-install id you supply (e.g. identifierForVendor).

FieldTypeDescription
appKeyStringPer-app identifier issued by Patch.
appIDString?Backend app UUID, sent as app_id in the update check. Optional — when nil the SDK sends app_key and the backend resolves it (SDK ≥ 1.0.3; older SDKs require it for remote checks).
apiBaseURLURL?API root. When nil, the SDK runs purely from the cached/bundled module (no remote check).
fingerprintString?This build's native-shell fingerprint (patchcli init bakes it in). When reported, the backend serves only modules built for the matching shell; a device that reports none is served best-effort.
deviceIDString?Stable anonymous device id, used for deterministic rollout bucketing + telemetry.
channelPatchChannelUpdate channel to subscribe to. Presets .production, .staging, .development, or .custom("…") for any backend channel string. (A channelName: string overload also exists.)
autoApplyBoolWhether start() may download + activate an available update automatically. Default true.
nativeFallbackEnabledBoolFall back to the baked-in native code if no/invalid module. Default true.

Startup updates with start()

The simplest integration: call start() once. It activates the best already-cached module immediately (so the app runs OTA code instantly, even offline), then — if an apiBaseURL is set and autoApply is true — checks for an update and applies it in the background, with download/activation telemetry. Any failure leaves the active module untouched and falls back down the chain.

App launch
ContentView().task {
  let outcome = await Patch.shared.start()
  switch outcome {
  case .activated(let v): print("Running OTA module \(v)")
  case .fallback(let s): print("Native fallback: \(s)")
  case .noModule:      print("No module — fully native")
  }
}

Imperative flow: check → fetch → reload

For full control over when an update applies — for example to show an "Update available → Download now" prompt — use the imperative API (EAS / Expo-Updates style). checkForUpdate() reports availability without applying; fetchUpdate() downloads + verifies + stages it; reloadAsync() activates the staged module with a hot-swap.

Imperative update API
public struct UpdateInfo: Sendable {
  public let version: String
  public let releaseNotes: String?
  public let isMandatory: Bool      // from the release's `mandatory` flag
  public let sizeBytes: Int
}

// 1 · is there a newer module for our channel + fingerprint?  (does NOT apply)
if let update = try await Patch.shared.checkForUpdate() {
  showBanner("Update \(update.version) available")

  // 2 · download + verify + stage it on disk
  let staged = try await Patch.shared.fetchUpdate()

  // 3 · activate now via hot-swap (next calls hit the new module)
  if staged { try await Patch.shared.reloadAsync() }
}

Observable state for SwiftUI

The SDK exposes a @MainActor observable update state you can drive UI off directly — render a banner, a progress bar, and a "Download now" button without managing your own flags.

UpdateBanner.swift
// PatchUpdateState: .idle | .checking | .available(UpdateInfo)
//   | .downloading(Double) | .readyToReload | .upToDate | .failed(String)
struct UpdateBanner: View {
  // The observable is Patch.shared.updateState (a PatchUpdateStateObservable).
  @ObservedObject var updates = Patch.shared.updateState

  var body: some View {
    switch updates.state {
    case .available(let info):
      Button("Download update \(info.version)") {
        Task { try await Patch.shared.fetchUpdate(); try await Patch.shared.reloadAsync() }
      }
    case .downloading(let p): ProgressView(value: p)
    case .readyToReload:    Button("Restart to apply") { Task { try await Patch.shared.reloadAsync() } }
    default: EmptyView()
    }
  }
}

Force / mandatory updates

When a release is marked --mandatory, UpdateInfo.isMandatory is true. You decide whether to block UI; the SDK also offers a convenience that fetches + reloads any mandatory update automatically. See Force updates for the full pattern.

Mandatory enforcement
// Auto fetch+reload if the available update is mandatory; otherwise no-op.
await Patch.shared.enforceMandatoryUpdates()

Telemetry

The SDK fires download, activation, error, and fallback events to the backend automatically (gated by your config). These power the dashboard's adoption and failure-rate numbers, the patchcli status telemetry, and the received-% on staged rollouts. No extra wiring is required.

Deployment channels

A channel is a named update stream — production, staging, beta, or any string you like. The same app build (same fingerprint) can subscribe to a channel and receive only the releases pushed to it. Channels let you run an internal beta and production in parallel from one codebase.

Subscribe a build to a channel

Set the channel on the SDK at init. It can be any string; the PatchChannel presets are just conveniences.

Choosing a channel
// a preset…
Patch.configure(.init(appKey: "pak_live_…", channel: .staging))

// …or an arbitrary channel string (e.g. a per-tester or per-tenant stream)
Patch.configure(.init(appKey: "pak_live_…", channel: "beta-eu"))

Release to a channel

Pass --channel on release/push. A device only ever receives modules pushed to the channel it's subscribed to. status and rollback are also channel-scoped.

~/MyApp — zsh
# release a build to the staging channel for internal testers
$ patchcli release --channel staging --message "Try the new checkout flow"

# promote the same logic to production once it looks good
$ patchcli release --channel production --rollout 25

# inspect / roll back a single channel
$ patchcli status   --channel staging
$ patchcli rollback --channel staging
Channels vs fingerprints

Channels are independent of the compatibility fingerprint. A module pushed to any channel still has to match the installed app's native-shell fingerprint to be served — channels segment who gets a release, the fingerprint guarantees it's safe for that build.

Staged rollouts & A/B (%)

Every release carries a rollout percentage. Ship to a slice of devices first, watch the failure rate, then widen to 100%. Because bucketing is deterministic, a device that's "in" the rollout stays in as you raise the percentage — adoption only ever grows, devices never flip-flop.

Release a staged rollout with --rollout

~/MyApp — zsh
# start at 10% of eligible devices on production
$ patchcli release --rollout 10 --message "New pricing engine"

# widen the SAME release — devices already in the bucket stay in
$ patchcli release --rollout 50
$ patchcli release --rollout 100

Deterministic bucketing

The backend buckets each device by hashing its stable device_id together with the release into a value in 0–99. A device receives the release when bucket < rollout_pct. Because the hash is stable per device + release:

  • The same device always lands in the same bucket for a given release — no flapping between launches.
  • Raising rollout_pct only ever adds devices; it never removes a device already serving the release.
  • A rollout_pct below 100 is, in effect, a percentage-based A/B test: the in-bucket cohort runs the new logic, everyone else stays on the previous module.

Watch & adjust

The dashboard shows targeted % (the release's rollout_pct) and received % (activations over estimated-eligible devices). From the CLI, patchcli status surfaces adoption and a failure rate, with a hint to roll back if failures climb past 2%.

~/MyApp — zsh
$ patchcli status --channel production
Active version:   2026.06.03.142210
Rollout:          10%
  Downloads:      1,204
  Activations:    1,160
  Adoption:       96.4% (activations / downloads)
  Failure rate:   0.2% (errors / (activations+errors))

Rollback

If a staged release misbehaves, roll back. The previous module re-activates and propagates to devices on their next update check.

~/MyApp — zsh
$ patchcli rollback --channel production
Rolled back:   2026.06.03.142210  (now inactive)
Now active:    2026.05.30.090112  (rollout 100%)
Takes effect on each device's next update check.

Release targeting

A release can be limited to a device cohort by app version and OS version. Targeting answers "which devices are even eligible for this release" — and it composes with the rollout %: a device must satisfy the targeting constraints and fall inside the rollout bucket before it's served the module. Use it to ship a fix only where it applies — for example, a fix that depends on an API added in app 2.1 on iOS 16.

Set targeting at push time

Targeting is set when you ship a release, from the CLI (or CI), with four flags on push/release. Any flag you omit is left unconstrained. The constraints are stored on the release and shown in the console and in patchcli status.

~/MyApp — zsh
# release a fix only to app ≥ 2.1.0 running on iOS ≥ 16.0
$ patchcli release --min-app-version 2.1.0 --min-os-version 16.0 \
           --message "Fix that needs the 2.1 checkout API"

# bound it on both ends — only the 2.x line, excluding 3.0+
$ patchcli release --min-app-version 2.0.0 --max-app-version 2.9.99

# targeting composes with the rollout % — eligible devices, then 10% of them
$ patchcli release --min-app-version 2.1.0 --rollout 10

# tag the release with a cohort label (shown in the console / status)
$ patchcli release --target-cohort beta-eu

The same four flags are available on patchcli push. When any are set, the preflight (and patchcli release --dry-run) prints a one-line Targeting: summary, e.g. app ≥ 2.1.0, iOS ≥ 16.0, so you can confirm the cohort before uploading.

In the console

Rollout rows whose release carries any constraint show a Targeted chip; the release drawer has a Targeting section that lists the active constraints (app ≥ / ≤, iOS ≥, cohort). patchcli status surfaces the same summary on its Targeting: line.

How it's evaluated

On every update check the backend evaluates the active release's constraints against the device, then applies the rollout-% bucket. Both must pass for the device to receive the module.

  • Unset = no constraint. A field left NULL (flag omitted) targets everyone; a release with no targeting behaves exactly as before.
  • Semver is numeric, not lexicographic. Versions compare segment-by-segment as integers, so 1.10 > 1.9 and 1.2 equals 1.2.0. Pre-release / build / OS-build suffixes are ignored (16.4 (20E247)16.4, 2.1.0-beta.32.1.0).
  • Bounds. --min-app-version requires the device app version the value; --max-app-version requires ; --min-os-version requires the device OS version the value.
  • Fail-open per version constraint. If a device reports an absent or unparseable version, that version constraint is skipped rather than blocking the device — the rollout-% gate still applies. (Cohort matching is the exception — see below — it is exact-match.)
  • Cohort filtering. --target-cohort <name> now gates eligibility: the release is served only to devices that report the matching cohort. The SDK reports an app-assigned cohort (PatchConfiguration.cohort, e.g. "beta"/"internal"); when the app sets none, the backend derives a stable hash-bucket cohort from the device id (so percentage-style cohort slices still work). Cohort matching is exact and fail-closed: a device that reports no cohort is excluded from a release that targets a named cohort. Composes (logical AND) with the app/OS-version constraints and the rollout-% bucket.
Targeting vs. channels vs. rollout %

These three controls are independent and stack. Targeting decides which devices are eligible (by app / OS version). A channel decides which track a device follows (production, staging, a per-tenant stream). The rollout % decides what fraction of the eligible devices get the release in this staged step. An update is served only to a device that is on the release's channel, satisfies its targeting, and lands inside the rollout bucket.

Force updates

Some fixes can't wait for the next natural launch — a critical tax bug, a broken eligibility rule. Mark a release mandatory and the SDK surfaces an isMandatory flag so you can require the update before the user continues. The pattern mirrors EAS / Expo-Updates' "Update available → Download now."

Mark a release mandatory

~/MyApp — zsh
# push the fix to everyone and flag it mandatory
$ patchcli release --rollout 100 --mandatory --message "Critical: VAT rounding fix"

The mandatory flag rides through the update-check response. On the device, checkForUpdate() returns an UpdateInfo with isMandatory == true.

The "Download now" pattern

You stay in control of UI. The recommended flow: check on launch, and if a mandatory update exists, present a blocking sheet that downloads and reloads before letting the user proceed.

Mandatory gate
func gateOnMandatoryUpdate() async throws {
  guard let info = try await Patch.shared.checkForUpdate() else { return }

  if info.isMandatory {
    // Block the UI: "A required update is available."
    presentBlockingSheet(version: info.version, notes: info.releaseNotes)

    // Download → verify → stage → hot-swap, then dismiss the gate.
    if try await Patch.shared.fetchUpdate() {
      try await Patch.shared.reloadAsync()
      dismissBlockingSheet()
    }
  } else {
    // Optional update — show a dismissible banner instead.
    showDismissibleBanner(info)
  }
}

Prefer it fully automatic? Call the convenience, which fetches + reloads any mandatory update and no-ops otherwise:

Auto-enforce
ContentView().task {
  await Patch.shared.start()                  // normal startup updates
  await Patch.shared.enforceMandatoryUpdates()  // force any mandatory release
}
Scope

Force updates apply to the OTA module only — they hot-swap WASM logic, not native code. A change to the native shell still requires an App Store release (the fingerprint gate enforces this). Keep mandatory updates within Apple's Developer Program License Agreement and Review Guidelines.

CI/CD

For production, run patchcli release from CI so deploys are reproducible, auditable, and free of long-lived credentials on developer machines. The shape of a pipeline:

  1. Analyze (gate): on pull requests, run patchcli analyze --check-fingerprint. If the native shell changed, fail the job — that change must ship through the App Store.
  2. Release: on merge to main, run patchcli release with the swift.org WASM SDK to build the module and push it behind the fingerprint check, then start a staged rollout.

GitHub Actions

.github/workflows/patch.yml
# Build + release an OTA patch on every merge to main — automatically SKIPS the
# release when the change isn't OTA-compatible (it touched the native shell,
# which must ship through the App Store). One workflow, no separate gate.
name: Patch OTA
on:
  push:
    branches: [main]

jobs:
  release:
    runs-on: macos-14
    steps:
      - uses: actions/checkout@v4

      # The Patch CLI + the Swift→WASM toolchain it compiles with
      - name: Install patchcli + toolchain
        run: |
          brew install patch-release/tap/patchcli
          patchcli setup

      # Ship the patch ONLY if the native shell is unchanged: analyze exits 1 when it
      # changed → not OTA-compatible → skip the release (ship via the App Store).
      - name: Release OTA if compatible
        run: |
          if patchcli analyze ./Sources --check-fingerprint; then
            patchcli release ./Sources --channel production --rollout 10 \
              --message "OTA: ${{ github.event.head_commit.message }}"
          else
            echo "Native shell changed — skipping OTA release (ship via the App Store)."
          fi
        env:
          PATCH_API_KEY: ${{ secrets.PATCH_API_KEY }}

Prefer to catch it at PR time too? Run the same patchcli analyze ./Sources --check-fingerprint as a pull_request check to block an incompatible merge before it lands.

API keys & secrets

CLI/SDK calls authenticate with a per-app API key (X-API-Key: pak_…). In CI, store it as a secret and pass it to patchcli via PATCH_API_KEY — that's all the release step needs. (Self-hosting Patch? Point the CLI at your own backend with PATCH_API_URL.)

Team accounts & roles

Patch organizes everything under a workspace. A workspace owns apps, releases, channels, and members. When you first sign in to the dashboard, Patch creates a personal workspace for you with the owner role; you can then invite teammates and provision more apps. Team collaboration (inviting a second member) is a paid feature — see Plans & billing.

Roles (RBAC)

Every member holds exactly one role in their workspace. The roles are ranked owner > admin > member, and that rank drives who can manage whom.

Owner

Full control: manage the billing plan, apps, channels, and members. Can invite, remove, and change roles for anyone — and is the only role that can grant the owner role. A workspace must always keep at least one owner.

Admin

Manage apps, ship releases, adjust rollouts, roll back, and invite, remove, or re-role members — but only admin/member roles. An admin can never grant owner, nor manage (re-role/remove) an existing owner.

Member

Day-to-day access: view apps, releases, and rollout status, and ship within the team's apps. Can view the member list, but cannot invite, remove, or re-role anyone, change the plan, or manage workspace settings.

Who can do what

Two invariants govern every action: the rank rule (you can only assign or manage a role at or below your own rank) and the last-owner protection (the workspace can never drop to zero owners).

ActionOwnerAdminMember
View the member list
Invite an admin / member
Invite / grant the owner role
Change an admin / member role
Change (re-role) an existing owner
Remove an admin / member
Remove an owner
Remove / demote the last owner
Change the billing plan

A deactivated member loses access to all role-gated actions regardless of their assigned role. Requests to a workspace you don't belong to are rejected.

Member endpoints

These workspace-scoped routes back the dashboard's Team page. You must be a member of the workspace; mutating actions additionally require owner or admin.

EndpointWhat it doesWho
POST /workspaces/{id}/membersInvite a member by email (creates a pending member until they sign in). Audited as member.invite.owner / admin
GET /workspaces/{id}/membersList all members (active and pending) of the workspace.any member
PATCH /workspaces/{id}/members/{user_id}Change a member's role. Blocks demoting the last owner. Audited as member.role_change.owner / admin
DELETE /workspaces/{id}/members/{user_id}Remove a member (revokes access immediately). Blocks removing the last owner. Audited as member.remove.owner / admin

Inviting members

Owners and admins invite by email from the dashboard's Team page. An invite creates a pending member; when that person signs in with the same email, their account links automatically and they take the assigned role. Removing a member revokes access immediately (you can't remove the last owner). Trying to invite a second member on the free Hobby plan returns 402 Payment Required with an upgrade message — see Plans & billing.

Auth model

The CLI and SDK authenticate with the per-app API key (X-API-Key: pak_…). The dashboard uses Firebase Auth (Google + email/password): the web app obtains an ID token and sends it as Authorization: Bearer <idToken>. The two surfaces are independent — humans use the dashboard, machines use the app key.

The dashboard

The dashboard is the team's web app for everything that isn't a CLI command:

  • Apps overview — every app in the workspace; pick one to drill in.
  • Rollouts — the main per-app view: recent releases across channels, newest first, with version, channel, status (active / superseded / rolled back), targeted %, received % (a progress bar), the mandatory flag, and when it was pushed. Click a release for stats (received vs targeted, download / activation / error counts) and actions to adjust the rollout % or roll back.
  • Channels — the active module per channel and its rollout/mandatory state.
  • Usage — per-app release-health analytics: active devices, adoption, error rate, and trends over the window — plus devices checked in from the base build before your first release ships (see Usage & analytics).
  • Team — list, invite, re-role, and remove members.
  • Activity — the workspace audit trail of who did what, on the Enterprise plan (see Audit log).
  • Settings & Billing — the current plan, the plan comparison matrix, and the owner-only upgrade/downgrade control (see Plans & billing).
  • Quickstart — for a workspace that hasn't deployed yet, a guided setup with your app's API key prefilled and the same patchcli init / patchcli release snippets shown here.

Plans & billing

Patch is free to start. Every workspace carries a billing plan that gates a small set of team-scale features; everything core to shipping OTA updates — unlimited apps, unlimited 100% production releases, instant rollback, the CLI, CI/CD, and the ship-safety checks — is available on the free tier.

Tiers

The pricing tiers on patchrelease.com map to the plan stored on your workspace:

Marketing tierWorkspace planWho it's for
Hobby (Free)hobbyIndividual workspace — one member, full 100% production releases, up to 100 distinct devices.
StartupproTeams that need collaboration, staged rollouts, A/B, and channels. Serves up to 10,000 devices.
EnterpriseteamLarger orgs; everything in Startup, plus the audit log & activity feed, support/SLA, and an unlimited device fleet.

A new workspace defaults to hobby. Startup and Enterprise differ commercially (usage limits, support) and on one capability — the audit log & activity feed is an Enterprise (Team) plan feature.

A small set of features are paid-only, gated consistently across the CLI, API, and console:

  • Team collaboration — inviting members. Hobby is an individual workspace capped at one member; a second invite is a paid feature.
  • Staged rollouts & A/B — any non-100% rollout_pct. Hobby ships full 100% releases only; shipping to a percentage of devices (a phased rollout / A/B cohort) is paid.
  • Non-production channels — Hobby ships to production only; canary, beta, staging and other channels are paid.
  • Device fleet size — Hobby serves patches to at most 100 distinct devices (counted over the recent active window). Pro raises the cap to 10,000 devices and Team is unlimited. Once a Hobby workspace is at the cap, a new device's update check simply returns "no update" — devices already in the counted set keep updating, so the existing fleet is never cut off.
FeatureHobby (free)Pro / Team (paid)
AppsUnlimitedUnlimited
OTA releases (100% production)UnlimitedUnlimited
Instant rollback
Usage analytics
Distinct devices servedUp to 100Pro: 10,000 · Team: unlimited
Team members1 (individual)Unlimited
Staged / A-B rollouts (non-100%)
Release channels (beyond production)
Audit log & activity feedEnterprise (Team) only

How gating works

When a Hobby workspace attempts a paid action, you receive a 402 Payment Required response with a plain-language upgrade message. The console surfaces that message as an upgrade prompt. The affected operations:

  • Inviting a second member (POST /workspaces/{id}/members).
  • Pushing a non-100% rollout or a non-production channel (POST /modules).
  • Adjusting rollout % from the console on a Hobby plan.

The device-fleet cap works differently: once a Hobby workspace reaches its 100-device cap, new devices are simply served "no update" at their next check — devices already in the active set keep receiving updates, so the existing fleet is never cut off. Upgrading lifts the cap immediately.

Changing the plan

The plan lives on the workspace and is changed by the owner from the console's Settings & Billing page, which calls:

PATCH the workspace plan
$ curl -X PATCH https://api.patchrelease.com/api/v1/workspaces/$WS/plan \
    -H "Authorization: Bearer $ID_TOKEN" \
    -H "Content-Type: application/json" \
    -d '{"plan":"pro"}'

Only the workspace owner may call it; valid plans are hobby, pro, and team. The change is recorded in the audit log as plan.change.

Downgrading

Switching back to Hobby re-applies the free-tier limits going forward — new invites and non-100% rollouts are gated again. Existing data isn't deleted; the gate simply applies the next time you attempt a paid action.

Usage & analytics

The SDK reports download, activation, and error events for every device (see Telemetry). The console's Usage page rolls those events into release-health analytics so you can answer "is this release healthy and adopted?" at a glance. Everything is a read-only aggregate over device events — no extra wiring beyond the SDK, and every query is scoped to your own workspace.

Endpoints

Both endpoints are Firebase-authed and take an optional ?days=N window (default 30, 1–365). They power the console; you can also call them directly for your own dashboards.

EndpointWhat it returns
GET /apps/{id}/usage?days=30App-level summary: active devices, adoption %, error rate, distinct releases, plus a daily time series and breakdowns by version, by channel, and by OS.
GET /workspaces/{id}/usage?days=30Workspace rollup: one row per app (active devices, error rate, latest released version) and workspace-wide totals for the window.

What each metric means

  • Active devices — distinct devices (by device_id) that sent any event in the window.
  • Adoption % — of those active devices, the share currently running the app's active production version. This is the number that should climb toward 100% as a rollout widens.
  • Error rateerrors / (activations + errors) over the window; 0 when there's no activity. The same definition the CLI's patchcli status uses.
  • Releases — count of distinct module versions ever pushed for the app.
  • Daily time series — one zero-filled point per calendar day (UTC) in the window — downloads, activations, errors, and active devices — so the series length always equals days.
  • By version — downloads / activations / errors / active devices per module version.
  • By channel — the same counts folded per channel, with the channel's currently-active version.
  • By OS — the top OS versions by distinct devices (active devices + event count each), to spot OS-specific regressions.
Scoping

Usage is strictly workspace-scoped: you can only query apps in your own workspace, and the workspace rollup only ever covers your own data.

Audit log

Enterprise plan

The audit log & activity feed is an Enterprise (Team) plan feature. The events below are recorded for every workspace, but the Activity page and the audit API are available on the Enterprise plan — see Plans & billing.

Every meaningful action in a workspace is recorded to an immutable audit trail — useful for security review, compliance, and answering "who changed this, and when?". The console surfaces it on the Activity page; you can also read it over the API.

What's recorded

Each entry captures the actor (a user's email, or api-key for CLI/SDK-driven actions), the workspace, the app when relevant, the action, a structured details payload (old → new values), and a timestamp. The recorded actions:

ActionWhen
login / account.createA user signs in; a brand-new account + personal workspace is created on first sign-in.
app.create / fingerprint.registerAn app is provisioned; a native-shell fingerprint is registered.
module.pushA module is pushed (via patchcli release / push).
rollout.changeA release's rollout % is adjusted.
module.rollbackA release is rolled back.
member.invite / member.role_change / member.removeA teammate is invited, re-roled, or removed.
plan.changeThe workspace billing plan changes (old → new).

Reading the trail

The Activity page reads GET /api/v1/audit. It's always scoped to your own workspace (a cross-workspace read is impossible), newest first, with pagination and optional filters.

Read the audit trail
# newest 50 events for your workspace
$ curl https://api.patchrelease.com/api/v1/audit \
    -H "Authorization: Bearer $ID_TOKEN"

# filter by action and/or app, with pagination
$ curl "https://api.patchrelease.com/api/v1/audit?action=module.rollback&limit=20&offset=0" \
    -H "Authorization: Bearer $ID_TOKEN"

$ curl "https://api.patchrelease.com/api/v1/audit?app_id=$APP_ID" \
    -H "Authorization: Bearer $ID_TOKEN"

Query params: action (an exact action string), app_id (must belong to your workspace, else 403), limit (1–200, default 50), and offset.

Webhooks & error-spike alerts

Webhooks push workspace events to your own systems in real time — post a release to Slack, page on-call when error rates spike, or kick off a downstream job. Patch sends a signed POST to every HTTPS endpoint you register that's subscribed to the event. They're managed by a workspace owner or admin, in the console under Settings → Webhooks or over the API.

Events

Pick any combination of the four event types per endpoint:

EventFires when
release.pushedA new OTA release is shipped (patchcli release / push). Payload includes the app, module & version, channel, rollout %, mandatory, sha256, and release notes.
rollout.changedA release's rollout % is raised or lowered.
rollbackA release is rolled back to the previous module.
error_spikeAn app's active production version crosses the error-rate threshold (see below). Payload includes the app, version, error_rate, errors, activations, sample, window_minutes, and threshold.

Register an endpoint

In the console, open Settings → Webhooks, paste an HTTPS URL, tick the events you want, and save. Patch reveals a one-time signing secret at creation — copy it then; it's the key you use to verify deliveries. Each row has a Test button that sends a sample ping delivery so you can confirm connectivity and your signature check before relying on it, and a Delete that stops delivery immediately.

The same operations are available over the API (owner/admin, scoped to the workspace): POST / GET / DELETE /api/v1/workspaces/{workspace_id}/webhooks and POST …/{webhook_id}/test.

Create a webhook
# register an endpoint subscribed to releases + error spikes
$ curl -X POST https://api.patchrelease.com/api/v1/workspaces/$WORKSPACE_ID/webhooks \
    -H "Authorization: Bearer $ID_TOKEN" \
    -H "Content-Type: application/json" \
    -d '{"url":"https://hooks.example.com/patch","events":["release.pushed","error_spike"]}'

# the response includes the signing secret — store it now, you'll need it to verify
# { "id": "...", "url": "...", "secret": "whsec_…", "events": [...], "is_active": true }

# send a sample `ping` delivery to confirm the endpoint + signature setup
$ curl -X POST https://api.patchrelease.com/api/v1/workspaces/$WORKSPACE_ID/webhooks/$WEBHOOK_ID/test \
    -H "Authorization: Bearer $ID_TOKEN"

Delivery format

Every delivery is an HTTP POST with a compact-JSON body — the envelope {event, workspace_id, data, timestamp}, where data is the event-specific payload — and an X-Patch-Signature header:

POST body (error_spike)
{
  "event": "error_spike",
  "workspace_id": "a1b2c3d4-…",
  "data": {
    "app_id": "…", "app_name": "Acme", "version": "1.4.2",
    "error_rate": 0.18, "errors": 9, "activations": 41,
    "sample": 50, "window_minutes": 15, "threshold": 0.1
  },
  "timestamp": "2026-06-04T12:00:00Z"
}

Verify the signature

The X-Patch-Signature header is sha256=<hex HMAC-SHA256(body, secret)>, computed over the raw request bytes with your endpoint's signing secret. Recompute the HMAC on your side and compare with a constant-time check before trusting a delivery:

verify_signature.py
# Flask receiver — reject any delivery whose signature doesn't match.
import hmac, hashlib

SECRET = "whsec_…"  # the signing secret shown once when the webhook was created

def verify(raw_body: bytes, header: str) -> bool:
    expected = "sha256=" + hmac.new(
        SECRET.encode(), raw_body, hashlib.sha256
    ).hexdigest()
    # constant-time compare — never use ==
    return hmac.compare_digest(expected, header or "")

# in your handler: hash the *raw* body, not a re-serialized dict
# if not verify(request.get_data(), request.headers.get("X-Patch-Signature")):
#     abort(401)

Error-spike alerts

Patch watches the error rate of each app's active production version from the device events it ingests, and fires error_spike when something goes wrong in the field — without you polling a dashboard.

Error-spike threshold

The alert fires when the error rate exceeds 10% over a rolling 15-minute window, with a minimum sample of 10 events (activations + errors) so a single early error can't trip a false 100% rate. It's debounced per app + version: once fired, the same version won't alert again for 15 minutes, so a burst sends at most one notification.

Deliveries are best-effort

Webhook delivery is fire-and-forget with a short timeout — a slow or failing receiver is logged and skipped, never retried, and never blocks the action (a release or rollback still succeeds even if your endpoint is down). Own your endpoint's availability: keep it fast, return 2xx quickly, and treat events as best-effort rather than guaranteed-once.

Troubleshooting & FAQ

Your native shell changed since the last App Store release (a native .swift file, a bridge toggle, Info.plist, entitlements, a linked framework, the deployment target, or the compiler version), so the OTA module isn't compatible with installed apps. Run patchcli fingerprint diff to see exactly what changed. Ship the change through the App Store, then re-baseline with patchcli fingerprint register after the new build is live. Pure-logic patches never trip this — only native-shell changes do.

It touches a must-stay-native API (low-level platform rendering, the file system, threads, the ObjC runtime, unsafe pointers, or device/OS APIs) somewhere it depends on, or it relies on a bridge you disabled in .Patch.yml. Logic and async/await code is updatable, and SwiftUI view code is updatable when SwiftUI coverage is enabled — run patchcli build --verbose (or patchcli analyze --verbose) to see the per-function classification and the reason each eligible function wasn't emitted. Factoring the pure logic out of a UI/IO function usually moves it into the WASM bucket.

Almost certainly yes, if it's SwiftUI. An OTA patch can author, restyle, reorder, rebind, and re-flow essentially your entire view hierarchy — text, images, layout, styling, navigation, presentation, controls, and view structure. The only things it can't reach are the binary-symbol wall: introducing a native capability/framework/entitlement that isn't already compiled and signed into your app (e.g. a camera or HealthKit prompt the app never declared), and rewriting the internals of non-View.body native code (a UIViewRepresentable's makeUIView, a custom ButtonStyle's makeBody, a Core Data fetch predicate). An existing MKMapView/WKWebView/custom view still renders, moves, and reorders over the air — only its native internals are frozen. See What Patch can & can't update for the full map.

You need the swift.org toolchain plus the WebAssembly Swift SDK — the Apple/Xcode toolchain cannot target wasm32-unknown-wasi. Install with swiftly install 6.3.2 and swift sdk install https://download.swift.org/swift-6.3.2-release/wasm-sdk/swift-6.3.2-RELEASE/swift-6.3.2-RELEASE_wasm.artifactbundle.tar.gz --checksum a61f0584c93283589f8b2f42db05c1f9a182b506c2957271402992655591dd7c. Without them, patchcli build still generates sources and a coverage report (effectively a dry run) but emits no .wasm.

Check three things: (1) the app is on the same channel you shipped to; (2) the device is inside the current rollout % — raise it if you're staging; (3) the app's fingerprint matches the release. Devices resolve the active release through the update-check API, so changes apply on the next poll. With start() the update applies on the next launch/check; with the imperative API you must call fetchUpdate() then reloadAsync() yourself.

Small. Most modules use the embedded tier (tens of KB), borrowing the native shell's Foundation through host bridges. Even when a module needs the full-Foundation base, that base ships once; every update after is a tiny compressed binary diff against the version already on the device — typically a few hundred bytes to tens of KB.

No — the overhead is negligible. Patched code runs in WasmKit at near-native speed, and for the kind of code Patch ships (business logic, validation, pricing, formatting, networking glue, view construction) the per-call cost is on the order of microseconds — far below anything a user could perceive, and dwarfed by the time the app already spends on network, disk, and rendering. There's no launch penalty: start() activates the cached module instantly (and offline), then checks for updates in the background, and applying an update is a hot-swap rather than a re-launch. Anything genuinely performance-critical — low-level rendering, AVFoundation, heavy compute, tight numeric loops — automatically stays native via the WASM/native split, so your hot paths are always compiled machine code.

No — it's explicitly allowed by Apple's own rules. Apple's Developer Program License Agreement §3.3.2 expressly permits an app to download and run interpreted code, as long as it doesn't change the app's primary purpose, doesn't create a storefront for other code, and doesn't bypass code signing, the sandbox, or other OS security. Patch satisfies all three: it ships logic as interpreted WebAssembly, your signed App Store binary never changes, and signing and the sandbox are untouched. This is the exact same provision Expo / EAS Update and Microsoft CodePush have relied on for nearly a decade to push interpreted code to React Native apps — across tens of thousands of live App Store apps. Use Patch for what it's for — fixing bugs, tuning logic, and adjusting UI within your app's existing purpose, not shipping a fundamentally different app or circumventing review — and you're squarely inside Apple's rules.

Run patchcli rollback --channel <channel> to re-activate the previous module; it propagates within ~60s. On-device, the SDK already recovers automatically — if a module fails to verify or activate, it falls back to the previous good module (or the bundled native code) so the app keeps working. Watch the failure rate with patchcli status; it warns past 2%.

Ready to ship your first patch?

Install the CLI, run patchcli init, and patchcli release a fix this afternoon.

Start the Quick Start