diff --git a/CLAUDE.md b/CLAUDE.md index 9c2b1e1..e4f0f7d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 @@ -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 || }}` (`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 @@ -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" } } @@ -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 @@ -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 diff --git a/README.md b/README.md index cc24fe8..6a0ea2f 100644 --- a/README.md +++ b/README.md @@ -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 | @@ -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) @@ -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": "", @@ -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: diff --git a/cmd/builder/providers.go b/cmd/builder/providers.go index 113e282..daf266e 100644 --- a/cmd/builder/providers.go +++ b/cmd/builder/providers.go @@ -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" @@ -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" } @@ -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 } @@ -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() diff --git a/cmd/builder/providers_test.go b/cmd/builder/providers_test.go index 29358d7..46bcd09 100644 --- a/cmd/builder/providers_test.go +++ b/cmd/builder/providers_test.go @@ -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 } diff --git a/cmd/builder/root.go b/cmd/builder/root.go index 0dcd66e..4ee2706 100644 --- a/cmd/builder/root.go +++ b/cmd/builder/root.go @@ -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" { @@ -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 @@ -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") diff --git a/cmd/builder/runner_test.go b/cmd/builder/runner_test.go new file mode 100644 index 0000000..b150e2e --- /dev/null +++ b/cmd/builder/runner_test.go @@ -0,0 +1,124 @@ +package main + +import ( + "os" + "reflect" + "strings" + "testing" + + "github.com/MobAI-App/ios-builder/internal/config" + "github.com/spf13/cobra" +) + +func runnerFlagCommand() *cobra.Command { + cmd := &cobra.Command{} + cmd.Flags().String("runner", "", "") + cmd.Flags().String("stack", "", "") + return cmd +} + +func TestApplyRunnerFlag(t *testing.T) { + cfg := &config.Config{Runner: config.Runner{"macos-15"}} + if err := applyRunnerFlag(runnerFlagCommand(), cfg); err != nil || !reflect.DeepEqual(cfg.Runner, config.Runner{"macos-15"}) { + t.Fatalf("no flag changed the runner: %v %v", cfg.Runner, err) + } + cmd := runnerFlagCommand() + _ = cmd.Flags().Set("runner", "self-hosted,macOS,ARM64") + if err := applyRunnerFlag(cmd, cfg); err != nil || !reflect.DeepEqual(cfg.Runner, config.Runner{"self-hosted", "macOS", "ARM64"}) { + t.Fatalf("runner = %v, %v", cfg.Runner, err) + } + cmd = runnerFlagCommand() + _ = cmd.Flags().Set("runner", "macos-latest") + if err := applyRunnerFlag(cmd, cfg); err != nil || !reflect.DeepEqual(cfg.Runner, config.Runner{"macos-latest"}) { + t.Fatalf("runner = %v, %v", cfg.Runner, err) + } + cmd = runnerFlagCommand() + _ = cmd.Flags().Set("runner", "self-hosted,,x") + if err := applyRunnerFlag(cmd, cfg); err == nil { + t.Fatal("empty label accepted") + } + cmd = runnerFlagCommand() + _ = cmd.Flags().Set("stack", "osx-xcode-16.2.x") + if err := applyRunnerFlag(cmd, cfg); err == nil { + t.Fatal("--stack accepted for GitHub") + } +} + +func TestWriteGitHubWorkflowsRendersRunner(t *testing.T) { + dir := t.TempDir() + paths, err := writeGitHubWorkflows(dir, config.Runner{"self-hosted", "macOS"}) + if err != nil { + t.Fatal(err) + } + if len(paths) != 2 { + t.Fatalf("paths = %v", paths) + } + build, _ := os.ReadFile(paths[0]) + share, _ := os.ReadFile(paths[1]) + if !strings.Contains(string(build), `runs-on: ${{ fromJSON(inputs.profile || '{}').runner || fromJSON('["self-hosted","macOS"]') }}`) { + t.Error("ios-build.yml runs-on not rendered") + } + if !strings.Contains(string(share), `runs-on: ["self-hosted","macOS"]`) { + t.Error("ios-share.yml runs-on not rendered") + } +} + +func TestProviderInitMachine(t *testing.T) { + chdir(t) + mgr := config.NewManager() + if err := mgr.Save(&config.Config{Project: "App", GitHub: config.GitHubConfig{Owner: "owner", Repo: "repo"}}); err != nil { + t.Fatal(err) + } + cmd := providerInitCommand("codemagic") + _ = cmd.Flags().Set("runner", "mac_mini_m4") + if err := runProviderInit(cmd); err != nil { + t.Fatal(err) + } + cmd = providerInitCommand("bitrise") + _ = cmd.Flags().Set("runner", "g2.mac.large") + _ = cmd.Flags().Set("stack", "osx-xcode-16.2.x") + if err := runProviderInit(cmd); err != nil { + t.Fatal(err) + } + got, err := mgr.Load() + if err != nil { + t.Fatal(err) + } + if got.Codemagic.InstanceType != "mac_mini_m4" || got.Bitrise.MachineTypeID != "g2.mac.large" || got.Bitrise.Stack != "osx-xcode-16.2.x" { + t.Fatalf("machines not saved: %+v %+v", got.Codemagic, got.Bitrise) + } + cm, _ := os.ReadFile("codemagic.yaml") + if strings.Count(string(cm), "instance_type: mac_mini_m4") != 2 { + t.Errorf("codemagic.yaml:\n%s", cm) + } + br, _ := os.ReadFile("bitrise.yml") + if strings.Count(string(br), "machine_type_id: g2.mac.large") != 3 || strings.Count(string(br), "stack: osx-xcode-16.2.x") != 3 { + t.Errorf("bitrise.yml:\n%s", br) + } + + // Re-running without the flags keeps what builder.json has. + if err := runProviderInit(providerInitCommand("codemagic")); err != nil { + t.Fatal(err) + } + cm, _ = os.ReadFile("codemagic.yaml") + if !strings.Contains(string(cm), "instance_type: mac_mini_m4") { + t.Error("re-init dropped the instance type") + } + + for _, tt := range []struct{ provider, runner, stack string }{ + {"github-labels", "self-hosted,macOS", ""}, + {"codemagic", "", "osx-xcode-16.2.x"}, + {"codemagic", "mac mini", ""}, + } { + provider := tt.provider + if provider == "github-labels" { + provider = "codemagic" + } + cmd := providerInitCommand(provider) + _ = cmd.Flags().Set("runner", tt.runner) + _ = cmd.Flags().Set("stack", tt.stack) + if err := runProviderInit(cmd); err == nil { + t.Errorf("%+v accepted", tt) + } + } +} diff --git a/docs/providers.md b/docs/providers.md index 9e8d7f7..9951bd7 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -88,14 +88,17 @@ Use a personal account and a YAML-configured app. Create an environment group named `builder` accessible to that app; add `BUILDER=1` if you only need unsigned builds. Store any signing/MobAI secrets in that group. The generated workflows use `mac_mini_m2`, the machine eligible for personal free minutes, and have no -automatic push triggers. +automatic push triggers. `init --provider codemagic --runner ` +(saved as `codemagic.instance_type`) picks another machine. ### Bitrise Use a Hobby app connected to your repository. Enable **configuration from the repository** so Bitrise loads the committed `bitrise.yml`; dashboard-only YAML -will not pick up these files. Select a macOS Xcode stack in the app settings. -The workflow requests `g2.mac.medium`. Disable any automatically generated push +will not pick up these files. Select a macOS Xcode stack in the app settings, +or pass `--stack` to `init` to put one in `bitrise.yml` (`bitrise.stack`). +The workflow requests `g2.mac.medium`; `--runner ` (saved as +`bitrise.machine_type_id`) picks another. Disable any automatically generated push triggers if you only want builds explicitly started by Builder. Add secrets in the app's Secrets settings. Keep the app's total build timeout at 90 minutes or less; IPA/script limits are also included in the generated workflow. Unsigned diff --git a/internal/build/coordinator.go b/internal/build/coordinator.go index 4fd5613..c047dd0 100644 --- a/internal/build/coordinator.go +++ b/internal/build/coordinator.go @@ -187,7 +187,7 @@ func (c *Coordinator) Build(ctx context.Context, opts *BuildOptions) (*BuildResu // Generate build ID buildID := uuid.New().String()[:8] c.progress.Start(buildID) - c.progress.Settings(settings, name) + c.progress.Settings(settings, name, c.config.RunnerName(name, settings)) // Step 1: Snapshot the working tree so the build matches what's on disk c.progress.Update(PhaseSnapshot, "Snapshotting working tree...") diff --git a/internal/build/inputs_test.go b/internal/build/inputs_test.go index 6aeeb82..b3545dd 100644 --- a/internal/build/inputs_test.go +++ b/internal/build/inputs_test.go @@ -112,6 +112,37 @@ func TestGitHubInputsMapping(t *testing.T) { } } +// The runner reaches runs-on through the profile input: a profile's runner +// wins over the top-level one, and a top-level runner goes even without a +// profile, under an empty name the workflow reads as no profile. +func TestRunnerTravelsInProfileInput(t *testing.T) { + cfg := profiledConfig() + cfg.Runner = config.Runner{"self-hosted", "macOS"} + cfg.Profiles["office"] = config.Profile{Runner: config.Runner{"macos-15"}} + c := NewCoordinatorWithOutput(cfg, nil, io.Discard) + for profile, want := range map[string]struct{ name, runner string }{ + "": {"", `["self-hosted","macOS"]`}, + "preview": {"preview", `["self-hosted","macOS"]`}, + "office": {"office", `"macos-15"`}, + } { + s, _, err := c.settings(profile, "", false) + if err != nil { + t.Fatal(err) + } + got := c.buildInputs("abcdef12", "ref", s, "") + var in struct { + Name string `json:"name"` + Runner json.RawMessage `json:"runner"` + } + if err := json.Unmarshal([]byte(got["profile"]), &in); err != nil { + t.Fatalf("%q: profile input %q: %v", profile, got["profile"], err) + } + if in.Name != want.name || string(in.Runner) != want.runner { + t.Errorf("%q: profile input %s", profile, got["profile"]) + } + } +} + func TestTriggerErrorExplainsOldWorkflow(t *testing.T) { rejected := errors.New(`failed to trigger workflow (status 422): {"message":"Unexpected inputs provided: [\"profile\"]"}`) err := triggerError(rejected, map[string]string{"profile": "{}"}, WorkflowFile) @@ -157,8 +188,8 @@ func TestSettingsPrinted(t *testing.T) { var out bytes.Buffer p := NewProgress(&out) p.Start("abcdef12") - p.Settings(&config.BuildSettings{Profile: "preview", Configuration: "Release", Signing: true, Env: map[string]string{"B": "2", "A": "1"}, Distribution: "ad-hoc"}, "github") - for _, want := range []string{"Profile: preview", "Configuration: Release", "Scheme: (auto-detected)", "Signing: signed (set AD_HOC)", "Provider: github", "Env: A, B", "Distribution: ad-hoc"} { + p.Settings(&config.BuildSettings{Profile: "preview", Configuration: "Release", Signing: true, Env: map[string]string{"B": "2", "A": "1"}, Distribution: "ad-hoc"}, "github", "self-hosted,macOS") + for _, want := range []string{"Profile: preview", "Configuration: Release", "Scheme: (auto-detected)", "Signing: signed (set AD_HOC)", "Provider: github", "Runner: self-hosted,macOS", "Env: A, B", "Distribution: ad-hoc"} { if !strings.Contains(out.String(), want) { t.Errorf("missing %q in:\n%s", want, out.String()) } @@ -170,12 +201,12 @@ func TestSettingsPrinted(t *testing.T) { // Signed without a distribution is the legacy path with the unsuffixed // secrets; --unsigned leaves a distribution build unsigned. out.Reset() - p.Settings(&config.BuildSettings{Signing: true}, "github") + p.Settings(&config.BuildSettings{Signing: true}, "github", "") if !strings.Contains(out.String(), "Signing: signed (unsuffixed IOS_* secrets)") { t.Errorf("legacy path not printed:\n%s", out.String()) } out.Reset() - p.Settings(&config.BuildSettings{Distribution: "store"}, "github") + p.Settings(&config.BuildSettings{Distribution: "store"}, "github", "") if !strings.Contains(out.String(), "Signing: unsigned") || strings.Contains(out.String(), "set STORE") { t.Errorf("unsigned build printed a signing set:\n%s", out.String()) } diff --git a/internal/build/progress.go b/internal/build/progress.go index f4c736a..1aef73c 100644 --- a/internal/build/progress.go +++ b/internal/build/progress.go @@ -73,7 +73,8 @@ func (p *Progress) Start(buildID string) { // Settings prints what the job will run with, before anything is dispatched, // so a wrong profile or flag is visible without opening the provider's logs. // It completes the header that Start begins. -func (p *Progress) Settings(s *config.BuildSettings, provider string) { +// runner is what the provider runs the job on (config.RunnerName). +func (p *Progress) Settings(s *config.BuildSettings, provider, runner string) { p.mu.Lock() defer p.mu.Unlock() @@ -95,6 +96,9 @@ func (p *Progress) Settings(s *config.BuildSettings, provider string) { fmt.Fprintf(p.writer, " Scheme: %s\n", orDefault(s.Scheme, "(auto-detected)")) fmt.Fprintf(p.writer, " Signing: %s\n", signing) fmt.Fprintf(p.writer, " Provider: %s\n", provider) + if runner != "" { + fmt.Fprintf(p.writer, " Runner: %s\n", runner) + } if len(s.Env) > 0 { keys := slices.Sorted(maps.Keys(s.Env)) fmt.Fprintf(p.writer, " Env: %s\n", strings.Join(keys, ", ")) diff --git a/internal/build/remote.go b/internal/build/remote.go index 4f48493..518dc4d 100644 --- a/internal/build/remote.go +++ b/internal/build/remote.go @@ -42,9 +42,11 @@ func RemoteProvider(cfg *config.Config, override string) (ci.Provider, config.CI } switch name { case "codemagic": - return ci.NewCodemagic(cfgCI, token), cfgCI, nil + p := ci.NewCodemagic(&cfgCI, token) + return p, cfgCI, nil case "bitrise": - return ci.NewBitrise(cfgCI, token), cfgCI, nil + p := ci.NewBitrise(&cfgCI, token) + return p, cfgCI, nil default: return nil, cfgCI, fmt.Errorf("%s is not an external CI provider", name) } @@ -133,7 +135,7 @@ func (c *Coordinator) buildRemote(ctx context.Context, opts *BuildOptions, s *co started := time.Now() buildID := uuid.New().String()[:8] c.progress.Start(buildID) - c.progress.Settings(s, p.Name()) + c.progress.Settings(s, p.Name(), c.config.RunnerName(p.Name(), s)) ref, sha, err := c.pushSnapshot(ctx, opts.Remote, buildID) if err != nil { return nil, err diff --git a/internal/build/remote_test.go b/internal/build/remote_test.go index fff8fc5..ffa2782 100644 --- a/internal/build/remote_test.go +++ b/internal/build/remote_test.go @@ -156,7 +156,7 @@ func TestRemoteSnapshotLifecycle(t *testing.T) { } return &http.Response{StatusCode: code, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body)), Request: r}, nil }) - c.provider = ci.NewCodemagic(cfg.Codemagic, "test-token") + c.provider = ci.NewCodemagic(&cfg.Codemagic, "test-token") } var result *BuildResult diff --git a/internal/ci/bitrise.go b/internal/ci/bitrise.go index a8fa6ef..4441928 100644 --- a/internal/ci/bitrise.go +++ b/internal/ci/bitrise.go @@ -18,8 +18,8 @@ type Bitrise struct { baseURL string } -func NewBitrise(cfg config.CIConfig, token string) *Bitrise { - return &Bitrise{api: newAPI(token, "Authorization"), config: cfg, baseURL: "https://api.bitrise.io/v0.1"} +func NewBitrise(cfg *config.CIConfig, token string) *Bitrise { + return &Bitrise{api: newAPI(token, "Authorization"), config: *cfg, baseURL: "https://api.bitrise.io/v0.1"} } func (*Bitrise) Name() string { return "bitrise" } func (b *Bitrise) buildsURL() string { diff --git a/internal/ci/codemagic.go b/internal/ci/codemagic.go index f401abe..279931c 100644 --- a/internal/ci/codemagic.go +++ b/internal/ci/codemagic.go @@ -15,8 +15,8 @@ type Codemagic struct { dispatchURL, statusURL string } -func NewCodemagic(cfg config.CIConfig, token string) *Codemagic { - return &Codemagic{api: newAPI(token, "x-auth-token"), config: cfg, dispatchURL: "https://api.codemagic.io", statusURL: "https://codemagic.io/api/v3"} +func NewCodemagic(cfg *config.CIConfig, token string) *Codemagic { + return &Codemagic{api: newAPI(token, "x-auth-token"), config: *cfg, dispatchURL: "https://api.codemagic.io", statusURL: "https://codemagic.io/api/v3"} } func (*Codemagic) Name() string { return "codemagic" } func (c *Codemagic) Start(ctx context.Context, req Request) (Run, error) { diff --git a/internal/ci/providers_test.go b/internal/ci/providers_test.go index 5c0962a..1605fd8 100644 --- a/internal/ci/providers_test.go +++ b/internal/ci/providers_test.go @@ -22,7 +22,7 @@ func response(r *http.Request, code int, body string) *http.Response { } func TestCodemagicContract(t *testing.T) { - c := NewCodemagic(config.CIConfig{AppID: "app", Branch: "release"}, "secret") + c := NewCodemagic(&config.CIConfig{AppID: "app", Branch: "release"}, "secret") c.api.retryDelay = time.Millisecond c.api.http.Transport = transportFunc(func(r *http.Request) (*http.Response, error) { if r.Header.Get("x-auth-token") != "secret" { @@ -80,7 +80,7 @@ func TestCodemagicStates(t *testing.T) { {"failed", true, false, false}, {"canceled", true, false, false}, {"timeout", true, false, false}, {"skipped", true, false, false}, {"", false, false, true}, {"success", false, false, true}, } { t.Run(tt.state, func(t *testing.T) { - c := NewCodemagic(config.CIConfig{}, "token") + c := NewCodemagic(&config.CIConfig{}, "token") c.api.retryDelay = time.Millisecond c.api.http.Transport = transportFunc(func(r *http.Request) (*http.Response, error) { return response(r, 200, `{"data":{"status":"`+tt.state+`"}}`), nil @@ -95,7 +95,7 @@ func TestCodemagicStates(t *testing.T) { func TestBitriseTriggerFormatsAndLiteralInputs(t *testing.T) { for _, body := range []string{`{"build_slug":"run","status":"ok"}`, `{"results":[{"build_slug":"run","status":"ok"}]}`} { - b := NewBitrise(config.CIConfig{AppID: "app", Branch: "main"}, "secret") + b := NewBitrise(&config.CIConfig{AppID: "app", Branch: "main"}, "secret") b.api.retryDelay = time.Millisecond b.api.http.Transport = transportFunc(func(r *http.Request) (*http.Response, error) { if r.Header.Get("Authorization") != "secret" || r.URL.Path != "/v0.1/apps/app/builds" { @@ -128,7 +128,7 @@ func TestBitriseTriggerFormatsAndLiteralInputs(t *testing.T) { } func TestBitriseArtifactsPaginationAndDownload(t *testing.T) { - b := NewBitrise(config.CIConfig{AppID: "app"}, "secret") + b := NewBitrise(&config.CIConfig{AppID: "app"}, "secret") b.api.retryDelay = time.Millisecond calls := 0 b.api.http.Transport = transportFunc(func(r *http.Request) (*http.Response, error) { @@ -173,7 +173,7 @@ func TestBitriseArtifactsPaginationAndDownload(t *testing.T) { func TestBitriseUnsignedArtifactNames(t *testing.T) { for _, name := range []string{"app.ipa", "app.ipa.zip", "logs.zip"} { t.Run(name, func(t *testing.T) { - b := NewBitrise(config.CIConfig{AppID: "app"}, "secret") + b := NewBitrise(&config.CIConfig{AppID: "app"}, "secret") b.api.retryDelay = time.Millisecond b.api.http.Transport = transportFunc(func(r *http.Request) (*http.Response, error) { if strings.HasSuffix(r.URL.Path, "/artifacts") { @@ -200,7 +200,7 @@ func TestBitriseUnsignedArtifactNames(t *testing.T) { func TestBitriseAbortStates(t *testing.T) { for _, state := range []string{"0", "1", "2", "3", "4", "null", "5"} { t.Run(state, func(t *testing.T) { - b := NewBitrise(config.CIConfig{AppID: "app"}, "secret") + b := NewBitrise(&config.CIConfig{AppID: "app"}, "secret") b.api.retryDelay = time.Millisecond b.api.http.Transport = transportFunc(func(r *http.Request) (*http.Response, error) { if strings.HasSuffix(r.URL.Path, "/artifacts") { @@ -336,7 +336,7 @@ func TestDispatchRejectionClassification(t *testing.T) { } func TestBitriseCompletedRunDoesNotRequireArtifactsToCancel(t *testing.T) { - b := NewBitrise(config.CIConfig{AppID: "app"}, "secret") + b := NewBitrise(&config.CIConfig{AppID: "app"}, "secret") b.api.retryDelay = time.Millisecond b.api.http.Transport = transportFunc(func(r *http.Request) (*http.Response, error) { if r.Method != "GET" || strings.Contains(r.URL.Path, "artifacts") { @@ -380,8 +380,8 @@ func TestMalformedStatusRecovers(t *testing.T) { for _, malformed := range []string{"maintenance", `{}`, `{"data":{}}`, `{"data":{"status":`} { t.Run(provider+"/"+malformed, func(t *testing.T) { var p Provider - c := NewCodemagic(config.CIConfig{}, "token") - b := NewBitrise(config.CIConfig{}, "token") + c := NewCodemagic(&config.CIConfig{}, "token") + b := NewBitrise(&config.CIConfig{}, "token") c.api.retryDelay, b.api.retryDelay = time.Millisecond, time.Millisecond calls := 0 transport := transportFunc(func(r *http.Request) (*http.Response, error) { @@ -409,7 +409,7 @@ func TestMalformedStatusRecovers(t *testing.T) { } func TestBitriseAbortReservesTimeAfterRateLimitedStatus(t *testing.T) { - b := NewBitrise(config.CIConfig{AppID: "app"}, "secret") + b := NewBitrise(&config.CIConfig{AppID: "app"}, "secret") aborts := 0 b.api.http.Transport = transportFunc(func(r *http.Request) (*http.Response, error) { if r.Method == "POST" && strings.HasSuffix(r.URL.Path, "/abort") { diff --git a/internal/config/profile.go b/internal/config/profile.go index 86c42ec..93ef00d 100644 --- a/internal/config/profile.go +++ b/internal/config/profile.go @@ -24,6 +24,9 @@ type BuildSettings struct { // Distribution is the profile's distribution, canonical (internal is // ad-hoc); empty for unsigned builds and the legacy path. Distribution string + // Runner is the GitHub runs-on: the profile's runner, else the top-level + // one; empty means the workflow's rendered default. + Runner Runner } // reservedEnv names the variables the runners, the shell and the CI services @@ -81,6 +84,10 @@ func (c *Config) ResolveProfile(name string) (BuildSettings, error) { Scheme: c.IOS.Scheme, Signing: c.IOS.Signing, Provider: c.Provider, + Runner: c.Runner, + } + if err := c.Runner.Validate(); err != nil { + return s, err } source := "profile" if name == "" { @@ -108,6 +115,9 @@ func (c *Config) ResolveProfile(name string) (BuildSettings, error) { return s, fmt.Errorf("profile %q: env name %q is reserved for the runner", name, k) } } + if err := p.Runner.Validate(); err != nil { + return s, fmt.Errorf("profile %q: %w", name, err) + } s.Profile = name s.Distribution = distribution s.Signing = distribution != "" @@ -128,6 +138,9 @@ func (c *Config) ResolveProfile(name string) (BuildSettings, error) { if len(p.Env) > 0 { s.Env = p.Env } + if len(p.Runner) > 0 { + s.Runner = p.Runner + } return s, nil } @@ -142,11 +155,14 @@ func (s *BuildSettings) EnvJSON() string { return string(data) } -// ProfileInput encodes name, env and distribution as the single `profile` -// dispatch input, keeping the workflow under GitHub's limit of ten inputs. It -// is empty when no profile is selected, so older workflow files still work. +// ProfileInput encodes name, env, distribution and runner as the single +// `profile` dispatch input, keeping the workflow under GitHub's limit of ten +// inputs; the workflow's runs-on reads the runner from it. It is empty when no +// profile is selected and no runner is configured, so older workflow files +// still work. A runner without a profile goes with an empty name, which the +// workflow treats as no profile. func (s *BuildSettings) ProfileInput() string { - if s.Profile == "" { + if s.Profile == "" && len(s.Runner) == 0 { return "" } env := s.Env @@ -157,6 +173,7 @@ func (s *BuildSettings) ProfileInput() string { Name string `json:"name"` Env map[string]string `json:"env"` Distribution string `json:"distribution"` - }{s.Profile, env, s.Distribution}) + Runner Runner `json:"runner,omitempty"` + }{s.Profile, env, s.Distribution, s.Runner}) return string(data) } diff --git a/internal/config/runner.go b/internal/config/runner.go new file mode 100644 index 0000000..811af2e --- /dev/null +++ b/internal/config/runner.go @@ -0,0 +1,166 @@ +package config + +import ( + "encoding/json" + "fmt" + "regexp" + "slices" + "strings" +) + +// DefaultRunner is the GitHub Actions runner a workflow runs on when neither +// builder.json nor the profile names one. +const DefaultRunner = "macos-latest" + +// Default machines of the other providers, as the templates hardcoded them. +const ( + DefaultCodemagicInstance = "mac_mini_m2" + DefaultBitriseMachine = "g2.mac.medium" +) + +// Runner is the GitHub Actions `runs-on` of a build: one label +// ("macos-latest", "macos-15", "self-hosted") or several that a runner must +// all carry (["self-hosted", "macOS", "ARM64"]). In builder.json it is a +// string or an array of strings. +type Runner []string + +// runnerLabelRe keeps labels to what can be put into YAML and a workflow +// expression unquoted and unescaped. +var runnerLabelRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) + +// UnmarshalJSON accepts "label" or ["label", ...]; an empty string is no runner. +func (r *Runner) UnmarshalJSON(data []byte) error { + var one string + if err := json.Unmarshal(data, &one); err == nil { + if one == "" { + *r = nil + } else { + *r = Runner{one} + } + return nil + } + var many []string + if err := json.Unmarshal(data, &many); err != nil { + return fmt.Errorf("runner must be a label or an array of labels") + } + *r = Runner(many) + return nil +} + +// MarshalJSON writes a single label as a string, several as an array. +func (r Runner) MarshalJSON() ([]byte, error) { + if len(r) == 1 { + return json.Marshal(r[0]) + } + return json.Marshal([]string(r)) +} + +// ParseRunner reads the --runner flag: one label or a comma-separated list. +func ParseRunner(s string) (Runner, error) { + if strings.TrimSpace(s) == "" { + return nil, nil + } + var r Runner + for _, label := range strings.Split(s, ",") { + r = append(r, strings.TrimSpace(label)) + } + return r, r.Validate() +} + +// Validate checks that every label is non-empty and plain. +func (r Runner) Validate() error { + for _, label := range r { + if !runnerLabelRe.MatchString(label) { + return fmt.Errorf("runner label %q must be letters, digits, '.', '_' or '-'", label) + } + } + return nil +} + +// SelfHosted reports whether the labels select a self-hosted runner. +func (r Runner) SelfHosted() bool { + return slices.ContainsFunc(r, func(l string) bool { return strings.EqualFold(l, "self-hosted") }) +} + +// String is the comma-separated form --runner takes; empty is DefaultRunner. +func (r Runner) String() string { + if len(r) == 0 { + return DefaultRunner + } + return strings.Join(r, ",") +} + +// Warning is a note for labels that are valid but unlikely to build iOS: +// a GitHub-hosted runner that is not macOS. Empty when there is nothing to say. +func (r Runner) Warning() string { + if len(r) == 0 || r.SelfHosted() { + return "" + } + if len(r) == 1 && strings.HasPrefix(strings.ToLower(r[0]), "macos") { + return "" + } + return fmt.Sprintf("runner %q is not a GitHub-hosted macOS image (macos-*) and does not include self-hosted; iOS builds need macOS with Xcode", r.String()) +} + +// Known machines of the other providers. Others are passed through with a +// warning, since both services add types over time. +var ( + knownCodemagicInstances = []string{"mac_mini_m1", "mac_mini_m2", "mac_mini_m4", "mac_pro"} + knownBitriseMachines = []string{ + "g2.mac.medium", "g2.mac.large", "g2.mac.x-large", "g2.mac.4large", + "g2-m1.4core", "g2-m1.8core", "g2-m1-max.5core", "g2-m1-max.10core", + } + machineRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) +) + +// CheckMachine validates a Codemagic instance_type, Bitrise machine_type_id or +// Bitrise stack before it is written into a CI file. It returns a warning for a +// value Builder does not know, and an error for one that is not a plain name. +func CheckMachine(provider, field, value string) (warning string, err error) { + if value == "" { + return "", nil + } + if !machineRe.MatchString(value) { + return "", fmt.Errorf("%s %s %q must be letters, digits, '.', '_' or '-'", provider, field, value) + } + var known []string + switch { + case provider == "codemagic" && field == "instance_type": + known = knownCodemagicInstances + case provider == "bitrise" && field == "machine_type_id": + known = knownBitriseMachines + default: + return "", nil + } + if slices.Contains(known, value) { + return "", nil + } + return fmt.Sprintf("%s %s %q is not one Builder knows (%s); using it as given", provider, field, value, strings.Join(known, ", ")), nil +} + +// RunnerName is what a build on the provider runs on, for the build banner: +// the resolved GitHub runner, the Codemagic instance type, or the Bitrise +// machine type (with its stack when set). +func (c *Config) RunnerName(provider string, s *BuildSettings) string { + switch provider { + case "codemagic": + if c.Codemagic.InstanceType != "" { + return c.Codemagic.InstanceType + } + return DefaultCodemagicInstance + case "bitrise": + m := c.Bitrise.MachineTypeID + if m == "" { + m = DefaultBitriseMachine + } + if c.Bitrise.Stack != "" { + m += " (" + c.Bitrise.Stack + ")" + } + return m + default: + if s != nil && len(s.Runner) > 0 { + return s.Runner.String() + } + return DefaultRunner + } +} diff --git a/internal/config/runner_test.go b/internal/config/runner_test.go new file mode 100644 index 0000000..19de685 --- /dev/null +++ b/internal/config/runner_test.go @@ -0,0 +1,178 @@ +package config + +import ( + "encoding/json" + "reflect" + "strings" + "testing" +) + +func TestRunnerJSON(t *testing.T) { + for _, tt := range []struct { + name, in string + want Runner + out string + }{ + {"string", `{"runner":"macos-15"}`, Runner{"macos-15"}, `"runner":"macos-15"`}, + {"array", `{"runner":["self-hosted","macOS","ARM64"]}`, Runner{"self-hosted", "macOS", "ARM64"}, `"runner":["self-hosted","macOS","ARM64"]`}, + {"one-element array writes back as a string", `{"runner":["self-hosted"]}`, Runner{"self-hosted"}, `"runner":"self-hosted"`}, + {"empty string is unset", `{"runner":""}`, nil, ""}, + {"absent", `{}`, nil, ""}, + } { + t.Run(tt.name, func(t *testing.T) { + var cfg Config + if err := json.Unmarshal([]byte(tt.in), &cfg); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(cfg.Runner, tt.want) { + t.Fatalf("runner = %#v, want %#v", cfg.Runner, tt.want) + } + data, err := json.Marshal(cfg) + if err != nil { + t.Fatal(err) + } + if tt.out == "" { + if strings.Contains(string(data), `"runner"`) { + t.Fatalf("empty runner written: %s", data) + } + } else if !strings.Contains(string(data), tt.out) { + t.Fatalf("marshalled %s, want %s", data, tt.out) + } + }) + } + var cfg Config + if err := json.Unmarshal([]byte(`{"runner":3}`), &cfg); err == nil { + t.Fatal("a number was accepted as a runner") + } + if err := json.Unmarshal([]byte(`{"profiles":{"ci":{"runner":["self-hosted","ARM64"]}}}`), &cfg); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(cfg.Profiles["ci"].Runner, Runner{"self-hosted", "ARM64"}) { + t.Fatalf("profile runner = %#v", cfg.Profiles["ci"].Runner) + } +} + +func TestParseRunner(t *testing.T) { + for in, want := range map[string]Runner{ + "": nil, + "macos-latest": {"macos-latest"}, + "self-hosted, macOS,ARM64": {"self-hosted", "macOS", "ARM64"}, + } { + got, err := ParseRunner(in) + if err != nil || !reflect.DeepEqual(got, want) { + t.Errorf("ParseRunner(%q) = %#v, %v; want %#v", in, got, err, want) + } + } + for _, bad := range []string{"self-hosted,,macOS", "mac os", `macos"`, "-x", "a,${{ secrets.X }}"} { + if _, err := ParseRunner(bad); err == nil { + t.Errorf("ParseRunner(%q) accepted", bad) + } + } +} + +func TestRunnerWarning(t *testing.T) { + for _, r := range []Runner{nil, {"macos-latest"}, {"macos-15-xlarge"}, {"self-hosted"}, {"self-hosted", "Linux"}} { + if w := r.Warning(); w != "" { + t.Errorf("%v: unexpected warning %q", r, w) + } + } + for _, r := range []Runner{{"ubuntu-latest"}, {"macOS", "ARM64"}} { + if r.Warning() == "" { + t.Errorf("%v: no warning", r) + } + } +} + +func TestResolveProfileRunner(t *testing.T) { + cfg := &Config{ + Runner: Runner{"macos-15"}, + Profiles: map[string]Profile{ + "plain": {}, + "office": {Runner: Runner{"self-hosted", "macOS"}}, + "bad": {Runner: Runner{"a b"}}, + }, + } + for name, want := range map[string]Runner{"": {"macos-15"}, "plain": {"macos-15"}, "office": {"self-hosted", "macOS"}} { + s, err := cfg.ResolveProfile(name) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(s.Runner, want) { + t.Errorf("profile %q runner = %v, want %v", name, s.Runner, want) + } + } + if _, err := cfg.ResolveProfile("bad"); err == nil || !strings.Contains(err.Error(), `profile "bad"`) { + t.Fatalf("bad profile runner: %v", err) + } + cfg.Runner = Runner{"x y"} + if _, err := cfg.ResolveProfile(""); err == nil { + t.Fatal("bad top-level runner accepted") + } +} + +func TestProfileInputCarriesRunner(t *testing.T) { + type input struct { + Name string `json:"name"` + Runner json.RawMessage `json:"runner"` + } + decode := func(s string) input { + t.Helper() + var in input + if err := json.Unmarshal([]byte(s), &in); err != nil { + t.Fatalf("%q: %v", s, err) + } + return in + } + if got := (&BuildSettings{}).ProfileInput(); got != "" { + t.Fatalf("no profile and no runner sent %q", got) + } + if in := decode((&BuildSettings{Profile: "dev"}).ProfileInput()); in.Runner != nil { + t.Fatalf("runner sent without one configured: %s", in.Runner) + } + in := decode((&BuildSettings{Runner: Runner{"self-hosted", "macOS"}}).ProfileInput()) + if in.Name != "" || string(in.Runner) != `["self-hosted","macOS"]` { + t.Fatalf("runner without profile = %+v", in) + } + in = decode((&BuildSettings{Profile: "ci", Runner: Runner{"macos-15"}}).ProfileInput()) + if in.Name != "ci" || string(in.Runner) != `"macos-15"` { + t.Fatalf("profile runner = %+v", in) + } +} + +func TestCheckMachine(t *testing.T) { + for _, tt := range []struct { + provider, field, value string + warn, err bool + }{ + {"codemagic", "instance_type", "", false, false}, + {"codemagic", "instance_type", "mac_mini_m2", false, false}, + {"codemagic", "instance_type", "mac_mini_m9", true, false}, + {"bitrise", "machine_type_id", "g2.mac.large", false, false}, + {"bitrise", "machine_type_id", "g9.mac.huge", true, false}, + {"bitrise", "stack", "osx-xcode-16.2.x", false, false}, + {"bitrise", "machine_type_id", "g2 mac", false, true}, + {"codemagic", "instance_type", "mac\nscripts:", false, true}, + } { + w, err := CheckMachine(tt.provider, tt.field, tt.value) + if (w != "") != tt.warn || (err != nil) != tt.err { + t.Errorf("CheckMachine(%s, %s, %q) = %q, %v", tt.provider, tt.field, tt.value, w, err) + } + } +} + +func TestRunnerName(t *testing.T) { + cfg := &Config{} + if got := cfg.RunnerName("github", &BuildSettings{}); got != "macos-latest" { + t.Errorf("github default = %q", got) + } + if got := cfg.RunnerName("github", &BuildSettings{Runner: Runner{"self-hosted", "ARM64"}}); got != "self-hosted,ARM64" { + t.Errorf("github = %q", got) + } + if got := cfg.RunnerName("codemagic", nil); got != "mac_mini_m2" { + t.Errorf("codemagic default = %q", got) + } + cfg.Bitrise = CIConfig{MachineTypeID: "g2.mac.large", Stack: "osx-xcode-16.2.x"} + if got := cfg.RunnerName("bitrise", nil); got != "g2.mac.large (osx-xcode-16.2.x)" { + t.Errorf("bitrise = %q", got) + } +} diff --git a/internal/config/types.go b/internal/config/types.go index 2d38ec0..7e0a151 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -7,10 +7,13 @@ import ( // Config represents the builder.json configuration file type Config struct { - Project string `json:"project"` - Platform string `json:"platform"` - GitHub GitHubConfig `json:"github"` - Provider string `json:"provider,omitempty"` + Project string `json:"project"` + Platform string `json:"platform"` + GitHub GitHubConfig `json:"github"` + Provider string `json:"provider,omitempty"` + // Runner is the GitHub Actions runs-on of the generated workflows: a label + // or labels; empty is macos-latest. init renders it into the workflow files. + Runner Runner `json:"runner,omitempty"` Codemagic CIConfig `json:"codemagic,omitempty"` Bitrise CIConfig `json:"bitrise,omitempty"` IOS IOSConfig `json:"ios,omitempty"` @@ -46,6 +49,9 @@ type Profile struct { // ad-hoc or internal, store, enterprise; empty is unsigned): it selects the // signing set and the type the provisioning profile in it must have. Distribution string `json:"distribution,omitempty"` + // Runner overrides the top-level runner for builds dispatched with this + // profile (GitHub only; a tag-triggered build uses the rendered default). + Runner Runner `json:"runner,omitempty"` } // CIConfig identifies an app already connected to the project's GitHub repository. @@ -55,6 +61,12 @@ type CIConfig struct { Branch string `json:"branch,omitempty"` BuildWorkflow string `json:"build_workflow,omitempty"` ShareWorkflow string `json:"share_workflow,omitempty"` + // InstanceType is the Codemagic instance_type (default mac_mini_m2). + InstanceType string `json:"instance_type,omitempty"` + // MachineTypeID is the Bitrise machine_type_id (default g2.mac.medium). + MachineTypeID string `json:"machine_type_id,omitempty"` + // Stack is the Bitrise stack (e.g. osx-xcode-16.2.x); empty leaves it to the app settings. + Stack string `json:"stack,omitempty"` } // ProviderName resolves a command override, the project default, then GitHub. diff --git a/internal/workflow/providers.go b/internal/workflow/providers.go index 584344d..de53099 100644 --- a/internal/workflow/providers.go +++ b/internal/workflow/providers.go @@ -5,11 +5,24 @@ import ( "fmt" "os" "path/filepath" + "regexp" "sort" + + "github.com/MobAI-App/ios-builder/internal/config" +) + +// The machine lines of the templates. Group 1 is the indent and group 2 the +// line ending's \r, if any, so a CRLF checkout renders and stays CRLF. +var ( + codemagicInstanceRe = regexp.MustCompile(`(?m)^([ \t]*)instance_type: ` + regexp.QuoteMeta(config.DefaultCodemagicInstance) + `(\r?)$`) + bitriseMachineRe = regexp.MustCompile(`(?m)^([ \t]*)machine_type_id: ` + regexp.QuoteMeta(config.DefaultBitriseMachine) + `(\r?)$`) ) -// ProviderFiles returns files to commit to the provider's configured branch. -func ProviderFiles(provider string) (map[string][]byte, error) { +// ProviderFiles returns files to commit to the provider's configured branch, +// with the machine from ci: Codemagic instance_type, Bitrise machine_type_id +// and stack (empty keeps the template's default; no stack leaves it to the +// Bitrise app settings). +func ProviderFiles(provider string, ci *config.CIConfig) (map[string][]byte, error) { name := "" switch provider { case "codemagic": @@ -23,6 +36,14 @@ func ProviderFiles(provider string) (map[string][]byte, error) { if err != nil { return nil, err } + for _, f := range []struct{ field, value string }{ + {"instance_type", ci.InstanceType}, {"machine_type_id", ci.MachineTypeID}, {"stack", ci.Stack}, + } { + if _, err := config.CheckMachine(provider, f.field, f.value); err != nil { + return nil, err + } + } + yaml = renderMachine(provider, yaml, ci) script, err := GetTemplate("runner.sh") if err != nil { return nil, err @@ -30,15 +51,36 @@ func ProviderFiles(provider string) (map[string][]byte, error) { return map[string][]byte{name: yaml, ".builder/ci/runner.sh": script}, nil } +// renderMachine writes ci's machine settings into a provider template. +func renderMachine(provider string, yaml []byte, ci *config.CIConfig) []byte { + switch provider { + case "codemagic": + if ci.InstanceType != "" { + yaml = codemagicInstanceRe.ReplaceAll(yaml, []byte("${1}instance_type: "+ci.InstanceType+"${2}")) + } + case "bitrise": + machine := ci.MachineTypeID + if machine == "" { + machine = config.DefaultBitriseMachine + } + repl := "${1}machine_type_id: " + machine + "${2}" + if ci.Stack != "" { + repl += "\n${1}stack: " + ci.Stack + "${2}" + } + yaml = bitriseMachineRe.ReplaceAll(yaml, []byte(repl)) + } + return yaml +} + // WriteProviderFiles refuses to replace unrelated CI files and checks all // destinations before writing any file. Existing Builder-generated files update. -func WriteProviderFiles(dir, provider string) ([]string, error) { +func WriteProviderFiles(dir, provider string, ci *config.CIConfig) ([]string, error) { root, err := filepath.Abs(dir) if err != nil { return nil, err } dir = root - files, err := ProviderFiles(provider) + files, err := ProviderFiles(provider, ci) if err != nil { return nil, err } diff --git a/internal/workflow/providers_test.go b/internal/workflow/providers_test.go index bbf3d4d..d2b64a0 100644 --- a/internal/workflow/providers_test.go +++ b/internal/workflow/providers_test.go @@ -12,6 +12,7 @@ import ( "testing" "text/template" + "github.com/MobAI-App/ios-builder/internal/config" "github.com/MobAI-App/ios-builder/internal/signing" "github.com/MobAI-App/ios-builder/internal/xcodeproj" "go.yaml.in/yaml/v3" @@ -22,7 +23,7 @@ func TestProviderYAMLAndPreservation(t *testing.T) { for _, name := range []string{"codemagic", "bitrise"} { t.Run(name, func(t *testing.T) { dir := t.TempDir() - files, err := ProviderFiles(name) + files, err := ProviderFiles(name, &config.CIConfig{}) if err != nil { t.Fatal(err) } @@ -42,17 +43,17 @@ func TestProviderYAMLAndPreservation(t *testing.T) { t.Fatal(err) } } - if _, err := WriteProviderFiles(dir, name); err == nil { + if _, err := WriteProviderFiles(dir, name, &config.CIConfig{}); err == nil { t.Fatal("overwrote unrelated workflow") } if _, err := os.Stat(filepath.Join(dir, ".builder")); !os.IsNotExist(err) { t.Fatal("partial write before collision check") } dir = t.TempDir() - if _, err := WriteProviderFiles(dir, name); err != nil { + if _, err := WriteProviderFiles(dir, name, &config.CIConfig{}); err != nil { t.Fatal(err) } - if _, err := WriteProviderFiles(dir, name); err != nil { + if _, err := WriteProviderFiles(dir, name, &config.CIConfig{}); err != nil { t.Fatal("idempotent setup:", err) } }) diff --git a/internal/workflow/runner_test.go b/internal/workflow/runner_test.go new file mode 100644 index 0000000..de09a53 --- /dev/null +++ b/internal/workflow/runner_test.go @@ -0,0 +1,353 @@ +package workflow + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/MobAI-App/ios-builder/internal/config" + "go.yaml.in/yaml/v3" +) + +type parsedWorkflow struct { + Jobs map[string]struct { + RunsOn any `yaml:"runs-on"` + Steps []struct { + Name string `yaml:"name"` + If string `yaml:"if"` + Uses string `yaml:"uses"` + Run string `yaml:"run"` + } `yaml:"steps"` + } `yaml:"jobs"` +} + +func parseWorkflow(t *testing.T, data []byte) parsedWorkflow { + t.Helper() + var w parsedWorkflow + if err := yaml.Unmarshal(data, &w); err != nil { + t.Fatalf("workflow does not parse: %v", err) + } + return w +} + +func TestRenderWorkflowRunsOn(t *testing.T) { + const prefix = "${{ fromJSON(inputs.profile || '{}').runner || " + for _, tt := range []struct { + name string + runner config.Runner + want string + }{ + {"default", nil, prefix + "'macos-latest' }}"}, + {"hosted image", config.Runner{"macos-15"}, prefix + "'macos-15' }}"}, + {"self-hosted", config.Runner{"self-hosted"}, prefix + "'self-hosted' }}"}, + {"labels", config.Runner{"self-hosted", "macOS", "ARM64"}, prefix + `fromJSON('["self-hosted","macOS","ARM64"]') }}`}, + } { + t.Run(tt.name, func(t *testing.T) { + data, err := RenderWorkflow(tt.runner) + if err != nil { + t.Fatal(err) + } + w := parseWorkflow(t, data) + if got := w.Jobs["build"].RunsOn; got != tt.want { + t.Fatalf("runs-on = %v, want %s", got, tt.want) + } + }) + } + // The embedded template is the default rendering. + raw, _ := GetWorkflowTemplate() + def, _ := RenderWorkflow(nil) + if !bytes.Equal(raw, def) { + t.Fatal("RenderWorkflow(nil) differs from the embedded template") + } + if _, err := RenderWorkflow(config.Runner{"x' }}"}); err == nil { + t.Fatal("an unsafe label was rendered") + } +} + +func TestRenderShareWorkflowRunsOn(t *testing.T) { + for _, tt := range []struct { + runner config.Runner + want any + }{ + {nil, "macos-latest"}, + {config.Runner{"macos-15"}, "macos-15"}, + {config.Runner{"self-hosted", "macOS"}, []any{"self-hosted", "macOS"}}, + } { + data, err := RenderShareWorkflow(tt.runner) + if err != nil { + t.Fatal(err) + } + got := parseWorkflow(t, data).Jobs["simulator"].RunsOn + if s, ok := tt.want.(string); ok { + if got != s { + t.Errorf("%v: runs-on = %v", tt.runner, got) + } + continue + } + list, ok := got.([]any) + if !ok || len(list) != 2 || list[0] != "self-hosted" || list[1] != "macOS" { + t.Errorf("%v: runs-on = %#v", tt.runner, got) + } + } +} + +// A self-hosted Mac keeps its Xcode selection, has no passwordless sudo for +// setup-xcode, and has tools installed or not: brew runs only for a missing one. +func TestTemplatesSafeOnPersistentRunner(t *testing.T) { + for _, name := range []string{"ios-build.yml", "ios-share.yml"} { + data, err := GetTemplate(name) + if err != nil { + t.Fatal(err) + } + for _, job := range parseWorkflow(t, data).Jobs { + var check bool + for _, s := range job.Steps { + if strings.HasPrefix(s.Uses, "maxim-lobanov/setup-xcode") && s.If != "runner.environment == 'github-hosted'" { + t.Errorf("%s: setup-xcode runs on self-hosted runners (if: %q)", name, s.If) + } + if s.Name == "Check Xcode" && s.If == "runner.environment != 'github-hosted'" && strings.Contains(s.Run, "xcodebuild -version") { + check = true + } + } + if !check { + t.Errorf("%s: no Check Xcode step for self-hosted runners", name) + } + } + for i, line := range strings.Split(string(data), "\n") { + if strings.Contains(line, "brew install") && !strings.Contains(line, "command -v") { + t.Errorf("%s:%d: unconditional brew install: %s", name, i+1, strings.TrimSpace(line)) + } + } + } +} + +func buildStep(t *testing.T, name string) string { + t.Helper() + data, err := GetWorkflowTemplate() + if err != nil { + t.Fatal(err) + } + for _, s := range parseWorkflow(t, data).Jobs["build"].Steps { + if s.Name == name { + return s.Run + } + } + t.Fatalf("no step %q", name) + return "" +} + +func TestPreviousOutputsCleared(t *testing.T) { + run := buildStep(t, "Clear previous outputs") + for _, path := range []string{"build/App.xcarchive", "build/export", "build/*.ipa", "Payload"} { + if !strings.Contains(run, path) { + t.Errorf("Clear previous outputs leaves %s", path) + } + } + signing := buildStep(t, "Install certificate and provisioning profile") + for _, want := range []string{`"$RUNNER_TEMP/keychains-before"`, `echo "$PROFILE_UUID" >> "$RUNNER_TEMP/installed-profiles"`} { + if !strings.Contains(signing, want) { + t.Errorf("signing step does not record %s", want) + } + } + if strings.Contains(signing, `list-keychain -d user -s "$KEYCHAIN_PATH"`+"\n") { + t.Error("signing step replaces the keychain search list") + } +} + +// Runs the cleanup step against a fake `security` and checks that it leaves +// the machine as the job found it: search list restored, keychain deleted, +// every installed profile removed and nothing else touched. +func TestCleanupSigningRestoresMachine(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("macOS/Linux shell test") + } + run := buildStep(t, "Cleanup signing") + tmp := t.TempDir() + home, runnerTemp, bin := filepath.Join(tmp, "home"), filepath.Join(tmp, "runner"), filepath.Join(tmp, "bin") + profiles := filepath.Join(home, "Library", "MobileDevice", "Provisioning Profiles") + for _, d := range []string{profiles, filepath.Join(runnerTemp, "extensions"), bin} { + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatal(err) + } + } + write := func(path, content string) { + t.Helper() + if err := os.WriteFile(path, []byte(content), 0o755); err != nil { + t.Fatal(err) + } + } + log := filepath.Join(tmp, "security.log") + write(filepath.Join(bin, "security"), "#!/bin/bash\nprintf '%s|' \"$@\" >> "+log+"\necho >> "+log+"\n") + keychain := filepath.Join(runnerTemp, "app-signing.keychain-db") + login := "/Users/runner/Library/Keychains/login.keychain-db" + write(keychain, "") + write(filepath.Join(runnerTemp, "keychains-before"), login+"\n/Library/Keychains/System.keychain\n") + write(filepath.Join(runnerTemp, "installed-profiles"), "APP-UUID\n") + write(filepath.Join(runnerTemp, "extensions", "installed"), "EXT-UUID\n") + for _, uuid := range []string{"APP-UUID", "EXT-UUID", "OWNER-UUID"} { + write(filepath.Join(profiles, uuid+".mobileprovision"), "") + } + + cmd := exec.Command("bash", "-e", "-c", run) + cmd.Env = append(os.Environ(), "HOME="+home, "RUNNER_TEMP="+runnerTemp, "PATH="+bin+":"+os.Getenv("PATH")) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("cleanup failed: %v\n%s", err, out) + } + calls, _ := os.ReadFile(log) + want := "list-keychains|-d|user|-s|" + login + "|/Library/Keychains/System.keychain|\ndelete-keychain|" + keychain + "|\n" + if string(calls) != want { + t.Fatalf("security calls:\n%s\nwant:\n%s", calls, want) + } + for uuid, kept := range map[string]bool{"APP-UUID": false, "EXT-UUID": false, "OWNER-UUID": true} { + _, err := os.Stat(filepath.Join(profiles, uuid+".mobileprovision")) + if (err == nil) != kept { + t.Errorf("%s: kept=%v, want %v", uuid, err == nil, kept) + } + } + if _, err := os.Stat(filepath.Join(runnerTemp, "extensions")); !os.IsNotExist(err) { + t.Error("extension profiles left in RUNNER_TEMP") + } + + // Signing never started: nothing to restore, nothing fails. + empty := t.TempDir() + cmd = exec.Command("bash", "-e", "-c", run) + cmd.Env = append(os.Environ(), "HOME="+home, "RUNNER_TEMP="+empty, "PATH="+bin+":"+os.Getenv("PATH")) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("cleanup with nothing installed failed: %v\n%s", err, out) + } +} + +// The signing step prepends its keychain to the search list as `security +// list-keychains` prints it (indented, quoted) instead of replacing the list. +func TestSigningKeepsKeychainSearchList(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("macOS/Linux shell test") + } + signing := buildStep(t, "Install certificate and provisioning profile") + start := strings.Index(signing, "security list-keychains -d user | sed") + end := strings.Index(signing, `security list-keychains -d user -s "$KEYCHAIN_PATH" "${KEYCHAINS[@]}"`) + if start < 0 || end < start { + t.Fatal("keychain search list snippet not found") + } + snippet := signing[start:end] + `security list-keychains -d user -s "$KEYCHAIN_PATH" "${KEYCHAINS[@]}"` + tmp := t.TempDir() + bin := filepath.Join(tmp, "bin") + if err := os.MkdirAll(bin, 0o755); err != nil { + t.Fatal(err) + } + log := filepath.Join(tmp, "security.log") + fake := `#!/bin/bash +if [ "$4" = "-s" ]; then printf '%s|' "$@" > ` + log + `; exit 0; fi +printf ' "%s"\n' "/Users/me/Library/Keychains/login.keychain-db" "/Users/me/Library/Keychains/My Team.keychain-db" +` + if err := os.WriteFile(filepath.Join(bin, "security"), []byte(fake), 0o755); err != nil { + t.Fatal(err) + } + cmd := exec.Command("bash", "-e", "-c", snippet) + cmd.Env = append(os.Environ(), "RUNNER_TEMP="+tmp, "KEYCHAIN_PATH=/tmp/app-signing.keychain-db", "PATH="+bin+":"+os.Getenv("PATH")) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("%v\n%s", err, out) + } + got, _ := os.ReadFile(log) + want := "-d|user|-s|/tmp/app-signing.keychain-db|/Users/me/Library/Keychains/login.keychain-db|/Users/me/Library/Keychains/My Team.keychain-db|" + if !strings.HasPrefix(string(got), "list-keychains|") || strings.TrimPrefix(string(got), "list-keychains|") != want { + t.Fatalf("search list set to %q", got) + } +} + +// A Windows checkout with core.autocrlf embeds the templates with CRLF line +// endings; rendering must still find its lines and keep the endings. +func TestRenderingSurvivesCRLF(t *testing.T) { + crlf := func(b []byte) []byte { + return bytes.ReplaceAll(bytes.ReplaceAll(b, []byte("\r\n"), []byte("\n")), []byte("\n"), []byte("\r\n")) + } + for name, old := range map[string]string{"ios-build.yml": buildRunsOn, "ios-share.yml": shareRunsOn} { + raw, _ := GetTemplate(name) + got, err := replaceOnce(crlf(raw), old, " runs-on: x", name) + if err != nil { + t.Fatalf("%s: %v", name, err) + } + if !bytes.Contains(got, []byte(" runs-on: x\r\n")) { + t.Errorf("%s: runs-on line lost its CRLF", name) + } + } + raw, _ := GetTemplate("codemagic.yaml") + got := renderMachine("codemagic", crlf(raw), &config.CIConfig{InstanceType: "mac_mini_m4"}) + if bytes.Count(got, []byte("instance_type: mac_mini_m4\r\n")) != 2 { + t.Errorf("codemagic CRLF:\n%q", got) + } + raw, _ = GetTemplate("bitrise.yml") + got = renderMachine("bitrise", crlf(raw), &config.CIConfig{MachineTypeID: "g2.mac.large", Stack: "osx-xcode-16.2.x"}) + if bytes.Count(got, []byte("machine_type_id: g2.mac.large\r\n")) != 3 || bytes.Count(got, []byte("stack: osx-xcode-16.2.x\r\n")) != 3 { + t.Errorf("bitrise CRLF:\n%q", got) + } + if bytes.Contains(bytes.ReplaceAll(got, []byte("\r\n"), nil), []byte("\n")) { + t.Error("bitrise CRLF rendering introduced a bare LF") + } +} + +func TestProviderMachines(t *testing.T) { + files, err := ProviderFiles("codemagic", &config.CIConfig{InstanceType: "mac_mini_m4"}) + if err != nil { + t.Fatal(err) + } + var cm struct { + Workflows map[string]struct { + InstanceType string `yaml:"instance_type"` + } + } + if err := yaml.Unmarshal(files["codemagic.yaml"], &cm); err != nil { + t.Fatal(err) + } + for name, w := range cm.Workflows { + if w.InstanceType != "mac_mini_m4" { + t.Errorf("codemagic %s instance_type = %q", name, w.InstanceType) + } + } + + files, err = ProviderFiles("bitrise", &config.CIConfig{MachineTypeID: "g2.mac.large", Stack: "osx-xcode-16.2.x"}) + if err != nil { + t.Fatal(err) + } + type meta struct { + Bitrise struct { + Machine string `yaml:"machine_type_id"` + Stack string `yaml:"stack"` + } `yaml:"bitrise.io"` + } + var br struct { + Meta meta + Workflows map[string]struct{ Meta meta } + } + if err := yaml.Unmarshal(files["bitrise.yml"], &br); err != nil { + t.Fatal(err) + } + metas := []meta{br.Meta} + for _, w := range br.Workflows { + metas = append(metas, w.Meta) + } + for _, m := range metas { + if m.Bitrise.Machine != "g2.mac.large" || m.Bitrise.Stack != "osx-xcode-16.2.x" { + t.Errorf("bitrise meta = %+v", m.Bitrise) + } + } + + // Defaults leave the templates as embedded. + for provider, name := range map[string]string{"codemagic": "codemagic.yaml", "bitrise": "bitrise.yml"} { + files, err := ProviderFiles(provider, &config.CIConfig{}) + if err != nil { + t.Fatal(err) + } + raw, _ := GetTemplate(name) + if !bytes.Equal(files[name], raw) { + t.Errorf("%s: default rendering differs from the template", name) + } + } + if _, err := ProviderFiles("bitrise", &config.CIConfig{Stack: "x\nworkflows: {}"}); err == nil { + t.Fatal("an unsafe stack was rendered") + } +} diff --git a/internal/workflow/templates.go b/internal/workflow/templates.go index ab38995..6b5db4f 100644 --- a/internal/workflow/templates.go +++ b/internal/workflow/templates.go @@ -3,8 +3,12 @@ package workflow import ( + "bytes" "embed" + "encoding/json" "fmt" + + "github.com/MobAI-App/ios-builder/internal/config" ) //go:embed templates/* @@ -29,3 +33,63 @@ func GetWorkflowTemplate() ([]byte, error) { func GetShareWorkflowTemplate() ([]byte, error) { return GetTemplate("ios-share.yml") } + +// The runs-on lines of the templates, as embedded (default runner). They are +// matched without the line ending, so a CRLF checkout renders the same way. +const ( + buildRunsOn = " runs-on: ${{ fromJSON(inputs.profile || '{}').runner || 'macos-latest' }}" + shareRunsOn = " runs-on: macos-latest" +) + +// RenderWorkflow returns ios-build.yml with runner as the default runs-on. +// A dispatch still takes the runner from the profile input when it carries +// one; a tag push, which has no inputs, always runs on this default. +func RenderWorkflow(runner config.Runner) ([]byte, error) { + if err := runner.Validate(); err != nil { + return nil, err + } + content, err := GetWorkflowTemplate() + if err != nil { + return nil, err + } + def := "'" + config.DefaultRunner + "'" + switch len(runner) { + case 0: + case 1: + def = "'" + runner[0] + "'" + default: + data, _ := json.Marshal([]string(runner)) + def = "fromJSON('" + string(data) + "')" + } + line := " runs-on: ${{ fromJSON(inputs.profile || '{}').runner || " + def + " }}" + return replaceOnce(content, buildRunsOn, line, "ios-build.yml") +} + +// RenderShareWorkflow returns ios-share.yml running on runner. The share +// dispatch carries no profile, so the rendered runner is the only one. +func RenderShareWorkflow(runner config.Runner) ([]byte, error) { + if err := runner.Validate(); err != nil { + return nil, err + } + content, err := GetShareWorkflowTemplate() + if err != nil { + return nil, err + } + if len(runner) == 0 { + return content, nil + } + var data []byte + if len(runner) == 1 { + data, _ = json.Marshal(runner[0]) + } else { + data, _ = json.Marshal([]string(runner)) + } + return replaceOnce(content, shareRunsOn, " runs-on: "+string(data), "ios-share.yml") +} + +func replaceOnce(content []byte, old, new, name string) ([]byte, error) { + if n := bytes.Count(content, []byte(old)); n != 1 { + return nil, fmt.Errorf("%s: expected one runs-on line to render, found %d", name, n) + } + return bytes.Replace(content, []byte(old), []byte(new), 1), nil +} diff --git a/internal/workflow/templates/ios-build.yml b/internal/workflow/templates/ios-build.yml index d78a18e..941a1d6 100644 --- a/internal/workflow/templates/ios-build.yml +++ b/internal/workflow/templates/ios-build.yml @@ -66,7 +66,10 @@ on: jobs: build: - runs-on: macos-latest + # The runner from the profile input (a profile's runner, else builder.json's + # top-level one), else the default `builder init` rendered here. A tag push + # has no inputs, so it always takes the rendered default. + runs-on: ${{ fromJSON(inputs.profile || '{}').runner || 'macos-latest' }} permissions: contents: write # deletes the trigger tag timeout-minutes: 90 # a cold React Native/Expo build on the free runner takes 30-60 minutes @@ -87,6 +90,12 @@ jobs: git checkout --force snapshot echo "Building $(git rev-parse --short HEAD)" + # A self-hosted runner keeps its workspace between jobs. checkout cleans + # it, but the snapshot checkout above does not, so outputs of an earlier + # run never reach this run's artifact. + - name: Clear previous outputs + run: rm -rf build/App.xcarchive build/export build/Payload build/*.ipa Payload + # Dispatch inputs arrive with their declared defaults; a tag push has none, # so the values come from builder.json in the tagged commit (defaultProfile # included) and the build id is the tag name after the prefix. @@ -191,11 +200,24 @@ jobs: echo "env: $name" done < <(jq -r '.env // {} | to_entries[] | "\(.key | @base64) \(.value | tostring | @base64)"' <<< "$PROFILE_JSON") + # setup-xcode switches with sudo between the Xcodes of the hosted image. A + # self-hosted runner keeps the Xcode its owner selected (xcode-select, or + # DEVELOPER_DIR in the runner's .env). - name: Setup Xcode + if: runner.environment == 'github-hosted' uses: maxim-lobanov/setup-xcode@v1 with: xcode-version: latest-stable + - name: Check Xcode + if: runner.environment != 'github-hosted' + run: | + if ! xcodebuild -version; then + echo "::error::No usable Xcode on this runner. Install Xcode and select it with sudo xcode-select -s /Applications/Xcode.app, or set DEVELOPER_DIR in the runner's .env." + exit 1 + fi + xcode-select -p + - name: Restore DerivedData cache uses: actions/cache/restore@v6 id: cache-deriveddata @@ -271,7 +293,7 @@ jobs: echo "Found a committed .xcodeproj — skipping generation." else echo "Found $MANIFEST and no .xcodeproj — generating with XcodeGen." - brew install xcodegen + command -v xcodegen >/dev/null || brew install xcodegen xcodegen generate fi @@ -827,7 +849,13 @@ jobs: echo "$IOS_CERTIFICATE" | base64 --decode > "$CERTIFICATE_PATH" security import "$CERTIFICATE_PATH" -P "$IOS_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH" security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" - security list-keychain -d user -s "$KEYCHAIN_PATH" + # Put the keychain first in the search list without dropping the + # others, and keep the list as it was for Cleanup signing to restore: + # on a self-hosted Mac it holds the owner's login keychain. + security list-keychains -d user | sed -e 's/^[[:space:]]*"//' -e 's/"$//' > "$RUNNER_TEMP/keychains-before" + KEYCHAINS=() + while IFS= read -r k; do [ -n "$k" ] && KEYCHAINS+=("$k"); done < "$RUNNER_TEMP/keychains-before" + security list-keychains -d user -s "$KEYCHAIN_PATH" "${KEYCHAINS[@]}" # The certificate has to be the kind the profile asks for, under # whichever of its names it carries. Say so here instead of letting @@ -837,7 +865,10 @@ jobs: CODE_SIGN_IDENTITY=$(signing_identity "$EXPORT_METHOD" "$IDENTITIES") || fail "IOS_CERTIFICATE${SIGNING_SET:+_$SIGNING_SET} holds no $(signing_identities "$EXPORT_METHOD" | paste -sd '/' -) certificate, which an $EXPORT_METHOD profile must be signed with. Run builder signing setup --distribution ${DISTRIBUTION:-} to issue the right one." # Install the provisioning profiles: the app's, then its extensions' + # Every profile installed is listed for Cleanup signing (the extensions' + # in extensions/installed), so a persistent machine keeps none of them. mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles + echo "$PROFILE_UUID" >> "$RUNNER_TEMP/installed-profiles" cp "$PROFILE_PATH" ~/Library/MobileDevice/Provisioning\ Profiles/"$PROFILE_UUID".mobileprovision EXTENSION_PROFILES=$(install_extension_profiles "$RUNNER_TEMP/extensions") @@ -1015,6 +1046,7 @@ jobs: # Install pods if Podfile exists (always run to ensure sync with Podfile.lock) if [ -f "Podfile" ]; then echo "Running pod install..." + command -v pod >/dev/null || brew install cocoapods pod install WORKSPACE=$(find . -maxdepth 1 -name "*.xcworkspace" | head -1) fi @@ -1294,12 +1326,29 @@ jobs: path: DerivedData key: deriveddata-${{ github.run_id }} + # Leaves a self-hosted Mac as the job found it, even when signing failed + # halfway: the paths are fixed rather than read from $GITHUB_ENV, which the + # signing step only writes at its end. - name: Cleanup signing if: always() && steps.params.outputs.use_signing == 'true' run: | - if [ -n "$KEYCHAIN_PATH" ] && [ -f "$KEYCHAIN_PATH" ]; then + KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db + if [ -f "$RUNNER_TEMP/keychains-before" ]; then + KEYCHAINS=() + while IFS= read -r k; do [ -n "$k" ] && [ "$k" != "$KEYCHAIN_PATH" ] && KEYCHAINS+=("$k"); done < "$RUNNER_TEMP/keychains-before" + security list-keychains -d user -s "${KEYCHAINS[@]}" || true + fi + if [ -f "$KEYCHAIN_PATH" ]; then security delete-keychain "$KEYCHAIN_PATH" || true fi + for list in "$RUNNER_TEMP/installed-profiles" "$RUNNER_TEMP/extensions/installed"; do + [ -f "$list" ] || continue + while IFS= read -r uuid; do + [ -n "$uuid" ] && rm -f "$HOME/Library/MobileDevice/Provisioning Profiles/$uuid.mobileprovision" + done < "$list" + done + rm -f "$RUNNER_TEMP/certificate.p12" "$RUNNER_TEMP/profile.mobileprovision" "$RUNNER_TEMP/profile.plist" + rm -rf "$RUNNER_TEMP/extensions" - name: Build summary if: always() diff --git a/internal/workflow/templates/ios-share.yml b/internal/workflow/templates/ios-share.yml index 4beb1dc..1d49ade 100644 --- a/internal/workflow/templates/ios-share.yml +++ b/internal/workflow/templates/ios-share.yml @@ -56,6 +56,7 @@ on: jobs: simulator: + # Rendered by `builder init` from builder.json's top-level runner. runs-on: macos-latest permissions: contents: write # deletes the trigger tag @@ -118,11 +119,24 @@ jobs: with: boot-sim: true # UDID lands in $MOBAI_SIM_UDID + # setup-xcode switches with sudo between the Xcodes of the hosted image. A + # self-hosted runner keeps the Xcode its owner selected (xcode-select, or + # DEVELOPER_DIR in the runner's .env). - name: Setup Xcode + if: runner.environment == 'github-hosted' uses: maxim-lobanov/setup-xcode@v1 with: xcode-version: latest-stable + - name: Check Xcode + if: runner.environment != 'github-hosted' + run: | + if ! xcodebuild -version; then + echo "::error::No usable Xcode on this runner. Install Xcode and select it with sudo xcode-select -s /Applications/Xcode.app, or set DEVELOPER_DIR in the runner's .env." + exit 1 + fi + xcode-select -p + - name: Restore DerivedData cache uses: actions/cache/restore@v6 with: @@ -195,7 +209,7 @@ jobs: echo "Found a committed .xcodeproj — skipping generation." else echo "Found $MANIFEST and no .xcodeproj — generating with XcodeGen." - brew install xcodegen + command -v xcodegen >/dev/null || brew install xcodegen xcodegen generate fi diff --git a/pkg/ci/ci.go b/pkg/ci/ci.go index 5ee592d..613893b 100644 --- a/pkg/ci/ci.go +++ b/pkg/ci/ci.go @@ -18,5 +18,5 @@ type ( Bitrise = ci.Bitrise ) -func NewCodemagic(cfg config.CIConfig, token string) *Codemagic { return ci.NewCodemagic(cfg, token) } -func NewBitrise(cfg config.CIConfig, token string) *Bitrise { return ci.NewBitrise(cfg, token) } +func NewCodemagic(cfg *config.CIConfig, token string) *Codemagic { return ci.NewCodemagic(cfg, token) } +func NewBitrise(cfg *config.CIConfig, token string) *Bitrise { return ci.NewBitrise(cfg, token) } diff --git a/pkg/config/config.go b/pkg/config/config.go index a9a15f1..e6fa03d 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -53,3 +53,12 @@ func SigningSet(distribution string) (string, error) { return config.SigningSet( // SigningSecretNames are the four secrets of a signing set. func SigningSecretNames(set string) SigningSecrets { return config.SigningSecretNames(set) } + +// Runner is a GitHub Actions runs-on: one label or several. +type Runner = config.Runner + +// DefaultRunner is the runs-on used when no runner is configured. +const DefaultRunner = config.DefaultRunner + +// ParseRunner reads one label or a comma-separated list of labels. +func ParseRunner(s string) (Runner, error) { return config.ParseRunner(s) } diff --git a/pkg/workflow/workflow.go b/pkg/workflow/workflow.go index 5335fb9..0742e9e 100644 --- a/pkg/workflow/workflow.go +++ b/pkg/workflow/workflow.go @@ -2,7 +2,10 @@ // outside this module. package workflow -import "github.com/MobAI-App/ios-builder/internal/workflow" +import ( + "github.com/MobAI-App/ios-builder/internal/config" + "github.com/MobAI-App/ios-builder/internal/workflow" +) // GetTemplate returns the named embedded template. func GetTemplate(name string) ([]byte, error) { @@ -18,3 +21,13 @@ func GetWorkflowTemplate() ([]byte, error) { func GetShareWorkflowTemplate() ([]byte, error) { return workflow.GetShareWorkflowTemplate() } + +// RenderWorkflow returns ios-build.yml with runner as its default runs-on. +func RenderWorkflow(runner config.Runner) ([]byte, error) { + return workflow.RenderWorkflow(runner) +} + +// RenderShareWorkflow returns ios-share.yml running on runner. +func RenderShareWorkflow(runner config.Runner) ([]byte, error) { + return workflow.RenderShareWorkflow(runner) +}