Skip to content
26 changes: 23 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ go install ./cmd/builder
# Run
./builder auth github # Authenticate with GitHub (OAuth device flow)
./builder init # Set up workflow in current repo
./builder init --runner self-hosted,macOS,ARM64 # runs-on rendered into both workflows; also macos-15 etc.
./builder init --provider bitrise --app-id X --branch main --runner g2.mac.large --stack osx-xcode-16.2.x
./builder ios build # Trigger build and download IPA to ./dist/
./builder ios build --profile production # Build with a builder.json profile
./builder dev flutter # Flutter hot reload with MobAI
Expand Down Expand Up @@ -428,6 +430,20 @@ internal/
- **QR Rendering**: `skip2/go-qrcode` at error-correction Low, Unicode half blocks (two module rows per
line, 2-module quiet zone), light modules as `█` so it scans on a dark terminal (`--qr-invert` for light);
`TestQRFitsATerminal` pins a representative link at 41 modules (version 6). Printed only on a TTY or `--qr`.
- **Runner Selection**: `config.Runner` is a label or label list (JSON string or array, `--runner` comma
list, labels `^[A-Za-z0-9][A-Za-z0-9._-]*$` so they render unescaped). `ios-build.yml` has
`runs-on: ${{ fromJSON(inputs.profile || '{}').runner || <default> }}` (`inputs` is allowed in
`runs-on`); `ProfileInput` carries the profile's runner, else the top-level one, and is sent with an
empty name when only a runner is set. `workflow.RenderWorkflow` swaps only the default (the embedded
file is the `macos-latest` rendering) and must find exactly one runs-on line; `ios-share.yml` and tag
builds get only the rendered top-level runner. Codemagic/Bitrise use `codemagic.instance_type`,
`bitrise.machine_type_id`/`stack` (`ProviderFiles(provider, ci)`, unknown values warn, unsafe ones fail)
- **Persistent Runners**: `setup-xcode` only `if: runner.environment == 'github-hosted'`, else a
`Check Xcode` step; `brew install` only behind `command -v` (`TestTemplatesSafeOnPersistentRunner`);
`Clear previous outputs` after the snapshot checkout; the signing step prepends its keychain to the
saved search list (`$RUNNER_TEMP/keychains-before`) and lists installed profile UUIDs
(`installed-profiles`, `extensions/installed`), which `Cleanup signing` restores/removes from fixed
`$RUNNER_TEMP` paths, since `$GITHUB_ENV` is written only at the end of the signing step
- **Signing Sets As A Library**: `signing.Setup` and `signing.EnsureSecrets`
(internal/signing/sets.go) hold the non-interactive core of `signing setup`
and on-demand provisioning; cmd/builder keeps the prompts, the plan and the
Expand All @@ -442,10 +458,11 @@ internal/
"project": "MyApp",
"platform": "ios",
"github": { "owner": "username", "repo": "my-ios-app" },
"runner": ["self-hosted", "macOS", "ARM64"],
"ios": { "path": "ios", "scheme": "", "bundleId": "com.example.app" },
"defaultProfile": "development",
"profiles": {
"development": { "distribution": "development" },
"development": { "distribution": "development", "runner": "macos-15" },
"preview": { "distribution": "internal", "env": { "API_URL": "https://staging.example.com" } },
"production": { "distribution": "store", "scheme": "MyApp", "provider": "codemagic" }
}
Expand All @@ -459,7 +476,10 @@ the first IPA exists. `signing.dir` is the last automatic `signing setup`'s `--o

