Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,28 @@ internal/
- **Run Correlation**: `run-name` carries the build ID so concurrent builds cannot adopt each
other's runs
- **Flutter Detection**: Auto-detects Flutter projects, runs `flutter pub get`, uses `Runner` scheme
- **Expo Detection**: an `expo` dependency in `package.json` (the CLI parses the dependency maps;
the runners grep `'"expo"'`) with no `.xcodeproj`/`.xcworkspace` anywhere and no `pubspec.yaml` is
a managed project. `detectIOSPath` still returns `ios`, because that is where `expo prebuild` puts
the project on the runner; nothing is generated locally, and managed projects gitignore `ios/`
so the snapshot carries none. All three runners (`ios-build.yml`, `ios-share.yml`, `runner.sh`)
prebuild with `CI=1` after the node install and before the Pods step, skip it when the iOS path
already holds an Xcode project (ejected), and fail with a named error when the app config has no
`ios.bundleIdentifier` — without one `expo prebuild` prompts and the job would hang. The steps
that walk the iOS path before that (XcodeGen, base-configuration check) skip a missing directory.
- **JS Package Manager**: React Native and Expo dependencies install with the manager the
project declares — `packageManager` in `package.json` first, then the lockfile
(`pnpm-lock.yaml`, `yarn.lock`, `bun.lock`/`bun.lockb`, `package-lock.json`), else npm.
pnpm and yarn come from `corepack` (installed with npm where Node 25+ or a provider image
lacks it), bun from its installer, and `expo prebuild` and the other `npx` calls run
through the same manager (`pnpm exec`/`yarn`/`bunx`/`npx`). Running
`npm install` on a pnpm or yarn workspace fails with `EUNSUPPORTEDPROTOCOL Unsupported URL
Type "workspace:"`, so the guess is not free. The Node version is `.nvmrc`/`.node-version`
(as `node-version-file`), else `engines.node` with the range prefix stripped, else 22;
`with:` cannot be conditional per key, so a `Resolve JS toolchain` step computes both and
setup-node ignores the empty one. The shell for all of this is one block between
`# >>> js toolchain` and `# <<< js toolchain`, repeated verbatim in `ios-build.yml` (twice),
`ios-share.yml` (twice) and `runner.sh`; `TestJSToolchainBlockIdentical` fails on drift.
- **DerivedData Caching**: `restore` keys on `github.run_id` and only the prefix in `restore-keys`
ever hits, so every run must pair with a `cache/save` step or later builds stay cold. `ios-share`
saves before it shares the simulator, since that step blocks until the session ends.
Expand Down
35 changes: 34 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,11 +126,44 @@ and connect the same repository to each provider before building. See
|-----------|----------|---------------|
| Native iOS/Swift | `.` (root) | Yes |
| React Native | `ios/` | Yes |
| Expo (ejected) | `ios/` | Yes |
| Expo (managed or ejected) | `ios/` | Yes |
| Flutter | `ios/` | Yes |
| Kotlin Multiplatform | `iosApp/` | Yes |
| Cordova/Ionic | `platforms/ios/` | Yes |

### React Native

The runner installs JavaScript dependencies with the package manager the project
already uses — npm, Yarn, pnpm or Bun, from `packageManager` in `package.json` or
from the lockfile — on the Node version from `.nvmrc`, `.node-version` or
`engines.node`.

### Expo

Dependencies install the way they do for React Native: the project's own package
manager (npm, Yarn, pnpm or Bun) and Node version, with `expo prebuild` running
through that same manager.

A managed Expo project has no `ios/` directory in git. `builder init` detects it
as *Expo (managed)*, still records `"ios": { "path": "ios" }`, and the runner
generates the native project with `expo prebuild --platform ios --no-install`
before building it. Ejected projects keep the committed `ios/` they have: the
prebuild step skips a directory that already holds an Xcode project.

`expo prebuild` has to run unattended, so the app config must set the bundle
identifier — `expo.ios.bundleIdentifier` in `app.json`, or `ios.bundleIdentifier`
in `app.config.js` / `app.config.ts`. Without one, prebuild would stop and ask
for it; instead the build fails immediately and names the missing setting.

