Skip to content
Open
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
36 changes: 34 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -289,7 +289,36 @@ internal/
`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.
saves before it shares the simulator, since that step blocks until the session ends. Prefixes are
`deriveddata-device-`/`deriveddata-sim-` (and `ccache-device-`/`ccache-sim-`) so neither workflow
restores the other's products.
- **Cache Pairing**: `ios-build` uses combined `actions/cache` for lockfile-keyed caches; `ios-share`
restores every cache with `cache/restore` and saves it (success only, key missed,
`cache-primary-key`) before the share step. `TestGitHubCachesPaired` fails on a restore without a
save for the same paths, or a save after the share step.
- **Swift Package Cache**: every runner clones packages into `~/.ios-builder/SourcePackages`
(outside the checkout, so `find`/`hashFiles` never walk package sources) via
`-clonedSourcePackagesDirPath` on every xcodebuild that passes `-derivedDataPath` and on the
`apply_build_number` calls (`TestSourcePackagesDirUsed`). GitHub keys on the workspace/project
`Package.resolved` with `!DerivedData/**`, `!**/node_modules/**`, `!**/Pods/**`, and skips the cache
without one. `DerivedData/SourcePackages` from older runs is deleted before the DerivedData save.
- **Pub Cache**: flutter-action runs with `pub-cache: false`; our own step keys `~/.pub-cache` on
`pubspec.lock` with a prefix fallback, which flutter-action's has none of.
- **Bitrise Caches**: `restore-cache@3`/`save-cache@1` keyed on lockfile `checksum`s (exact key, then
the `<kind>-{{ .OS }}-{{ .Arch }}-` prefix); DerivedData and ccache key on `BITRISE_BUILD_NUMBER` and
are the only `is_always_run` saves. The restores need the snapshot's lockfiles, so a `runner.sh
checkout` step runs first and the build step reuses the runner.sh copied before it.
`BITRISE_CACHE_HIT` of the node_modules restore is aliased to `NODE_MODULES_CACHE_HIT`, and `exact`
becomes `JS_DEPS_CACHED=true`. Paths that do not exist are skipped by save-cache with a warning.
- **Codemagic Caches**: path-only `cache_paths` (no keys): DerivedData, Gradle, pub cache, Swift
packages, ccache. Pods and node_modules are not cached there, since without a lockfile key a stale
copy would be restored on every build.
- **ccache Is Opt-In**: `cache.ccache` in builder.json (`config.CacheConfig`), read by the runners from
the snapshot, React Native and Expo only. The shared block between `# >>> ccache` and `# <<< ccache`
(`ccache_enabled`, `ccache_setup`) is verbatim in both GitHub templates and `runner.sh`
(`TestCcacheBlockIdentical`) and runs before `pod install`, where RN's `react_native_post_install`
reads `USE_CCACHE=1`; Expo's Podfile reads `apple.ccacheEnabled` instead. It is opt-in because a
Podfile that ignores it gains nothing and the install costs time on every run.
- **Scheme Selection**: `xcodebuild -list -json` plus the scheme named after the workspace/project;
taking the first scheme picks a package or pod scheme in package-heavy repos
- **Product Selection**: the built `.app` comes from `-showBuildSettings -json` (the target whose
Expand Down Expand Up @@ -461,6 +490,9 @@ A profile's fields are `distribution` (`development`, `ad-hoc`/`internal`, `stor
only signing field, omitted = unsigned), `configuration` (else Debug for development, Release
otherwise), `scheme`, `provider`, `env`. `runner`/`submit` are planned on `config.Profile`, not read.

`cache.ccache` (top level, default false) turns ccache on for React Native and Expo builds on every
provider; the runners read it from builder.json in the snapshot, not from a dispatch input.

## Workflow Features

The embedded workflow template (`internal/workflow/templates/ios-build.yml`):
Expand All @@ -484,7 +516,7 @@ The embedded workflow template (`internal/workflow/templates/ios-build.yml`):
unfiltered `on: push` also fires on these tags.
- Runs on `macos-latest`
- Detects Flutter projects (checks for `pubspec.yaml`)
- Restores and saves DerivedData for fast incremental builds
- Restores and saves DerivedData, Swift packages, Pods, node_modules, the pub cache and (opt-in) ccache
- Auto-detects workspace/project and scheme
- Flutter: uses `Runner` scheme, runs `flutter pub get`
- Installs CocoaPods if Podfile exists
Expand Down
52 changes: 52 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,7 @@ machine-readable output and never prompts, so agents and CI jobs can drive them.
| `ios.extensions` | Bundle identifiers of the app's extension targets (widgets, share/notification extensions, watch apps, app clips), each signed with its own profile | filled by `init` and `signing setup` from the Xcode project; list them by hand for a managed Expo project |
| `ios.configuration` | Xcode build configuration. **Builds are `Debug` unless you set `Release`**; Debug is faster and is what the dev commands expect | `Debug` |
| `ios.signing` | Legacy: sign builds that select no profile, with the unsuffixed `IOS_CERTIFICATE`, `IOS_CERTIFICATE_PASSWORD` and `IOS_PROVISIONING_PROFILE` secrets. Profiles ignore it; use `distribution` there | `false` |
| `cache.ccache` | React Native and Expo: compile native code through ccache and keep its cache between runs (see [Build Caching](#build-caching)) | `false` |

### Build Profiles

Expand Down Expand Up @@ -1055,6 +1056,57 @@ app that is already installed.
**App launches then immediately exits**
- Launch with `builder dev kmp --logs` to see the device output

## Build Caching

Every provider keeps the slow parts of a build between runs. What is cached,
and what the cache is keyed on:

| Cache | GitHub Actions (`ios-build`, `ios-share`) | Bitrise | Codemagic |
|-------|-------------------------------------------|---------|-----------|
| DerivedData | per run, newest restored (device and simulator kept apart) | per build, newest restored (device and simulator kept apart) | by path |
| Swift packages | `Package.resolved` of the workspace/project | `Package.resolved` | by path |
| CocoaPods (`Pods`, `~/.cocoapods/repos`) | `Podfile.lock` | `Podfile.lock` | — |
| `node_modules` | lockfiles + package manager | `package.json` + lockfiles | — |
| `~/.pub-cache` (Flutter) | `pubspec.lock` | `pubspec.lock` | by path |
| Flutter SDK | flutter-action | — | — |
| Gradle (KMP) | setup-gradle | Gradle files | `~/.gradle/caches` by path |
| ccache (opt-in) | per run, newest restored | per build, newest restored | by path |

A key that misses falls back to the newest cache of the same kind, so a changed
lockfile still starts warm. An exact `node_modules` hit skips the install.

**Swift packages** are cloned into `~/.ios-builder/SourcePackages` on every
provider (`xcodebuild -clonedSourcePackagesDirPath`), not into
`DerivedData/SourcePackages`, so they get a key of their own. On GitHub there
is no cache without a committed `Package.resolved` (in
`*.xcworkspace/xcshareddata/swiftpm/` or
`*.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/`). `flutter build ios`
resolves its own packages and does not use this directory.

**ccache** is off by default. It is worth it for React Native and Expo, whose
native code (React Native itself and every native module) is compiled again on
every run: a fresh checkout gives every file a new modification time, so a
restored DerivedData does not spare that work, while ccache matches on file
contents. Turn it on in `builder.json`:

```json
{
"cache": { "ccache": true }
}
```

The runner then installs ccache, sets `USE_CCACHE=1` before `pod install` and
keeps `~/.ccache` (2 GB at most) between runs. Whether clang actually goes
through it is the Podfile's decision: React Native's `react_native_post_install`
reads `USE_CCACHE`, while Expo's generated Podfile reads `apple.ccacheEnabled`
from `ios/Podfile.properties.json` instead, so an Expo project sets that too.
It is opt-in because a Podfile that does neither never uses it, installing
ccache costs time on every run, and a compiler cache is one more thing to rule
out when a build misbehaves. Native, Flutter and KMP builds ignore the switch.

An older generated `bitrise.yml` has no cache steps; run
`builder init --provider bitrise` again to regenerate it.

## Build Limits

Free allowances belong to each provider account and depend on the plan and
Expand Down
6 changes: 5 additions & 1 deletion docs/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,11 @@ To change it, edit `provider`, or pass `--set-default` when configuring a provid
Native, Flutter, React Native/Expo, and KMP use the same framework settings as
GitHub builds. A pinned `flutter.version` installs that SDK; `kmp.jdkVersion`
selects the major JDK version. Provider caches and setup costs differ, so the
same build may consume different minutes on each provider.
same build may consume different minutes on each provider; the
[caching table](../README.md#build-caching) lists what each one keeps.
Bitrise restores its caches in steps between the snapshot checkout and the
build, so the generated `bitrise.yml` checks out the snapshot in a step of its
own.

### Signing

Expand Down
34 changes: 34 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package config
import (
"os"
"path/filepath"
"strings"
"testing"
)

Expand Down Expand Up @@ -121,6 +122,39 @@ func TestManager_SaveAndLoad(t *testing.T) {
}
}

// TestManager_KeepsCacheSwitch: the runners read cache.ccache from builder.json,
// so a command that rewrites the file must not drop it.
func TestManager_KeepsCacheSwitch(t *testing.T) {
path := filepath.Join(t.TempDir(), "builder.json")
if err := os.WriteFile(path, []byte(`{"project":"App","platform":"ios","github":{"owner":"o","repo":"r"},"cache":{"ccache":true}}`), 0644); err != nil {
t.Fatal(err)
}
mgr := &Manager{path: path}
cfg, err := mgr.Load()
if err != nil {
t.Fatal(err)
}
if cfg.Cache == nil || !cfg.Cache.CCache {
t.Fatalf("cache.ccache not loaded: %+v", cfg.Cache)
}
if err := mgr.Save(cfg); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), `"ccache": true`) {
t.Fatalf("cache.ccache lost on save:\n%s", data)
}
if err := mgr.Save(&Config{Project: "App"}); err != nil {
t.Fatal(err)
}
if data, _ := os.ReadFile(path); strings.Contains(string(data), `"cache"`) {
t.Fatalf("an unset cache block is written:\n%s", data)
}
}

func TestManager_Load_NotFound(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "nonexistent.json")
Expand Down
11 changes: 11 additions & 0 deletions internal/config/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,17 @@ type Config struct {
// runs have no flags, so it is also the only way they can select a profile.
DefaultProfile string `json:"defaultProfile,omitempty"`
Profiles map[string]Profile `json:"profiles,omitempty"`
// Cache holds opt-in build caches. The runners read it from builder.json
// in the snapshot; the CLI only keeps it when it rewrites the file.
Cache *CacheConfig `json:"cache,omitempty"`
}

// CacheConfig switches on build caches that are off by default.
type CacheConfig struct {
// CCache installs ccache and keeps its directory between runs for React
// Native and Expo builds; the project's Podfile decides whether clang
// actually goes through it (USE_CCACHE=1 for React Native's hook).
CCache bool `json:"ccache,omitempty"`
}

// SigningConfig is where the signing material lives on this machine.
Expand Down
Loading
Loading