A profile's fields are `distribution` (`development`, `ad-hoc`/`internal`, `store`, `enterprise`; the
only signing field, omitted = unsigned), `configuration` (else Debug for development, Release
otherwise), `scheme`, `provider`, `env`. `runner`/`submit` are planned on `config.Profile`, not read.
otherwise), `scheme`, `provider`, `env`, `runner` (GitHub runs-on, overrides the top-level `runner`;
empty everywhere is `macos-latest`). `submit` is planned, not read. `codemagic.instance_type`,
`bitrise.machine_type_id` and `bitrise.stack` pick those providers' machines (set by `init --provider
... --runner/--stack`).

## Workflow Features

Expand All @@ -482,7 +502,7 @@ The embedded workflow template (`internal/workflow/templates/ios-build.yml`):
`signing_set`. The job deletes
the tag when it ends (`permissions: contents: write`). Any other workflow in the repo with an
unfiltered `on: push` also fires on these tags.
- Runs on `macos-latest`
- Runs on the profile input's `runner`, else the default `init` rendered (`macos-latest` unless `runner` is set)
- Detects Flutter projects (checks for `pubspec.yaml`)
- Restores and saves DerivedData for fast incremental builds
- Auto-detects workspace/project and scheme
Expand Down
75 changes: 75 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,78 @@ runner script `.builder/ci/runner.sh`. Commit them to the configured branch
and connect the same repository to each provider before building. See
[provider setup, signing, simulator sessions, and free allowances](docs/providers.md).

The machine is `mac_mini_m2` on Codemagic and `g2.mac.medium` on Bitrise
unless you pick another with `--runner` (and, on Bitrise, a stack with `--stack`):

```bash
builder init --provider codemagic --app-id YOUR_APP_ID --branch main --runner mac_mini_m4
builder init --provider bitrise --app-id YOUR_APP_SLUG --branch main --runner g2.mac.large --stack osx-xcode-16.2.x
```

They are saved as `codemagic.instance_type`, `bitrise.machine_type_id` and
`bitrise.stack` in `builder.json` and rendered into the YAML. A machine type
Builder does not know is used as given, with a warning. Larger machines are
paid on both services.

## Self-hosted runners

GitHub builds run on `macos-latest` by default. `init --runner` picks another
GitHub-hosted image or your own Mac:

```bash
builder init --runner macos-15 # a pinned hosted image
builder init --runner self-hosted # any of your self-hosted runners
builder init --runner self-hosted,macOS,ARM64 # a runner carrying all of these labels
```

The value is saved in `builder.json` as `"runner": "macos-15"` or
`"runner": ["self-hosted", "macOS", "ARM64"]`, and a [profile](#build-profiles)
can override it with its own `runner`. `init` renders the top-level runner into
`.github/workflows/ios-build.yml` and `ios-share.yml`; commit and push them to
the default branch as usual. `ios build` prints the runner it dispatches to.

How the runner is chosen:

- `ios build` sends the profile's runner, else the top-level one, inside the
`profile` dispatch input, and the workflow's `runs-on` reads it from there.
Changing `runner` in `builder.json` therefore takes effect on the next
dispatch without regenerating the workflow.
- A [tag-triggered build](#triggering-from-git-only) and `ios share` have no
profile input: they run on the runner `init` rendered. Run `builder init`
again after changing the top-level `runner`.
- Codemagic and Bitrise ignore `runner`; see
[Additional macOS Providers](#additional-macos-providers).

A self-hosted Mac keeps its state between jobs, so the workflow:

- does not switch Xcode: `setup-xcode` (which needs sudo) runs only on
GitHub-hosted runners, and a self-hosted one uses the Xcode selected with
`sudo xcode-select -s` (or `DEVELOPER_DIR` in the runner's `.env`), failing
early if there is none;
- creates the signing keychain in the job's temp directory, puts it in front
of the existing search list instead of replacing it, and in the always-run
cleanup restores the list, deletes the keychain and removes every
provisioning profile it installed;
- clears the previous run's IPA, archive and export from `build/`;
- runs `brew install` only for a tool that is missing (XcodeGen, CocoaPods).

Runner requirements:

| Needed for | Install |
|------------|---------|
| Every build | macOS with Xcode (and its iOS platform) selected, command line tools, `git`, `jq`, Homebrew |
| CocoaPods projects (React Native, Expo, Flutter plugins) | `pod` on `PATH`, else the job runs `brew install cocoapods` |
| React Native / Expo | nothing: `actions/setup-node` installs Node into the runner's tool cache |
| Flutter | nothing: `subosito/flutter-action` installs the SDK into the tool cache |
| Kotlin Multiplatform | nothing: `actions/setup-java` installs the JDK |
| XcodeGen projects | `xcodegen`, else `brew install xcodegen` |
| `ios share` | an iOS simulator runtime for the selected Xcode |

The runner user needs a login session for `security` and the keychain, so run
the runner as a LaunchAgent of a logged-in user (the `svc.sh install` default)
rather than as a LaunchDaemon. GitHub advises against self-hosted runners on
public repositories, where workflows from forks' pull requests could reach them.

## Supported Frameworks

| Framework | iOS Path | Auto-detected |
Expand Down Expand Up @@ -207,6 +279,7 @@ 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 (github, codemagic, bitrise, apple)
builder init # Set up workflows in current repo
builder init --runner self-hosted,macOS # ...running on your own Mac (or macos-15, ...)
builder update # Update builder to the latest release

# Building (builds the working tree, including uncommitted changes)
Expand Down Expand Up @@ -288,6 +361,7 @@ machine-readable output and never prompts, so agents and CI jobs can drive them.
"owner": "username",
"repo": "my-ios-app"
},
"runner": "macos-latest",
"ios": {
"path": "ios",
"scheme": "",
Expand Down Expand Up @@ -353,6 +427,7 @@ builder ios build --profile preview
| `scheme` | Overrides `ios.scheme` |
| `provider` | Overrides the top-level `provider` (`github`, `codemagic`, `bitrise`) |
| `env` | String map exported as environment variables on the runner before dependencies are installed and the app is built, so `pod install`, `npm install`, `flutter pub get`, Gradle and xcodebuild all see them |
| `runner` | GitHub only: overrides the top-level [`runner`](#self-hosted-runners) for builds with this profile, e.g. `"macos-15"` or `["self-hosted", "macOS", "ARM64"]` |

How a build's settings are resolved:

Expand Down
50 changes: 47 additions & 3 deletions cmd/builder/providers.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"os"
"path/filepath"
"strings"

"github.com/MobAI-App/ios-builder/internal/build"
"github.com/MobAI-App/ios-builder/internal/config"
Expand Down Expand Up @@ -68,6 +69,9 @@ func runProviderInit(cmd *cobra.Command) error {
ciCfg = cfg.Bitrise
}
ciCfg.AppID, ciCfg.Branch = appID, branch
if err := applyProviderMachine(cmd, name, &ciCfg); err != nil {
return err
}
if ciCfg.BuildWorkflow == "" {
ciCfg.BuildWorkflow = "ios-build"
}
Expand All @@ -89,7 +93,7 @@ func runProviderInit(cmd *cobra.Command) error {
var paths []string
customWorkflows := ciCfg.BuildWorkflow != "ios-build" || ciCfg.ShareWorkflow != "ios-share"
if !customWorkflows {
paths, err = workflow.WriteProviderFiles(".", name)
paths, err = workflow.WriteProviderFiles(".", name, &ciCfg)
if err != nil {
return err
}
Expand All @@ -109,14 +113,54 @@ func runProviderInit(cmd *cobra.Command) error {
if name == "codemagic" {
fmt.Println("Create an environment group named builder (add BUILDER=1 for unsigned builds); put signing/MobAI secrets there.")
}
if name == "bitrise" {
fmt.Println("Use bitrise.yml from the repository and select a macOS Xcode stack in the app settings. Put signing/MobAI secrets in the app Secrets tab.")
if name == "bitrise" && ciCfg.Stack == "" {
fmt.Println("Use bitrise.yml from the repository and select a macOS Xcode stack in the app settings (or pass --stack). Put signing/MobAI secrets in the app Secrets tab.")
} else if name == "bitrise" {
fmt.Println("Use bitrise.yml from the repository. Put signing/MobAI secrets in the app Secrets tab.")
}
fmt.Println("Then: builder ios build --provider " + name)
fmt.Println("App creation, repository connection, and token guide: https://github.com/MobAI-App/ios-builder/blob/main/docs/provider-setup.md")
return nil
}

// applyProviderMachine stores --runner as the Codemagic instance_type or the
// Bitrise machine_type_id, and --stack as the Bitrise stack. Values Builder
// does not know are kept with a warning; flags left out keep builder.json.
func applyProviderMachine(cmd *cobra.Command, provider string, ci *config.CIConfig) error {
runner, _ := cmd.Flags().GetString("runner")
stack, _ := cmd.Flags().GetString("stack")
if strings.Contains(runner, ",") {
return fmt.Errorf("--runner for %s is one machine type, not a list of labels", provider)
}
if stack != "" && provider != "bitrise" {
return fmt.Errorf("--stack applies to Bitrise only; Codemagic selects Xcode with environment.xcode")
}
field := "instance_type"
if provider == "bitrise" {
field = "machine_type_id"
}
for _, f := range []struct{ field, value string }{{field, runner}, {"stack", stack}} {
warning, err := config.CheckMachine(provider, f.field, f.value)
if err != nil {
return err
}
if warning != "" {
fmt.Println("Warning:", warning)
}
}
if runner != "" {
if provider == "bitrise" {
ci.MachineTypeID = runner
} else {
ci.InstanceType = runner
}
}
if stack != "" {
ci.Stack = stack
}
return nil
}

func init() {
cancelCmd := &cobra.Command{Use: "cancel", Short: "Cancel a submitted Codemagic or Bitrise run", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error {
cfg, err := loadConfig()
Expand Down
2 changes: 2 additions & 0 deletions cmd/builder/providers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ func providerInitCommand(name string) *cobra.Command {
cmd.Flags().String("app-id", name+"-app", "")
cmd.Flags().String("branch", "ci-main", "")
cmd.Flags().Bool("set-default", false, "")
cmd.Flags().String("runner", "", "")
cmd.Flags().String("stack", "", "")
return cmd
}

Expand Down
99 changes: 69 additions & 30 deletions cmd/builder/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,58 @@ func detectGitHubRepo(remoteName string) (owner, repo string, err error) {
return "", "", fmt.Errorf("could not parse GitHub URL from: %s", remoteURL)
}

// applyRunnerFlag stores --runner (one label or a comma list) as the
// top-level runner; without the flag builder.json's runner is kept.
func applyRunnerFlag(cmd *cobra.Command, cfg *config.Config) error {
if stack, _ := cmd.Flags().GetString("stack"); stack != "" {
return fmt.Errorf("--stack applies to Bitrise only (builder init --provider bitrise)")
}
if !cmd.Flags().Changed("runner") {
return cfg.Runner.Validate()
}
value, _ := cmd.Flags().GetString("runner")
runner, err := config.ParseRunner(value)
if err != nil {
return fmt.Errorf("--runner: %w", err)
}
cfg.Runner = runner
if w := runner.Warning(); w != "" {
fmt.Println("Warning:", w)
}
return nil
}

// writeGitHubWorkflows writes ios-build.yml and ios-share.yml under
// dir/.github/workflows with runner as their runs-on. The share workflow ships
// with the build one so `builder ios share` needs no extra setup; it is
// dispatch-only, so it costs nothing until used.
func writeGitHubWorkflows(dir string, runner config.Runner) ([]string, error) {
workflowDir := filepath.Join(dir, ".github", "workflows")
if err := os.MkdirAll(workflowDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create workflow directory: %w", err)
}
build, err := workflow.RenderWorkflow(runner)
if err != nil {
return nil, fmt.Errorf("failed to render workflow: %w", err)
}
share, err := workflow.RenderShareWorkflow(runner)
if err != nil {
return nil, fmt.Errorf("failed to render simulator workflow: %w", err)
}
var paths []string
for _, f := range []struct {
name string
data []byte
}{{"ios-build.yml", build}, {"ios-share.yml", share}} {
path := filepath.Join(workflowDir, f.name)
if err := os.WriteFile(path, f.data, 0644); err != nil {
return nil, fmt.Errorf("failed to write %s: %w", path, err)
}
paths = append(paths, path)
}
return paths, nil
}

func runInit(cmd *cobra.Command, args []string) error {
provider, _ := cmd.Flags().GetString("provider")
if provider != "" && provider != "github" {
Expand Down Expand Up @@ -430,46 +482,31 @@ func runInit(cmd *cobra.Command, args []string) error {
if jdkVersion != "" {
fmt.Printf("JDK: %s\n", jdkVersion)
}
fmt.Println()

// Create workflow file locally
fmt.Println("Creating workflow file...")
workflowDir := ".github/workflows"
if err := os.MkdirAll(workflowDir, 0755); err != nil {
return fmt.Errorf("failed to create workflow directory: %w", err)
cfg, err := config.NewManager().Load()
if err != nil && err != config.ErrConfigNotFound {
return err
}

workflowContent, err := workflow.GetWorkflowTemplate()
if err != nil {
return fmt.Errorf("failed to get workflow template: %w", err)
if cfg == nil {
cfg = &config.Config{Provider: "github"}
}

workflowPath := filepath.Join(workflowDir, "ios-build.yml")
if err := os.WriteFile(workflowPath, workflowContent, 0644); err != nil {
return fmt.Errorf("failed to write workflow file: %w", err)
if err := applyRunnerFlag(cmd, cfg); err != nil {
return err
}
fmt.Printf(" Created: %s\n", workflowPath)
fmt.Printf("Runner: %s\n", cfg.Runner)
fmt.Println()

// Ship the share workflow next to the build one so `builder ios share` needs
// no extra setup. Dispatch-only, so it costs nothing until used.
shareContent, err := workflow.GetShareWorkflowTemplate()
// Create workflow file locally
fmt.Println("Creating workflow file...")
paths, err := writeGitHubWorkflows(".", cfg.Runner)
if err != nil {
return fmt.Errorf("failed to get simulator workflow template: %w", err)
return err
}
sharePath := filepath.Join(workflowDir, "ios-share.yml")
if err := os.WriteFile(sharePath, shareContent, 0644); err != nil {
return fmt.Errorf("failed to write simulator workflow file: %w", err)
for _, p := range paths {
fmt.Printf(" Created: %s\n", p)
}
fmt.Printf(" Created: %s\n", sharePath)

// Save config
cfg, err := config.NewManager().Load()
if err != nil && err != config.ErrConfigNotFound {
return err
}
if cfg == nil {
cfg = &config.Config{Provider: "github"}
}
cfg.Project, cfg.Platform = projectName, "ios"
cfg.GitHub = config.GitHubConfig{Owner: githubOwner, Repo: repoName}
cfg.IOS.Path, cfg.IOS.Scheme = iosPath, scheme
Expand Down Expand Up @@ -626,6 +663,8 @@ func init() {
initCmd.Flags().String("app-id", "", "Codemagic app ID or Bitrise app slug")
initCmd.Flags().String("branch", "", "Committed branch containing the provider workflow")
initCmd.Flags().Bool("set-default", false, "Make this provider the project default")
initCmd.Flags().String("runner", "", "Machine to build on: GitHub runs-on label or comma list (macos-latest, macos-15, self-hosted, self-hosted,macOS,ARM64), Codemagic instance_type, or Bitrise machine_type_id")
initCmd.Flags().String("stack", "", "Bitrise stack (e.g. osx-xcode-16.2.x)")

// iOS build command flags
iosBuildCmd.Flags().StringP("output", "o", "dist", "Output directory for IPA")
Expand Down
Loading
Loading