Skip to content

expo: build managed Expo projects by prebuilding on the runner - #18

Merged
Interlap01 merged 12 commits into
mainfrom
feat/expo-prebuild
Sep 16, 2026
Merged

Interlap01 merged 12 commits into
mainfrom
feat/expo-prebuild

Conversation

@Interlap01

@Interlap01 Interlap01 commented Sep 16, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Builder only supported ejected Expo projects with a committed ios/ directory. This adds managed projects, the most obvious gap for anyone comparing Builder with EAS Build.

  • builder init detects "Expo (managed)" when package.json lists expo in dependencies and no Xcode project exists anywhere. It writes ios.path: ios and reactNative.expo: true and tells the user the native project is generated on the runner. Detection parses dependencies rather than matching the word, and Flutter/KMP/native/ejected detection is unchanged and takes precedence.
  • New Expo prebuild step in ios-build.yml, ios-share.yml and runner.sh (Codemagic/Bitrise): after node dependencies, before Pods and scheme detection. It skips when an Xcode project already exists, resolves the bundle identifier via npx expo config with an app.json fallback, fails with a named error when it is missing instead of hanging on a prompt, runs npx expo prebuild --platform ios --no-install under CI=1, and errors if no project appeared or ios.path is not ios.
  • runner.sh previously ran an unconditional prebuild that could clobber ejected projects; it is now guarded the same way.
  • Managed projects gitignore ios/, so the working-tree snapshot naturally excludes it and the runner generates it.
  • Tests: detection matrix in cmd/builder/expo_test.go; internal/workflow/expo_test.go parses the YAML, checks the step gate and ordering, and executes the extracted step scripts and the runner function against a stubbed npx.
  • README gains an Expo section, including that the default Debug configuration expects Metro and ios.configuration: Release produces a standalone IPA.

Test plan

  • go build ./... && go vet ./... && go test ./...
  • builder init in a fresh npx create-expo-app project detects Expo (managed)
  • builder ios build on that project produces an IPA; the log shows the prebuild step
  • An ejected Expo project skips the prebuild step and builds as before
  • A managed project without ios.bundleIdentifier fails with the named error

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.
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.
`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.
A Debug IPA loads its JavaScript from Metro, so a standalone Expo IPA
needs ios.configuration set to Release.
…and Node version

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.
…acks 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.
@Interlap01
Interlap01 merged commit 9adf88b into main Sep 16, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant