Skip to content
Closed
45 changes: 42 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ go install ./cmd/builder
./builder dev flutter --skip-install --bundle-id <id> # Use already installed app
./builder dev rn --skip-install --bundle-id <id> # Use already installed app
./builder auth apple # Save an App Store Connect API key
./builder signing setup --devices-from-mobai # Certificate + devices + profile via the ASC API, secrets to GitHub
./builder signing setup --type app-store --yes --json # Distribution certificate + App Store profile, no prompts
./builder ios upload --wait # Upload dist/*.ipa to App Store Connect, wait for processing
./builder ios submit --testflight --group <name> --notes <text> # TestFlight
./builder ios submit --app-store --release after-approval # App Review
Expand Down Expand Up @@ -106,6 +108,20 @@ builder dev kmp ─────────► Connects to MobAI
▼
Launches app and streams output (no hot reload)

builder signing setup ───► Bundle ID: --bundle-id → ios.bundleId → dist/*.ipa → prompt
│
▼
App Store Connect API (signing.Auto)
├─ bundleIds?filter[identifier] → POST bundleIds
├─ certificates?filter[certificateType] → reuse if the
│ key matches, else CSR → POST certificates → .p12
├─ devices?filter[platform]=IOS → POST devices (dev/ad-hoc)
└─ profiles?filter[name] → reuse / DELETE + POST profiles
│
▼
Writes key/.p12/.mobileprovision, uploads the three IOS_*
secrets (GitHub) or prints them (Codemagic/Bitrise)

builder ios upload ──────► Reads bundle ID / version / build number from dist/*.ipa
│
▼
Expand Down Expand Up @@ -135,11 +151,12 @@ cmd/builder/ # CLI entrypoint (Cobra)
internal/
auth/ # GitHub OAuth device flow + keyring storage (also CI tokens, ASC API key)
github/ # GitHub REST API (workflow dispatch, artifacts)
asc/ # App Store Connect API client (JWT, JSON:API, builds, uploads, TestFlight, review)
asc/ # App Store Connect API client (JWT, JSON:API, builds, uploads, TestFlight, review,
# bundle IDs, certificates, devices, profiles)
distribute/ # Upload / TestFlight / App Store flows on top of asc
ipa/ # Info.plist reading from .ipa archives
build/ # Build coordination (snapshot + trigger + poll + download)
signing/ # CSR generation and .p12 assembly (signing without a Mac)
signing/ # CSR generation, .p12 assembly, and Auto (portal-free provisioning on top of asc)
snapshot/ # Working-tree snapshot as a throwaway commit on a remote ref
workflow/ # Workflow template (embedded)
config/ # builder.json management
Expand Down Expand Up @@ -224,6 +241,24 @@ internal/
chosen group is external and none exists) → add groups. App Store reuses an open
`reviewSubmission` (READY_FOR_REVIEW/UNRESOLVED_ISSUES), skips the item when the version is
already in it, and rewrites ASC 409/422 with a "complete the metadata" hint.
- **Automatic Signing** (`signing.Auto`, behind `signing setup` without `--certificate`/
`--profile`): idempotent and never revokes. A certificate is reused only when its private key
is local (`--key`, or the `ios-signing.key` a previous run left in `--out-dir`), since a .p12
needs the key; otherwise a new one is issued and Apple's quota error (2 Development /
3 Distribution) gets a hint. Dev/ad-hoc profiles cover every ENABLED iOS device on the
account, not just the ones passed; App Store profiles send no `devices` relationship at
all (an empty one is rejected). Profile membership is read from
`/v1/profiles/{id}/relationships/{certificates,devices}` (paginated), not `include=`, which
caps linkage arrays. The profile `Builder <type> <bundle id>` is recreated when INVALID,
expired, `--force`, or when the certificate/device set differs; same-named duplicates are
deleted with it. `filter[identifier]` on bundleIds is a prefix match, so the exact identifier
is checked client-side. The manual `--certificate`/`--profile` path in `runSigningSetup` is
untouched; the automatic one lives in `cmd/builder/signing_auto.go`.
- **Export Method Follows The Profile**: the `method` in ExportOptions.plist must match the
uploaded profile's type (`development`, `ad-hoc`, `app-store`), or xcodebuild refuses the
export. `signing setup --type ad-hoc|app-store` only produces the material; deriving the
method from the profile in `ios-build.yml` and `runner.sh` is PR #17, so those IPAs work
once both are merged.
- **Extension Points**: a future `ios release` (upload + TestFlight, automatic build numbers)
composes `distribute.Upload` and `distribute.SubmitTestFlight` and reads `asc.Client.ListBuilds`
for the latest build number; the `pkg/` wrappers do not expose `asc` yet.
Expand All @@ -236,10 +271,14 @@ internal/
"project": "MyApp",
"platform": "ios",
"github": { "owner": "username", "repo": "my-ios-app" },
"ios": { "path": "ios", "scheme": "" }
"ios": { "path": "ios", "scheme": "", "bundleId": "com.example.app" }
}
```

`ios.bundleId` is optional: `init` fills it from `PRODUCT_BUNDLE_IDENTIFIER` when the Xcode
project has exactly one app target (test targets and `$(…)` values are skipped), and
`signing setup` saves whatever it resolved.

## Workflow Features

The embedded workflow template (`internal/workflow/templates/ios-build.yml`):
Expand Down
102 changes: 80 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,10 +197,12 @@ builder mobai install <ipa> # Install an IPA on the device
builder mobai run-debug <bundle-id> # Launch an app with the debugger attached
builder mobai forward <device-port> <host-port> # Forward a device port

# Code signing
builder signing csr # Create a private key + certificate signing request
builder signing p12 # Assemble a .p12 from the key and Apple's certificate
builder signing setup # Upload code signing secrets to GitHub
# Code signing (automatic mode needs builder auth apple)
builder signing setup --devices-from-mobai # Certificate, devices, profile and GitHub secrets, no portal
builder signing setup --type app-store # Apple Distribution certificate + App Store profile
builder signing setup --certificate ios-signing.p12 --profile MyApp.mobileprovision # Upload your own files
builder signing csr # Manual path: create a private key + certificate signing request
builder signing p12 # Manual path: assemble a .p12 from the key and Apple's certificate

# TestFlight and App Store (needs builder auth apple)
builder ios upload --wait # Upload ./dist/*.ipa to App Store Connect and wait for processing
Expand Down Expand Up @@ -250,6 +252,7 @@ never prompts, so agents and CI jobs can drive them.
|-------|-------------|---------|
| `ios.path` | Path to the Xcode project relative to the repo root | detected by `init` |
| `ios.scheme` | Xcode scheme to build | auto-detected |
| `ios.bundleId` | App bundle identifier, used by `signing setup` | detected by `init` when the project has one app target; else saved by `signing setup` |
| `ios.signing` | Sign the IPA with the uploaded certificate and profile | `false` |
| `ios.configuration` | Xcode build configuration. **Builds are `Debug` unless you set `Release`**; Debug is faster and is what the dev commands expect | `Debug` |

Expand Down Expand Up @@ -278,23 +281,76 @@ mirrored networking.

## Code Signing

For Codemagic and Bitrise, follow the [signing and MobAI secrets guide](docs/provider-secrets.md)
for dashboard instructions, file encoding, and verification. The `signing setup`
command below uploads to GitHub Actions only.

By default, builds are unsigned. Signed builds need a signing certificate and a
provisioning profile — and despite what many guides claim, **you do not need a
Mac to create either one**. The `.p12` certificate is normally created through
Keychain Access, but Builder does the same thing itself: it generates the
private key and certificate signing request, and assembles the `.p12` from the
certificate Apple issues.
Mac to create either one**, nor a tour of the Apple Developer portal. With an
App Store Connect API key, `builder signing setup` does the whole thing through
the API; the [manual path](#manual-path-through-the-apple-developer-portal)
below is the fallback when you would rather click, or already have the files.

You need a paid [Apple Developer Program](https://developer.apple.com/programs/)
membership — the portal only issues certificates to paid accounts. (Without one,
membership — Apple only issues certificates to paid accounts. (Without one,
build unsigned and let [MobAI](https://mobai.run) re-sign on install with a free
Apple ID.)

### 1. Create a certificate signing request
### Automatic setup

```bash
builder auth apple # once: save the App Store Connect API key
builder signing setup --devices-from-mobai # development signing for the devices MobAI sees
```

The key needs the **Admin** role (or App Manager plus *Access to Certificates,
Identifiers & Profiles*): Developer-role keys cannot create certificates.
`setup` then:

1. Registers the **App ID** if the bundle identifier is not on the account yet.
The bundle ID comes from `--bundle-id`, `ios.bundleId` in `builder.json`
(which `init` fills when the Xcode project has a single app target), or the
newest IPA in `./dist/`; in a terminal it asks as a last resort.
2. Issues a **certificate** — Apple Development for `--type development`, Apple
Distribution for `ad-hoc` and `app-store` — for a private key generated on
your machine (`ios-signing.key`, or `--key` to reuse one from `signing csr`).
A valid certificate on the account is reused only when its private key is
here, because that is the only way to build the `.p12`; otherwise a new one
is issued. Nothing is ever revoked: when Apple's limit (2 Development, 3
Distribution) is hit, the error names it and points at the portal.
3. Registers **devices** from `--device <udid>` (repeatable) and
`--devices-from-mobai` (name and UDID of every physical iOS device MobAI has
connected; simulators and cloud farm devices are skipped). Development and
ad-hoc profiles cover every enabled iOS device on the account, so with none
given and none registered the command stops and
says so. App Store profiles take no devices. Apple allows 100 devices per
membership year and never frees a slot; that error is passed through too.
4. Creates the **profile** `Builder <type> <bundle id>` (iOS App Development,
Ad Hoc or App Store). An existing one is reused while it is `ACTIVE`,
unexpired and still lists exactly this certificate and these devices;
otherwise it is deleted and recreated, and the summary says why (`invalid`,
`expired`, `certificate changed`, `devices changed`, `forced`).
5. Writes `ios-signing.key` (when generated), `ios-signing.p12` and
`Builder-<type>-<bundle id>.mobileprovision` to `--out-dir` (default `.`),
uploads `IOS_CERTIFICATE`, `IOS_CERTIFICATE_PASSWORD` and
`IOS_PROVISIONING_PROFILE` to GitHub Secrets and sets `ios.signing` to
`true`. For Codemagic and Bitrise it prints the three values to paste
instead, following the [signing and MobAI secrets guide](docs/provider-secrets.md).

The command shows its plan and asks once before creating anything; `--yes`
skips that (required without a terminal), and then the `.p12` password is
generated and printed once unless `--password` is given. `--json` prints the
result as JSON with progress on stderr. Keep the written files out of git.

Run it again whenever you like: it reports what it found and recreates only what
is missing, expired, invalid or changed — add a device, re-run, rebuild.
`--force` issues a fresh certificate and profile regardless. For TestFlight use
`--type app-store` and set `ios.configuration` to `Release`.

### Manual path through the Apple Developer portal

The `.p12` certificate is normally created through Keychain Access, but Builder
does the same thing itself: it generates the private key and certificate
signing request, and assembles the `.p12` from the certificate Apple issues.

#### 1. Create a certificate signing request

```bash
builder signing csr
Expand All @@ -305,13 +361,13 @@ directory: `ios-signing.key` (your private key) and `ios-signing.csr`. Keep
the key wherever suits you — just don't commit it (add it to `.gitignore`;
gitignored files are also excluded from build snapshots).

### 2. Create the certificate
#### 2. Create the certificate

1. Go to [Certificates](https://developer.apple.com/account/resources/certificates/add) on the Apple Developer portal
2. Choose **Apple Development** (installs on registered devices) or **Apple Distribution** (App Store/Ad Hoc)
3. Upload `ios-signing.csr` and download the resulting `.cer` file

### 3. Assemble the .p12
#### 3. Assemble the .p12

```bash
builder signing p12 --certificate development.cer --key ios-signing.key
Expand All @@ -322,21 +378,23 @@ password you choose — byte-for-byte the same kind of file Keychain Access
exports, and usable anywhere one is: `builder signing setup`, Sideloadly,
AltStore, or importing it on a Mac. Keep it, and don't commit it.

### 4. Create a provisioning profile
#### 4. Create a provisioning profile

On the portal:

1. **Identifiers** → register an App ID matching your app's bundle identifier
2. **Devices** → register your device's UDID (shown in [MobAI](https://mobai.run) when the device is connected; on Windows, iTunes shows it when you click the serial number on the device page)
3. **Profiles** → create an **iOS App Development** (or Ad Hoc) profile, select your App ID, certificate, and devices, then download the `.mobileprovision` file

### 5. Upload the signing secrets
#### 5. Upload the signing secrets

```bash
builder signing setup --certificate ios-signing.p12 --profile MyApp.mobileprovision
```

This uploads the signing material to GitHub Secrets:
With `--certificate` and `--profile` given, `setup` takes the files as they are
(no App Store Connect key involved) and uploads the signing material to GitHub
Secrets:
- `IOS_CERTIFICATE` - Base64-encoded .p12 file
- `IOS_CERTIFICATE_PASSWORD` - Certificate password
- `IOS_PROVISIONING_PROFILE` - Base64-encoded .mobileprovision file
Expand Down Expand Up @@ -366,9 +424,9 @@ You need:
membership and an app record in App Store Connect (My Apps → +) with your
bundle ID
- An IPA signed with an **Apple Distribution** certificate and an **App Store**
provisioning profile. `builder signing setup` accepts both, exactly as in the
steps above; pick those types on the portal instead of the development ones.
An IPA signed for development is rejected at upload.
provisioning profile: `builder signing setup --type app-store` creates both,
or pick those types on the portal in the manual path. An IPA signed for
development is rejected at upload.
- `"configuration": "Release"` under `ios` in `builder.json`: `ios build`
defaults to `Debug`, which is what the dev commands expect, not what you want
to ship.
Expand Down
42 changes: 42 additions & 0 deletions cmd/builder/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,45 @@ func detectIOSPath() (string, string) {
return "", ""
}

// bundleIDRe matches PRODUCT_BUNDLE_IDENTIFIER assignments in a project.pbxproj.
var bundleIDRe = regexp.MustCompile(`PRODUCT_BUNDLE_IDENTIFIER\s*=\s*"?([^";\s]+)"?\s*;`)

// detectBundleID reads the app's bundle identifier from the Xcode project
// under iosPath. Test targets (…Tests) and values built from build settings
// ($(…)) are ignored; anything still ambiguous yields "" so init leaves the
// field for `signing setup` to resolve.
func detectBundleID(iosPath string) string {
if iosPath == "" {
iosPath = "."
}
projects, _ := filepath.Glob(filepath.Join(iosPath, "*.xcodeproj", "project.pbxproj"))
var found []string
for _, path := range projects {
data, err := os.ReadFile(path)
if err != nil {
continue
}
found = append(found, bundleIDsFromPbxproj(string(data))...)
}
if len(found) == 1 {
return found[0]
}
return ""
}

// bundleIDsFromPbxproj returns the distinct app bundle identifiers in pbxproj text.
func bundleIDsFromPbxproj(text string) []string {
var ids []string
for _, m := range bundleIDRe.FindAllStringSubmatch(text, -1) {
id := m[1]
if strings.Contains(id, "$") || strings.HasSuffix(id, "Tests") || slices.Contains(ids, id) {
continue
}
ids = append(ids, id)
}
return ids
}

func detectGitHubRepo(remoteName string) (owner, repo string, err error) {
// Try to get GitHub remote URL from git
cmd := exec.Command("git", "remote", "get-url", remoteName)
Expand Down Expand Up @@ -397,6 +436,9 @@ func runInit(cmd *cobra.Command, args []string) error {
cfg.Project, cfg.Platform = projectName, "ios"
cfg.GitHub = config.GitHubConfig{Owner: githubOwner, Repo: repoName}
cfg.IOS.Path, cfg.IOS.Scheme = iosPath, scheme
if cfg.IOS.BundleID == "" {
cfg.IOS.BundleID = detectBundleID(iosPath)
}
if flutterVersion != "" {
cfg.Flutter.Version = flutterVersion
}
Expand Down
66 changes: 66 additions & 0 deletions cmd/builder/root_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package main

import (
"os"
"path/filepath"
"testing"
)

const flutterPbxproj = `
97C147061CF9000F007C117D /* Debug */ = {
buildSettings = {
PRODUCT_BUNDLE_IDENTIFIER = com.example.myApp;
PRODUCT_NAME = "$(TARGET_NAME)";
};
};
97C147071CF9000F007C117D /* Release */ = {
buildSettings = {
PRODUCT_BUNDLE_IDENTIFIER = com.example.myApp;
};
};
331C8088294A63A400263BE5 /* Debug */ = {
buildSettings = {
PRODUCT_BUNDLE_IDENTIFIER = com.example.myApp.RunnerTests;
};
};
`

func TestBundleIDsFromPbxproj(t *testing.T) {
if got := bundleIDsFromPbxproj(flutterPbxproj); len(got) != 1 || got[0] != "com.example.myApp" {
t.Errorf("bundleIDsFromPbxproj = %v, want [com.example.myApp]", got)
}
quoted := `PRODUCT_BUNDLE_IDENTIFIER = "com.example.my-app"; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).app";`
if got := bundleIDsFromPbxproj(quoted); len(got) != 1 || got[0] != "com.example.my-app" {
t.Errorf("bundleIDsFromPbxproj(quoted) = %v", got)
}
two := `PRODUCT_BUNDLE_IDENTIFIER = com.example.free; PRODUCT_BUNDLE_IDENTIFIER = com.example.pro;`
if got := bundleIDsFromPbxproj(two); len(got) != 2 {
t.Errorf("bundleIDsFromPbxproj(two apps) = %v", got)
}
}

func TestDetectBundleID(t *testing.T) {
dir := t.TempDir()
proj := filepath.Join(dir, "ios", "Runner.xcodeproj")
if err := os.MkdirAll(proj, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(proj, "project.pbxproj"), []byte(flutterPbxproj), 0644); err != nil {
t.Fatal(err)
}
if got := detectBundleID(filepath.Join(dir, "ios")); got != "com.example.myApp" {
t.Errorf("detectBundleID = %q", got)
}
if got := detectBundleID(filepath.Join(dir, "missing")); got != "" {
t.Errorf("detectBundleID(missing) = %q, want empty", got)
}
// Two app targets: ambiguous, leave it to signing setup.
two := filepath.Join(dir, "two", "App.xcodeproj")
if err := os.MkdirAll(two, 0755); err != nil {
t.Fatal(err)
}
_ = os.WriteFile(filepath.Join(two, "project.pbxproj"), []byte(`PRODUCT_BUNDLE_IDENTIFIER = com.example.free; PRODUCT_BUNDLE_IDENTIFIER = com.example.pro;`), 0644)
if got := detectBundleID(filepath.Join(dir, "two")); got != "" {
t.Errorf("detectBundleID(two apps) = %q, want empty", got)
}
}
Loading