Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 49 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co

## Project Overview

**Builder** is a Go CLI tool for iOS development without a Mac. It has two main capabilities:
**Builder** is a Go CLI tool for iOS development without a Mac. It has three main capabilities:
1. **Remote builds**: Build iOS apps via GitHub Actions from any platform
2. **Dev tools**: Hot reload on real iOS devices using MobAI (Flutter and React Native)
3. **Distribution**: Upload builds to App Store Connect and submit them to TestFlight or App Review

## Build Commands

Expand All @@ -29,6 +30,10 @@ go install ./cmd/builder
./builder dev kmp # Kotlin Multiplatform install + launch (no hot reload)
./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 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
```

## Architecture
Expand Down Expand Up @@ -100,15 +105,39 @@ builder dev kmp ─────────► Connects to MobAI
│
▼
Launches app and streams output (no hot reload)

builder ios upload ──────► Reads bundle ID / version / build number from dist/*.ipa
│
▼
App Store Connect API (ES256 JWT from the .p8 key)
├─ apps?filter[bundleId]
├─ POST buildUploads → POST buildUploadFiles
├─ PUT chunks to presigned URLs
├─ PATCH buildUploadFiles uploaded=true
└─ --wait: poll buildUploads state, then builds → VALID
│
▼
PATCH builds usesNonExemptEncryption (plist / --no-encryption)

builder ios submit ──────► Picks the newest VALID build (or --build-number)
├─ --testflight: betaBuildLocalizations (notes),
│ betaAppReviewSubmissions (external groups),
│ builds/{id}/relationships/betaGroups
└─ --app-store: appStoreVersions (find/create, attach
build, releaseType), reviewSubmissions +
reviewSubmissionItems, PATCH submitted=true
```

### Module Layout

```
cmd/builder/ # CLI entrypoint (Cobra)
internal/
auth/ # GitHub OAuth device flow + keyring storage
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)
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)
snapshot/ # Working-tree snapshot as a throwaway commit on a remote ref
Expand Down Expand Up @@ -169,6 +198,24 @@ internal/
CLI calls KMP but the runner does not gets no JDK, and vice versa.
- **KMP Has No Hot Reload**: shared Kotlin compiles to a native framework at build time, so
`dev kmp` only installs, launches and streams output; code changes need `ios build`
- **ASC Client** (`internal/asc`): runs locally, never on the runner; ES256 JWT (15 min, cached)
from the `.p8`, generic JSON:API plumbing (`getOne`/`getAll`/`post`/`patch`, `getAll` follows
`links.next`). 429 retries on any method, 5xx only off POST; every wait goes through `Client.sleep`.
- **ASC Credentials**: one JSON secret (`apple-asc-key`) in the keyring/file store, via the shared
`readSecret`/`writeSecret`/`deleteSecret` helpers. `ASC_ISSUER_ID`, `ASC_KEY_ID` +
`ASC_PRIVATE_KEY`|`ASC_KEY_PATH` win; a partial environment is an error. Only `auth apple` prompts.
- **Build Upload**: `buildUploads` → `buildUploadFiles` (returns `uploadOperations`) → PUT each byte
range with its `requestHeaders`, no bearer token → PATCH `uploaded=true` → poll the upload `state`,
then `builds` until VALID. The IPA must be App Store signed with an ever-higher `CFBundleVersion`.
- **Export Compliance**: a build sits in "Missing Compliance" until `usesNonExemptEncryption` is
answered; `upload --wait` PATCHes it from the plist or `--no-encryption`. The build must exist
first, so without `--wait` it falls to `submit`, which refuses unanswered builds for TestFlight.
- **Submit Order**: TestFlight is compliance → notes → `betaAppReviewSubmissions` (only for a new
external group) → add groups. App Store reuses an open `reviewSubmission`, skips an item the
version is already in, and rewrites ASC 409/422 with a "complete the metadata" hint.
- **Extension Points**: a future `ios release` composes `distribute.Upload` and
`distribute.SubmitTestFlight`, reading `asc.Client.ListBuilds` for the latest build number; the
`pkg/` wrappers do not expose `asc` yet.

## Configuration

Expand Down
103 changes: 102 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ Builder is a CLI tool for iOS development without a Mac. It uses GitHub Actions
- **Flutter & React Native dev tools**: Hot reload on real iOS devices from Windows/Linux
- **Simple setup**: One command to add the workflow to your repo
- **Code signing**: Optional signing with your certificate and provisioning profile
- **TestFlight and App Store**: Upload builds and submit them for review through the App Store Connect API, from any platform
- **Device integration**: Install and run apps via MobAI

## How It Works
Expand Down Expand Up @@ -165,8 +166,9 @@ go build -o builder ./cmd/builder
# Setup
builder auth github # Authenticate with GitHub
builder auth codemagic # Authenticate with Codemagic (also: bitrise)
builder auth apple # Save an App Store Connect API key
builder auth status # Show which providers you are signed in to
builder auth logout [name] # Remove stored credentials
builder auth logout [name] # Remove stored credentials (github, codemagic, bitrise, apple)
builder init # Set up workflows in current repo
builder update # Update builder to the latest release

Expand Down Expand Up @@ -199,8 +201,16 @@ builder mobai forward <device-port> <host-port> # Forward a device port
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

# TestFlight and App Store (needs builder auth apple)
builder ios upload --wait # Upload ./dist/*.ipa to App Store Connect and wait for processing
builder ios submit --testflight --group "Beta Testers" --notes "What to test"
builder ios submit --app-store --release after-approval # Submit the version for App Review
```