The default `Debug` configuration builds an IPA that loads its JavaScript from
Metro, so set `"ios": { "configuration": "Release" }` in `builder.json` for a
standalone IPA with the bundle baked in.

An `ios/` directory left over from running `expo prebuild` locally is not
uploaded: managed projects gitignore it, and the working-tree snapshot skips
gitignored files. That is what you want — the runner prebuilds from the app
config on every build, so it cannot drift from a stale local copy.

## Installation

### Windows
Expand Down
102 changes: 102 additions & 0 deletions cmd/builder/expo_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package main

import (
"path/filepath"
"testing"
)

// TestDetectIOSPathExpo covers the managed Expo case, where package.json
// depends on Expo but no Xcode project is committed anywhere, next to the
// project shapes that must keep detecting exactly as before.
func TestDetectIOSPathExpo(t *testing.T) {
tests := []struct {
name string
files map[string]string
wantPath string
wantFramework string
wantExpo bool
}{
{
name: "managed expo without an ios directory",
files: map[string]string{"package.json": `{"dependencies":{"expo":"~51.0.0"}}`},
wantPath: "ios",
wantFramework: expoManagedFramework,
wantExpo: true,
},
{
name: "ejected expo keeps the react native path",
files: map[string]string{
"package.json": `{"dependencies":{"expo":"~51.0.0","react-native":"0.74.0"}}`,
"ios/MyApp.xcodeproj/project.pbxproj": "// project",
},
wantPath: "ios",
wantFramework: "React Native/Expo",
wantExpo: true,
},
{
name: "plain react native without ios stays undetected",
files: map[string]string{"package.json": `{"dependencies":{"react-native":"0.74.0"}}`},
wantPath: "",
wantFramework: "",
wantExpo: false,
},
{
name: "native project at the root",
files: map[string]string{"MyApp.xcodeproj/project.pbxproj": "// project"},
wantPath: "",
wantFramework: "Native iOS",
wantExpo: false,
},
{
// The runners detect pubspec.yaml before package.json, so a
// Flutter repo whose ios/ is not committed must not be claimed
// here either — the runner would never prebuild it.
name: "flutter wins over an expo dependency",
files: map[string]string{
"pubspec.yaml": "name: app\n",
"package.json": `{"dependencies":{"expo":"~51.0.0"}}`,
},
wantPath: "",
wantFramework: "",
wantExpo: true,
},
{
// KMP keeps its Xcode project in iosApp/, which is matched before
// the Expo fallback is reached.
name: "kmp keeps its own path",
files: map[string]string{
"iosApp/iosApp.xcodeproj/project.pbxproj": "// project",
"package.json": `{"dependencies":{"expo":"~51.0.0"}}`,
},
wantPath: "iosApp",
wantFramework: "Kotlin Multiplatform",
wantExpo: true,
},
{
// "expo" appears in the file, but not as a dependency.
name: "unrelated node project naming expo",
files: map[string]string{
"package.json": `{"name":"expo","keywords":["expo"],"scripts":{"expo":"echo"},"dependencies":{"expo-server-sdk":"^3.7.0"}}`,
},
wantPath: "",
wantFramework: "",
wantExpo: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dir := chdir(t)
for name, content := range tt.files {
writeFile(t, filepath.Join(dir, filepath.FromSlash(name)), content)
}
path, framework := detectIOSPath()
if path != tt.wantPath || framework != tt.wantFramework {
t.Fatalf("detectIOSPath() = (%q, %q), want (%q, %q)", path, framework, tt.wantPath, tt.wantFramework)
}
if got := isExpoProject(); got != tt.wantExpo {
t.Fatalf("isExpoProject() = %v, want %v", got, tt.wantExpo)
}
})
}
}
41 changes: 38 additions & 3 deletions cmd/builder/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,14 +114,35 @@ func isFlutterProject() bool {
return err == nil
}

