From c55feffa87a2b903410309971425b081244b8333 Mon Sep 17 00:00:00 2001 From: Interlap Date: Sun, 4 Oct 2026 09:45:26 +0200 Subject: [PATCH 1/7] config: keep cache.ccache in builder.json The runners read the opt-in ccache switch from builder.json in the snapshot; without a field for it, any command that rewrites the file would drop it. --- internal/config/config_test.go | 34 ++++++++++++++++++++++++++++++++++ internal/config/types.go | 11 +++++++++++ 2 files changed, 45 insertions(+) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 67edcc1..1c00aa3 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -3,6 +3,7 @@ package config import ( "os" "path/filepath" + "strings" "testing" ) @@ -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") diff --git a/internal/config/types.go b/internal/config/types.go index 2d38ec0..2a02cf5 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -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. From c84ea78a708d8b2634bfd3f24f8662470e5f35a1 Mon Sep 17 00:00:00 2001 From: Interlap Date: Sun, 4 Oct 2026 09:45:26 +0200 Subject: [PATCH 2/7] workflow: cache Swift packages, the pub cache and ccache on GitHub Swift packages are cloned into ~/.ios-builder/SourcePackages with -clonedSourcePackagesDirPath on every xcodebuild that resolves the package graph, and cached on the workspace/project Package.resolved. The pub cache gets a step of its own with a fallback key, and ccache is opt-in for React Native and Expo through cache.ccache. ios-share now restores Pods, node_modules, Swift packages, the pub cache and ccache too, and saves them before the share step. The device DerivedData prefix becomes deriveddata-device-, since deriveddata- also matched the simulator workflow's caches. --- internal/workflow/templates/ios-build.yml | 117 +++++++++++++++-- internal/workflow/templates/ios-share.yml | 147 +++++++++++++++++++++- 2 files changed, 256 insertions(+), 8 deletions(-) diff --git a/internal/workflow/templates/ios-build.yml b/internal/workflow/templates/ios-build.yml index d78a18e..f34e492 100644 --- a/internal/workflow/templates/ios-build.yml +++ b/internal/workflow/templates/ios-build.yml @@ -201,9 +201,11 @@ jobs: id: cache-deriveddata with: path: DerivedData - key: deriveddata-${{ github.run_id }} + key: deriveddata-device-${{ github.run_id }} + # "deriveddata-" alone would also match the simulator workflow's + # deriveddata-sim-*, whose products are no use to a device build. restore-keys: | - deriveddata- + deriveddata-device- - name: Detect project type id: detect @@ -315,6 +317,19 @@ jobs: flutter-version: ${{ steps.params.outputs.flutter_version || '' }} channel: stable cache: true + pub-cache: false # cached below, with a fallback key + + # flutter-action's own pub cache has no fallback key, so any change to + # pubspec.lock started from an empty ~/.pub-cache. + - name: Restore pub cache + if: steps.detect.outputs.type == 'flutter' + uses: actions/cache@v6 + id: pub-cache + with: + path: ~/.pub-cache + key: pub-${{ runner.os }}-${{ hashFiles('pubspec.lock') }} + restore-keys: | + pub-${{ runner.os }}- - name: Flutter pub get if: steps.detect.outputs.type == 'flutter' @@ -642,6 +657,74 @@ jobs: restore-keys: | pods-${{ runner.os }}- + # Swift packages are cloned into a directory of their own (the build + # passes -clonedSourcePackagesDirPath) rather than DerivedData/SourcePackages, + # so they are keyed on the Package.resolved files that pin them. It sits + # outside the checkout, so nothing that walks the repository walks into + # package sources. No Package.resolved, nothing stable to key on: no cache. + - name: Restore Swift packages cache + if: hashFiles('**/*.xcworkspace/xcshareddata/swiftpm/Package.resolved', '**/*.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved', '!DerivedData/**', '!**/node_modules/**', '!**/Pods/**') != '' + uses: actions/cache@v6 + id: spm-cache + with: + path: ~/.ios-builder/SourcePackages + key: spm-${{ runner.os }}-${{ hashFiles('**/*.xcworkspace/xcshareddata/swiftpm/Package.resolved', '**/*.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved', '!DerivedData/**', '!**/node_modules/**', '!**/Pods/**') }} + restore-keys: | + spm-${{ runner.os }}- + + # Before the build step's pod install, which is where React Native's + # Podfile hook reads USE_CCACHE. + - name: Enable ccache + id: ccache + env: + PROJECT_TYPE: ${{ steps.detect.outputs.type }} + run: | + set -e + # >>> ccache (keep identical across ios-build.yml, ios-share.yml, runner.sh) + # Opt-in with "cache": {"ccache": true} in builder.json, React Native and + # Expo only. A fresh checkout gives every source a new mtime, so a restored + # DerivedData still recompiles the native code; ccache hashes contents. + # React Native's Podfile hook (react_native_post_install) compiles through + # ccache when USE_CCACHE=1 is set at pod install; Expo's generated Podfile + # reads apple.ccacheEnabled from Podfile.properties.json instead. Off by + # default: a Podfile that does neither never calls it, and installing + # ccache costs time on every run. + ccache_enabled() { + case "$1" in reactnative|expo) ;; *) return 1 ;; esac + [ -f builder.json ] && [ "$(jq -r '.cache.ccache // false' builder.json 2>/dev/null || true)" = true ] + } + + ccache_setup() { + command -v ccache >/dev/null 2>&1 || brew install ccache + export USE_CCACHE=1 CCACHE_DIR="$HOME/.ccache" CCACHE_MAXSIZE="${CCACHE_MAXSIZE:-2G}" + mkdir -p "$CCACHE_DIR" + echo "ccache: $(ccache --version | head -n1), cache in $CCACHE_DIR (max $CCACHE_MAXSIZE)" + } + # <<< ccache + if ccache_enabled "$PROJECT_TYPE"; then + ccache_setup + { + echo "USE_CCACHE=$USE_CCACHE" + echo "CCACHE_DIR=$CCACHE_DIR" + echo "CCACHE_MAXSIZE=$CCACHE_MAXSIZE" + } >> "$GITHUB_ENV" + echo "enabled=true" >> "$GITHUB_OUTPUT" + else + echo "ccache off (\"cache\": {\"ccache\": true} in builder.json turns it on for React Native and Expo)" + fi + + # Content-addressed, so like DerivedData it is keyed per run and only the + # prefix ever hits; the save step at the end must stay paired with this. + - name: Restore ccache + if: steps.ccache.outputs.enabled == 'true' + uses: actions/cache/restore@v6 + id: ccache-cache + with: + path: ~/.ccache + key: ccache-device-${{ runner.os }}-${{ github.run_id }} + restore-keys: | + ccache-device-${{ runner.os }}- + # One set of secrets per distribution, IOS_*_; the unsuffixed names # serve builds without a profile. A secret that does not exist arrives empty. - name: Install certificate and provisioning profile @@ -1071,6 +1154,9 @@ jobs: # Use custom DerivedData path for caching DERIVED_DATA_PATH="${GITHUB_WORKSPACE}/DerivedData" + # Swift packages go where the Restore Swift packages cache step put them; + # every xcodebuild that resolves the package graph gets the same path. + SOURCE_PACKAGES_PATH="$HOME/.ios-builder/SourcePackages" if [ "$PROJECT_TYPE" = "flutter" ]; then cd "$GITHUB_WORKSPACE" @@ -1104,11 +1190,12 @@ jobs: # Flutter wrote the build number into Generated.xcconfig; this # only catches a Runner Info.plist that hardcodes it. - apply_build_number "$TARGET_FLAG" "$TARGET_PATH" -scheme "${SCHEME:-Runner}" + apply_build_number "$TARGET_FLAG" "$TARGET_PATH" -scheme "${SCHEME:-Runner}" -clonedSourcePackagesDirPath "$SOURCE_PACKAGES_PATH" ARCHIVE_CMD="$ARCHIVE_CMD -scheme '${SCHEME:-Runner}'" ARCHIVE_CMD="$ARCHIVE_CMD -configuration '$CONFIGURATION'" ARCHIVE_CMD="$ARCHIVE_CMD -destination 'generic/platform=iOS'" ARCHIVE_CMD="$ARCHIVE_CMD -derivedDataPath '$DERIVED_DATA_PATH'" + ARCHIVE_CMD="$ARCHIVE_CMD -clonedSourcePackagesDirPath '$SOURCE_PACKAGES_PATH'" ARCHIVE_CMD="$ARCHIVE_CMD COMPILER_INDEX_STORE_ENABLE=NO $version_settings" ARCHIVE_CMD="$ARCHIVE_CMD -quiet" ARCHIVE_CMD="$ARCHIVE_CMD -archivePath '$GITHUB_WORKSPACE/build/App.xcarchive' archive" @@ -1148,11 +1235,12 @@ jobs: fi fi - apply_build_number "$TARGET_FLAG" "$TARGET_PATH" -scheme "$SCHEME" + apply_build_number "$TARGET_FLAG" "$TARGET_PATH" -scheme "$SCHEME" -clonedSourcePackagesDirPath "$SOURCE_PACKAGES_PATH" BUILD_CMD="$BUILD_CMD -scheme '$SCHEME'" BUILD_CMD="$BUILD_CMD -configuration '$CONFIGURATION'" BUILD_CMD="$BUILD_CMD -destination 'generic/platform=iOS'" BUILD_CMD="$BUILD_CMD -derivedDataPath '$DERIVED_DATA_PATH'" + BUILD_CMD="$BUILD_CMD -clonedSourcePackagesDirPath '$SOURCE_PACKAGES_PATH'" BUILD_CMD="$BUILD_CMD COMPILER_INDEX_STORE_ENABLE=NO $version_settings" BUILD_CMD="$BUILD_CMD -quiet" @@ -1176,11 +1264,12 @@ jobs: BUILD_CMD="$BUILD_CMD -project $PROJECT" fi - apply_build_number "$TARGET_FLAG" "$TARGET_PATH" -scheme "$SCHEME" + apply_build_number "$TARGET_FLAG" "$TARGET_PATH" -scheme "$SCHEME" -clonedSourcePackagesDirPath "$SOURCE_PACKAGES_PATH" BUILD_CMD="$BUILD_CMD -scheme '$SCHEME'" BUILD_CMD="$BUILD_CMD -configuration '$CONFIGURATION'" BUILD_CMD="$BUILD_CMD -destination 'generic/platform=iOS'" BUILD_CMD="$BUILD_CMD -derivedDataPath '$DERIVED_DATA_PATH'" + BUILD_CMD="$BUILD_CMD -clonedSourcePackagesDirPath '$SOURCE_PACKAGES_PATH'" BUILD_CMD="$BUILD_CMD $version_settings" # Speed optimization flags @@ -1246,6 +1335,7 @@ jobs: -configuration "$CONFIGURATION" \ -destination 'generic/platform=iOS' \ -derivedDataPath "$DERIVED_DATA_PATH" \ + -clonedSourcePackagesDirPath "$SOURCE_PACKAGES_PATH" \ -showBuildSettings -json 2>/dev/null \ | jq -r 'map(.buildSettings) | map(select(.PRODUCT_TYPE == "com.apple.product-type.application" @@ -1280,11 +1370,14 @@ jobs: if-no-files-found: error # Flutter builds through its own tree, so DerivedData can be absent and a - # save would fail on a path that never existed. + # save would fail on a path that never existed. Swift packages have their + # own cache now; a SourcePackages left by an older run is dropped here + # instead of riding along in every DerivedData save. - name: Check for DerivedData id: dd if: always() run: | + rm -rf DerivedData/SourcePackages if [ -d DerivedData ]; then echo "exists=yes" >> $GITHUB_OUTPUT; fi - name: Save DerivedData cache @@ -1292,7 +1385,14 @@ jobs: if: always() && steps.dd.outputs.exists == 'yes' with: path: DerivedData - key: deriveddata-${{ github.run_id }} + key: deriveddata-device-${{ github.run_id }} + + - name: Save ccache + uses: actions/cache/save@v6 + if: always() && steps.ccache.outputs.enabled == 'true' + with: + path: ~/.ccache + key: ccache-device-${{ runner.os }}-${{ github.run_id }} - name: Cleanup signing if: always() && steps.params.outputs.use_signing == 'true' @@ -1323,6 +1423,9 @@ jobs: echo "- **DerivedData Cache:** ${{ steps.cache-deriveddata.outputs.cache-hit == 'true' && 'Hit' || 'Miss' }}" >> $GITHUB_STEP_SUMMARY echo "- **Pods Cache:** ${{ steps.pods-cache.outputs.cache-hit == 'true' && 'Hit' || 'Miss' }}" >> $GITHUB_STEP_SUMMARY echo "- **Node Modules Cache:** ${{ steps.node-modules-cache.outputs.cache-hit == 'true' && 'Hit' || 'N/A' }}" >> $GITHUB_STEP_SUMMARY + echo "- **Swift Packages Cache:** ${{ steps.spm-cache.outputs.cache-hit == 'true' && 'Hit' || (steps.spm-cache.outcome == 'success' && 'Miss' || 'N/A') }}" >> $GITHUB_STEP_SUMMARY + echo "- **Pub Cache:** ${{ steps.pub-cache.outputs.cache-hit == 'true' && 'Hit' || (steps.pub-cache.outcome == 'success' && 'Miss' || 'N/A') }}" >> $GITHUB_STEP_SUMMARY + echo "- **ccache:** ${{ steps.ccache.outputs.enabled == 'true' && 'On' || 'Off' }}" >> $GITHUB_STEP_SUMMARY if [ "$USE_SIGNING" = "true" ]; then echo "- **Signing:** Signed (${EXPORT_METHOD:-unknown}, set ${SIGNING_SET_USED:-unknown})" >> $GITHUB_STEP_SUMMARY else diff --git a/internal/workflow/templates/ios-share.yml b/internal/workflow/templates/ios-share.yml index 4beb1dc..1fa1439 100644 --- a/internal/workflow/templates/ios-share.yml +++ b/internal/workflow/templates/ios-share.yml @@ -239,6 +239,17 @@ jobs: flutter-version: ${{ steps.params.outputs.flutter_version || '' }} channel: stable cache: true + pub-cache: false # cached below, saved before the share step + + - name: Restore pub cache + if: steps.detect.outputs.type == 'flutter' + uses: actions/cache/restore@v6 + id: pub-cache + with: + path: ~/.pub-cache + key: pub-${{ runner.os }}-${{ hashFiles('pubspec.lock') }} + restore-keys: | + pub-${{ runner.os }}- - name: Flutter pub get if: steps.detect.outputs.type == 'flutter' @@ -377,12 +388,25 @@ jobs: node-version: ${{ steps.js.outputs.node_version }} node-version-file: ${{ steps.js.outputs.node_version_file }} + # Same key as the build workflow's: node_modules does not depend on the + # destination, so either workflow's save serves the other. + - name: Restore node_modules cache + if: steps.detect.outputs.type == 'reactnative' || steps.detect.outputs.type == 'expo' + uses: actions/cache/restore@v6 + id: node-modules-cache + with: + path: node_modules + 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 }}-${{ 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) @@ -538,6 +562,81 @@ jobs: if: steps.detect.outputs.type == 'kmp' uses: gradle/actions/setup-gradle@v6 + # Every cache below is restored here and saved before the share step, + # which blocks until the session ends: a post-job save would come after + # a timeout, or not at all. Keys match the build workflow's except where + # the output depends on the destination (DerivedData, ccache). + - name: Restore Pods cache + if: hashFiles(format('{0}/Podfile', steps.params.outputs.ios_path)) != '' + uses: actions/cache/restore@v6 + id: pods-cache + with: + path: | + ${{ steps.params.outputs.ios_path }}/Pods + ~/.cocoapods/repos + key: pods-${{ runner.os }}-${{ hashFiles(format('{0}/Podfile.lock', steps.params.outputs.ios_path)) }} + restore-keys: | + pods-${{ runner.os }}- + + - name: Restore Swift packages cache + if: hashFiles('**/*.xcworkspace/xcshareddata/swiftpm/Package.resolved', '**/*.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved', '!DerivedData/**', '!**/node_modules/**', '!**/Pods/**') != '' + uses: actions/cache/restore@v6 + id: spm-cache + with: + path: ~/.ios-builder/SourcePackages + key: spm-${{ runner.os }}-${{ hashFiles('**/*.xcworkspace/xcshareddata/swiftpm/Package.resolved', '**/*.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved', '!DerivedData/**', '!**/node_modules/**', '!**/Pods/**') }} + restore-keys: | + spm-${{ runner.os }}- + + - name: Enable ccache + id: ccache + env: + PROJECT_TYPE: ${{ steps.detect.outputs.type }} + run: | + set -e + # >>> ccache (keep identical across ios-build.yml, ios-share.yml, runner.sh) + # Opt-in with "cache": {"ccache": true} in builder.json, React Native and + # Expo only. A fresh checkout gives every source a new mtime, so a restored + # DerivedData still recompiles the native code; ccache hashes contents. + # React Native's Podfile hook (react_native_post_install) compiles through + # ccache when USE_CCACHE=1 is set at pod install; Expo's generated Podfile + # reads apple.ccacheEnabled from Podfile.properties.json instead. Off by + # default: a Podfile that does neither never calls it, and installing + # ccache costs time on every run. + ccache_enabled() { + case "$1" in reactnative|expo) ;; *) return 1 ;; esac + [ -f builder.json ] && [ "$(jq -r '.cache.ccache // false' builder.json 2>/dev/null || true)" = true ] + } + + ccache_setup() { + command -v ccache >/dev/null 2>&1 || brew install ccache + export USE_CCACHE=1 CCACHE_DIR="$HOME/.ccache" CCACHE_MAXSIZE="${CCACHE_MAXSIZE:-2G}" + mkdir -p "$CCACHE_DIR" + echo "ccache: $(ccache --version | head -n1), cache in $CCACHE_DIR (max $CCACHE_MAXSIZE)" + } + # <<< ccache + if ccache_enabled "$PROJECT_TYPE"; then + ccache_setup + { + echo "USE_CCACHE=$USE_CCACHE" + echo "CCACHE_DIR=$CCACHE_DIR" + echo "CCACHE_MAXSIZE=$CCACHE_MAXSIZE" + } >> "$GITHUB_ENV" + echo "enabled=true" >> "$GITHUB_OUTPUT" + else + echo "ccache off (\"cache\": {\"ccache\": true} in builder.json turns it on for React Native and Expo)" + fi + + - name: Restore ccache + if: steps.ccache.outputs.enabled == 'true' + uses: actions/cache/restore@v6 + id: ccache-cache + with: + path: ~/.ccache + key: ccache-sim-${{ runner.os }}-${{ github.run_id }} + restore-keys: | + ccache-sim-${{ runner.os }}- + - name: Build for the simulator id: build env: @@ -546,6 +645,8 @@ jobs: PROJECT_TYPE: ${{ steps.detect.outputs.type }} run: | set -e + # Where the Restore Swift packages cache step put them. + SOURCE_PACKAGES_PATH="$HOME/.ios-builder/SourcePackages" if [ "$PROJECT_TYPE" = "flutter" ]; then # Flutter drives xcodebuild itself and puts the bundle in a known @@ -622,6 +723,7 @@ jobs: -configuration Debug \ "$DEST_FLAG" "$DEST_VALUE" \ -derivedDataPath "$GITHUB_WORKSPACE/DerivedData" \ + -clonedSourcePackagesDirPath "$SOURCE_PACKAGES_PATH" \ COMPILER_INDEX_STORE_ENABLE=NO \ CODE_SIGNING_ALLOWED=NO \ build @@ -634,6 +736,7 @@ jobs: -configuration Debug \ "$DEST_FLAG" "$DEST_VALUE" \ -derivedDataPath "$GITHUB_WORKSPACE/DerivedData" \ + -clonedSourcePackagesDirPath "$SOURCE_PACKAGES_PATH" \ -showBuildSettings -json 2>/dev/null \ | jq -r 'map(.buildSettings) | map(select(.PRODUCT_TYPE == "com.apple.product-type.application" @@ -654,11 +757,13 @@ jobs: echo "app_path=$APP_PATH" >> $GITHUB_OUTPUT # Flutter builds through its own tree, so DerivedData can be absent and a - # save would fail on a path that never existed. + # save would fail on a path that never existed. A SourcePackages left by + # an older run is dropped: Swift packages have a cache of their own. - name: Check for DerivedData id: dd if: always() run: | + rm -rf DerivedData/SourcePackages if [ -d DerivedData ]; then echo "exists=yes" >> $GITHUB_OUTPUT; fi # Saved before the sharing step, which blocks for as long as the @@ -671,6 +776,46 @@ jobs: path: DerivedData key: deriveddata-sim-${{ github.run_id }} + - name: Save ccache + if: always() && steps.ccache.outputs.enabled == 'true' + uses: actions/cache/save@v6 + with: + path: ~/.ccache + key: ccache-sim-${{ runner.os }}-${{ github.run_id }} + + # The dependency caches are saved the way actions/cache's post-job step + # would: only after a successful build, only when the key missed, under + # the key computed at restore time. + - name: Save pub cache + if: steps.pub-cache.outcome == 'success' && steps.pub-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@v6 + with: + path: ~/.pub-cache + key: ${{ steps.pub-cache.outputs.cache-primary-key }} + + - name: Save node_modules cache + if: steps.node-modules-cache.outcome == 'success' && steps.node-modules-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@v6 + with: + path: node_modules + key: ${{ steps.node-modules-cache.outputs.cache-primary-key }} + + - name: Save Pods cache + if: steps.pods-cache.outcome == 'success' && steps.pods-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@v6 + with: + path: | + ${{ steps.params.outputs.ios_path }}/Pods + ~/.cocoapods/repos + key: ${{ steps.pods-cache.outputs.cache-primary-key }} + + - name: Save Swift packages cache + if: steps.spm-cache.outcome == 'success' && steps.spm-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@v6 + with: + path: ~/.ios-builder/SourcePackages + key: ${{ steps.spm-cache.outputs.cache-primary-key }} + # Installs the build on the simulator and publishes it to the MobAI app. # The step (and the job) stay running until the simulator is released # there or goes unused. From ee72ba0471e18e1bff2f6dbc552570f1cc9d33b2 Mon Sep 17 00:00:00 2001 From: Interlap Date: Sun, 4 Oct 2026 09:45:26 +0200 Subject: [PATCH 3/7] workflow: Swift packages directory and ccache in runner.sh, Codemagic cache paths runner.sh clones Swift packages into the same directory as the GitHub workflows and carries the shared ccache block; Codemagic caches both paths. --- internal/workflow/templates/codemagic.yaml | 7 ++++ internal/workflow/templates/runner.sh | 40 ++++++++++++++++++++-- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/internal/workflow/templates/codemagic.yaml b/internal/workflow/templates/codemagic.yaml index 2f9b298..20597c4 100644 --- a/internal/workflow/templates/codemagic.yaml +++ b/internal/workflow/templates/codemagic.yaml @@ -1,4 +1,7 @@ # Generated by ios-builder. Commit this file and .builder/ci/runner.sh to the configured branch. +# Codemagic caches by path, with no key: DerivedData, Gradle, the pub cache, +# Swift packages (runner.sh clones them into ~/.ios-builder/SourcePackages) and +# ccache (filled only with "cache": {"ccache": true} in builder.json). workflows: ios-build: name: Builder iOS IPA @@ -13,6 +16,8 @@ workflows: - $CM_BUILD_DIR/DerivedData - $HOME/.gradle/caches - $HOME/.pub-cache + - $HOME/.ios-builder/SourcePackages + - $HOME/.ccache scripts: - name: Build IPA from snapshot script: | @@ -35,6 +40,8 @@ workflows: - $CM_BUILD_DIR/DerivedData - $HOME/.gradle/caches - $HOME/.pub-cache + - $HOME/.ios-builder/SourcePackages + - $HOME/.ccache scripts: - name: Build simulator from snapshot script: | diff --git a/internal/workflow/templates/runner.sh b/internal/workflow/templates/runner.sh index caa1b5c..8636de6 100644 --- a/internal/workflow/templates/runner.sh +++ b/internal/workflow/templates/runner.sh @@ -14,6 +14,11 @@ export DISTRIBUTION="${DISTRIBUTION:-}" BUILD_ENV="${BUILD_ENV:-}" # the CFBundleVersion to stamp as BUILDER_BUILD_NUMBER; empty means none. export BUILD_NUMBER="${BUILDER_BUILD_NUMBER:-}" +# Swift packages resolve here instead of DerivedData/SourcePackages, so the +# provider caches them on their own (Codemagic by path, Bitrise keyed on the +# Package.resolved files). Same directory as the GitHub workflows. +source_packages_dir="$HOME/.ios-builder/SourcePackages" + fail() { echo "$*" >&2; exit 1; } # Exports the profile's env before any dependency install or build, as the @@ -148,6 +153,28 @@ js_install() { } # <<< js toolchain +# >>> ccache (keep identical across ios-build.yml, ios-share.yml, runner.sh) +# Opt-in with "cache": {"ccache": true} in builder.json, React Native and +# Expo only. A fresh checkout gives every source a new mtime, so a restored +# DerivedData still recompiles the native code; ccache hashes contents. +# React Native's Podfile hook (react_native_post_install) compiles through +# ccache when USE_CCACHE=1 is set at pod install; Expo's generated Podfile +# reads apple.ccacheEnabled from Podfile.properties.json instead. Off by +# default: a Podfile that does neither never calls it, and installing +# ccache costs time on every run. +ccache_enabled() { + case "$1" in reactnative|expo) ;; *) return 1 ;; esac + [ -f builder.json ] && [ "$(jq -r '.cache.ccache // false' builder.json 2>/dev/null || true)" = true ] +} + +ccache_setup() { + command -v ccache >/dev/null 2>&1 || brew install ccache + export USE_CCACHE=1 CCACHE_DIR="$HOME/.ccache" CCACHE_MAXSIZE="${CCACHE_MAXSIZE:-2G}" + mkdir -p "$CCACHE_DIR" + echo "ccache: $(ccache --version | head -n1), cache in $CCACHE_DIR (max $CCACHE_MAXSIZE)" +} +# <<< ccache + # 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() { @@ -211,6 +238,8 @@ prepare() { echo "Project type: $project_type" if ! command -v jq >/dev/null; then brew install jq; fi export_build_env + # Before pod install below, where React Native's Podfile hook reads USE_CCACHE. + if ccache_enabled "$project_type"; then ccache_setup; fi # Match the GitHub workflows' committed xcconfig-template convention. find . -path ./DerivedData -prune -o -type f \ @@ -623,11 +652,12 @@ build_ipa() { select_project # Flutter wrote the build number into Generated.xcconfig; for it this only # catches a Runner Info.plist that hardcodes CFBundleVersion. - apply_build_number "${target[@]}" -scheme "$SCHEME" + apply_build_number "${target[@]}" -scheme "$SCHEME" -clonedSourcePackagesDirPath "$source_packages_dir" # version_settings is unquoted on purpose: validated above, it holds zero # to two KEY=VALUE words. args=("${target[@]}" -scheme "$SCHEME" -configuration "$CONFIGURATION" -destination 'generic/platform=iOS' - -derivedDataPath "$BUILDER_WORKSPACE/DerivedData" COMPILER_INDEX_STORE_ENABLE=NO $version_settings) + -derivedDataPath "$BUILDER_WORKSPACE/DerivedData" -clonedSourcePackagesDirPath "$source_packages_dir" + COMPILER_INDEX_STORE_ENABLE=NO $version_settings) mkdir -p "$BUILDER_WORKSPACE/build" if [ "$USE_SIGNING" = true ]; then # After pod install / expo prebuild / flutter build ios, so the project @@ -674,7 +704,8 @@ build_simulator() { else select_project args=("${target[@]}" -scheme "$SCHEME" -configuration Debug -destination "id=$sim_udid" - -derivedDataPath "$BUILDER_WORKSPACE/DerivedData" COMPILER_INDEX_STORE_ENABLE=NO CODE_SIGNING_ALLOWED=NO) + -derivedDataPath "$BUILDER_WORKSPACE/DerivedData" -clonedSourcePackagesDirPath "$source_packages_dir" + COMPILER_INDEX_STORE_ENABLE=NO CODE_SIGNING_ALLOWED=NO) xcodebuild "${args[@]}" build app_path=$(xcodebuild "${args[@]}" -showBuildSettings -json | jq -r 'map(.buildSettings) | map(select(.PRODUCT_TYPE == "com.apple.product-type.application" and (.TARGET_BUILD_DIR | contains("-iphonesimulator")))) | map(.TARGET_BUILD_DIR + "/" + .FULL_PRODUCT_NAME) | first // empty') fi @@ -690,6 +721,9 @@ case "$mode" in snapshot_checkout prepare if [ "$mode" = build ]; then build_ipa; else build_simulator; fi + # Left by runs before Swift packages had their own directory; dropped so + # the DerivedData cache does not carry a second copy of every package. + rm -rf "$BUILDER_WORKSPACE/DerivedData/SourcePackages" ;; share) export PATH="$ci_dir/bin:$PATH" From f99e223b067576774e69d232b9939d95f64b8a43 Mon Sep 17 00:00:00 2001 From: Interlap Date: Sun, 4 Oct 2026 09:45:27 +0200 Subject: [PATCH 4/7] workflow: key-based caches on Bitrise restore-cache/save-cache for DerivedData, Swift packages, Pods, node_modules, the pub cache, Gradle and ccache. The keys checksum the snapshot's lockfiles, so the snapshot is checked out in a step of its own before the restores. An exact node_modules hit skips the install, and ios-share saves before the share step. --- internal/workflow/templates/bitrise.yml | 215 +++++++++++++++++++++++- 1 file changed, 211 insertions(+), 4 deletions(-) diff --git a/internal/workflow/templates/bitrise.yml b/internal/workflow/templates/bitrise.yml index 3a525d8..7182486 100644 --- a/internal/workflow/templates/bitrise.yml +++ b/internal/workflow/templates/bitrise.yml @@ -1,4 +1,6 @@ # Generated by ios-builder. Use repository YAML mode and commit this file and .builder/ci/runner.sh. +# Caches use Bitrise's key-based restore-cache/save-cache steps: DerivedData, +# Swift packages, Pods, node_modules, the pub cache, Gradle and ccache. format_version: '13' default_step_lib_source: https://github.com/bitrise-io/bitrise-steplib.git project_type: ios @@ -15,14 +17,67 @@ workflows: run_if: '{{getenv "SSH_RSA_PRIVATE_KEY" | ne ""}}' - git-clone@8: {} - script@1: - title: Build IPA from snapshot - timeout: 1800 + title: Check out the snapshot inputs: - content: |- #!/bin/bash set -euo pipefail + # Copied before the checkout: the snapshot need not carry runner.sh. mkdir -p "$HOME/.ios-builder-ci" cp .builder/ci/runner.sh "$HOME/.ios-builder-ci/runner.sh" + bash "$HOME/.ios-builder-ci/runner.sh" checkout + # The cache keys checksum the snapshot's lockfiles, so these steps come + # after the checkout and before the build. The keys fall back to a prefix + # match, and the matching save steps below write the exact keys. + - restore-cache@3: + title: Restore DerivedData + inputs: + - key: deriveddata-device-{{ .OS }}-{{ .Arch }}- + - restore-cache@3: + title: Restore Swift packages + inputs: + - key: |- + spm-{{ .OS }}-{{ .Arch }}-{{ checksum "$IOS_PATH/*.xcworkspace/xcshareddata/swiftpm/Package.resolved" "$IOS_PATH/*.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved" }} + spm-{{ .OS }}-{{ .Arch }}- + - restore-cache@3: + title: Restore Pods + inputs: + - key: |- + pods-{{ .OS }}-{{ .Arch }}-{{ checksum "$IOS_PATH/Podfile.lock" }} + pods-{{ .OS }}-{{ .Arch }}- + - restore-cache@3: + title: Restore node_modules + inputs: + - key: |- + node-modules-{{ .OS }}-{{ .Arch }}-{{ checksum "package.json" "package-lock.json" "yarn.lock" "pnpm-lock.yaml" "bun.lock" "bun.lockb" }} + node-modules-{{ .OS }}-{{ .Arch }}- + outputs: + - BITRISE_CACHE_HIT: NODE_MODULES_CACHE_HIT + - restore-cache@3: + title: Restore pub cache + inputs: + - key: |- + pub-{{ .OS }}-{{ .Arch }}-{{ checksum "pubspec.lock" }} + pub-{{ .OS }}-{{ .Arch }}- + - restore-cache@3: + title: Restore Gradle + inputs: + - key: |- + gradle-{{ .OS }}-{{ .Arch }}-{{ checksum "*.gradle*" "*/*.gradle*" "gradle/*.versions.toml" "gradle/wrapper/gradle-wrapper.properties" }} + gradle-{{ .OS }}-{{ .Arch }}- + - restore-cache@3: + title: Restore ccache + inputs: + - key: ccache-device-{{ .OS }}-{{ .Arch }}- + - script@1: + title: Build IPA from snapshot + timeout: 1800 + inputs: + - content: |- + #!/bin/bash + set -euo pipefail + # An exact node_modules hit skips the install, as on GitHub. + if [ "${NODE_MODULES_CACHE_HIT:-}" = exact ]; then export JS_DEPS_CACHED=true; fi bash "$HOME/.ios-builder-ci/runner.sh" build mkdir -p "$BITRISE_DEPLOY_DIR" # Bitrise's installable-IPA uploader requires a provisioning @@ -35,6 +90,51 @@ workflows: inputs: - is_enable_public_page: "false" - notify_user_groups: "none" + # DerivedData and ccache are content-addressed and keyed per build, so + # they are saved even when the build fails. The dependency caches are + # saved only after a successful build, under their lockfile checksums; + # a path that does not exist is skipped with a warning. + - save-cache@1: + title: Save DerivedData + is_always_run: true + inputs: + - key: deriveddata-device-{{ .OS }}-{{ .Arch }}-{{ getenv "BITRISE_BUILD_NUMBER" }} + - paths: DerivedData + - save-cache@1: + title: Save ccache + is_always_run: true + inputs: + - key: ccache-device-{{ .OS }}-{{ .Arch }}-{{ getenv "BITRISE_BUILD_NUMBER" }} + - paths: ~/.ccache + - save-cache@1: + title: Save Swift packages + inputs: + - key: spm-{{ .OS }}-{{ .Arch }}-{{ checksum "$IOS_PATH/*.xcworkspace/xcshareddata/swiftpm/Package.resolved" "$IOS_PATH/*.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved" }} + - paths: ~/.ios-builder/SourcePackages + - save-cache@1: + title: Save Pods + inputs: + - key: pods-{{ .OS }}-{{ .Arch }}-{{ checksum "$IOS_PATH/Podfile.lock" }} + - paths: |- + $IOS_PATH/Pods + ~/.cocoapods/repos + - save-cache@1: + title: Save node_modules + inputs: + - key: node-modules-{{ .OS }}-{{ .Arch }}-{{ checksum "package.json" "package-lock.json" "yarn.lock" "pnpm-lock.yaml" "bun.lock" "bun.lockb" }} + - paths: node_modules + - save-cache@1: + title: Save pub cache + inputs: + - key: pub-{{ .OS }}-{{ .Arch }}-{{ checksum "pubspec.lock" }} + - paths: ~/.pub-cache + - save-cache@1: + title: Save Gradle + inputs: + - key: gradle-{{ .OS }}-{{ .Arch }}-{{ checksum "*.gradle*" "*/*.gradle*" "gradle/*.versions.toml" "gradle/wrapper/gradle-wrapper.properties" }} + - paths: |- + ~/.gradle/caches + ~/.gradle/wrapper ios-share: meta: bitrise.io: @@ -44,13 +144,120 @@ workflows: run_if: '{{getenv "SSH_RSA_PRIVATE_KEY" | ne ""}}' - git-clone@8: {} - script@1: - title: Build and share simulator - timeout: 5400 + title: Check out the snapshot inputs: - content: |- #!/bin/bash set -euo pipefail + # Copied before the checkout: the snapshot need not carry runner.sh. mkdir -p "$HOME/.ios-builder-ci" cp .builder/ci/runner.sh "$HOME/.ios-builder-ci/runner.sh" + bash "$HOME/.ios-builder-ci/runner.sh" checkout + # The cache keys checksum the snapshot's lockfiles, so these steps come + # after the checkout and before the build. The keys fall back to a prefix + # match, and the matching save steps below write the exact keys. + - restore-cache@3: + title: Restore DerivedData + inputs: + - key: deriveddata-sim-{{ .OS }}-{{ .Arch }}- + - restore-cache@3: + title: Restore Swift packages + inputs: + - key: |- + spm-{{ .OS }}-{{ .Arch }}-{{ checksum "$IOS_PATH/*.xcworkspace/xcshareddata/swiftpm/Package.resolved" "$IOS_PATH/*.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved" }} + spm-{{ .OS }}-{{ .Arch }}- + - restore-cache@3: + title: Restore Pods + inputs: + - key: |- + pods-{{ .OS }}-{{ .Arch }}-{{ checksum "$IOS_PATH/Podfile.lock" }} + pods-{{ .OS }}-{{ .Arch }}- + - restore-cache@3: + title: Restore node_modules + inputs: + - key: |- + node-modules-{{ .OS }}-{{ .Arch }}-{{ checksum "package.json" "package-lock.json" "yarn.lock" "pnpm-lock.yaml" "bun.lock" "bun.lockb" }} + node-modules-{{ .OS }}-{{ .Arch }}- + outputs: + - BITRISE_CACHE_HIT: NODE_MODULES_CACHE_HIT + - restore-cache@3: + title: Restore pub cache + inputs: + - key: |- + pub-{{ .OS }}-{{ .Arch }}-{{ checksum "pubspec.lock" }} + pub-{{ .OS }}-{{ .Arch }}- + - restore-cache@3: + title: Restore Gradle + inputs: + - key: |- + gradle-{{ .OS }}-{{ .Arch }}-{{ checksum "*.gradle*" "*/*.gradle*" "gradle/*.versions.toml" "gradle/wrapper/gradle-wrapper.properties" }} + gradle-{{ .OS }}-{{ .Arch }}- + - restore-cache@3: + title: Restore ccache + inputs: + - key: ccache-sim-{{ .OS }}-{{ .Arch }}- + - script@1: + title: Build for the simulator + timeout: 1800 + inputs: + - content: |- + #!/bin/bash + set -euo pipefail + # An exact node_modules hit skips the install, as on GitHub. + if [ "${NODE_MODULES_CACHE_HIT:-}" = exact ]; then export JS_DEPS_CACHED=true; fi bash "$HOME/.ios-builder-ci/runner.sh" simulator + # Saved before the share step, which blocks for as long as the + # simulator is in use. + # DerivedData and ccache are content-addressed and keyed per build, so + # they are saved even when the build fails. The dependency caches are + # saved only after a successful build, under their lockfile checksums; + # a path that does not exist is skipped with a warning. + - save-cache@1: + title: Save DerivedData + is_always_run: true + inputs: + - key: deriveddata-sim-{{ .OS }}-{{ .Arch }}-{{ getenv "BITRISE_BUILD_NUMBER" }} + - paths: DerivedData + - save-cache@1: + title: Save ccache + is_always_run: true + inputs: + - key: ccache-sim-{{ .OS }}-{{ .Arch }}-{{ getenv "BITRISE_BUILD_NUMBER" }} + - paths: ~/.ccache + - save-cache@1: + title: Save Swift packages + inputs: + - key: spm-{{ .OS }}-{{ .Arch }}-{{ checksum "$IOS_PATH/*.xcworkspace/xcshareddata/swiftpm/Package.resolved" "$IOS_PATH/*.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved" }} + - paths: ~/.ios-builder/SourcePackages + - save-cache@1: + title: Save Pods + inputs: + - key: pods-{{ .OS }}-{{ .Arch }}-{{ checksum "$IOS_PATH/Podfile.lock" }} + - paths: |- + $IOS_PATH/Pods + ~/.cocoapods/repos + - save-cache@1: + title: Save node_modules + inputs: + - key: node-modules-{{ .OS }}-{{ .Arch }}-{{ checksum "package.json" "package-lock.json" "yarn.lock" "pnpm-lock.yaml" "bun.lock" "bun.lockb" }} + - paths: node_modules + - save-cache@1: + title: Save pub cache + inputs: + - key: pub-{{ .OS }}-{{ .Arch }}-{{ checksum "pubspec.lock" }} + - paths: ~/.pub-cache + - save-cache@1: + title: Save Gradle + inputs: + - key: gradle-{{ .OS }}-{{ .Arch }}-{{ checksum "*.gradle*" "*/*.gradle*" "gradle/*.versions.toml" "gradle/wrapper/gradle-wrapper.properties" }} + - paths: |- + ~/.gradle/caches + ~/.gradle/wrapper + - script@1: + title: Share the simulator + timeout: 5400 + inputs: + - content: |- + #!/bin/bash + set -euo pipefail bash "$HOME/.ios-builder-ci/runner.sh" share From 45478f5c2a2e395047a83ea5810c1bc063e99486 Mon Sep 17 00:00:00 2001 From: Interlap Date: Sun, 4 Oct 2026 09:45:27 +0200 Subject: [PATCH 5/7] workflow: test cache pairing, keys and the shared ccache block --- internal/workflow/cache_test.go | 534 ++++++++++++++++++++++++++++++++ 1 file changed, 534 insertions(+) create mode 100644 internal/workflow/cache_test.go diff --git a/internal/workflow/cache_test.go b/internal/workflow/cache_test.go new file mode 100644 index 0000000..7239285 --- /dev/null +++ b/internal/workflow/cache_test.go @@ -0,0 +1,534 @@ +package workflow + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" + "slices" + "strings" + "testing" + "text/template" + + "go.yaml.in/yaml/v3" +) + +const ( + ccacheBlockStart = "# >>> ccache" + ccacheBlockEnd = "# <<< ccache\n" + // sourcePackages is where every runner clones Swift packages; the GitHub + // cache steps spell it with ~, the shell with $HOME. + sourcePackages = ".ios-builder/SourcePackages" +) + +type actionStep struct { + Name string `yaml:"name"` + ID string `yaml:"id"` + If string `yaml:"if"` + Uses string `yaml:"uses"` + With map[string]string `yaml:"with"` + Run string `yaml:"run"` +} + +func actionSteps(t *testing.T, name string) []actionStep { + t.Helper() + data, err := GetTemplate(name) + if err != nil { + t.Fatal(err) + } + var parsed struct { + Jobs map[string]struct { + Steps []actionStep `yaml:"steps"` + } `yaml:"jobs"` + } + if err := yaml.Unmarshal(data, &parsed); err != nil { + t.Fatalf("%s: %v", name, err) + } + for _, job := range parsed.Jobs { + return job.Steps + } + t.Fatalf("%s: no job", name) + return nil +} + +// cachePaths normalizes a cache step's path input to its set of lines. +func cachePaths(s actionStep) string { + var lines []string + for _, line := range strings.Split(s.With["path"], "\n") { + if line = strings.TrimSpace(line); line != "" { + lines = append(lines, line) + } + } + slices.Sort(lines) + return strings.Join(lines, "\n") +} + +// TestGitHubCachesPaired: a cache/restore step restores and nothing else, so +// every one must have a cache/save step for the same paths under a key it can +// restore later, or later builds stay cold. In the share workflow the saves +// must come before the step that blocks for the whole session. +func TestGitHubCachesPaired(t *testing.T) { + for _, name := range []string{"ios-build.yml", "ios-share.yml"} { + t.Run(name, func(t *testing.T) { + all := actionSteps(t, name) + share := slices.IndexFunc(all, func(s actionStep) bool { return s.Name == "Share the simulator" }) + for i, restore := range all { + if !strings.HasPrefix(restore.Uses, "actions/cache/restore@") { + continue + } + j := slices.IndexFunc(all, func(s actionStep) bool { + return strings.HasPrefix(s.Uses, "actions/cache/save@") && cachePaths(s) == cachePaths(restore) + }) + if j < 0 { + t.Errorf("%q has no save step for %q", restore.Name, cachePaths(restore)) + continue + } + save := all[j] + if j < i { + t.Errorf("%q saves before %q restores", save.Name, restore.Name) + } + if share >= 0 && j > share { + t.Errorf("%q runs after the share step, which blocks until the session ends", save.Name) + } + primary := "${{ steps." + restore.ID + ".outputs.cache-primary-key }}" + if save.With["key"] != restore.With["key"] && (restore.ID == "" || save.With["key"] != primary) { + t.Errorf("%q saves key %q, %q restores %q", save.Name, save.With["key"], restore.Name, restore.With["key"]) + } + // A per-run key only ever hits through its prefix. + if strings.Contains(restore.With["key"], "github.run_id") { + prefix := strings.TrimSpace(restore.With["restore-keys"]) + if prefix == "" || !strings.HasPrefix(restore.With["key"], prefix) { + t.Errorf("%q: per-run key %q needs its prefix as restore-keys, got %q", restore.Name, restore.With["key"], prefix) + } + } + } + for _, save := range all { + if !strings.HasPrefix(save.Uses, "actions/cache/save@") { + continue + } + if !slices.ContainsFunc(all, func(s actionStep) bool { + return strings.HasPrefix(s.Uses, "actions/cache/restore@") && cachePaths(s) == cachePaths(save) + }) { + t.Errorf("%q saves %q, which nothing restores", save.Name, cachePaths(save)) + } + } + }) + } +} + +// TestGitHubCacheKeys pins what each GitHub workflow caches and what the keys +// are made of, so a cache cannot silently fall out or lose its lockfile. +func TestGitHubCacheKeys(t *testing.T) { + type want struct{ path, key, prefix string } + common := []want{ + {"node_modules", "hashFiles('package-lock.json', 'yarn.lock', 'pnpm-lock.yaml', 'bun.lock', 'bun.lockb')", "node-modules-"}, + {"~/.pub-cache", "hashFiles('pubspec.lock')", "pub-"}, + {"${{ steps.params.outputs.ios_path }}/Pods\n~/.cocoapods/repos", "Podfile.lock", "pods-"}, + {"~/" + sourcePackages, "xcshareddata/swiftpm/Package.resolved", "spm-"}, + } + for name, perRun := range map[string][]want{ + "ios-build.yml": {{"DerivedData", "github.run_id", "deriveddata-device-"}, {"~/.ccache", "github.run_id", "ccache-device-"}}, + "ios-share.yml": {{"DerivedData", "github.run_id", "deriveddata-sim-"}, {"~/.ccache", "github.run_id", "ccache-sim-"}}, + } { + t.Run(name, func(t *testing.T) { + all := actionSteps(t, name) + for _, w := range append(slices.Clone(common), perRun...) { + i := slices.IndexFunc(all, func(s actionStep) bool { + return strings.HasPrefix(s.Uses, "actions/cache") && !strings.Contains(s.Uses, "/save@") && cachePaths(s) == w.path + }) + if i < 0 { + t.Errorf("nothing restores %q", w.path) + continue + } + key := all[i].With["key"] + if !strings.HasPrefix(key, w.prefix) || !strings.Contains(key, w.key) { + t.Errorf("%q key %q, want prefix %q and %q", all[i].Name, key, w.prefix, w.key) + } + } + // The Swift packages key must not hash package checkouts or + // node_modules copies, or it changes from run to run. + spm := all[slices.IndexFunc(all, func(s actionStep) bool { return s.ID == "spm-cache" })] + for _, exclude := range []string{"'!DerivedData/**'", "'!**/node_modules/**'", "'!**/Pods/**'"} { + if !strings.Contains(spm.With["key"], exclude) || !strings.Contains(spm.If, exclude) { + t.Errorf("Swift packages key or gate misses %s", exclude) + } + } + // flutter-action's own pub cache would be a second copy under another key. + flutter := all[slices.IndexFunc(all, func(s actionStep) bool { return s.Name == "Setup Flutter" })] + if flutter.With["pub-cache"] != "false" { + t.Errorf("Setup Flutter pub-cache = %q, want false", flutter.With["pub-cache"]) + } + // An exact node_modules hit skips the install. + data, _ := GetTemplate(name) + if !strings.Contains(string(data), "JS_DEPS_CACHED: ${{ steps.node-modules-cache.outputs.cache-hit }}") { + t.Error("Install JS dependencies does not learn about a node_modules cache hit") + } + }) + } +} + +// TestSourcePackagesDirUsed: the Swift packages cache is only worth anything if +// every xcodebuild that resolves the package graph clones into the cached +// directory; one without the flag clones everything again into DerivedData. +func TestSourcePackagesDirUsed(t *testing.T) { + for _, name := range []string{"ios-build.yml", "ios-share.yml", "runner.sh"} { + t.Run(name, func(t *testing.T) { + data, err := GetTemplate(name) + if err != nil { + t.Fatal(err) + } + text := string(data) + if !strings.Contains(text, `="$HOME/`+sourcePackages+`"`) { + t.Fatalf("no $HOME/%s assignment", sourcePackages) + } + lines := strings.Split(text, "\n") + uses := 0 + for i, line := range lines { + if !strings.Contains(line, "-derivedDataPath") { + continue + } + uses++ + next := "" + if i+1 < len(lines) { + next = lines[i+1] + } + if !strings.Contains(line+next, "-clonedSourcePackagesDirPath") { + t.Errorf("line %d passes -derivedDataPath without -clonedSourcePackagesDirPath: %s", i+1, strings.TrimSpace(line)) + } + } + if uses == 0 { + t.Fatal("no xcodebuild with -derivedDataPath") + } + // apply_build_number's -showBuildSettings resolves packages too. + for i, line := range lines { + if strings.Contains(line, "apply_build_number \"") && !strings.Contains(line, "-clonedSourcePackagesDirPath") { + t.Errorf("line %d: apply_build_number without -clonedSourcePackagesDirPath", i+1) + } + } + }) + } +} + +// ccacheBlocks collects the shared ccache shell block from every runner. +func ccacheBlocks(t *testing.T) map[string]string { + t.Helper() + extract := func(label, script string) string { + start := strings.Index(script, ccacheBlockStart) + if start < 0 { + t.Fatalf("%s: no ccache block", label) + } + end := strings.Index(script[start:], ccacheBlockEnd) + if end < 0 { + t.Fatalf("%s: ccache block is not terminated", label) + } + return script[start : start+end+len(ccacheBlockEnd)] + } + data, err := GetTemplate("runner.sh") + if err != nil { + t.Fatal(err) + } + found := map[string]string{"runner.sh": extract("runner.sh", strings.ReplaceAll(string(data), "\r\n", "\n"))} + for _, name := range []string{"ios-build.yml", "ios-share.yml"} { + all := actionSteps(t, name) + i := slices.IndexFunc(all, func(s actionStep) bool { return s.Name == "Enable ccache" }) + if i < 0 { + t.Fatalf("%s: no Enable ccache step", name) + } + found[name] = extract(name, all[i].Run) + // pod install, where React Native's hook reads USE_CCACHE, is in the build step. + build := slices.IndexFunc(all, func(s actionStep) bool { return strings.HasPrefix(s.Name, "Build ") }) + if build < i { + t.Fatalf("%s: Enable ccache runs after the build step", name) + } + for _, env := range []string{"USE_CCACHE", "CCACHE_DIR", "CCACHE_MAXSIZE"} { + if !strings.Contains(all[i].Run, `echo "`+env+`=$`+env+`"`) { + t.Fatalf("%s: Enable ccache does not export %s to later steps", name, env) + } + } + } + return found +} + +func TestCcacheBlockIdentical(t *testing.T) { + blocks := ccacheBlocks(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) + } + } + runner, _ := GetTemplate("runner.sh") + prepare := shellFunc(t, string(runner), "prepare") + setup, install := strings.Index(prepare, "then ccache_setup"), strings.Index(prepare, "\n pod install") + if setup < 0 || install < 0 || setup > install { + t.Fatal("runner.sh must set ccache up before pod install") + } +} + +// TestCcacheOptIn runs the shared block: on only for React Native and Expo with +// cache.ccache true, and then USE_CCACHE and a fixed cache directory. +func TestCcacheOptIn(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("macOS/Linux shell test") + } + if _, err := exec.LookPath("jq"); err != nil { + t.Skip("jq unavailable") + } + block := ccacheBlocks(t)["runner.sh"] + bin := t.TempDir() + // brew "installs" ccache by dropping the stub on PATH. + ccache := "#!/bin/sh\necho 'ccache version 4.10'\n" + brew := "#!/bin/sh\necho \"brew $*\" >> \"$CMD_LOG\"\nprintf '%s' '" + ccache + "' > \"$(dirname \"$0\")/ccache\"\nchmod +x \"$(dirname \"$0\")/ccache\"\n" + if err := os.WriteFile(filepath.Join(bin, "brew"), []byte(brew), 0755); err != nil { + t.Fatal(err) + } + script := "set -eu\n" + block + `if ccache_enabled "$1"; then ccache_setup; echo "on $USE_CCACHE $CCACHE_DIR $CCACHE_MAXSIZE"; else echo off; fi +` + for _, tt := range []struct { + name, kind, config, want string + }{ + {"react native on", "reactnative", `{"cache":{"ccache":true}}`, "on 1 HOME/.ccache 2G"}, + {"expo on", "expo", `{"cache":{"ccache":true}}`, "on 1 HOME/.ccache 2G"}, + {"off by default", "reactnative", `{}`, "off"}, + {"explicitly off", "reactnative", `{"cache":{"ccache":false}}`, "off"}, + {"string is not true", "reactnative", `{"cache":{"ccache":"yes"}}`, "off"}, + {"no builder.json", "reactnative", "", "off"}, + {"broken builder.json", "reactnative", "{", "off"}, + {"native ignores it", "native", `{"cache":{"ccache":true}}`, "off"}, + {"flutter ignores it", "flutter", `{"cache":{"ccache":true}}`, "off"}, + } { + t.Run(tt.name, func(t *testing.T) { + work, home := t.TempDir(), t.TempDir() + os.Remove(filepath.Join(bin, "ccache")) + if tt.config != "" { + if err := os.WriteFile(filepath.Join(work, "builder.json"), []byte(tt.config), 0644); err != nil { + t.Fatal(err) + } + } + log := filepath.Join(work, "cmd.log") + cmd := exec.Command("bash", "-c", script, "ccache-test", tt.kind) + cmd.Dir = work + cmd.Env = append(os.Environ(), "PATH="+bin+string(os.PathListSeparator)+"/usr/bin:/bin", "HOME="+home, "CMD_LOG="+log) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("%s %v", out, err) + } + lines := strings.Split(strings.TrimSpace(string(out)), "\n") + got := strings.ReplaceAll(lines[len(lines)-1], home, "HOME") + if got != tt.want { + t.Fatalf("got %q, want %q\n%s", got, tt.want, out) + } + calls, _ := os.ReadFile(log) + if tt.want == "off" && len(calls) != 0 { + t.Fatalf("installed ccache while off: %s", calls) + } + if tt.want != "off" { + if string(calls) != "brew install ccache\n" { + t.Fatalf("brew calls = %q", calls) + } + if info, err := os.Stat(filepath.Join(home, ".ccache")); err != nil || !info.IsDir() { + t.Fatal("cache directory not created") + } + } + }) + } +} + +type bitriseStep struct { + Title string `yaml:"title"` + IsAlwaysRun bool `yaml:"is_always_run"` + Inputs []map[string]string `yaml:"inputs"` + Outputs []map[string]string `yaml:"outputs"` +} + +func (s bitriseStep) input(name string) string { + for _, in := range s.Inputs { + if v, ok := in[name]; ok { + return v + } + } + return "" +} + +// family is a cache key up to its first template action: the part every +// fallback prefix and every save of that cache shares. +func family(key string) string { + key = strings.TrimSpace(strings.SplitN(key, "\n", 2)[0]) + if i := strings.Index(key, "{{"); i >= 0 { + key = key[:i] + } + return key +} + +// TestBitriseCaches pins the Bitrise workflows' key-based caches: the same +// caches the other providers keep, restored after the snapshot checkout (the +// keys checksum its lockfiles) and before the build, each paired with a save +// whose key the restore can find again, saved before the share step. +func TestBitriseCaches(t *testing.T) { + data, err := GetTemplate("bitrise.yml") + if err != nil { + t.Fatal(err) + } + var config struct { + Workflows map[string]struct { + Steps []map[string]bitriseStep `yaml:"steps"` + } `yaml:"workflows"` + } + if err := yaml.Unmarshal(data, &config); err != nil { + t.Fatal(err) + } + funcs := template.FuncMap{ + "checksum": func(paths ...string) string { return "sum" }, + "getenv": func(string) string { return "42" }, + } + evaluate := func(key string) string { + tmpl, err := template.New("key").Funcs(funcs).Parse(key) + if err != nil { + t.Fatalf("key %q: %v", key, err) + } + var out strings.Builder + if err := tmpl.Execute(&out, map[string]string{"OS": "darwin", "Arch": "arm64"}); err != nil { + t.Fatalf("key %q: %v", key, err) + } + if strings.Contains(out.String(), ",") { + t.Fatalf("key %q has a comma, which Bitrise refuses", key) + } + return out.String() + } + for name, dest := range map[string]string{"ios-build": "device", "ios-share": "sim"} { + t.Run(name, func(t *testing.T) { + workflow, ok := config.Workflows[name] + if !ok { + t.Fatal("missing workflow") + } + type entry struct { + id string + step bitriseStep + } + var all []entry + for _, step := range workflow.Steps { + if len(step) != 1 { + t.Fatalf("step with %d ids", len(step)) + } + for id, s := range step { + all = append(all, entry{id, s}) + } + } + index := func(title string) int { + i := slices.IndexFunc(all, func(e entry) bool { return e.step.Title == title }) + if i < 0 { + t.Fatalf("no step titled %q", title) + } + return i + } + checkout := index("Check out the snapshot") + if !strings.Contains(all[checkout].step.input("content"), `runner.sh" checkout`) { + t.Fatal("the checkout step does not run runner.sh checkout") + } + buildTitle, end := "Build IPA from snapshot", len(all) + if name == "ios-share" { + buildTitle, end = "Build for the simulator", index("Share the simulator") + } + build := index(buildTitle) + script := all[build].step.input("content") + if strings.Contains(script, "cp .builder/ci/runner.sh") { + t.Fatal("the build step copies runner.sh from the snapshot instead of using the one copied before checkout") + } + if !strings.Contains(script, `"${NODE_MODULES_CACHE_HIT:-}" = exact`) || !strings.Contains(script, "JS_DEPS_CACHED=true") { + t.Fatal("the build step does not skip the install on an exact node_modules hit") + } + + restores, saves := map[string]entry{}, map[string]entry{} + for i, e := range all { + switch { + case strings.HasPrefix(e.id, "restore-cache@"): + if i < checkout || i > build { + t.Errorf("%q must run between the snapshot checkout and the build", e.step.Title) + } + restores[family(e.step.input("key"))] = e + case strings.HasPrefix(e.id, "save-cache@"): + if i < build || i > end { + t.Errorf("%q must run after the build and before the share step", e.step.Title) + } + if e.step.input("paths") == "" { + t.Errorf("%q has no paths", e.step.Title) + } + saves[family(e.step.input("key"))] = e + } + } + want := []string{"deriveddata-" + dest + "-", "ccache-" + dest + "-", "spm-", "pods-", "node-modules-", "pub-", "gradle-"} + for _, f := range want { + restore, ok := restores[f] + if !ok { + t.Errorf("no restore-cache for %s", f) + continue + } + save, ok := saves[f] + if !ok { + t.Errorf("no save-cache for %s", f) + continue + } + keys := strings.Split(strings.TrimSpace(restore.step.input("key")), "\n") + saved := evaluate(save.step.input("key")) + for _, k := range keys { + if !strings.HasPrefix(saved, evaluate(strings.TrimSpace(k))) { + t.Errorf("%s: saved key %q cannot be found by restore key %q", f, saved, k) + } + } + perBuild := strings.Contains(save.step.input("key"), "BITRISE_BUILD_NUMBER") + if perBuild != save.step.IsAlwaysRun { + t.Errorf("%s: is_always_run = %v; only the per-build caches are saved after a failed build", f, save.step.IsAlwaysRun) + } + if !perBuild { + if strings.TrimSpace(keys[0]) != save.step.input("key") { + t.Errorf("%s: first restore key %q differs from the save key %q", f, keys[0], save.step.input("key")) + } + if len(keys) != 2 || strings.TrimSpace(keys[1]) != f+"{{ .OS }}-{{ .Arch }}-" { + t.Errorf("%s: restore keys %q want the exact key then the %q prefix", f, keys, f) + } + if !strings.Contains(save.step.input("key"), "checksum") { + t.Errorf("%s: lockfile cache key has no checksum", f) + } + } + } + if len(restores) != len(want) || len(saves) != len(want) { + t.Errorf("caches = %d restores, %d saves; want %d each", len(restores), len(saves), len(want)) + } + if got := saves["spm-"].step.input("paths"); got != "~/"+sourcePackages { + t.Errorf("Swift packages saved from %q", got) + } + if !slices.ContainsFunc(restores["node-modules-"].step.Outputs, func(o map[string]string) bool { + return o["BITRISE_CACHE_HIT"] == "NODE_MODULES_CACHE_HIT" + }) { + t.Error("the node_modules restore does not alias BITRISE_CACHE_HIT to NODE_MODULES_CACHE_HIT") + } + }) + } +} + +// TestCodemagicCachePaths: Codemagic caches by path only, so the paths are the +// whole contract, and the Swift packages one must be where runner.sh clones. +func TestCodemagicCachePaths(t *testing.T) { + data, err := GetTemplate("codemagic.yaml") + if err != nil { + t.Fatal(err) + } + var config struct { + Workflows map[string]struct { + Cache struct { + Paths []string `yaml:"cache_paths"` + } `yaml:"cache"` + } `yaml:"workflows"` + } + if err := yaml.Unmarshal(data, &config); err != nil { + t.Fatal(err) + } + for _, name := range []string{"ios-build", "ios-share"} { + paths := config.Workflows[name].Cache.Paths + for _, want := range []string{"$CM_BUILD_DIR/DerivedData", "$HOME/.gradle/caches", "$HOME/.pub-cache", "$HOME/" + sourcePackages, "$HOME/.ccache"} { + if !slices.Contains(paths, want) { + t.Errorf("%s does not cache %s: %v", name, want, paths) + } + } + } +} From 3a0b6d633d76b27613618ae24240d66ee907f940 Mon Sep 17 00:00:00 2001 From: Interlap Date: Sun, 4 Oct 2026 09:45:27 +0200 Subject: [PATCH 6/7] docs: build caching per provider and the ccache switch --- CLAUDE.md | 36 ++++++++++++++++++++++++++++++-- README.md | 52 +++++++++++++++++++++++++++++++++++++++++++++++ docs/providers.md | 6 +++++- 3 files changed, 91 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9c2b1e1..a8597e0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 `-{{ .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 @@ -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`): @@ -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 diff --git a/README.md b/README.md index cc24fe8..1b247b8 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/docs/providers.md b/docs/providers.md index 9e8d7f7..492032f 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -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 From dbc59dcefd4bb1d07bfb87045454fb1bc4bf0b41 Mon Sep 17 00:00:00 2001 From: Interlap Date: Sun, 4 Oct 2026 09:49:07 +0200 Subject: [PATCH 7/7] workflow: check os.Remove and pass cache steps by pointer in the cache tests --- internal/workflow/cache_test.go | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/internal/workflow/cache_test.go b/internal/workflow/cache_test.go index 7239285..c256cbe 100644 --- a/internal/workflow/cache_test.go +++ b/internal/workflow/cache_test.go @@ -52,7 +52,7 @@ func actionSteps(t *testing.T, name string) []actionStep { } // cachePaths normalizes a cache step's path input to its set of lines. -func cachePaths(s actionStep) string { +func cachePaths(s *actionStep) string { var lines []string for _, line := range strings.Split(s.With["path"], "\n") { if line = strings.TrimSpace(line); line != "" { @@ -77,10 +77,10 @@ func TestGitHubCachesPaired(t *testing.T) { continue } j := slices.IndexFunc(all, func(s actionStep) bool { - return strings.HasPrefix(s.Uses, "actions/cache/save@") && cachePaths(s) == cachePaths(restore) + return strings.HasPrefix(s.Uses, "actions/cache/save@") && cachePaths(&s) == cachePaths(&restore) }) if j < 0 { - t.Errorf("%q has no save step for %q", restore.Name, cachePaths(restore)) + t.Errorf("%q has no save step for %q", restore.Name, cachePaths(&restore)) continue } save := all[j] @@ -107,9 +107,9 @@ func TestGitHubCachesPaired(t *testing.T) { continue } if !slices.ContainsFunc(all, func(s actionStep) bool { - return strings.HasPrefix(s.Uses, "actions/cache/restore@") && cachePaths(s) == cachePaths(save) + return strings.HasPrefix(s.Uses, "actions/cache/restore@") && cachePaths(&s) == cachePaths(&save) }) { - t.Errorf("%q saves %q, which nothing restores", save.Name, cachePaths(save)) + t.Errorf("%q saves %q, which nothing restores", save.Name, cachePaths(&save)) } } }) @@ -134,7 +134,7 @@ func TestGitHubCacheKeys(t *testing.T) { all := actionSteps(t, name) for _, w := range append(slices.Clone(common), perRun...) { i := slices.IndexFunc(all, func(s actionStep) bool { - return strings.HasPrefix(s.Uses, "actions/cache") && !strings.Contains(s.Uses, "/save@") && cachePaths(s) == w.path + return strings.HasPrefix(s.Uses, "actions/cache") && !strings.Contains(s.Uses, "/save@") && cachePaths(&s) == w.path }) if i < 0 { t.Errorf("nothing restores %q", w.path) @@ -299,7 +299,9 @@ func TestCcacheOptIn(t *testing.T) { } { t.Run(tt.name, func(t *testing.T) { work, home := t.TempDir(), t.TempDir() - os.Remove(filepath.Join(bin, "ccache")) + if err := os.Remove(filepath.Join(bin, "ccache")); err != nil && !os.IsNotExist(err) { + t.Fatal(err) + } if tt.config != "" { if err := os.WriteFile(filepath.Join(work, "builder.json"), []byte(tt.config), 0644); err != nil { t.Fatal(err)