From 167da0105133d26daf68d5472dcbf2103f7c16cb Mon Sep 17 00:00:00 2001 From: Interlap Date: Wed, 16 Sep 2026 13:57:53 +0200 Subject: [PATCH 01/12] expo: build managed projects by prebuilding on the runner A managed Expo project keeps no ios/ directory in git, so detection returned no iOS path and the runners walked straight into a missing directory. detectIOSPath now reports "Expo (managed)" with path "ios" when package.json depends on Expo and no Xcode project exists anywhere; init says the native project is generated on the runner. Nothing is generated locally, and managed projects gitignore ios/, so the working-tree snapshot stays free of it. All three runners gained an Expo prebuild step, after the node install (which provides the CLI) and before the Pods step (which reads the Podfile prebuild writes). It skips a path that already holds an Xcode project, so ejected projects are untouched, and refuses a project whose app config has no ios.bundleIdentifier: expo prebuild would otherwise prompt and hang the job until the timeout. The steps that walk the iOS path earlier - XcodeGen and the base-configuration check - now skip a directory that is not there yet. The Pods cache key hashes a Podfile.lock that a managed project does not have before the prebuild; hashFiles then returns an empty string and the key falls back to its prefix, which still restores and saves, and pod install syncs it. --- cmd/builder/expo_test.go | 67 ++++++ cmd/builder/root.go | 16 ++ internal/workflow/expo_test.go | 241 ++++++++++++++++++++++ internal/workflow/templates/ios-build.yml | 52 +++++ internal/workflow/templates/ios-share.yml | 47 +++++ internal/workflow/templates/runner.sh | 32 ++- 6 files changed, 454 insertions(+), 1 deletion(-) create mode 100644 cmd/builder/expo_test.go create mode 100644 internal/workflow/expo_test.go diff --git a/cmd/builder/expo_test.go b/cmd/builder/expo_test.go new file mode 100644 index 0000000..c80b9e9 --- /dev/null +++ b/cmd/builder/expo_test.go @@ -0,0 +1,67 @@ +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, + }, + } + + 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..c201c2e 100644 --- a/cmd/builder/root.go +++ b/cmd/builder/root.go @@ -122,6 +122,12 @@ func isExpoProject() bool { return strings.Contains(string(data), `"expo"`) } +// 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 +222,12 @@ 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`. + if isExpoProject() { + return "ios", expoManagedFramework + } + return "", "" } @@ -296,6 +308,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, diff --git a/internal/workflow/expo_test.go b/internal/workflow/expo_test.go new file mode 100644 index 0000000..baf22a7 --- /dev/null +++ b/internal/workflow/expo_test.go @@ -0,0 +1,241 @@ +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 npm 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") + } + + // 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) + } + start := strings.Index(string(data), "\nexpo_prebuild() {\n") + if start < 0 { + t.Fatal("runner.sh has no expo_prebuild function") + } + body := string(data)[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/templates/ios-build.yml b/internal/workflow/templates/ios-build.yml index e98bc2b..836c2d8 100644 --- a/internal/workflow/templates/ios-build.yml +++ b/internal/workflow/templates/ios-build.yml @@ -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 @@ -247,6 +257,44 @@ jobs: if: (steps.detect.outputs.type == 'reactnative' || steps.detect.outputs.type == 'expo') && steps.node-modules-cache.outputs.cache-hit != 'true' run: npm 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 }} + 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=$(npx expo config --type public --json 2>/dev/null | jq -r '.ios.bundleIdentifier // empty' || 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'..." + + 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' uses: actions/setup-java@v6 @@ -258,6 +306,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..15f9a80 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 @@ -244,6 +254,43 @@ jobs: if: steps.detect.outputs.type == 'reactnative' || steps.detect.outputs.type == 'expo' run: npm 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 }} + 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=$(npx expo config --type public --json 2>/dev/null | jq -r '.ios.bundleIdentifier // empty' || 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'..." + + 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. - name: Setup JDK diff --git a/internal/workflow/templates/runner.sh b/internal/workflow/templates/runner.sh index 7ed603e..625b9c1 100644 --- a/internal/workflow/templates/runner.sh +++ b/internal/workflow/templates/runner.sh @@ -25,6 +25,36 @@ snapshot_checkout() { echo "Building snapshot $SNAPSHOT_SHA" } +# 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=$(npx expo config --type public --json 2>/dev/null | jq -r '.ios.bundleIdentifier // empty' || 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 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 @@ -67,7 +97,7 @@ prepare() { 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 + 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 From 1d900f108d18ba7b990370e1394f22d76dfef228 Mon Sep 17 00:00:00 2001 From: Interlap Date: Wed, 16 Sep 2026 13:57:53 +0200 Subject: [PATCH 02/12] docs: describe managed Expo support --- CLAUDE.md | 8 ++++++++ README.md | 20 +++++++++++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index b15fd87..2ac0b12 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -135,6 +135,14 @@ 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**: `"expo"` in `package.json` with no `.xcodeproj`/`.xcworkspace` anywhere 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. - **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..ec276bd 100644 --- a/README.md +++ b/README.md @@ -126,11 +126,29 @@ 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 | +### Expo + +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. + +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 From 709e8524364c6202e8b963878b3a8eb68b34143a Mon Sep 17 00:00:00 2001 From: Interlap Date: Wed, 16 Sep 2026 14:05:18 +0200 Subject: [PATCH 03/12] expo: detect the dependency, not the word, and let Flutter win isExpoProject searched package.json for the raw string "expo", so a project named expo, an "expo" script or an "expo" keyword made an unrelated Node repo look like an Expo app. Now that the string also decides whether a repo with no Xcode project anywhere is an iOS project, parse the dependency maps instead and fall back to the substring only when package.json does not parse. Check pubspec.yaml before the Expo fallback too, for the same reason the runners do: a Flutter repo that does not commit ios/ is not a managed Expo project, and the runner would detect it as Flutter and never prebuild it. --- CLAUDE.md | 5 +++-- cmd/builder/expo_test.go | 35 +++++++++++++++++++++++++++++++++++ cmd/builder/root.go | 23 +++++++++++++++++++++-- 3 files changed, 59 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2ac0b12..28dd535 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -135,8 +135,9 @@ 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**: `"expo"` in `package.json` with no `.xcodeproj`/`.xcworkspace` anywhere is a - managed project. `detectIOSPath` still returns `ios`, because that is where `expo prebuild` puts +- **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 diff --git a/cmd/builder/expo_test.go b/cmd/builder/expo_test.go index c80b9e9..e4dd01b 100644 --- a/cmd/builder/expo_test.go +++ b/cmd/builder/expo_test.go @@ -47,6 +47,41 @@ func TestDetectIOSPathExpo(t *testing.T) { 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 { diff --git a/cmd/builder/root.go b/cmd/builder/root.go index c201c2e..cabf93b 100644 --- a/cmd/builder/root.go +++ b/cmd/builder/root.go @@ -114,12 +114,27 @@ 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 @@ -224,7 +239,11 @@ 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`. - if isExpoProject() { + // 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 } From 722b731082ccddf6b189c46a46cca2d365cdc1af Mon Sep 17 00:00:00 2001 From: Interlap Date: Wed, 16 Sep 2026 14:05:27 +0200 Subject: [PATCH 04/12] expo: keep jq quiet and pin prebuild before the build step `npx expo config` printing anything but JSON made jq spill a parse error into the log before the app.json fallback silently succeeded, which reads like a failed build. Send that jq's stderr to /dev/null; the fallback and the named error still report a genuinely missing bundle identifier. Assert prebuild precedes the build step in both workflows, not just the Pods cache in ios-build.yml: the scheme detection and `pod install` both live there and need the project prebuild generates. --- internal/workflow/expo_test.go | 9 +++++++++ internal/workflow/templates/ios-build.yml | 2 +- internal/workflow/templates/ios-share.yml | 2 +- internal/workflow/templates/runner.sh | 2 +- 4 files changed, 12 insertions(+), 3 deletions(-) diff --git a/internal/workflow/expo_test.go b/internal/workflow/expo_test.go index baf22a7..bda46b1 100644 --- a/internal/workflow/expo_test.go +++ b/internal/workflow/expo_test.go @@ -80,6 +80,15 @@ func TestExpoPrebuildStep(t *testing.T) { 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. diff --git a/internal/workflow/templates/ios-build.yml b/internal/workflow/templates/ios-build.yml index 836c2d8..dc64144 100644 --- a/internal/workflow/templates/ios-build.yml +++ b/internal/workflow/templates/ios-build.yml @@ -275,7 +275,7 @@ jobs: # Without a bundle identifier prebuild asks for one interactively, # which on a runner means waiting for the job timeout. - BUNDLE_ID=$(npx expo config --type public --json 2>/dev/null | jq -r '.ios.bundleIdentifier // empty' || true) + BUNDLE_ID=$(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 diff --git a/internal/workflow/templates/ios-share.yml b/internal/workflow/templates/ios-share.yml index 15f9a80..ba44088 100644 --- a/internal/workflow/templates/ios-share.yml +++ b/internal/workflow/templates/ios-share.yml @@ -271,7 +271,7 @@ jobs: # Without a bundle identifier prebuild asks for one interactively, # which on a runner means waiting for the job timeout. - BUNDLE_ID=$(npx expo config --type public --json 2>/dev/null | jq -r '.ios.bundleIdentifier // empty' || true) + BUNDLE_ID=$(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 diff --git a/internal/workflow/templates/runner.sh b/internal/workflow/templates/runner.sh index 625b9c1..8426e6e 100644 --- a/internal/workflow/templates/runner.sh +++ b/internal/workflow/templates/runner.sh @@ -34,7 +34,7 @@ expo_prebuild() { echo "$IOS_PATH already holds an Xcode project (ejected Expo); skipping prebuild" return 0 fi - bundle_id=$(npx expo config --type public --json 2>/dev/null | jq -r '.ios.bundleIdentifier // empty' || true) + bundle_id=$(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 From 4a79ee7060b4582f7b36b7fd3e8aaa9677bb7fb2 Mon Sep 17 00:00:00 2001 From: Interlap Date: Wed, 16 Sep 2026 14:05:28 +0200 Subject: [PATCH 05/12] docs: point Expo builds at the Release configuration A Debug IPA loads its JavaScript from Metro, so a standalone Expo IPA needs ios.configuration set to Release. --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index ec276bd..3d59e57 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,10 @@ identifier — `expo.ios.bundleIdentifier` in `app.json`, or `ios.bundleIdentifi 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 From 6f741599aca61e1b6d2bc29d2613cd0f5a75e4bb Mon Sep 17 00:00:00 2001 From: Interlap Date: Wed, 16 Sep 2026 14:13:07 +0200 Subject: [PATCH 06/12] expo: gofmt the detection test --- cmd/builder/expo_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/builder/expo_test.go b/cmd/builder/expo_test.go index e4dd01b..78915b1 100644 --- a/cmd/builder/expo_test.go +++ b/cmd/builder/expo_test.go @@ -66,7 +66,7 @@ func TestDetectIOSPathExpo(t *testing.T) { name: "kmp keeps its own path", files: map[string]string{ "iosApp/iosApp.xcodeproj/project.pbxproj": "// project", - "package.json": `{"dependencies":{"expo":"~51.0.0"}}`, + "package.json": `{"dependencies":{"expo":"~51.0.0"}}`, }, wantPath: "iosApp", wantFramework: "Kotlin Multiplatform", From 8c3f062480fcd4d411cc22752bf6579e8195903b Mon Sep 17 00:00:00 2001 From: Interlap Date: Wed, 16 Sep 2026 14:19:27 +0200 Subject: [PATCH 07/12] expo: convert the runner script once before searching it --- internal/workflow/expo_test.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/workflow/expo_test.go b/internal/workflow/expo_test.go index bda46b1..17ee908 100644 --- a/internal/workflow/expo_test.go +++ b/internal/workflow/expo_test.go @@ -197,11 +197,12 @@ func TestRunnerExpoPrebuild(t *testing.T) { if err != nil { t.Fatal(err) } - start := strings.Index(string(data), "\nexpo_prebuild() {\n") + script := string(data) + start := strings.Index(script, "\nexpo_prebuild() {\n") if start < 0 { t.Fatal("runner.sh has no expo_prebuild function") } - body := string(data)[start:] + body := script[start:] end := strings.Index(body, "\n}\n") if end < 0 { t.Fatal("expo_prebuild is not terminated") From dea7782c3e3b2acd7ba438c957b102ea18b2fbb4 Mon Sep 17 00:00:00 2001 From: Interlap Date: Wed, 16 Sep 2026 21:30:09 +0200 Subject: [PATCH 08/12] workflow: install JS dependencies with the project's package manager and Node version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A managed Expo app that pins pnpm through `packageManager` (cherry-studio-app, for one) died in the node step: `npm install` walks a workspace protocol it does not implement and exits with EUNSUPPORTEDPROTOCOL Unsupported URL Type "workspace:". The hard-coded Node 20 was the other half of the problem — that project asks for 24.x in engines.node. All three runners now share one shell block, delimited so a test can compare the copies: the manager comes from `packageManager`, then the lockfile, then npm; pnpm and yarn arrive through corepack and bun through its installer; `expo prebuild` runs through whichever one won. The GitHub workflows resolve the Node version in a step before setup-node, because `with:` cannot choose between node-version and node-version-file on its own, and the node_modules key now hashes every lockfile and carries the manager name. --- internal/workflow/expo_test.go | 2 +- internal/workflow/js_test.go | 275 ++++++++++++++++++++++ internal/workflow/templates/ios-build.yml | 246 ++++++++++++++++++- internal/workflow/templates/ios-share.yml | 236 ++++++++++++++++++- internal/workflow/templates/runner.sh | 127 +++++++++- 5 files changed, 864 insertions(+), 22 deletions(-) create mode 100644 internal/workflow/js_test.go diff --git a/internal/workflow/expo_test.go b/internal/workflow/expo_test.go index 17ee908..bbe3e4f 100644 --- a/internal/workflow/expo_test.go +++ b/internal/workflow/expo_test.go @@ -74,7 +74,7 @@ func TestExpoPrebuildStep(t *testing.T) { t.Fatalf("prebuild script is missing %q", want) } } - if prebuild < indexOfStep(t, all, "Install npm dependencies") { + 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") { diff --git a/internal/workflow/js_test.go b/internal/workflow/js_test.go new file mode 100644 index 0000000..debd1d1 --- /dev/null +++ b/internal/workflow/js_test.go @@ -0,0 +1,275 @@ +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 + 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"}, + }} { + 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 err := os.WriteFile(filepath.Join(bin, name), []byte(stub), 0755); 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)+os.Getenv("PATH"), + "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) + } + }) + } +} + +// 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 dc64144..c9d11b7 100644 --- a/internal/workflow/templates/ios-build.yml +++ b/internal/workflow/templates/ios-build.yml @@ -237,25 +237,254 @@ 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 verbatim, + # else the first comparator of engines.node with the range prefix stripped + # ("24.x" is a version setup-node takes as it is), else 22. + 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}' | sed -E 's/^(>=|<=|>|<|=|\^|~|v)+//') + fi + 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 enable || true # ships with Node 16.10+, installs the pinned version + 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 verbatim, + # else the first comparator of engines.node with the range prefix stripped + # ("24.x" is a version setup-node takes as it is), else 22. + 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}' | sed -E 's/^(>=|<=|>|<|=|\^|~|v)+//') + fi + 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 enable || true # ships with Node 16.10+, installs the pinned version + 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 @@ -265,6 +494,7 @@ jobs: 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 @@ -275,7 +505,7 @@ jobs: # Without a bundle identifier prebuild asks for one interactively, # which on a runner means waiting for the job timeout. - BUNDLE_ID=$(npx expo config --type public --json 2>/dev/null | jq -r '.ios.bundleIdentifier // empty' 2>/dev/null || true) + 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 @@ -289,7 +519,7 @@ jobs: fi echo "Prebuilding $BUNDLE_ID into '$IOS_PATH'..." - npx expo prebuild --platform ios --no-install + ${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 diff --git a/internal/workflow/templates/ios-share.yml b/internal/workflow/templates/ios-share.yml index ba44088..2993146 100644 --- a/internal/workflow/templates/ios-share.yml +++ b/internal/workflow/templates/ios-share.yml @@ -244,15 +244,240 @@ 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 verbatim, + # else the first comparator of engines.node with the range prefix stripped + # ("24.x" is a version setup-node takes as it is), else 22. + 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}' | sed -E 's/^(>=|<=|>|<|=|\^|~|v)+//') + fi + 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 enable || true # ships with Node 16.10+, installs the pinned version + 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 verbatim, + # else the first comparator of engines.node with the range prefix stripped + # ("24.x" is a version setup-node takes as it is), else 22. + 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}' | sed -E 's/^(>=|<=|>|<|=|\^|~|v)+//') + fi + 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 enable || true # ships with Node 16.10+, installs the pinned version + 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 @@ -261,6 +486,7 @@ jobs: 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 @@ -271,7 +497,7 @@ jobs: # Without a bundle identifier prebuild asks for one interactively, # which on a runner means waiting for the job timeout. - BUNDLE_ID=$(npx expo config --type public --json 2>/dev/null | jq -r '.ios.bundleIdentifier // empty' 2>/dev/null || true) + 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 @@ -285,7 +511,7 @@ jobs: fi echo "Prebuilding $BUNDLE_ID into '$IOS_PATH'..." - npx expo prebuild --platform ios --no-install + ${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 diff --git a/internal/workflow/templates/runner.sh b/internal/workflow/templates/runner.sh index 8426e6e..876fdcb 100644 --- a/internal/workflow/templates/runner.sh +++ b/internal/workflow/templates/runner.sh @@ -25,6 +25,121 @@ 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 verbatim, +# else the first comparator of engines.node with the range prefix stripped +# ("24.x" is a version setup-node takes as it is), else 22. +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}' | sed -E 's/^(>=|<=|>|<|=|\^|~|v)+//') + fi + 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 enable || true # ships with Node 16.10+, installs the pinned version + 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 @@ -34,7 +149,7 @@ expo_prebuild() { echo "$IOS_PATH already holds an Xcode project (ejected Expo); skipping prebuild" return 0 fi - bundle_id=$(npx expo config --type public --json 2>/dev/null | jq -r '.ios.bundleIdentifier // empty' 2>/dev/null || true) + 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 @@ -48,7 +163,7 @@ expo_prebuild() { exit 1 fi echo "Prebuilding $bundle_id into $IOS_PATH" - CI=1 npx expo prebuild --platform ios --no-install + 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 @@ -91,12 +206,8 @@ 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 + 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 From 0c6746bc7f01977a1798b29804d9eef2bfc6e763 Mon Sep 17 00:00:00 2001 From: Interlap Date: Wed, 16 Sep 2026 21:30:13 +0200 Subject: [PATCH 09/12] docs: say which package manager and Node version the runner uses --- CLAUDE.md | 12 ++++++++++++ README.md | 11 +++++++++++ 2 files changed, 23 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 28dd535..347aad4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -144,6 +144,18 @@ internal/ 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`, 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 3d59e57..536c3fb 100644 --- a/README.md +++ b/README.md @@ -131,8 +131,19 @@ and connect the same repository to each provider before building. See | 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` From 32947e5b96725cc8632f48f1a0ce01cd165a7756 Mon Sep 17 00:00:00 2001 From: Interlap Date: Wed, 16 Sep 2026 22:05:02 +0200 Subject: [PATCH 10/12] workflow: allow 90 minutes for a build; a cold Expo build exceeded 30 --- cmd/builder/root.go | 4 ++-- internal/build/coordinator.go | 2 +- internal/workflow/templates/ios-build.yml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cmd/builder/root.go b/cmd/builder/root.go index cabf93b..87c0d51 100644 --- a/cmd/builder/root.go +++ b/cmd/builder/root.go @@ -508,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, }) } @@ -582,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/workflow/templates/ios-build.yml b/internal/workflow/templates/ios-build.yml index c9d11b7..f5b60b3 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 From d2e45fd3d3dc7713c791cbb44584ac0cce7ca668 Mon Sep 17 00:00:00 2001 From: Interlap Date: Thu, 17 Sep 2026 00:01:15 +0200 Subject: [PATCH 11/12] github: tolerate transient status errors while waiting for the artifact --- internal/github/workflow.go | 26 +++++++++--- internal/github/workflow_test.go | 71 ++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 6 deletions(-) create mode 100644 internal/github/workflow_test.go 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) + } +} From 78f445431d726233b9ec2a4eb3daca892c6bd724 Mon Sep 17 00:00:00 2001 From: Interlap Date: Thu, 17 Sep 2026 00:52:01 +0200 Subject: [PATCH 12/12] workflow: accept a v-prefixed .nvmrc and install corepack when Node lacks it js_node_version stripped the range prefix from engines.node but took .nvmrc verbatim, so "v20.11.1" matched neither [0-9]* nor lts/* and runner.sh asked nvm for Node 22. GitHub was unaffected: there the file goes to setup-node as node-version-file. The prefix is now dropped from either source. Node 25 no longer bundles corepack and provider images may lack it, so `corepack enable || true` fell through to an unpinned `npm install -g pnpm`. Install corepack with npm first, so the packageManager pin is what runs. The stubbed install test now runs on a PATH of its own, so a manager on the developer's machine cannot stand in for a missing stub. --- CLAUDE.md | 5 +- internal/workflow/js_test.go | 81 ++++++++++++++++++++++- internal/workflow/templates/ios-build.yml | 28 +++++--- internal/workflow/templates/ios-share.yml | 28 +++++--- internal/workflow/templates/runner.sh | 14 ++-- 5 files changed, 128 insertions(+), 28 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 347aad4..d1575fb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -147,8 +147,9 @@ internal/ - **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`, bun from its installer, and `expo prebuild` and the - other `npx` calls run through the same manager (`pnpm exec`/`yarn`/`bunx`/`npx`). Running + 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; diff --git a/internal/workflow/js_test.go b/internal/workflow/js_test.go index debd1d1..cf0e035 100644 --- a/internal/workflow/js_test.go +++ b/internal/workflow/js_test.go @@ -100,6 +100,7 @@ printf '%s %s\n' "$(basename "$0")" "$*" >> "$CMD_LOG" name string files map[string]string cached bool + noCorepack bool wantManager string wantCmds []string }{{ @@ -144,6 +145,14 @@ printf '%s %s\n' "$(basename "$0")" "$*" >> "$CMD_LOG" 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() @@ -152,10 +161,28 @@ printf '%s %s\n' "$(basename "$0")" "$*" >> "$CMD_LOG" 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) @@ -169,7 +196,7 @@ printf '%s %s\n' "$(basename "$0")" "$*" >> "$CMD_LOG" cmd := exec.Command("bash", path) cmd.Dir = dir cmd.Env = append(os.Environ(), - "PATH="+bin+string(os.PathListSeparator)+os.Getenv("PATH"), + "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") @@ -196,6 +223,58 @@ printf '%s %s\n' "$(basename "$0")" "$*" >> "$CMD_LOG" } } +// 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) { diff --git a/internal/workflow/templates/ios-build.yml b/internal/workflow/templates/ios-build.yml index f5b60b3..240fda1 100644 --- a/internal/workflow/templates/ios-build.yml +++ b/internal/workflow/templates/ios-build.yml @@ -275,9 +275,9 @@ jobs: echo "Package manager: $JS_MANAGER" } - # The Node version the project asks for: .nvmrc or .node-version verbatim, - # else the first comparator of engines.node with the range prefix stripped - # ("24.x" is a version setup-node takes as it is), else 22. + # 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 @@ -288,8 +288,9 @@ jobs: 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}' | sed -E 's/^(>=|<=|>|<|=|\^|~|v)+//') + 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 ;; @@ -304,7 +305,10 @@ jobs: js_detect_manager case "$JS_MANAGER" in pnpm|yarn) - corepack enable || true # ships with Node 16.10+, installs the pinned version + # 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) @@ -419,9 +423,9 @@ jobs: echo "Package manager: $JS_MANAGER" } - # The Node version the project asks for: .nvmrc or .node-version verbatim, - # else the first comparator of engines.node with the range prefix stripped - # ("24.x" is a version setup-node takes as it is), else 22. + # 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 @@ -432,8 +436,9 @@ jobs: 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}' | sed -E 's/^(>=|<=|>|<|=|\^|~|v)+//') + 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 ;; @@ -448,7 +453,10 @@ jobs: js_detect_manager case "$JS_MANAGER" in pnpm|yarn) - corepack enable || true # ships with Node 16.10+, installs the pinned version + # 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) diff --git a/internal/workflow/templates/ios-share.yml b/internal/workflow/templates/ios-share.yml index 2993146..4beb1dc 100644 --- a/internal/workflow/templates/ios-share.yml +++ b/internal/workflow/templates/ios-share.yml @@ -282,9 +282,9 @@ jobs: echo "Package manager: $JS_MANAGER" } - # The Node version the project asks for: .nvmrc or .node-version verbatim, - # else the first comparator of engines.node with the range prefix stripped - # ("24.x" is a version setup-node takes as it is), else 22. + # 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 @@ -295,8 +295,9 @@ jobs: 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}' | sed -E 's/^(>=|<=|>|<|=|\^|~|v)+//') + 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 ;; @@ -311,7 +312,10 @@ jobs: js_detect_manager case "$JS_MANAGER" in pnpm|yarn) - corepack enable || true # ships with Node 16.10+, installs the pinned version + # 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) @@ -412,9 +416,9 @@ jobs: echo "Package manager: $JS_MANAGER" } - # The Node version the project asks for: .nvmrc or .node-version verbatim, - # else the first comparator of engines.node with the range prefix stripped - # ("24.x" is a version setup-node takes as it is), else 22. + # 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 @@ -425,8 +429,9 @@ jobs: 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}' | sed -E 's/^(>=|<=|>|<|=|\^|~|v)+//') + 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 ;; @@ -441,7 +446,10 @@ jobs: js_detect_manager case "$JS_MANAGER" in pnpm|yarn) - corepack enable || true # ships with Node 16.10+, installs the pinned version + # 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) diff --git a/internal/workflow/templates/runner.sh b/internal/workflow/templates/runner.sh index 876fdcb..e2405fc 100644 --- a/internal/workflow/templates/runner.sh +++ b/internal/workflow/templates/runner.sh @@ -56,9 +56,9 @@ js_detect_manager() { echo "Package manager: $JS_MANAGER" } -# The Node version the project asks for: .nvmrc or .node-version verbatim, -# else the first comparator of engines.node with the range prefix stripped -# ("24.x" is a version setup-node takes as it is), else 22. +# 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 @@ -69,8 +69,9 @@ js_node_version() { 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}' | sed -E 's/^(>=|<=|>|<|=|\^|~|v)+//') + 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 ;; @@ -85,7 +86,10 @@ js_install() { js_detect_manager case "$JS_MANAGER" in pnpm|yarn) - corepack enable || true # ships with Node 16.10+, installs the pinned version + # 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)