Every `upload`/`submit` command takes `--json` for machine-readable output and
never prompts, so agents and CI jobs can drive them.

## Configuration

`builder.json`:
Expand Down Expand Up @@ -342,6 +352,97 @@ secrets by hand as described in the
[signing and MobAI secrets guide](docs/provider-secrets.md), then set
`ios.signing` to `true` yourself.

## TestFlight and App Store

Builder uploads builds to App Store Connect and submits them to TestFlight or
App Review through the App Store Connect API, from Windows, Linux or macOS. No
Transporter, `altool` or Xcode is involved, and the API key never leaves your
machine: the CI runner only builds and signs, the upload happens locally from
the IPA in `./dist/`.

You need:

- A paid [Apple Developer Program](https://developer.apple.com/programs/)
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.
- `"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.
- An App Store Connect API key: App Store Connect → Users and Access →
Integrations → App Store Connect API → Team Keys. Give it the **App Manager**
role, note the **Issuer ID** and **Key ID**, and download the
`AuthKey_<KEYID>.p8` file (Apple offers the download once).

### 1. Save the API key

```bash
builder auth apple --issuer-id 12345678-abcd-... --key-id ABC123DEFG --key AuthKey_ABC123DEFG.p8
```

Flags you leave out are prompted for. Builder verifies the key against App
Store Connect and stores it like the other logins (keychain, or a `0600` file on
Linux/WSL); `builder auth status` shows it and `builder auth logout apple`
removes it. In CI or for a coding agent, set `ASC_ISSUER_ID`, `ASC_KEY_ID` and
either `ASC_PRIVATE_KEY` (the .p8 contents; literal `\n` is fine) or
`ASC_KEY_PATH` instead — they take precedence over the saved login.

### 2. Upload the build

```bash
builder ios build # produces a signed dist/*.ipa
builder ios upload --wait
```

`upload` reads the bundle ID, version and build number from the newest IPA in
`./dist/` (or `--ipa <path>`), finds the app, uploads the archive in chunks
and, with `--wait`, follows App Store Connect until the build has finished
processing and prints its build ID and TestFlight link. Without `--wait` it
returns as soon as Apple has the file.

Two things Apple checks on every upload:

- **Build numbers must increase.** A second upload with the same
`CFBundleVersion` for the same version is rejected (`ITMS-90189`), so bump
it before rebuilding.
- **Export compliance.** A build shows as *Missing Compliance* in TestFlight
until you say whether it uses non-exempt encryption. If your Info.plist sets
`ITSAppUsesNonExemptEncryption` to `false`, `upload --wait` answers that
automatically; otherwise pass `--no-encryption` (here or to `submit`) when
your app only uses standard iOS encryption.

### 3. Distribute to TestFlight

```bash
builder ios submit --testflight --group "Beta Testers" --notes "New login flow"
```

This takes the newest processed build (or `--build-number N`), sets the *What
to Test* notes and adds it to the named groups (`--group` repeats). Internal
groups get the build immediately; the first external group triggers Apple's
beta review, which Builder submits for you (`--wait` follows the decision). Run
it without `--group` to see the build and the groups the app has.

### 4. Submit to the App Store

```bash
builder ios submit --app-store --release after-approval
```

Builder finds or creates the App Store version matching the IPA's marketing
version (or `--version X.Y.Z`), attaches the build, sets the release type
(`manual` or `after-approval`) and submits it for review. The version's
metadata — description, screenshots, age rating, pricing, privacy — must
already be complete: App Store Connect refuses the submission otherwise and
Builder prints Apple's reasons verbatim. Builder does not manage metadata,
screenshots or in-app purchases; fill them in App Store Connect, or on a Mac
with [asc-cli](https://github.com/tddworks/asc-cli), whose production use of
the `buildUploads` API also proved that the Mac-free upload path works and
served as the reference for Builder's implementation.

## Installing the IPA

Use [MobAI](https://mobai.run) to install your IPA directly on your device. It works with both signed and unsigned builds: an unsigned IPA can be re-signed on install with a free Apple ID (MobAI asks for the account).
Expand Down
93 changes: 91 additions & 2 deletions cmd/builder/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,17 @@ package main

import (
"context"
"errors"
"fmt"
"io"
"os"
"strings"

"github.com/MobAI-App/ios-builder/internal/asc"
"github.com/MobAI-App/ios-builder/internal/auth"
"github.com/MobAI-App/ios-builder/internal/ci"
"github.com/spf13/cobra"
"golang.org/x/term"
)

var authCmd = &cobra.Command{
Expand All @@ -24,8 +27,24 @@ var authGitHubCmd = &cobra.Command{
RunE: runAuthGitHub,
}

var authAppleCmd = &cobra.Command{
Use: "apple",
Short: "Authenticate with App Store Connect (API key)",
Long: `Saves an App Store Connect API key for builder ios upload and builder ios submit.

Create the key in App Store Connect under Users and Access → Integrations →
App Store Connect API (Team key, role App Manager or Admin). Note the Issuer ID
and Key ID shown there and download the AuthKey_<KEYID>.p8 file; Apple lets you
download it only once.

Flags left out are prompted for. In CI, set ASC_ISSUER_ID, ASC_KEY_ID and
ASC_PRIVATE_KEY (or ASC_KEY_PATH) instead; they take precedence over the saved login.`,
Args: cobra.NoArgs,
RunE: runAuthApple,
}

var authLogoutCmd = &cobra.Command{
Use: "logout [github|codemagic|bitrise]",
Use: "logout [github|codemagic|bitrise|apple]",
Args: cobra.MaximumNArgs(1),
Short: "Remove stored credentials",
RunE: runAuthLogout,
Expand All @@ -39,6 +58,10 @@ func init() {
cmd.Flags().Bool("token-stdin", false, "Read API token from stdin instead of a hidden-input prompt")
authCmd.AddCommand(cmd)
}
authAppleCmd.Flags().String("issuer-id", "", "Issuer ID from App Store Connect → Users and Access → Integrations")
authAppleCmd.Flags().String("key-id", "", "Key ID of the API key")
authAppleCmd.Flags().String("key", "", "Path to the AuthKey_<KEYID>.p8 private key")
authCmd.AddCommand(authAppleCmd)
authCmd.AddCommand(&cobra.Command{Use: "status", Short: "Show login availability for all providers", Args: cobra.NoArgs, RunE: runAuthStatus})
}

Expand Down Expand Up @@ -69,7 +92,10 @@ func runAuthLogout(cmd *cobra.Command, args []string) error {
return err
}
fmt.Printf("Removed saved %s login\n", provider)
if provider != "github" && os.Getenv(strings.ToUpper(provider)+"_API_TOKEN") != "" {
switch {
case provider == "apple" && os.Getenv("ASC_ISSUER_ID") != "":
fmt.Println("ASC_* environment variables are still set; unset them in your shell to stop using them.")
case provider != "github" && provider != "apple" && os.Getenv(strings.ToUpper(provider)+"_API_TOKEN") != "":
fmt.Println("An environment token is still set; unset it in your shell to stop using it.")
}
return nil
Expand Down Expand Up @@ -110,6 +136,58 @@ func runAuthProvider(cmd *cobra.Command, _ []string) error {
return nil
}

func runAuthApple(cmd *cobra.Command, _ []string) error {
issuerID, _ := cmd.Flags().GetString("issuer-id")
keyID, _ := cmd.Flags().GetString("key-id")
keyPath, _ := cmd.Flags().GetString("key")
if issuerID == "" || keyID == "" || keyPath == "" {
if stdin, ok := cmd.InOrStdin().(*os.File); !ok || !term.IsTerminal(int(stdin.Fd())) {
return fmt.Errorf("--issuer-id, --key-id and --key are required without a terminal (or set ASC_ISSUER_ID, ASC_KEY_ID and ASC_KEY_PATH)")
}
fmt.Println("App Store Connect → Users and Access → Integrations → App Store Connect API")
var err error
if issuerID == "" {
if issuerID, err = promptString("Issuer ID", ""); err != nil {
return err
}
}
if keyID == "" {
if keyID, err = promptString("Key ID", ""); err != nil {
return err
}
}
if keyPath == "" {
if keyPath, err = promptString("Path to AuthKey_"+keyID+".p8", ""); err != nil {
return err
}
}
}
keyPEM, err := os.ReadFile(keyPath)
if err != nil {
return fmt.Errorf("read private key: %w", err)
}
creds := auth.AppleCredentials{IssuerID: strings.TrimSpace(issuerID), KeyID: strings.TrimSpace(keyID), PrivateKey: auth.NormalizePEM(string(keyPEM))}
client, err := asc.NewClient(asc.Credentials{IssuerID: creds.IssuerID, KeyID: creds.KeyID, PrivateKey: creds.PrivateKey})
if err != nil {
return err
}
ctx := cmd.Context()
if ctx == nil {
ctx = context.Background()
}
if err := client.CheckAccess(ctx); err != nil {
return fmt.Errorf("the key was rejected by App Store Connect: %w", err)
}
if err := auth.StoreAppleCredentials(creds); err != nil {
return err
}
fmt.Printf("Verified and saved App Store Connect API key %s.\n", creds.KeyID)
if os.Getenv("ASC_ISSUER_ID") != "" {
fmt.Println("ASC_* environment variables are set and take precedence over this saved login.")
}
return nil
}

func runAuthStatus(_ *cobra.Command, _ []string) error {
for _, name := range []string{"github", "codemagic", "bitrise"} {
_, err := auth.GetProviderToken(name)
Expand All @@ -119,5 +197,16 @@ func runAuthStatus(_ *cobra.Command, _ []string) error {
}
fmt.Printf("%s: %s\n", name, state)
}
creds, source, err := auth.GetAppleCredentials()
switch {
case errors.Is(err, auth.ErrNotAuthenticated):
fmt.Println("apple: not logged in")
case err != nil:
fmt.Printf("apple: %v\n", err)
case source == auth.AppleSourceEnv:
fmt.Printf("apple: login available from ASC_* environment (key %s, not checked remotely)\n", creds.KeyID)
default:
fmt.Printf("apple: login available (key %s, not checked remotely)\n", creds.KeyID)
}
return nil
}
Loading
Loading