Skip to content
Closed
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
47 changes: 44 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ go install ./cmd/builder
./builder auth github # Authenticate with GitHub (OAuth device flow)
./builder init # Set up workflow in current repo
./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
./builder dev rn # React Native hot reload with MobAI
./builder dev kmp # Kotlin Multiplatform install + launch (no hot reload)
Expand Down Expand Up @@ -134,6 +135,30 @@ internal/
submodule commit that only exists locally fails checkout on the runner.
- **Run Correlation**: `run-name` carries the build ID so concurrent builds cannot adopt each
other's runs
- **Build Profiles**: `profiles.<name>` in `builder.json` overrides `ios.configuration`, `ios.scheme`,
`ios.signing` and `provider`, and adds `env` and the reserved `distribution`. `ios build` and
`ios share` take `--profile`; without it `defaultProfile` applies, and without that the top-level
settings are used unchanged. `config.ResolveProfile` does the merge, `Coordinator.settings` layers
`--unsigned`/`--provider` on top, and `Progress.Settings` prints the result before dispatch.
`Profile.Signing` is a `*bool` so a profile's `false` can override a top-level `true`; the jq in
`Resolve parameters` needs an explicit `!= null` test for the same reason, since `//` treats
`false` as missing. The runner receives env as one JSON object: the `profile` dispatch input
(`{"name","env","distribution"}`, one input to stay under the ten-input limit) on GitHub, and
`BUILD_ENV` plus `DISTRIBUTION` variables for `runner.sh`. Each entry is base64-encoded per
key and value on the runner (jq drops NUL bytes, and a key with a space must not split), the
`$GITHUB_ENV` heredoc uses a random delimiter so no value line can end it early, names are
checked against `^[A-Za-z_][A-Za-z0-9_]*$`, and `ResolveProfile` rejects the names the runners
own (`reservedEnv` and `reservedEnvPrefixes` in `internal/config/profile.go`: the runner
parameters, the signing secrets, `PATH`/`HOME`/`DEVELOPER_DIR`, and the `GITHUB_`, `RUNNER_`,
`CM_`, `BITRISE_`, `BUILDER_` namespaces; keep that list in step with what `runner.sh` and the
workflows read). `profile` is only sent when a profile is selected (`--profile` or
`defaultProfile`), because a workflow file from before profiles rejects a dispatch with an input
it does not declare; `triggerError` turns that 422 into a "run `builder init`" message. On
GitHub the profile's env lands in `$GITHUB_ENV`, and step-level `env:` (the signing secrets, the
build parameters) takes precedence over it. `distribution` reaches the runner as the
`steps.params.outputs.distribution` output on GitHub and the `DISTRIBUTION` variable for
`runner.sh`; the export step is meant to consume it under those names.
`env` is build-time configuration, not secrets: it sits in `builder.json` and in the run's inputs
- **Flutter Detection**: Auto-detects Flutter projects, runs `flutter pub get`, uses `Runner` scheme
- **DerivedData Caching**: `restore` keys on `github.run_id` and only the prefix in `restore-keys`
ever hits, so every run must pair with a `cache/save` step or later builds stay cold. `ios-share`
Expand Down Expand Up @@ -178,22 +203,38 @@ internal/
"project": "MyApp",
"platform": "ios",
"github": { "owner": "username", "repo": "my-ios-app" },
"ios": { "path": "ios", "scheme": "" }
"ios": { "path": "ios", "scheme": "" },
"defaultProfile": "development",
"profiles": {
"development": { "configuration": "Debug", "signing": false },
"preview": { "configuration": "Release", "signing": true, "env": { "API_URL": "https://staging.example.com" } },
"production": { "configuration": "Release", "signing": true, "scheme": "MyApp", "provider": "codemagic", "distribution": "app-store" }
}
}
```

`profiles` and `defaultProfile` are optional. A profile's fields are `configuration`, `scheme`,
`signing`, `provider`, `env` (string map) and `distribution` (`development`, `ad-hoc`, `app-store`,
`enterprise`; reserved for the export step, passed through but not applied yet). `runner` and
`submit` are planned for the same struct (`config.Profile`) but not read.

## Workflow Features

The embedded workflow template (`internal/workflow/templates/ios-build.yml`):
- Triggered via `workflow_dispatch` with `build_id`, `snapshot_ref`, `ios_path`, `scheme`
- Triggered via `workflow_dispatch` with `build_id`, `snapshot_ref`, `ios_path`, `scheme`,
`use_signing`, `configuration`, `flutter_version`, `jdk_version` and `profile` (nine of the ten
inputs GitHub allows; the last slot is meant for item 5's `build_number`, so add nothing else
without combining)
- Dispatch runs the workflow from the **default branch**, so edits to the workflow file itself
only take effect once pushed there — unlike app sources, which come from the snapshot ref
- Checks out `snapshot_ref` over the default-branch checkout when set
- Also triggered by pushing a tag `ios-build/<build-id>` (`ios-share/<build-id>` for the share
workflow) for environments without GitHub API access. Push events run the workflow file from
the tagged commit, `inputs` are empty, so a `Resolve parameters` step reads `ios_path`, `scheme`,
`use_signing`, `configuration`, `flutter_version` and `jdk_version` from `builder.json` in the
tagged tree; every later step reads `steps.params.outputs.*`, never `inputs.*`. The job deletes
tagged tree, applying the profile named by `defaultProfile` (a tag cannot pick one per run);
every later step reads `steps.params.outputs.*`, never `inputs.*`. The same step exports the
profile's `env` to `$GITHUB_ENV` and outputs `profile` and `distribution`. 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`
Expand Down
68 changes: 67 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,9 @@ The run is named after the tag. Build settings come from `builder.json` in the
tagged commit (`ios.path`, `ios.scheme`, `ios.signing`, `ios.configuration`,
`flutter.version`, `kmp.jdkVersion`), the simulator stays available for the
default 30 minutes, and the tag is deleted when the run ends. The IPA is
attached to the run as an artifact.
attached to the run as an artifact. A tag carries no flags, so a tag build
cannot pick a [profile](#build-profiles) per run; it applies the profile named
by `defaultProfile`, if there is one.

## Additional macOS Providers

Expand Down Expand Up @@ -174,6 +176,7 @@ builder update # Update builder to the latest release
builder ios build # Trigger build and download IPA to ./dist/
builder ios build --unsigned # Build without code signing (if signing is configured)
builder ios build --provider codemagic # Build on another provider (also: bitrise)
builder ios build --profile production # Build with a profile from builder.json

# Simulator (free, needs a MOBAI_API_KEY secret)
builder ios share # Try the build on a simulator in the MobAI app
Expand Down Expand Up @@ -243,6 +246,69 @@ builder signing setup # Upload code signing secrets to GitHub
| `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` |

### Build Profiles

Profiles are named sets of build settings, in the spirit of `eas.json`, selected
with `--profile` on `ios build` and `ios share`:

```json
{
"ios": { "path": "ios", "configuration": "Debug" },
"defaultProfile": "development",
"profiles": {
"development": { "configuration": "Debug", "signing": false },
"preview": { "configuration": "Release", "signing": true,
"env": { "API_URL": "https://staging.example.com" } },
"production": { "configuration": "Release", "signing": true, "scheme": "MyApp",
"provider": "codemagic", "distribution": "app-store" }
}
}
```

```bash
builder ios build --profile preview
builder ios share --profile preview
```

| Field | Description |
|-------|-------------|
| `configuration` | Overrides `ios.configuration` |
| `scheme` | Overrides `ios.scheme` |
| `signing` | Overrides `ios.signing`; `false` in a profile turns signing off even when the top level has it on |
| `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 |
| `distribution` | Reserved: one of `development`, `ad-hoc`, `app-store`, `enterprise`. Validated and passed to the runner; the export step does not act on it yet |

How a build's settings are resolved:

- Without `--profile`, the profile named by `defaultProfile` applies. With
neither, the top-level `ios.*` and `provider` settings are used exactly as
before, so existing projects are unaffected.
- A profile only overrides the fields it sets; everything else comes from the
top level. An unknown profile name is an error that lists the available ones.
- `--unsigned` and `--provider` on the command line override the profile.
- The resolved settings (profile, configuration, scheme, signing, provider, env
names) are printed before anything is dispatched.
- `ios share` only takes the profile's scheme, provider and env: simulator
builds are always Debug and unsigned.

**`env` values are build-time configuration, not secrets.** They are stored in
`builder.json`, sent to the CI provider as plain workflow inputs, and visible in
the run's inputs and logs. Keep tokens and passwords in the provider's secrets
instead (`gh secret set` on GitHub, or the [Codemagic / Bitrise secrets
guide](docs/provider-secrets.md)); the build reads those as environment
variables too. Names the runner owns are rejected: its own parameters
(`SCHEME`, `CONFIGURATION`, `USE_SIGNING`, `BUILD_ENV`, ...), the signing
secrets, `PATH`, `HOME`, `DEVELOPER_DIR`, and anything starting with `GITHUB_`,
`RUNNER_`, `CM_`, `BITRISE_` or `BUILDER_`.

Selecting a profile, with `--profile` or `defaultProfile`, needs the workflow
files from this version of Builder, which declare a `profile` input; an older
committed workflow rejects the dispatch. Run `builder init` again to refresh
`.github/workflows/ios-build.yml` and `ios-share.yml` (or `builder init
--provider ...` for `runner.sh`) in a project set up earlier, then commit and
push them to the default branch.

### MobAI Configuration

| Field | Description | Default |
Expand Down
38 changes: 33 additions & 5 deletions cmd/builder/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -471,7 +471,7 @@ func runInit(cmd *cobra.Command, args []string) error {

if buildErr == nil {
fmt.Println()
return runBuild(context.Background(), cfg, build.BuildOptions{
return runBuild(context.Background(), cfg, &build.BuildOptions{
OutputDir: "dist",
Timeout: 30 * time.Minute,
Remote: remoteName,
Expand Down Expand Up @@ -551,15 +551,31 @@ func init() {
iosBuildCmd.Flags().Bool("unsigned", false, "Build unsigned IPA (skip code signing even if configured)")
iosBuildCmd.Flags().StringP("remote", "r", "origin", "Git remote to push the working-tree snapshot to")
iosBuildCmd.Flags().String("provider", "", "Override CI provider (default github or builder.json provider)")
iosBuildCmd.Flags().String("profile", "", "Build profile from builder.json (default: defaultProfile, else the top-level ios settings)")
iosCmd.AddCommand(iosBuildCmd)

// iOS share command flags
iosShareCmd.Flags().Duration("duration", 30*time.Minute, "How long the simulator stays available while unused")
iosShareCmd.Flags().StringP("remote", "r", "origin", "Git remote to push the working-tree snapshot to")
iosShareCmd.Flags().String("provider", "", "Override CI provider (default github or builder.json provider)")
iosShareCmd.Flags().String("profile", "", "Build profile from builder.json; its scheme, provider and env apply to the simulator build")
iosCmd.AddCommand(iosShareCmd)
}

// effectiveProvider is the --provider flag, else the selected profile's
// provider, else builder.json's. The coordinator resolves the same chain; this
// exists so the GitHub client and signal handling agree with it.
func effectiveProvider(cfg *config.Config, profile, flag string) (string, error) {
if flag != "" {
return flag, nil
}
s, err := cfg.ResolveProfile(profile)
if err != nil {
return "", err
}
return s.Provider, nil
}

func runIOSBuild(cmd *cobra.Command, args []string) error {
cfg, err := loadConfig()
if err != nil {
Expand All @@ -574,13 +590,18 @@ func runIOSBuild(cmd *cobra.Command, args []string) error {
timeout, _ := cmd.Flags().GetDuration("timeout")
unsigned, _ := cmd.Flags().GetBool("unsigned")
remote, _ := cmd.Flags().GetString("remote")
provider, _ := cmd.Flags().GetString("provider")
providerFlag, _ := cmd.Flags().GetString("provider")
profile, _ := cmd.Flags().GetString("profile")

ctx := cmd.Context()
if ctx == nil {
ctx = context.Background()
}

provider, err := effectiveProvider(cfg, profile, providerFlag)
if err != nil {
return err
}
name, err := cfg.ProviderName(provider)
if err != nil {
return err
Expand All @@ -590,8 +611,9 @@ func runIOSBuild(cmd *cobra.Command, args []string) error {
ctx, stop = signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
defer stop()
}
return runBuild(ctx, cfg, build.BuildOptions{
return runBuild(ctx, cfg, &build.BuildOptions{
Provider: provider,
Profile: profile,
OutputDir: outputDir,
Timeout: timeout,
Unsigned: unsigned,
Expand All @@ -610,7 +632,8 @@ func runIOSShare(cmd *cobra.Command, args []string) error {

duration, _ := cmd.Flags().GetDuration("duration")
remote, _ := cmd.Flags().GetString("remote")
provider, _ := cmd.Flags().GetString("provider")
providerFlag, _ := cmd.Flags().GetString("provider")
profile, _ := cmd.Flags().GetString("profile")

ctx := cmd.Context()
if ctx == nil {
Expand All @@ -622,12 +645,17 @@ func runIOSShare(cmd *cobra.Command, args []string) error {
ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
defer stop()

provider, err := effectiveProvider(cfg, profile, providerFlag)
if err != nil {
return err
}
ghClient, err := clientForProvider(cfg, provider)
if err != nil {
return err
}
result, err := build.NewCoordinator(cfg, ghClient).Share(ctx, build.ShareOptions{
Provider: provider,
Profile: profile,
Duration: duration,
Remote: remote,
})
Expand All @@ -652,7 +680,7 @@ func runIOSShare(cmd *cobra.Command, args []string) error {
return nil
}

func runBuild(ctx context.Context, cfg *config.Config, opts build.BuildOptions) error {
func runBuild(ctx context.Context, cfg *config.Config, opts *build.BuildOptions) error {
ghClient, err := clientForProvider(cfg, opts.Provider)
if err != nil {
return err
Expand Down
6 changes: 4 additions & 2 deletions docs/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,8 +112,10 @@ builder ios build --provider bitrise --unsigned
builder ios build --provider github # explicit override
```

Provider selection is: command flag, then `builder.json`'s `provider`, then
`github`. Adding or logging into a provider does not change the default.
Provider selection is: command flag, then the selected build profile's
`provider` (see the README's Build Profiles section), then `builder.json`'s
`provider`, then `github`. Adding or logging into a provider does not change
the default.
To change it, edit `provider`, or pass `--set-default` when configuring a provider.

```json
Expand Down
Loading
Loading