// isExpoProject reports whether package.json declares a dependency on Expo.
// It reads the dependency maps rather than searching the raw text, so a
// package named "expo", a script that shells out to it, or a keyword does not
// make an unrelated Node project look like an Expo app. An unparseable
// package.json falls back to the substring test the runners use, so the CLI
// and the runners still agree on such a file.
func isExpoProject() bool {
data, err := os.ReadFile("package.json")
if err != nil {
return false
}
return strings.Contains(string(data), `"expo"`)
var pkg struct {
Dependencies map[string]json.RawMessage `json:"dependencies"`
DevDependencies map[string]json.RawMessage `json:"devDependencies"`
}
if err := json.Unmarshal(data, &pkg); err != nil {
return strings.Contains(string(data), `"expo"`)
}
_, dep := pkg.Dependencies["expo"]
_, devDep := pkg.DevDependencies["expo"]
return dep || devDep
}

// expoManagedFramework names a managed Expo project: one that depends on Expo
// but keeps no Xcode project in git, because `expo prebuild` generates it. The
// runner runs that prebuild, so the iOS path is still "ios" — that is where
// prebuild puts the project.
const expoManagedFramework = "Expo (managed)"

// kmpPluginRe matches a declaration of the Kotlin Multiplatform Gradle plugin,
// in the Kotlin DSL (`kotlin("multiplatform")`) or Groovy/plugin-id form. It
// must stay in step with the detection in the workflow template: a project the
Expand Down Expand Up @@ -216,6 +237,16 @@ func detectIOSPath() (string, string) {
}
}

// No Xcode project anywhere, but the app depends on Expo: a managed
// project, whose ios/ directory the runner generates with `expo prebuild`.
// Flutter is checked first for the same reason the runners check
// pubspec.yaml before package.json: a Flutter repo that does not commit
// ios/ is not a managed Expo project, and the runners would never prebuild
// it.
if !isFlutterProject() && isExpoProject() {
return "ios", expoManagedFramework
}

return "", ""
}

Expand Down Expand Up @@ -296,6 +327,10 @@ func runInit(cmd *cobra.Command, args []string) error {
detectedPath, framework := detectIOSPath()
if detectedPath != "" {
fmt.Printf("Detected %s project (iOS at '%s')\n", framework, detectedPath)
if framework == expoManagedFramework {
fmt.Printf("There is no '%s' directory yet; the build generates it on the runner with 'expo prebuild'.\n", detectedPath)
fmt.Println("app.json / app.config.js must set ios.bundleIdentifier, or prebuild cannot run unattended.")
}
confirmPrompt := promptui.Prompt{
Label: "Use this path",
IsConfirm: true,
Expand Down Expand Up @@ -473,7 +508,7 @@ func runInit(cmd *cobra.Command, args []string) error {
fmt.Println()
return runBuild(context.Background(), cfg, build.BuildOptions{
OutputDir: "dist",
Timeout: 30 * time.Minute,
Timeout: build.DefaultTimeout,
Remote: remoteName,
})
}
Expand Down Expand Up @@ -547,7 +582,7 @@ func init() {

// iOS build command flags
iosBuildCmd.Flags().StringP("output", "o", "dist", "Output directory for IPA")
iosBuildCmd.Flags().Duration("timeout", 30*time.Minute, "Build timeout")
iosBuildCmd.Flags().Duration("timeout", build.DefaultTimeout, "Build timeout")
iosBuildCmd.Flags().Bool("unsigned", false, "Build unsigned IPA (skip code signing even if configured)")
iosBuildCmd.Flags().StringP("remote", "r", "origin", "Git remote to push the working-tree snapshot to")
iosBuildCmd.Flags().String("provider", "", "Override CI provider (default github or builder.json provider)")
Expand Down
2 changes: 1 addition & 1 deletion internal/build/coordinator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
26 changes: 20 additions & 6 deletions internal/github/workflow.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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 {
Expand Down
71 changes: 71 additions & 0 deletions internal/github/workflow_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading