diff --git a/CLAUDE.md b/CLAUDE.md index b15fd87..d1575fb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -135,6 +135,28 @@ internal/ - **Run Correlation**: `run-name` carries the build ID so concurrent builds cannot adopt each other's runs - **Flutter Detection**: Auto-detects Flutter projects, runs `flutter pub get`, uses `Runner` scheme +- **Expo Detection**: an `expo` dependency in `package.json` (the CLI parses the dependency maps; + the runners grep `'"expo"'`) with no `.xcodeproj`/`.xcworkspace` anywhere and no `pubspec.yaml` is + a managed project. `detectIOSPath` still returns `ios`, because that is where `expo prebuild` puts + the project on the runner; nothing is generated locally, and managed projects gitignore `ios/` + so the snapshot carries none. All three runners (`ios-build.yml`, `ios-share.yml`, `runner.sh`) + prebuild with `CI=1` after the node install and before the Pods step, skip it when the iOS path + already holds an Xcode project (ejected), and fail with a named error when the app config has no + `ios.bundleIdentifier` — without one `expo prebuild` prompts and the job would hang. The steps + that walk the iOS path before that (XcodeGen, base-configuration check) skip a missing directory. +- **JS Package Manager**: React Native and Expo dependencies install with the manager the + project declares — `packageManager` in `package.json` first, then the lockfile + (`pnpm-lock.yaml`, `yarn.lock`, `bun.lock`/`bun.lockb`, `package-lock.json`), else npm. + pnpm and yarn come from `corepack` (installed with npm where Node 25+ or a provider image + lacks it), bun from its installer, and `expo prebuild` and the other `npx` calls run + through the same manager (`pnpm exec`/`yarn`/`bunx`/`npx`). Running + `npm install` on a pnpm or yarn workspace fails with `EUNSUPPORTEDPROTOCOL Unsupported URL + Type "workspace:"`, so the guess is not free. The Node version is `.nvmrc`/`.node-version` + (as `node-version-file`), else `engines.node` with the range prefix stripped, else 22; + `with:` cannot be conditional per key, so a `Resolve JS toolchain` step computes both and + setup-node ignores the empty one. The shell for all of this is one block between + `# >>> js toolchain` and `# <<< js toolchain`, repeated verbatim in `ios-build.yml` (twice), + `ios-share.yml` (twice) and `runner.sh`; `TestJSToolchainBlockIdentical` fails on drift. - **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` saves before it shares the simulator, since that step blocks until the session ends. diff --git a/README.md b/README.md index d99627b..536c3fb 100644 --- a/README.md +++ b/README.md @@ -126,11 +126,44 @@ and connect the same repository to each provider before building. See |-----------|----------|---------------| | Native iOS/Swift | `.` (root) | Yes | | React Native | `ios/` | Yes | -| Expo (ejected) | `ios/` | Yes | +| Expo (managed or ejected) | `ios/` | Yes | | Flutter | `ios/` | Yes | | Kotlin Multiplatform | `iosApp/` | Yes | | Cordova/Ionic | `platforms/ios/` | Yes | +### React Native + +The runner installs JavaScript dependencies with the package manager the project +already uses — npm, Yarn, pnpm or Bun, from `packageManager` in `package.json` or +from the lockfile — on the Node version from `.nvmrc`, `.node-version` or +`engines.node`. + +### Expo + +Dependencies install the way they do for React Native: the project's own package +manager (npm, Yarn, pnpm or Bun) and Node version, with `expo prebuild` running +through that same manager. + +A managed Expo project has no `ios/` directory in git. `builder init` detects it +as *Expo (managed)*, still records `"ios": { "path": "ios" }`, and the runner +generates the native project with `expo prebuild --platform ios --no-install` +before building it. Ejected projects keep the committed `ios/` they have: the +prebuild step skips a directory that already holds an Xcode project. + +`expo prebuild` has to run unattended, so the app config must set the bundle +identifier — `expo.ios.bundleIdentifier` in `app.json`, or `ios.bundleIdentifier` +in `app.config.js` / `app.config.ts`. Without one, prebuild would stop and ask +for it; instead the build fails immediately and names the missing setting. + +The default `Debug` configuration builds an IPA that loads its JavaScript from +Metro, so set `"ios": { "configuration": "Release" }` in `builder.json` for a +standalone IPA with the bundle baked in. + +An `ios/` directory left over from running `expo prebuild` locally is not +uploaded: managed projects gitignore it, and the working-tree snapshot skips +gitignored files. That is what you want — the runner prebuilds from the app +config on every build, so it cannot drift from a stale local copy. + ## Installation ### Windows diff --git a/cmd/builder/expo_test.go b/cmd/builder/expo_test.go new file mode 100644 index 0000000..78915b1 --- /dev/null +++ b/cmd/builder/expo_test.go @@ -0,0 +1,102 @@ +package main + +import ( + "path/filepath" + "testing" +) + +// TestDetectIOSPathExpo covers the managed Expo case, where package.json +// depends on Expo but no Xcode project is committed anywhere, next to the +// project shapes that must keep detecting exactly as before. +func TestDetectIOSPathExpo(t *testing.T) { + tests := []struct { + name string + files map[string]string + wantPath string + wantFramework string + wantExpo bool + }{ + { + name: "managed expo without an ios directory", + files: map[string]string{"package.json": `{"dependencies":{"expo":"~51.0.0"}}`}, + wantPath: "ios", + wantFramework: expoManagedFramework, + wantExpo: true, + }, + { + name: "ejected expo keeps the react native path", + files: map[string]string{ + "package.json": `{"dependencies":{"expo":"~51.0.0","react-native":"0.74.0"}}`, + "ios/MyApp.xcodeproj/project.pbxproj": "// project", + }, + wantPath: "ios", + wantFramework: "React Native/Expo", + wantExpo: true, + }, + { + name: "plain react native without ios stays undetected", + files: map[string]string{"package.json": `{"dependencies":{"react-native":"0.74.0"}}`}, + wantPath: "", + wantFramework: "", + wantExpo: false, + }, + { + name: "native project at the root", + files: map[string]string{"MyApp.xcodeproj/project.pbxproj": "// project"}, + wantPath: "", + wantFramework: "Native iOS", + wantExpo: false, + }, + { + // The runners detect pubspec.yaml before package.json, so a + // Flutter repo whose ios/ is not committed must not be claimed + // here either — the runner would never prebuild it. + name: "flutter wins over an expo dependency", + files: map[string]string{ + "pubspec.yaml": "name: app\n", + "package.json": `{"dependencies":{"expo":"~51.0.0"}}`, + }, + wantPath: "", + wantFramework: "", + wantExpo: true, + }, + { + // KMP keeps its Xcode project in iosApp/, which is matched before + // the Expo fallback is reached. + name: "kmp keeps its own path", + files: map[string]string{ + "iosApp/iosApp.xcodeproj/project.pbxproj": "// project", + "package.json": `{"dependencies":{"expo":"~51.0.0"}}`, + }, + wantPath: "iosApp", + wantFramework: "Kotlin Multiplatform", + wantExpo: true, + }, + { + // "expo" appears in the file, but not as a dependency. + name: "unrelated node project naming expo", + files: map[string]string{ + "package.json": `{"name":"expo","keywords":["expo"],"scripts":{"expo":"echo"},"dependencies":{"expo-server-sdk":"^3.7.0"}}`, + }, + wantPath: "", + wantFramework: "", + wantExpo: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := chdir(t) + for name, content := range tt.files { + writeFile(t, filepath.Join(dir, filepath.FromSlash(name)), content) + } + path, framework := detectIOSPath() + if path != tt.wantPath || framework != tt.wantFramework { + t.Fatalf("detectIOSPath() = (%q, %q), want (%q, %q)", path, framework, tt.wantPath, tt.wantFramework) + } + if got := isExpoProject(); got != tt.wantExpo { + t.Fatalf("isExpoProject() = %v, want %v", got, tt.wantExpo) + } + }) + } +} diff --git a/cmd/builder/root.go b/cmd/builder/root.go index cfda5b9..87c0d51 100644 --- a/cmd/builder/root.go +++ b/cmd/builder/root.go @@ -114,14 +114,35 @@ func isFlutterProject() bool { return err == nil } +// isExpoProject reports whether package.json declares a dependency on Expo. +// It reads the dependency maps rather than searching the raw text, so a +// package named "expo", a script that shells out to it, or a keyword does not +// make an unrelated Node project look like an Expo app. An unparseable +// package.json falls back to the substring test the runners use, so the CLI +// and the runners still agree on such a file. func isExpoProject() bool { data, err := os.ReadFile("package.json") if err != nil { return false } - return strings.Contains(string(data), `"expo"`) + var pkg struct { + Dependencies map[string]json.RawMessage `json:"dependencies"` + DevDependencies map[string]json.RawMessage `json:"devDependencies"` + } + if err := json.Unmarshal(data, &pkg); err != nil { + return strings.Contains(string(data), `"expo"`) + } + _, dep := pkg.Dependencies["expo"] + _, devDep := pkg.DevDependencies["expo"] + return dep || devDep } +// expoManagedFramework names a managed Expo project: one that depends on Expo +// but keeps no Xcode project in git, because `expo prebuild` generates it. The +// runner runs that prebuild, so the iOS path is still "ios" — that is where +// prebuild puts the project. +const expoManagedFramework = "Expo (managed)" + // kmpPluginRe matches a declaration of the Kotlin Multiplatform Gradle plugin, // in the Kotlin DSL (`kotlin("multiplatform")`) or Groovy/plugin-id form. It // must stay in step with the detection in the workflow template: a project the @@ -216,6 +237,16 @@ func detectIOSPath() (string, string) { } } + // No Xcode project anywhere, but the app depends on Expo: a managed + // project, whose ios/ directory the runner generates with `expo prebuild`. + // Flutter is checked first for the same reason the runners check + // pubspec.yaml before package.json: a Flutter repo that does not commit + // ios/ is not a managed Expo project, and the runners would never prebuild + // it. + if !isFlutterProject() && isExpoProject() { + return "ios", expoManagedFramework + } + return "", "" } @@ -296,6 +327,10 @@ func runInit(cmd *cobra.Command, args []string) error { detectedPath, framework := detectIOSPath() if detectedPath != "" { fmt.Printf("Detected %s project (iOS at '%s')\n", framework, detectedPath) + if framework == expoManagedFramework { + fmt.Printf("There is no '%s' directory yet; the build generates it on the runner with 'expo prebuild'.\n", detectedPath) + fmt.Println("app.json / app.config.js must set ios.bundleIdentifier, or prebuild cannot run unattended.") + } confirmPrompt := promptui.Prompt{ Label: "Use this path", IsConfirm: true, @@ -473,7 +508,7 @@ func runInit(cmd *cobra.Command, args []string) error { fmt.Println() return runBuild(context.Background(), cfg, build.BuildOptions{ OutputDir: "dist", - Timeout: 30 * time.Minute, + Timeout: build.DefaultTimeout, Remote: remoteName, }) } @@ -547,7 +582,7 @@ func init() { // iOS build command flags iosBuildCmd.Flags().StringP("output", "o", "dist", "Output directory for IPA") - iosBuildCmd.Flags().Duration("timeout", 30*time.Minute, "Build timeout") + iosBuildCmd.Flags().Duration("timeout", build.DefaultTimeout, "Build timeout") 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)") diff --git a/internal/build/coordinator.go b/internal/build/coordinator.go index 4dfc97f..19e510c 100644 --- a/internal/build/coordinator.go +++ b/internal/build/coordinator.go @@ -21,7 +21,7 @@ import ( const ( // DefaultTimeout is the default build timeout - DefaultTimeout = 30 * time.Minute + DefaultTimeout = 90 * time.Minute // WorkflowFile is the name of the workflow file WorkflowFile = "ios-build.yml" diff --git a/internal/github/workflow.go b/internal/github/workflow.go index 70dce2e..ff63d1a 100644 --- a/internal/github/workflow.go +++ b/internal/github/workflow.go @@ -260,13 +260,22 @@ func (c *Client) FindArtifactByName(ctx context.Context, owner, repo string, run return nil, fmt.Errorf("artifact %q not found", name) } +// artifactPollInterval is fixed (no backoff) to catch the artifact quickly +// after upload. A variable so tests can shorten it. +var artifactPollInterval = 5 * time.Second + +// maxConsecutiveStatusErrors bounds how long a failing run-status check is +// tolerated while polling: GitHub's API returns the odd 5xx during a long +// wait, and a build that is still running must not be abandoned for one. +// With the 5s interval this is a minute of failed polls. +const maxConsecutiveStatusErrors = 12 + // PollForArtifact polls until an artifact with the given name appears in a workflow run. // This allows downloading the artifact as soon as it's uploaded, without waiting for the // entire workflow to complete. onPoll, if non-nil, runs once per attempt. func (c *Client) PollForArtifact(ctx context.Context, owner, repo string, runID int64, artifactName string, timeout time.Duration, onPoll func()) (*Artifact, error) { deadline := time.Now().Add(timeout) - // Use fixed 5s interval (no backoff) to catch artifact quickly after upload - const artifactPollInterval = 5 * time.Second + statusErrors := 0 for { if time.Now().After(deadline) { @@ -281,11 +290,16 @@ func (c *Client) PollForArtifact(ctx context.Context, owner, repo string, runID // Check if workflow failed (no point waiting for artifact) run, err := c.GetWorkflowRun(ctx, owner, repo, runID) - if err != nil { - return nil, fmt.Errorf("failed to check workflow status: %w", err) - } - if run.Status == "completed" && run.Conclusion != "success" { + switch { + case err != nil: + statusErrors++ + if statusErrors >= maxConsecutiveStatusErrors { + return nil, fmt.Errorf("failed to check workflow status: %w", err) + } + case run.Status == "completed" && run.Conclusion != "success": return nil, fmt.Errorf("workflow failed with conclusion: %s", run.Conclusion) + default: + statusErrors = 0 } if onPoll != nil { diff --git a/internal/github/workflow_test.go b/internal/github/workflow_test.go new file mode 100644 index 0000000..8f3d149 --- /dev/null +++ b/internal/github/workflow_test.go @@ -0,0 +1,71 @@ +package github + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" +) + +// pollServer fakes the two endpoints PollForArtifact hits: the artifact list, +// empty until artifactAfter run-status calls have happened, and the run status, +// which answers 503 for the first statusErrors calls. +func pollServer(t *testing.T, statusErrors int, artifactAfter int) (*Client, *int32) { + t.Helper() + var statusCalls int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "/artifacts"): + if artifactAfter >= 0 && int(atomic.LoadInt32(&statusCalls)) >= artifactAfter { + w.Write([]byte(`{"artifacts":[{"id":7,"name":"ipa"}]}`)) + return + } + w.Write([]byte(`{"artifacts":[]}`)) + default: + n := atomic.AddInt32(&statusCalls, 1) + if int(n) <= statusErrors { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + w.Write([]byte(`{"id":1,"status":"in_progress","conclusion":""}`)) + } + })) + t.Cleanup(srv.Close) + c := NewClient("token") + c.baseURL = srv.URL + return c, &statusCalls +} + +func TestPollForArtifactSurvivesTransientStatusErrors(t *testing.T) { + old := artifactPollInterval + artifactPollInterval = time.Millisecond + t.Cleanup(func() { artifactPollInterval = old }) + + // Two 503s in a row, then the artifact appears: the poll must not give up. + c, calls := pollServer(t, 2, 3) + artifact, err := c.PollForArtifact(context.Background(), "o", "r", 1, "ipa", time.Second, nil) + if err != nil { + t.Fatalf("poll gave up on a transient error: %v", err) + } + if artifact.ID != 7 || atomic.LoadInt32(calls) < 3 { + t.Fatalf("artifact %+v after %d status calls", artifact, *calls) + } +} + +func TestPollForArtifactGivesUpWhenStatusKeepsFailing(t *testing.T) { + old := artifactPollInterval + artifactPollInterval = time.Millisecond + t.Cleanup(func() { artifactPollInterval = old }) + + c, calls := pollServer(t, 1000, -1) + _, err := c.PollForArtifact(context.Background(), "o", "r", 1, "ipa", time.Second, nil) + if err == nil || !strings.Contains(err.Error(), "failed to check workflow status") { + t.Fatalf("expected a status error, got %v", err) + } + if n := atomic.LoadInt32(calls); n != maxConsecutiveStatusErrors { + t.Fatalf("gave up after %d status calls, want %d", n, maxConsecutiveStatusErrors) + } +} diff --git a/internal/workflow/expo_test.go b/internal/workflow/expo_test.go new file mode 100644 index 0000000..bbe3e4f --- /dev/null +++ b/internal/workflow/expo_test.go @@ -0,0 +1,251 @@ +package workflow + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" + "slices" + "strings" + "testing" + + "go.yaml.in/yaml/v3" +) + +type workflowStep struct { + Name string `yaml:"name"` + If string `yaml:"if"` + Env map[string]any `yaml:"env"` + Run string `yaml:"run"` +} + +// steps parses a workflow template and returns the steps of its single job. +func steps(t *testing.T, template string) []workflowStep { + t.Helper() + data, err := GetTemplate(template) + if err != nil { + t.Fatal(err) + } + var parsed struct { + Jobs map[string]struct { + Steps []workflowStep `yaml:"steps"` + } `yaml:"jobs"` + } + if err := yaml.Unmarshal(data, &parsed); err != nil { + t.Fatalf("%s: %v", template, err) + } + if len(parsed.Jobs) != 1 { + t.Fatalf("%s: expected one job, got %d", template, len(parsed.Jobs)) + } + for _, job := range parsed.Jobs { + return job.Steps + } + return nil +} + +func indexOfStep(t *testing.T, all []workflowStep, name string) int { + t.Helper() + i := slices.IndexFunc(all, func(s workflowStep) bool { return s.Name == name }) + if i < 0 { + t.Fatalf("no step named %q", name) + } + return i +} + +// TestExpoPrebuildStep pins the managed-Expo contract in both GitHub +// workflows: prebuild runs for Expo projects only, after the node dependencies +// it needs and before the Pods steps that read the Podfile it writes, and it is +// non-interactive so a missing bundle identifier fails instead of hanging. +func TestExpoPrebuildStep(t *testing.T) { + for _, template := range []string{"ios-build.yml", "ios-share.yml"} { + t.Run(template, func(t *testing.T) { + all := steps(t, template) + prebuild := indexOfStep(t, all, "Expo prebuild") + step := all[prebuild] + + if step.If != "steps.detect.outputs.type == 'expo'" { + t.Fatalf("prebuild gate = %q", step.If) + } + if got := step.Env["CI"]; got != "1" { + t.Fatalf("CI env = %v, want \"1\"; prebuild would prompt", got) + } + for _, want := range []string{"expo prebuild --platform ios --no-install", "bundleIdentifier", "::error::"} { + if !strings.Contains(step.Run, want) { + t.Fatalf("prebuild script is missing %q", want) + } + } + if prebuild < indexOfStep(t, all, "Install JS dependencies") { + t.Fatal("prebuild runs before the node dependencies it needs") + } + if template == "ios-build.yml" && prebuild > indexOfStep(t, all, "Restore Pods cache") { + t.Fatal("prebuild runs after the Pods cache, so the Podfile it writes is cached too late") + } + // `pod install` and the scheme detection both live in the build + // step, which must see the project prebuild generates. + build := "Build IPA" + if template == "ios-share.yml" { + build = "Build for the simulator" + } + if prebuild > indexOfStep(t, all, build) { + t.Fatalf("prebuild runs after %q, which needs the project it generates", build) + } + + // The steps that walk the iOS directory run before prebuild, so + // they must tolerate it not existing yet. + for _, name := range []string{"Generate Xcode project (XcodeGen)", "Check base configuration files"} { + if !strings.Contains(all[indexOfStep(t, all, name)].Run, `if [ ! -d "$IOS_PATH" ]`) { + t.Fatalf("%q does not skip a missing iOS directory", name) + } + } + }) + } +} + +// expoPrebuildCases runs a prebuild script against stubbed tooling, so its +// decisions are checked without a macOS CI machine: skip an ejected project, +// refuse a project without a bundle identifier, and otherwise prebuild +// non-interactively. +func expoPrebuildCases(t *testing.T, script string) { + t.Helper() + + // npx answers `expo config` from NPX_CONFIG_JSON and records a prebuild + // instead of running one. + const npx = `#!/bin/bash +set -eu +if [ "${2:-}" = "config" ]; then + printf '%s' "${NPX_CONFIG_JSON:-{\}}" + exit 0 +fi +printf '%s' "${CI:-unset}" > "$CI_LOG" +mkdir -p "$IOS_PATH/App.xcodeproj" +` + + for _, tt := range []struct { + name string + appJSON string + configJSON string + ejected bool + wantErr string + wantRun bool + }{ + {name: "bundle id in app.json", appJSON: `{"expo":{"ios":{"bundleIdentifier":"com.example.app"}}}`, wantRun: true}, + {name: "bundle id from app.config.js", appJSON: `{"expo":{}}`, configJSON: `{"ios":{"bundleIdentifier":"com.example.app"}}`, wantRun: true}, + {name: "no bundle id", appJSON: `{"expo":{"name":"app"}}`, wantErr: "bundleIdentifier"}, + {name: "ejected project", appJSON: `{"expo":{}}`, ejected: true}, + } { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + bin := filepath.Join(dir, "bin") + if err := os.MkdirAll(bin, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(bin, "npx"), []byte(npx), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "app.json"), []byte(tt.appJSON), 0644); err != nil { + t.Fatal(err) + } + if tt.ejected { + if err := os.MkdirAll(filepath.Join(dir, "ios", "Ejected.xcodeproj"), 0755); err != nil { + t.Fatal(err) + } + } + path := filepath.Join(dir, "prebuild.sh") + if err := os.WriteFile(path, []byte(script), 0644); err != nil { + t.Fatal(err) + } + ciLog := filepath.Join(dir, "ci.log") + cmd := exec.Command("bash", path) + cmd.Dir = dir + cmd.Env = append(os.Environ(), + "PATH="+bin+string(os.PathListSeparator)+os.Getenv("PATH"), + "IOS_PATH=ios", "CI=1", "CI_LOG="+ciLog, "NPX_CONFIG_JSON="+tt.configJSON) + out, err := cmd.CombinedOutput() + + if tt.wantErr != "" { + if err == nil || !strings.Contains(string(out), tt.wantErr) { + t.Fatalf("expected failure mentioning %q, got: %s %v", tt.wantErr, out, err) + } + } else if err != nil { + t.Fatalf("prebuild: %s %v", out, err) + } + ci, ciErr := os.ReadFile(ciLog) + if tt.wantRun { + if ciErr != nil { + t.Fatal("prebuild did not run:", ciErr) + } + if string(ci) != "1" { + t.Fatalf("prebuild ran with CI=%q, want \"1\"", ci) + } + } else if ciErr == nil { + t.Fatal("prebuild ran when it should not have") + } + }) + } +} + +// TestRunnerExpoPrebuild exercises the Codemagic/Bitrise runner's +// expo_prebuild function. +func TestRunnerExpoPrebuild(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("macOS/Linux shell test") + } + if _, err := exec.LookPath("jq"); err != nil { + t.Skip("jq unavailable") + } + data, err := GetTemplate("runner.sh") + if err != nil { + t.Fatal(err) + } + script := string(data) + start := strings.Index(script, "\nexpo_prebuild() {\n") + if start < 0 { + t.Fatal("runner.sh has no expo_prebuild function") + } + body := script[start:] + end := strings.Index(body, "\n}\n") + if end < 0 { + t.Fatal("expo_prebuild is not terminated") + } + expoPrebuildCases(t, "set -euo pipefail\n"+body[:end+3]+"\nexpo_prebuild\n") +} + +// TestWorkflowExpoPrebuild exercises the same decisions in the GitHub +// workflows, whose step scripts are a separate copy of that logic. +func TestWorkflowExpoPrebuild(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("macOS/Linux shell test") + } + if _, err := exec.LookPath("jq"); err != nil { + t.Skip("jq unavailable") + } + for _, template := range []string{"ios-build.yml", "ios-share.yml"} { + t.Run(template, func(t *testing.T) { + all := steps(t, template) + expoPrebuildCases(t, all[indexOfStep(t, all, "Expo prebuild")].Run) + }) + } +} + +// TestWorkflowRunScriptsParse syntax-checks the shell in every step whose +// script holds no workflow expression. +func TestWorkflowRunScriptsParse(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("macOS/Linux shell syntax test") + } + dir := t.TempDir() + for _, template := range []string{"ios-build.yml", "ios-share.yml"} { + for i, step := range steps(t, template) { + if step.Run == "" || strings.Contains(step.Run, "${{") { + continue + } + script := filepath.Join(dir, strings.TrimSuffix(template, ".yml")+"-"+string(rune('a'+i))+".sh") + if err := os.WriteFile(script, []byte(step.Run), 0644); err != nil { + t.Fatal(err) + } + if out, err := exec.Command("bash", "-n", script).CombinedOutput(); err != nil { + t.Fatalf("%s step %q: %s %v", template, step.Name, out, err) + } + } + } +} diff --git a/internal/workflow/js_test.go b/internal/workflow/js_test.go new file mode 100644 index 0000000..cf0e035 --- /dev/null +++ b/internal/workflow/js_test.go @@ -0,0 +1,354 @@ +package workflow + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +const ( + jsBlockStart = "# >>> js toolchain" + jsBlockEnd = "# <<< js toolchain\n" +) + +// jsBlock returns the shared package-manager shell block embedded in script. +func jsBlock(t *testing.T, label, script string) string { + t.Helper() + script = strings.ReplaceAll(script, "\r\n", "\n") + start := strings.Index(script, jsBlockStart) + if start < 0 { + t.Fatalf("%s: no js toolchain block", label) + } + end := strings.Index(script[start:], jsBlockEnd) + if end < 0 { + t.Fatalf("%s: js toolchain block is not terminated", label) + } + return script[start : start+end+len(jsBlockEnd)] +} + +// jsBlocks collects every copy of the block across the three runner templates. +func jsBlocks(t *testing.T) map[string]string { + t.Helper() + data, err := GetTemplate("runner.sh") + if err != nil { + t.Fatal(err) + } + found := map[string]string{"runner.sh": jsBlock(t, "runner.sh", string(data))} + for _, template := range []string{"ios-build.yml", "ios-share.yml"} { + copies := 0 + for _, step := range steps(t, template) { + if !strings.Contains(step.Run, jsBlockStart) { + continue + } + copies++ + label := template + " step " + step.Name + found[label] = jsBlock(t, label, step.Run) + } + // The block is needed twice: once to resolve the manager before + // setup-node, once to install with it afterwards. + if copies != 2 { + t.Fatalf("%s embeds the js toolchain block %d times, want 2", template, copies) + } + } + return found +} + +// TestJSToolchainBlockIdentical keeps the three runners installing dependencies +// the same way: a project that builds on GitHub must build on Codemagic and +// Bitrise, and the share workflow must match the build workflow. +func TestJSToolchainBlockIdentical(t *testing.T) { + blocks := jsBlocks(t) + want := blocks["runner.sh"] + for label, got := range blocks { + if got != want { + t.Errorf("%s has drifted from runner.sh:\n%s", label, got) + } + } + for _, needle := range []string{"js_detect_manager()", "js_node_version()", "js_install()"} { + if !strings.Contains(want, needle) { + t.Fatalf("the shared block is missing %s", needle) + } + } +} + +// jsInstallScript is the shared block followed by one call, ready to run. +func jsInstallScript(t *testing.T) string { + t.Helper() + return "set -eu\n" + jsBlocks(t)["runner.sh"] + "js_install\n" +} + +// TestJSInstall runs the shared block against stubbed package managers. Every +// case asserts the exact commands: `npm install` in a pnpm or yarn workspace is +// what fails with EUNSUPPORTEDPROTOCOL Unsupported URL Type "workspace:". +func TestJSInstall(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("macOS/Linux shell test") + } + if _, err := exec.LookPath("jq"); err != nil { + t.Skip("jq unavailable") + } + script := jsInstallScript(t) + + // Every stubbed manager records its invocation instead of running. + const stub = `#!/bin/bash +printf '%s %s\n' "$(basename "$0")" "$*" >> "$CMD_LOG" +` + for _, tt := range []struct { + name string + files map[string]string + cached bool + noCorepack bool + wantManager string + wantCmds []string + }{{ + name: "packageManager pnpm", + files: map[string]string{"package.json": `{"packageManager":"pnpm@12.1.0"}`, "pnpm-lock.yaml": "lockfileVersion: '9.0'\n"}, + wantManager: "pnpm", + wantCmds: []string{"corepack enable", "pnpm install --frozen-lockfile"}, + }, { + name: "packageManager pnpm without a lockfile", + files: map[string]string{"package.json": `{"packageManager":"pnpm@12.1.0"}`}, + wantManager: "pnpm", + wantCmds: []string{"corepack enable", "pnpm install"}, + }, { + name: "yarn classic lockfile", + files: map[string]string{"package.json": `{}`, "yarn.lock": "# yarn lockfile v1\n"}, + wantManager: "yarn", + wantCmds: []string{"corepack enable", "yarn install --frozen-lockfile"}, + }, { + name: "yarn berry", + files: map[string]string{"package.json": `{"packageManager":"yarn@4.1.0"}`, "yarn.lock": "", ".yarnrc.yml": "nodeLinker: node-modules\n"}, + wantManager: "yarn", + wantCmds: []string{"corepack enable", "yarn install --immutable"}, + }, { + name: "bun lockfile", + files: map[string]string{"package.json": `{}`, "bun.lockb": ""}, + wantManager: "bun", + wantCmds: []string{"bun install --frozen-lockfile"}, + }, { + name: "package-lock.json", + files: map[string]string{"package.json": `{}`, "package-lock.json": `{}`}, + wantManager: "npm", + wantCmds: []string{"npm ci"}, + }, { + name: "no lockfile at all", + files: map[string]string{"package.json": `{}`}, + wantManager: "npm", + wantCmds: []string{"npm install"}, + }, { + // The manager still has to land on PATH for `expo prebuild`. + name: "restored from cache", + files: map[string]string{"package.json": `{"packageManager":"pnpm@12.1.0"}`, "pnpm-lock.yaml": ""}, + cached: true, + wantManager: "pnpm", + wantCmds: []string{"corepack enable"}, + }, { + // Node 25+ no longer bundles corepack; it is installed rather than + // replaced by an unpinned manager. + name: "corepack missing", + files: map[string]string{"package.json": `{"packageManager":"pnpm@12.1.0"}`, "pnpm-lock.yaml": ""}, + noCorepack: true, + wantManager: "pnpm", + wantCmds: []string{"npm install -g corepack", "pnpm install --frozen-lockfile"}, + }} { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + bin := filepath.Join(dir, "bin") + if err := os.MkdirAll(bin, 0755); err != nil { + t.Fatal(err) + } + for _, name := range []string{"corepack", "npm", "pnpm", "yarn", "bun"} { + if name == "corepack" && tt.noCorepack { + continue + } + if err := os.WriteFile(filepath.Join(bin, name), []byte(stub), 0755); err != nil { + t.Fatal(err) + } + } + // PATH holds the stubs and the few real tools the block uses, so a + // manager installed on this machine cannot stand in for a stub. + tools := filepath.Join(dir, "tools") + if err := os.MkdirAll(tools, 0755); err != nil { + t.Fatal(err) + } + for _, name := range []string{"jq", "tr", "head", "awk", "sed", "basename"} { + real, err := exec.LookPath(name) + if err != nil { + t.Skip(name + " unavailable") + } + if err := os.Symlink(real, filepath.Join(tools, name)); err != nil { + t.Fatal(err) + } + } + for name, content := range tt.files { + if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0644); err != nil { + t.Fatal(err) + } + } + path := filepath.Join(dir, "install.sh") + if err := os.WriteFile(path, []byte(script), 0644); err != nil { + t.Fatal(err) + } + log := filepath.Join(dir, "cmd.log") + cmd := exec.Command("bash", path) + cmd.Dir = dir + cmd.Env = append(os.Environ(), + "PATH="+bin+string(os.PathListSeparator)+tools, + "CMD_LOG="+log, "JS_MANAGER=", "JS_DEPS_CACHED=") + if tt.cached { + cmd.Env = append(cmd.Env, "JS_DEPS_CACHED=true") + } + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("js_install: %s %v", out, err) + } + if want := "Package manager: " + tt.wantManager; !strings.Contains(string(out), want) { + t.Fatalf("output %q does not report %q", out, want) + } + recorded, err := os.ReadFile(log) + if err != nil && len(tt.wantCmds) > 0 { + t.Fatal("no commands ran:", err) + } + got := strings.Split(strings.TrimSpace(string(recorded)), "\n") + if len(got) == 1 && got[0] == "" { + got = nil + } + if strings.Join(got, "|") != strings.Join(tt.wantCmds, "|") { + t.Fatalf("commands = %q, want %q", got, tt.wantCmds) + } + }) + } +} + +// TestJSNodeVersion runs js_node_version as runner.sh does, where the result +// goes to nvm or n rather than to setup-node: a version file with a leading +// "v" must not fall back to the default. +func TestJSNodeVersion(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("macOS/Linux shell test") + } + if _, err := exec.LookPath("jq"); err != nil { + t.Skip("jq unavailable") + } + script := "set -eu\n" + jsBlocks(t)["runner.sh"] + "js_node_version\n" + for _, tt := range []struct { + name string + files map[string]string + want string + }{ + {".nvmrc with v prefix", map[string]string{"package.json": `{}`, ".nvmrc": "v20.11.1\n"}, "20.11.1"}, + {".nvmrc lts alias", map[string]string{"package.json": `{}`, ".nvmrc": "lts/*\n"}, "lts/*"}, + {".node-version with CRLF", map[string]string{"package.json": `{}`, ".node-version": "22\r\n"}, "22"}, + {".nvmrc beats engines", map[string]string{"package.json": `{"engines":{"node":"18"}}`, ".nvmrc": "20\n"}, "20"}, + {"engines 24.x", map[string]string{"package.json": `{"engines":{"node":"24.x"}}`}, "24.x"}, + {"engines caret", map[string]string{"package.json": `{"engines":{"node":"^20.11"}}`}, "20.11"}, + {"engines range", map[string]string{"package.json": `{"engines":{"node":">=20 <23"}}`}, "20"}, + {"engines or", map[string]string{"package.json": `{"engines":{"node":"20 || 22"}}`}, "20"}, + {"engines wildcard", map[string]string{"package.json": `{"engines":{"node":"*"}}`}, "22"}, + {"engines without node", map[string]string{"package.json": `{"engines":{"npm":">=10"}}`}, "22"}, + {"nothing to go on", map[string]string{"package.json": `{}`}, "22"}, + } { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + for name, content := range tt.files { + if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0644); err != nil { + t.Fatal(err) + } + } + path := filepath.Join(dir, "version.sh") + if err := os.WriteFile(path, []byte(script), 0644); err != nil { + t.Fatal(err) + } + cmd := exec.Command("bash", path) + cmd.Dir = dir + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("js_node_version: %s %v", out, err) + } + if got := strings.TrimSpace(string(out)); got != tt.want { + t.Fatalf("js_node_version = %q, want %q", got, tt.want) + } + }) + } +} + +// TestResolveJSToolchainStep checks the step that feeds actions/setup-node, +// which cannot choose between node-version and node-version-file itself. +func TestResolveJSToolchainStep(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("macOS/Linux shell test") + } + if _, err := exec.LookPath("jq"); err != nil { + t.Skip("jq unavailable") + } + for _, template := range []string{"ios-build.yml", "ios-share.yml"} { + all := steps(t, template) + script := all[indexOfStep(t, all, "Resolve JS toolchain")].Run + for _, tt := range []struct { + name string + files map[string]string + want map[string]string + }{{ + name: ".nvmrc wins", + files: map[string]string{"package.json": `{"engines":{"node":"18"}}`, ".nvmrc": "20.11.1\n"}, + want: map[string]string{"node_version": "", "node_version_file": ".nvmrc"}, + }, { + name: ".node-version", + files: map[string]string{"package.json": `{}`, ".node-version": "21.6.0\n"}, + want: map[string]string{"node_version": "", "node_version_file": ".node-version"}, + }, { + name: "engines 24.x", + files: map[string]string{"package.json": `{"engines":{"node":"24.x"},"packageManager":"pnpm@12.1.0"}`}, + want: map[string]string{"node_version": "24.x", "node_version_file": "", "manager": "pnpm", "exec": "pnpm exec"}, + }, { + name: "engines caret range", + files: map[string]string{"package.json": `{"engines":{"node":"^20.11"}}`}, + want: map[string]string{"node_version": "20.11", "node_version_file": ""}, + }, { + name: "engines comparator range", + files: map[string]string{"package.json": `{"engines":{"node":">=18.17.0 <21"}}`}, + want: map[string]string{"node_version": "18.17.0", "node_version_file": ""}, + }, { + name: "nothing to go on", + files: map[string]string{"package.json": `{}`}, + want: map[string]string{"node_version": "22", "node_version_file": "", "manager": "npm", "exec": "npx"}, + }} { + t.Run(template+"/"+tt.name, func(t *testing.T) { + dir := t.TempDir() + for name, content := range tt.files { + if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0644); err != nil { + t.Fatal(err) + } + } + path := filepath.Join(dir, "resolve.sh") + if err := os.WriteFile(path, []byte(script), 0644); err != nil { + t.Fatal(err) + } + output := filepath.Join(dir, "github-output") + cmd := exec.Command("bash", path) + cmd.Dir = dir + cmd.Env = append(os.Environ(), "GITHUB_OUTPUT="+output, "JS_MANAGER=") + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("resolve: %s %v", out, err) + } + data, err := os.ReadFile(output) + if err != nil { + t.Fatal(err) + } + got := map[string]string{} + for _, line := range strings.Split(strings.TrimSpace(string(data)), "\n") { + key, value, _ := strings.Cut(line, "=") + got[key] = value + } + for key, want := range tt.want { + if got[key] != want { + t.Errorf("%s = %q, want %q (all: %v)", key, got[key], want, got) + } + } + }) + } + } +} diff --git a/internal/workflow/templates/ios-build.yml b/internal/workflow/templates/ios-build.yml index e98bc2b..240fda1 100644 --- a/internal/workflow/templates/ios-build.yml +++ b/internal/workflow/templates/ios-build.yml @@ -56,7 +56,7 @@ jobs: runs-on: macos-latest permissions: contents: write # deletes the trigger tag - timeout-minutes: 30 + timeout-minutes: 90 # a cold React Native/Expo build on the free runner takes 30-60 minutes steps: - name: Checkout repository @@ -164,6 +164,12 @@ jobs: IOS_PATH: ${{ steps.params.outputs.ios_path }} run: | set -e + # A managed Expo project has no iOS directory at all until the + # prebuild step below creates it. + if [ ! -d "$IOS_PATH" ]; then + echo "No '$IOS_PATH' directory yet — skipping." + exit 0 + fi cd "$IOS_PATH" # Projects generated by XcodeGen usually keep the .xcodeproj out of git, @@ -193,6 +199,10 @@ jobs: IOS_PATH: ${{ steps.params.outputs.ios_path }} run: | set -e + if [ ! -d "$IOS_PATH" ]; then + echo "No '$IOS_PATH' directory yet — skipping." + exit 0 + fi MISSING="" for pbx in $(find "$IOS_PATH" -maxdepth 2 -name project.pbxproj -not -path '*/Pods.xcodeproj/*'); do for id in $(grep -o 'baseConfigurationReference = [A-Za-z0-9]*' "$pbx" | awk '{print $3}' | sort -u); do @@ -227,25 +237,301 @@ jobs: if: steps.detect.outputs.type == 'flutter' run: flutter pub get + # setup-node cannot make `with:` keys conditional, so the version and the + # version file are both computed here; it ignores an empty version file. + - name: Resolve JS toolchain + id: js + if: steps.detect.outputs.type == 'reactnative' || steps.detect.outputs.type == 'expo' + run: | + set -e + # >>> js toolchain (keep identical across ios-build.yml, ios-share.yml, runner.sh) + # The package manager the project actually uses: the packageManager field + # first, then the lockfile, then npm. Guessing npm is not safe — `npm install` + # in a pnpm or yarn workspace fails with + # 'EUNSUPPORTEDPROTOCOL Unsupported URL Type "workspace:"'. + js_detect_manager() { + if [ -z "${JS_MANAGER:-}" ]; then + case "$(jq -r '.packageManager // empty' package.json 2>/dev/null || true)" in + pnpm|pnpm@*) JS_MANAGER=pnpm ;; + yarn|yarn@*) JS_MANAGER=yarn ;; + bun|bun@*) JS_MANAGER=bun ;; + npm|npm@*) JS_MANAGER=npm ;; + *) + if [ -f pnpm-lock.yaml ]; then JS_MANAGER=pnpm + elif [ -f yarn.lock ]; then JS_MANAGER=yarn + elif [ -f bun.lock ] || [ -f bun.lockb ]; then JS_MANAGER=bun + else JS_MANAGER=npm # package-lock.json, or no lockfile at all + fi + ;; + esac + fi + case "$JS_MANAGER" in + pnpm) JS_EXEC='pnpm exec' ;; + yarn) JS_EXEC=yarn ;; + bun) JS_EXEC=bunx ;; + *) JS_EXEC=npx ;; + esac + export JS_MANAGER JS_EXEC + echo "Package manager: $JS_MANAGER" + } + + # The Node version the project asks for: .nvmrc or .node-version, else the + # first comparator of engines.node, else 22. A leading "v" or range prefix + # is dropped: nvm, n and setup-node all take "20.11.1" and "24.x" as they are. + js_node_version() { + js_version="" + for js_file in .nvmrc .node-version; do + if [ -f "$js_file" ]; then + js_version=$(tr -d ' \t\r' < "$js_file" | head -n1) + break + fi + done + if [ -z "$js_version" ]; then + js_version=$(jq -r '.engines.node // empty' package.json 2>/dev/null || true) + js_version=$(printf '%s' "${js_version%%|*}" | awk '{print $1}') + fi + js_version=$(printf '%s' "$js_version" | sed -E 's/^(>=|<=|>|<|=|\^|~|v)+//') + case "$js_version" in + [0-9]*|lts/*) ;; + *) js_version=22 ;; + esac + printf '%s\n' "$js_version" + } + + # Put the manager on PATH first, then install — unless a cache already restored + # node_modules, in which case the manager is still wanted for `expo prebuild` + # and for the React Native build phases that shell out to it. + js_install() { + js_detect_manager + case "$JS_MANAGER" in + pnpm|yarn) + # corepack installs the version packageManager pins. Node 16.10-24 + # bundle it; Node 25+ and some provider images do not. + command -v corepack >/dev/null 2>&1 || npm install -g corepack || true + corepack enable || true + command -v "$JS_MANAGER" >/dev/null 2>&1 || npm install -g "$JS_MANAGER" + ;; + bun) + if ! command -v bun >/dev/null 2>&1; then + curl -fsSL https://bun.sh/install | bash + export BUN_INSTALL="${BUN_INSTALL:-$HOME/.bun}" + export PATH="$BUN_INSTALL/bin:$PATH" + # A step's PATH does not survive into the next GitHub Actions step. + if [ -n "${GITHUB_PATH:-}" ]; then echo "$BUN_INSTALL/bin" >> "$GITHUB_PATH"; fi + fi + ;; + esac + if [ "${JS_DEPS_CACHED:-}" = "true" ]; then + echo "node_modules restored from cache; skipping install" + return 0 + fi + case "$JS_MANAGER" in + pnpm) + if [ -f pnpm-lock.yaml ]; then pnpm install --frozen-lockfile; else pnpm install; fi + ;; + yarn) + if [ -f .yarnrc.yml ]; then yarn install --immutable + elif [ -f yarn.lock ]; then yarn install --frozen-lockfile + else yarn install + fi + ;; + bun) + if [ -f bun.lock ] || [ -f bun.lockb ]; then bun install --frozen-lockfile; else bun install; fi + ;; + *) + if [ -f package-lock.json ]; then npm ci; else npm install; fi + ;; + esac + } + # <<< js toolchain + js_detect_manager + NODE_VERSION_FILE="" + for f in .nvmrc .node-version; do + if [ -f "$f" ]; then NODE_VERSION_FILE="$f"; break; fi + done + NODE_VERSION="" + if [ -n "$NODE_VERSION_FILE" ]; then + echo "Node version: from $NODE_VERSION_FILE" + else + NODE_VERSION=$(js_node_version) + echo "Node version: $NODE_VERSION" + fi + { + echo "manager=$JS_MANAGER" + echo "exec=$JS_EXEC" + echo "node_version=$NODE_VERSION" + echo "node_version_file=$NODE_VERSION_FILE" + } >> "$GITHUB_OUTPUT" + - name: Setup Node.js if: steps.detect.outputs.type == 'reactnative' || steps.detect.outputs.type == 'expo' uses: actions/setup-node@v7 with: - node-version: '20' + node-version: ${{ steps.js.outputs.node_version }} + node-version-file: ${{ steps.js.outputs.node_version_file }} + # pnpm links its store into node_modules/.pnpm, so caching node_modules + # stays correct for every manager. The key carries the manager name + # because the same lockfiles can be installed by different ones. - name: Restore node_modules cache if: steps.detect.outputs.type == 'reactnative' || steps.detect.outputs.type == 'expo' uses: actions/cache@v6 id: node-modules-cache with: path: node_modules - key: node-modules-${{ runner.os }}-${{ hashFiles('package-lock.json', 'yarn.lock') }} + key: node-modules-${{ runner.os }}-${{ steps.js.outputs.manager }}-${{ hashFiles('package-lock.json', 'yarn.lock', 'pnpm-lock.yaml', 'bun.lock', 'bun.lockb') }} restore-keys: | - node-modules-${{ runner.os }}- + node-modules-${{ runner.os }}-${{ steps.js.outputs.manager }}- + + # Runs even on a cache hit: the manager itself still has to be on PATH for + # the prebuild and build steps that follow. + - name: Install JS dependencies + if: steps.detect.outputs.type == 'reactnative' || steps.detect.outputs.type == 'expo' + env: + JS_MANAGER: ${{ steps.js.outputs.manager }} + JS_DEPS_CACHED: ${{ steps.node-modules-cache.outputs.cache-hit }} + run: | + set -e + # >>> js toolchain (keep identical across ios-build.yml, ios-share.yml, runner.sh) + # The package manager the project actually uses: the packageManager field + # first, then the lockfile, then npm. Guessing npm is not safe — `npm install` + # in a pnpm or yarn workspace fails with + # 'EUNSUPPORTEDPROTOCOL Unsupported URL Type "workspace:"'. + js_detect_manager() { + if [ -z "${JS_MANAGER:-}" ]; then + case "$(jq -r '.packageManager // empty' package.json 2>/dev/null || true)" in + pnpm|pnpm@*) JS_MANAGER=pnpm ;; + yarn|yarn@*) JS_MANAGER=yarn ;; + bun|bun@*) JS_MANAGER=bun ;; + npm|npm@*) JS_MANAGER=npm ;; + *) + if [ -f pnpm-lock.yaml ]; then JS_MANAGER=pnpm + elif [ -f yarn.lock ]; then JS_MANAGER=yarn + elif [ -f bun.lock ] || [ -f bun.lockb ]; then JS_MANAGER=bun + else JS_MANAGER=npm # package-lock.json, or no lockfile at all + fi + ;; + esac + fi + case "$JS_MANAGER" in + pnpm) JS_EXEC='pnpm exec' ;; + yarn) JS_EXEC=yarn ;; + bun) JS_EXEC=bunx ;; + *) JS_EXEC=npx ;; + esac + export JS_MANAGER JS_EXEC + echo "Package manager: $JS_MANAGER" + } - - name: Install npm dependencies - if: (steps.detect.outputs.type == 'reactnative' || steps.detect.outputs.type == 'expo') && steps.node-modules-cache.outputs.cache-hit != 'true' - run: npm install + # The Node version the project asks for: .nvmrc or .node-version, else the + # first comparator of engines.node, else 22. A leading "v" or range prefix + # is dropped: nvm, n and setup-node all take "20.11.1" and "24.x" as they are. + js_node_version() { + js_version="" + for js_file in .nvmrc .node-version; do + if [ -f "$js_file" ]; then + js_version=$(tr -d ' \t\r' < "$js_file" | head -n1) + break + fi + done + if [ -z "$js_version" ]; then + js_version=$(jq -r '.engines.node // empty' package.json 2>/dev/null || true) + js_version=$(printf '%s' "${js_version%%|*}" | awk '{print $1}') + fi + js_version=$(printf '%s' "$js_version" | sed -E 's/^(>=|<=|>|<|=|\^|~|v)+//') + case "$js_version" in + [0-9]*|lts/*) ;; + *) js_version=22 ;; + esac + printf '%s\n' "$js_version" + } + + # Put the manager on PATH first, then install — unless a cache already restored + # node_modules, in which case the manager is still wanted for `expo prebuild` + # and for the React Native build phases that shell out to it. + js_install() { + js_detect_manager + case "$JS_MANAGER" in + pnpm|yarn) + # corepack installs the version packageManager pins. Node 16.10-24 + # bundle it; Node 25+ and some provider images do not. + command -v corepack >/dev/null 2>&1 || npm install -g corepack || true + corepack enable || true + command -v "$JS_MANAGER" >/dev/null 2>&1 || npm install -g "$JS_MANAGER" + ;; + bun) + if ! command -v bun >/dev/null 2>&1; then + curl -fsSL https://bun.sh/install | bash + export BUN_INSTALL="${BUN_INSTALL:-$HOME/.bun}" + export PATH="$BUN_INSTALL/bin:$PATH" + # A step's PATH does not survive into the next GitHub Actions step. + if [ -n "${GITHUB_PATH:-}" ]; then echo "$BUN_INSTALL/bin" >> "$GITHUB_PATH"; fi + fi + ;; + esac + if [ "${JS_DEPS_CACHED:-}" = "true" ]; then + echo "node_modules restored from cache; skipping install" + return 0 + fi + case "$JS_MANAGER" in + pnpm) + if [ -f pnpm-lock.yaml ]; then pnpm install --frozen-lockfile; else pnpm install; fi + ;; + yarn) + if [ -f .yarnrc.yml ]; then yarn install --immutable + elif [ -f yarn.lock ]; then yarn install --frozen-lockfile + else yarn install + fi + ;; + bun) + if [ -f bun.lock ] || [ -f bun.lockb ]; then bun install --frozen-lockfile; else bun install; fi + ;; + *) + if [ -f package-lock.json ]; then npm ci; else npm install; fi + ;; + esac + } + # <<< js toolchain + js_install + + # A managed Expo project keeps no ios/ directory in git — `expo prebuild` + # generates it from app.json / app.config.js. It needs node_modules, so it + # runs here, and it must run before the Pods steps: the Podfile it writes + # is what `pod install` later reads. + - name: Expo prebuild + if: steps.detect.outputs.type == 'expo' + env: + IOS_PATH: ${{ steps.params.outputs.ios_path }} + JS_EXEC: ${{ steps.js.outputs.exec }} + CI: '1' # prebuild answers its own prompts instead of hanging + run: | + set -e + if [ -n "$(find "$IOS_PATH" -maxdepth 1 \( -name '*.xcodeproj' -o -name '*.xcworkspace' \) -print -quit 2>/dev/null)" ]; then + echo "'$IOS_PATH' already holds an Xcode project (ejected Expo) — skipping prebuild." + exit 0 + fi + + # Without a bundle identifier prebuild asks for one interactively, + # which on a runner means waiting for the job timeout. + BUNDLE_ID=$(${JS_EXEC:-npx} expo config --type public --json 2>/dev/null | jq -r '.ios.bundleIdentifier // empty' 2>/dev/null || true) + if [ -z "$BUNDLE_ID" ]; then + for f in app.json app.config.json; do + [ -f "$f" ] || continue + BUNDLE_ID=$(jq -r '(.expo.ios.bundleIdentifier // .ios.bundleIdentifier) // empty' "$f" 2>/dev/null || true) + if [ -n "$BUNDLE_ID" ]; then break; fi + done + fi + if [ -z "$BUNDLE_ID" ]; then + echo "::error::This managed Expo project has no ios.bundleIdentifier. Set it in app.json (expo.ios.bundleIdentifier) or app.config.js, commit that, and build again — 'expo prebuild' cannot run unattended without it." + exit 1 + fi + echo "Prebuilding $BUNDLE_ID into '$IOS_PATH'..." + + ${JS_EXEC:-npx} expo prebuild --platform ios --no-install + if [ -z "$(find "$IOS_PATH" -maxdepth 1 -name '*.xcodeproj' -print -quit 2>/dev/null)" ]; then + echo "::error::'expo prebuild' produced no Xcode project in '$IOS_PATH'. If the project generates it elsewhere, set ios.path in builder.json." + exit 1 + fi - name: Setup JDK if: steps.detect.outputs.type == 'kmp' @@ -258,6 +544,10 @@ jobs: if: steps.detect.outputs.type == 'kmp' uses: gradle/actions/setup-gradle@v6 + # A managed Expo project has no Podfile.lock before the prebuild above, so + # hashFiles returns an empty string and the key degrades to the prefix. + # That still restores and saves a Pods directory, and the `pod install` in + # the build step syncs it, so an empty hash is not an error here. - name: Restore Pods cache uses: actions/cache@v6 id: pods-cache diff --git a/internal/workflow/templates/ios-share.yml b/internal/workflow/templates/ios-share.yml index 56f757f..4beb1dc 100644 --- a/internal/workflow/templates/ios-share.yml +++ b/internal/workflow/templates/ios-share.yml @@ -173,6 +173,12 @@ jobs: IOS_PATH: ${{ steps.params.outputs.ios_path }} run: | set -e + # A managed Expo project has no iOS directory at all until the + # prebuild step below creates it. + if [ ! -d "$IOS_PATH" ]; then + echo "No '$IOS_PATH' directory yet — skipping." + exit 0 + fi cd "$IOS_PATH" MANIFEST="" @@ -200,6 +206,10 @@ jobs: IOS_PATH: ${{ steps.params.outputs.ios_path }} run: | set -e + if [ ! -d "$IOS_PATH" ]; then + echo "No '$IOS_PATH' directory yet — skipping." + exit 0 + fi MISSING="" for pbx in $(find "$IOS_PATH" -maxdepth 2 -name project.pbxproj -not -path '*/Pods.xcodeproj/*'); do for id in $(grep -o 'baseConfigurationReference = [A-Za-z0-9]*' "$pbx" | awk '{print $3}' | sort -u); do @@ -234,15 +244,286 @@ jobs: if: steps.detect.outputs.type == 'flutter' run: flutter pub get + # setup-node cannot make `with:` keys conditional, so the version and the + # version file are both computed here; it ignores an empty version file. + - name: Resolve JS toolchain + id: js + if: steps.detect.outputs.type == 'reactnative' || steps.detect.outputs.type == 'expo' + run: | + set -e + # >>> js toolchain (keep identical across ios-build.yml, ios-share.yml, runner.sh) + # The package manager the project actually uses: the packageManager field + # first, then the lockfile, then npm. Guessing npm is not safe — `npm install` + # in a pnpm or yarn workspace fails with + # 'EUNSUPPORTEDPROTOCOL Unsupported URL Type "workspace:"'. + js_detect_manager() { + if [ -z "${JS_MANAGER:-}" ]; then + case "$(jq -r '.packageManager // empty' package.json 2>/dev/null || true)" in + pnpm|pnpm@*) JS_MANAGER=pnpm ;; + yarn|yarn@*) JS_MANAGER=yarn ;; + bun|bun@*) JS_MANAGER=bun ;; + npm|npm@*) JS_MANAGER=npm ;; + *) + if [ -f pnpm-lock.yaml ]; then JS_MANAGER=pnpm + elif [ -f yarn.lock ]; then JS_MANAGER=yarn + elif [ -f bun.lock ] || [ -f bun.lockb ]; then JS_MANAGER=bun + else JS_MANAGER=npm # package-lock.json, or no lockfile at all + fi + ;; + esac + fi + case "$JS_MANAGER" in + pnpm) JS_EXEC='pnpm exec' ;; + yarn) JS_EXEC=yarn ;; + bun) JS_EXEC=bunx ;; + *) JS_EXEC=npx ;; + esac + export JS_MANAGER JS_EXEC + echo "Package manager: $JS_MANAGER" + } + + # The Node version the project asks for: .nvmrc or .node-version, else the + # first comparator of engines.node, else 22. A leading "v" or range prefix + # is dropped: nvm, n and setup-node all take "20.11.1" and "24.x" as they are. + js_node_version() { + js_version="" + for js_file in .nvmrc .node-version; do + if [ -f "$js_file" ]; then + js_version=$(tr -d ' \t\r' < "$js_file" | head -n1) + break + fi + done + if [ -z "$js_version" ]; then + js_version=$(jq -r '.engines.node // empty' package.json 2>/dev/null || true) + js_version=$(printf '%s' "${js_version%%|*}" | awk '{print $1}') + fi + js_version=$(printf '%s' "$js_version" | sed -E 's/^(>=|<=|>|<|=|\^|~|v)+//') + case "$js_version" in + [0-9]*|lts/*) ;; + *) js_version=22 ;; + esac + printf '%s\n' "$js_version" + } + + # Put the manager on PATH first, then install — unless a cache already restored + # node_modules, in which case the manager is still wanted for `expo prebuild` + # and for the React Native build phases that shell out to it. + js_install() { + js_detect_manager + case "$JS_MANAGER" in + pnpm|yarn) + # corepack installs the version packageManager pins. Node 16.10-24 + # bundle it; Node 25+ and some provider images do not. + command -v corepack >/dev/null 2>&1 || npm install -g corepack || true + corepack enable || true + command -v "$JS_MANAGER" >/dev/null 2>&1 || npm install -g "$JS_MANAGER" + ;; + bun) + if ! command -v bun >/dev/null 2>&1; then + curl -fsSL https://bun.sh/install | bash + export BUN_INSTALL="${BUN_INSTALL:-$HOME/.bun}" + export PATH="$BUN_INSTALL/bin:$PATH" + # A step's PATH does not survive into the next GitHub Actions step. + if [ -n "${GITHUB_PATH:-}" ]; then echo "$BUN_INSTALL/bin" >> "$GITHUB_PATH"; fi + fi + ;; + esac + if [ "${JS_DEPS_CACHED:-}" = "true" ]; then + echo "node_modules restored from cache; skipping install" + return 0 + fi + case "$JS_MANAGER" in + pnpm) + if [ -f pnpm-lock.yaml ]; then pnpm install --frozen-lockfile; else pnpm install; fi + ;; + yarn) + if [ -f .yarnrc.yml ]; then yarn install --immutable + elif [ -f yarn.lock ]; then yarn install --frozen-lockfile + else yarn install + fi + ;; + bun) + if [ -f bun.lock ] || [ -f bun.lockb ]; then bun install --frozen-lockfile; else bun install; fi + ;; + *) + if [ -f package-lock.json ]; then npm ci; else npm install; fi + ;; + esac + } + # <<< js toolchain + js_detect_manager + NODE_VERSION_FILE="" + for f in .nvmrc .node-version; do + if [ -f "$f" ]; then NODE_VERSION_FILE="$f"; break; fi + done + NODE_VERSION="" + if [ -n "$NODE_VERSION_FILE" ]; then + echo "Node version: from $NODE_VERSION_FILE" + else + NODE_VERSION=$(js_node_version) + echo "Node version: $NODE_VERSION" + fi + { + echo "manager=$JS_MANAGER" + echo "exec=$JS_EXEC" + echo "node_version=$NODE_VERSION" + echo "node_version_file=$NODE_VERSION_FILE" + } >> "$GITHUB_OUTPUT" + - name: Setup Node.js if: steps.detect.outputs.type == 'reactnative' || steps.detect.outputs.type == 'expo' uses: actions/setup-node@v7 with: - node-version: '20' + node-version: ${{ steps.js.outputs.node_version }} + node-version-file: ${{ steps.js.outputs.node_version_file }} - - name: Install npm dependencies + # Runs even on a cache hit: the manager itself still has to be on PATH for + # the prebuild and build steps that follow. + - name: Install JS dependencies if: steps.detect.outputs.type == 'reactnative' || steps.detect.outputs.type == 'expo' - run: npm install + env: + JS_MANAGER: ${{ steps.js.outputs.manager }} + run: | + set -e + # >>> js toolchain (keep identical across ios-build.yml, ios-share.yml, runner.sh) + # The package manager the project actually uses: the packageManager field + # first, then the lockfile, then npm. Guessing npm is not safe — `npm install` + # in a pnpm or yarn workspace fails with + # 'EUNSUPPORTEDPROTOCOL Unsupported URL Type "workspace:"'. + js_detect_manager() { + if [ -z "${JS_MANAGER:-}" ]; then + case "$(jq -r '.packageManager // empty' package.json 2>/dev/null || true)" in + pnpm|pnpm@*) JS_MANAGER=pnpm ;; + yarn|yarn@*) JS_MANAGER=yarn ;; + bun|bun@*) JS_MANAGER=bun ;; + npm|npm@*) JS_MANAGER=npm ;; + *) + if [ -f pnpm-lock.yaml ]; then JS_MANAGER=pnpm + elif [ -f yarn.lock ]; then JS_MANAGER=yarn + elif [ -f bun.lock ] || [ -f bun.lockb ]; then JS_MANAGER=bun + else JS_MANAGER=npm # package-lock.json, or no lockfile at all + fi + ;; + esac + fi + case "$JS_MANAGER" in + pnpm) JS_EXEC='pnpm exec' ;; + yarn) JS_EXEC=yarn ;; + bun) JS_EXEC=bunx ;; + *) JS_EXEC=npx ;; + esac + export JS_MANAGER JS_EXEC + echo "Package manager: $JS_MANAGER" + } + + # The Node version the project asks for: .nvmrc or .node-version, else the + # first comparator of engines.node, else 22. A leading "v" or range prefix + # is dropped: nvm, n and setup-node all take "20.11.1" and "24.x" as they are. + js_node_version() { + js_version="" + for js_file in .nvmrc .node-version; do + if [ -f "$js_file" ]; then + js_version=$(tr -d ' \t\r' < "$js_file" | head -n1) + break + fi + done + if [ -z "$js_version" ]; then + js_version=$(jq -r '.engines.node // empty' package.json 2>/dev/null || true) + js_version=$(printf '%s' "${js_version%%|*}" | awk '{print $1}') + fi + js_version=$(printf '%s' "$js_version" | sed -E 's/^(>=|<=|>|<|=|\^|~|v)+//') + case "$js_version" in + [0-9]*|lts/*) ;; + *) js_version=22 ;; + esac + printf '%s\n' "$js_version" + } + + # Put the manager on PATH first, then install — unless a cache already restored + # node_modules, in which case the manager is still wanted for `expo prebuild` + # and for the React Native build phases that shell out to it. + js_install() { + js_detect_manager + case "$JS_MANAGER" in + pnpm|yarn) + # corepack installs the version packageManager pins. Node 16.10-24 + # bundle it; Node 25+ and some provider images do not. + command -v corepack >/dev/null 2>&1 || npm install -g corepack || true + corepack enable || true + command -v "$JS_MANAGER" >/dev/null 2>&1 || npm install -g "$JS_MANAGER" + ;; + bun) + if ! command -v bun >/dev/null 2>&1; then + curl -fsSL https://bun.sh/install | bash + export BUN_INSTALL="${BUN_INSTALL:-$HOME/.bun}" + export PATH="$BUN_INSTALL/bin:$PATH" + # A step's PATH does not survive into the next GitHub Actions step. + if [ -n "${GITHUB_PATH:-}" ]; then echo "$BUN_INSTALL/bin" >> "$GITHUB_PATH"; fi + fi + ;; + esac + if [ "${JS_DEPS_CACHED:-}" = "true" ]; then + echo "node_modules restored from cache; skipping install" + return 0 + fi + case "$JS_MANAGER" in + pnpm) + if [ -f pnpm-lock.yaml ]; then pnpm install --frozen-lockfile; else pnpm install; fi + ;; + yarn) + if [ -f .yarnrc.yml ]; then yarn install --immutable + elif [ -f yarn.lock ]; then yarn install --frozen-lockfile + else yarn install + fi + ;; + bun) + if [ -f bun.lock ] || [ -f bun.lockb ]; then bun install --frozen-lockfile; else bun install; fi + ;; + *) + if [ -f package-lock.json ]; then npm ci; else npm install; fi + ;; + esac + } + # <<< js toolchain + js_install + + # A managed Expo project keeps no ios/ directory in git — `expo prebuild` + # generates it from app.json / app.config.js. The simulator build needs + # the same native project the IPA build does. + - name: Expo prebuild + if: steps.detect.outputs.type == 'expo' + env: + IOS_PATH: ${{ steps.params.outputs.ios_path }} + JS_EXEC: ${{ steps.js.outputs.exec }} + CI: '1' # prebuild answers its own prompts instead of hanging + run: | + set -e + if [ -n "$(find "$IOS_PATH" -maxdepth 1 \( -name '*.xcodeproj' -o -name '*.xcworkspace' \) -print -quit 2>/dev/null)" ]; then + echo "'$IOS_PATH' already holds an Xcode project (ejected Expo) — skipping prebuild." + exit 0 + fi + + # Without a bundle identifier prebuild asks for one interactively, + # which on a runner means waiting for the job timeout. + BUNDLE_ID=$(${JS_EXEC:-npx} expo config --type public --json 2>/dev/null | jq -r '.ios.bundleIdentifier // empty' 2>/dev/null || true) + if [ -z "$BUNDLE_ID" ]; then + for f in app.json app.config.json; do + [ -f "$f" ] || continue + BUNDLE_ID=$(jq -r '(.expo.ios.bundleIdentifier // .ios.bundleIdentifier) // empty' "$f" 2>/dev/null || true) + if [ -n "$BUNDLE_ID" ]; then break; fi + done + fi + if [ -z "$BUNDLE_ID" ]; then + echo "::error::This managed Expo project has no ios.bundleIdentifier. Set it in app.json (expo.ios.bundleIdentifier) or app.config.js, commit that, and share again — 'expo prebuild' cannot run unattended without it." + exit 1 + fi + echo "Prebuilding $BUNDLE_ID into '$IOS_PATH'..." + + ${JS_EXEC:-npx} expo prebuild --platform ios --no-install + if [ -z "$(find "$IOS_PATH" -maxdepth 1 -name '*.xcodeproj' -print -quit 2>/dev/null)" ]; then + echo "::error::'expo prebuild' produced no Xcode project in '$IOS_PATH'. If the project generates it elsewhere, set ios.path in builder.json." + exit 1 + fi # KMP builds through xcodebuild like a native project, but its Xcode run # script phase shells out to Gradle, which needs a JDK on PATH. diff --git a/internal/workflow/templates/runner.sh b/internal/workflow/templates/runner.sh index 7ed603e..e2405fc 100644 --- a/internal/workflow/templates/runner.sh +++ b/internal/workflow/templates/runner.sh @@ -25,6 +25,155 @@ snapshot_checkout() { echo "Building snapshot $SNAPSHOT_SHA" } +# >>> js toolchain (keep identical across ios-build.yml, ios-share.yml, runner.sh) +# The package manager the project actually uses: the packageManager field +# first, then the lockfile, then npm. Guessing npm is not safe — `npm install` +# in a pnpm or yarn workspace fails with +# 'EUNSUPPORTEDPROTOCOL Unsupported URL Type "workspace:"'. +js_detect_manager() { + if [ -z "${JS_MANAGER:-}" ]; then + case "$(jq -r '.packageManager // empty' package.json 2>/dev/null || true)" in + pnpm|pnpm@*) JS_MANAGER=pnpm ;; + yarn|yarn@*) JS_MANAGER=yarn ;; + bun|bun@*) JS_MANAGER=bun ;; + npm|npm@*) JS_MANAGER=npm ;; + *) + if [ -f pnpm-lock.yaml ]; then JS_MANAGER=pnpm + elif [ -f yarn.lock ]; then JS_MANAGER=yarn + elif [ -f bun.lock ] || [ -f bun.lockb ]; then JS_MANAGER=bun + else JS_MANAGER=npm # package-lock.json, or no lockfile at all + fi + ;; + esac + fi + case "$JS_MANAGER" in + pnpm) JS_EXEC='pnpm exec' ;; + yarn) JS_EXEC=yarn ;; + bun) JS_EXEC=bunx ;; + *) JS_EXEC=npx ;; + esac + export JS_MANAGER JS_EXEC + echo "Package manager: $JS_MANAGER" +} + +# The Node version the project asks for: .nvmrc or .node-version, else the +# first comparator of engines.node, else 22. A leading "v" or range prefix +# is dropped: nvm, n and setup-node all take "20.11.1" and "24.x" as they are. +js_node_version() { + js_version="" + for js_file in .nvmrc .node-version; do + if [ -f "$js_file" ]; then + js_version=$(tr -d ' \t\r' < "$js_file" | head -n1) + break + fi + done + if [ -z "$js_version" ]; then + js_version=$(jq -r '.engines.node // empty' package.json 2>/dev/null || true) + js_version=$(printf '%s' "${js_version%%|*}" | awk '{print $1}') + fi + js_version=$(printf '%s' "$js_version" | sed -E 's/^(>=|<=|>|<|=|\^|~|v)+//') + case "$js_version" in + [0-9]*|lts/*) ;; + *) js_version=22 ;; + esac + printf '%s\n' "$js_version" +} + +# Put the manager on PATH first, then install — unless a cache already restored +# node_modules, in which case the manager is still wanted for `expo prebuild` +# and for the React Native build phases that shell out to it. +js_install() { + js_detect_manager + case "$JS_MANAGER" in + pnpm|yarn) + # corepack installs the version packageManager pins. Node 16.10-24 + # bundle it; Node 25+ and some provider images do not. + command -v corepack >/dev/null 2>&1 || npm install -g corepack || true + corepack enable || true + command -v "$JS_MANAGER" >/dev/null 2>&1 || npm install -g "$JS_MANAGER" + ;; + bun) + if ! command -v bun >/dev/null 2>&1; then + curl -fsSL https://bun.sh/install | bash + export BUN_INSTALL="${BUN_INSTALL:-$HOME/.bun}" + export PATH="$BUN_INSTALL/bin:$PATH" + # A step's PATH does not survive into the next GitHub Actions step. + if [ -n "${GITHUB_PATH:-}" ]; then echo "$BUN_INSTALL/bin" >> "$GITHUB_PATH"; fi + fi + ;; + esac + if [ "${JS_DEPS_CACHED:-}" = "true" ]; then + echo "node_modules restored from cache; skipping install" + return 0 + fi + case "$JS_MANAGER" in + pnpm) + if [ -f pnpm-lock.yaml ]; then pnpm install --frozen-lockfile; else pnpm install; fi + ;; + yarn) + if [ -f .yarnrc.yml ]; then yarn install --immutable + elif [ -f yarn.lock ]; then yarn install --frozen-lockfile + else yarn install + fi + ;; + bun) + if [ -f bun.lock ] || [ -f bun.lockb ]; then bun install --frozen-lockfile; else bun install; fi + ;; + *) + if [ -f package-lock.json ]; then npm ci; else npm install; fi + ;; + esac +} +# <<< js toolchain + +# Codemagic and Bitrise images ship Node already, so a mismatch is worth a log +# line, not a failed build. Switch only when a version manager is right there. +js_use_node_version() { + wanted_node=$(js_node_version) + nvm_node="${wanted_node%.x}" # nvm and n want 24, not the setup-node spelling 24.x + nvm_sh="${NVM_DIR:-$HOME/.nvm}/nvm.sh" + if [ -s "$nvm_sh" ]; then + set +u # nvm.sh reads variables it has not set yet + # shellcheck source=/dev/null + . "$nvm_sh" + nvm install "$nvm_node" || true + set -u + elif command -v n >/dev/null; then + n install "$nvm_node" || true + fi + echo "Node $(node --version 2>/dev/null || echo unknown); the project asks for $wanted_node" +} + +# A managed Expo project keeps no ios/ directory in git; `expo prebuild` +# generates it from app.json / app.config.js. An ejected project already has +# one and must keep it. CI=1 stops prebuild from prompting, which on a runner +# would mean waiting for the job timeout. +expo_prebuild() { + if [ -n "$(find "$IOS_PATH" -maxdepth 1 \( -name '*.xcodeproj' -o -name '*.xcworkspace' \) -print -quit 2>/dev/null)" ]; then + echo "$IOS_PATH already holds an Xcode project (ejected Expo); skipping prebuild" + return 0 + fi + bundle_id=$(${JS_EXEC:-npx} expo config --type public --json 2>/dev/null | jq -r '.ios.bundleIdentifier // empty' 2>/dev/null || true) + if [ -z "$bundle_id" ]; then + for manifest in app.json app.config.json; do + if [ -f "$manifest" ]; then + bundle_id=$(jq -r '(.expo.ios.bundleIdentifier // .ios.bundleIdentifier) // empty' "$manifest" 2>/dev/null || true) + if [ -n "$bundle_id" ]; then break; fi + fi + done + fi + if [ -z "$bundle_id" ]; then + echo "This managed Expo project has no ios.bundleIdentifier; set expo.ios.bundleIdentifier in app.json or app.config.js, since 'expo prebuild' cannot run unattended without it" >&2 + exit 1 + fi + echo "Prebuilding $bundle_id into $IOS_PATH" + CI=1 ${JS_EXEC:-npx} expo prebuild --platform ios --no-install + if [ -z "$(find "$IOS_PATH" -maxdepth 1 -name '*.xcodeproj' -print -quit 2>/dev/null)" ]; then + echo "'expo prebuild' produced no Xcode project in $IOS_PATH; set ios.path in builder.json if it lands elsewhere" >&2 + exit 1 + fi +} + prepare() { if [ -f pubspec.yaml ]; then project_type=flutter @@ -61,13 +210,9 @@ prepare() { flutter pub get elif [ "$project_type" = reactnative ] || [ "$project_type" = expo ]; then if ! command -v node >/dev/null; then brew install node@22; export PATH="$(brew --prefix node@22)/bin:$PATH"; fi - if [ -f yarn.lock ]; then - if ! command -v yarn >/dev/null; then npm install -g yarn; fi - yarn install --frozen-lockfile - elif [ -f package-lock.json ]; then npm ci - else npm install - fi - if [ "$project_type" = expo ]; then npx expo prebuild --platform ios --no-install; fi + js_use_node_version + js_install + if [ "$project_type" = expo ]; then expo_prebuild; fi elif [ "$project_type" = kmp ]; then if ! [[ "$JDK_VERSION" =~ ^[0-9]+$ ]]; then echo "JDK_VERSION must be a major version" >&2; exit 1; fi if JAVA_HOME=$(/usr/libexec/java_home -v "$JDK_VERSION" 2>/dev/null); then