Golden Rule: Only cache things downloaded from the internet, not things generated from your code.
Add to all Node.js steps:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm' # ✅ Official, battle-tested
cache-dependency-path: | # ✅ For multiple package.json files
package-lock.json
scripts/package-lock.json
cloudflare-worker/package-lock.jsonWhy safe:
- Official GitHub feature
- Only caches
node_modulesfrom npm registry - Auto-invalidates on package-lock.json changes
- Used by millions of repos
Files to update:
.github/workflows/ci.yml(test-e2e-web job).github/workflows/deploy-worker.yml(all jobs with Node)
Expected savings: 10-30s per job with npm install
Add to all Flutter workflows:
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: '3.44.0'
channel: 'stable'
cache: true # ✅ Caches Flutter SDK
- name: Cache pub packages
uses: actions/cache@v4
with:
path: ~/.pub-cache
key: ${{ runner.os }}-pub-${{ hashFiles('**/pubspec.lock') }}
restore-keys: |
${{ runner.os }}-pub-What's cached: Downloaded packages from pub.dev only
What's NOT cached: .dart_tool, generated code, build artifacts
Why safe:
- Only caches immutable packages
- pubspec.lock guarantees exact versions
- Flutter rebuilds package symlinks automatically
Trade-off:
flutter pub getstill runs (links packages: ~5-10s)- But packages aren't re-downloaded (~20-30s saved)
- Net savings: ~20-30s per job
Files to update:
.github/workflows/ci.yml(test, build-web, build-android jobs).github/workflows/release-android.yml.github/workflows/release-web.yml
Current implementation is good:
- name: Cache Gradle dependencies
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-Optional enhancement (low priority):
path: |
~/.gradle/caches
~/.gradle/wrapper
~/.android/build-cache # ⚠️ Only if you trust Gradle's incremental buildWhy hesitant: Android build cache can be finicky across machines
Recommendation: Leave as-is unless you see consistent build issues
# ❌ DON'T DO THIS
- name: Cache generated code
uses: actions/cache@v4
with:
path: |
.dart_tool/build
**/*.mocks.dartProblems:
- Cache key can't track all generation inputs
- Stale mocks cause hard-to-debug test failures
- build_runner is fast enough (10-30s)
Better: Just run dart run build_runner build every time
# ❌ DON'T DO THIS
path: ${{ github.workspace }}/.dart_toolProblems:
- Contains build artifacts, not just package configs
- Can cache stale analyzer snapshots
- Flutter/Dart version changes break cache
Better: Let Flutter rebuild this every time (fast anyway)
# ❌ DON'T DO THIS
path: build/webWhy: The whole point of CI is to build fresh every time!
# Test in test job first
test:
steps:
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: '3.44.0'
channel: 'stable'
cache: true
- name: Cache pub packages (TESTING)
uses: actions/cache@v4
with:
path: ~/.pub-cache
key: ${{ runner.os }}-pub-${{ hashFiles('**/pubspec.lock') }}
restore-keys: |
${{ runner.os }}-pub-First run (cache miss):
Cache not found for input keys: ubuntu-latest-pub-abc123
Downloading packages... (30s)
Post job: Saving cache...
Cache saved successfully
Second run (cache hit):
Cache restored from key: ubuntu-latest-pub-abc123
Linking packages... (5s)
Post job: Cache hit occurred, not saving
- Tests still pass ✅
- No weird "package not found" errors ✅
- Build outputs are identical ✅
Once verified in test job, add to build-web, build-android, etc.
# GitHub CLI
gh run list --workflow=ci.yml --limit=10 --json conclusion,name
# Look for "Cache restored" vs "Cache not found" in logsGood: 70-90% hit rate Bad: <50% hit rate (cache thrashing)
- Tests pass locally, fail in CI → Stale cache issue
- "Package not found" errors → Cache path wrong
- Cache size growing indefinitely → Need better invalidation
- Builds slower with cache than without → Cache overhead too high
If caching causes issues:
# Temporary: Bust all caches by changing key
key: v2-${{ runner.os }}-pub-${{ hashFiles('**/pubspec.lock') }}
# ^^ Add version prefixOr use GitHub UI: Settings → Actions → Caches → Delete all
test job:
Setup Flutter: 15s (cached by flutter-action)
flutter pub get: 35s ← downloading from pub.dev
build_runner: 25s
flutter test: 45s
Total: ~2m 30s
test job:
Setup Flutter: 5s (cache hit)
Restore pub cache: 3s
flutter pub get: 8s ← only linking, not downloading
build_runner: 25s (no change)
flutter test: 45s
Total: ~1m 30s
Savings: ~1 minute (40% faster)
| Job | Current | With Cache | Savings |
|---|---|---|---|
| test | 2m 30s | 1m 30s | 1m (40%) |
| build-web | 2m 00s | 1m 15s | 45s (38%) |
| build-android | 3m 00s | 2m 15s | 45s (25%) |
| test-e2e-web | 1m 30s | 1m 00s | 30s (33%) |
Total workflow: 9m 00s → 6m 00s = 33% faster
Monthly savings: ~100-150 runner minutes → ~65-100 minutes = 30-35% cost reduction
- ✅ Add
cache: 'npm'to allsetup-nodesteps (10 minutes) - ✅ Verify in one workflow run
- ✅ Done!
Effort: 10 minutes Risk: None (official feature) Gain: 10-30s per job with npm
- ✅ Add pub cache to
testjob only - ✅ Test with 2-3 workflow runs
- ✅ Verify tests still pass
- ✅ Roll out to other Flutter jobs
- ✅ Monitor for 1 week
Effort: 30 minutes + monitoring Risk: Low (widely used pattern) Gain: 20-30s per job
- ❌ Don't cache .dart_tool
- ❌ Don't cache build_runner outputs
- ❌ Don't cache build artifacts
Reason: High risk, low reward, hard to maintain
If caching causes issues:
# Quick rollback: Comment out cache step
# - name: Cache pub packages
# uses: actions/cache@v4
# with:
# path: ~/.pub-cache
# key: ...Or bust cache:
key: v2-${{ runner.os }}-pub-${{ hashFiles('**/pubspec.lock') }}Do now:
- Add npm cache (100% safe)
- Add pub cache for ~/.pub-cache only (95% safe)
Don't do:
- Cache .dart_tool
- Cache generated code
- Cache build outputs
Monitor:
- Cache hit rates
- Test reliability
- Build times
Expected outcome:
- 30-35% faster builds
- No correctness issues
- Easy to rollback if needed