On October 18, right in the middle of shipping a multi-feature audit and a supplement tracker redesign on my personal life_os_app (my daily Flutter Web + Cloud Firestore operating board), GitHub Actions stopped scheduling jobs. The billing meter for my personal account's hosted runner minutes had hit 100%.
Look at what my .github/workflows/ci.yml and deploy-live.yml were doing on every push and pull request:
- Spinning up an
ubuntu-latestVM. - Pulling the Flutter SDK and warming the Pub cache (~90s).
- Installing Python 3 and running 68 CLI sync unit tests (~15s).
- Running
flutter analyzeand 466 widget and accessibility tests (~110s). - Compiling
flutter build web --release --no-web-resources-cdn(~140s). - Authenticating via Workload Identity Federation (
google-github-actions/auth@v2) and deploying to Firebase Hosting targetboard(~35s).
That is 6.5 minutes per PR push and another 4 minutes on merge to main. Across 70+ PRs in October, a single-developer personal project burned through thousands of CI minutes doing work my local Apple Silicon MacBook finishes in under 35 seconds.
When your CI budget hits zero on a personal repo, you have two choices: pay for extra runner minutes to compile code your laptop already compiled, or build a deterministic local verification and deployment pipeline that is faster and safer than the YAML workflow it replaces.
Here is the exact architecture I use to keep life_os_app shipping to Firebase Hosting and Cloud Firestore with zero GitHub Actions spend.
Architecture: The Three-Layer Local Verification Gate
Moving deploys to your local machine only works if you never deploy unverified state. In life_os_app, the application state is split across two runtimes:
-
Python 3.14 CLI Sync Tools (
tool/board_*.py): Morning and evening scripts that pull from Apple Reminders, Google Calendar, Harvest, Notion, and GCP Billing, then write structured JSON documents into Cloud Firestore over REST. -
Flutter Web Client (
lib/): A Riverpod 3 + Material 3 multi-skin web app served from Firebase Hosting with a custom offline Service Worker (web/offline-sw.js).
Layer 1: Firestore Schema Validation Before Every REST Write
One of the biggest risks of running CLI sync tools outside a container is accidentally clobbering fields that the Flutter Web client writes directly from the browser. For example, on the Events and Admin tabs, I tick tasks directly in the UI (config/events.taskTicks and config/admin.done), while tool/board_events.py and tool/board_admin.py sync metadata from Notion and GCP Billing.
To prevent a CLI run from wiping browser taps, tool/board_firestore.py enforces two rules before any HTTP PATCH leaves the machine:
-
Explicit schema validation on every collection (
dayPlans,stats,config,finance,plan). -
Field-level update masks (
?updateMask.fieldPaths=...) plus hard blocks against overwriting client-owned map keys:
# tool/board_firestore.py
if path == "config/events" and "taskTicks" in data and not fields:
raise SystemExit(
"refusing to overwrite config/events.taskTicks without --fields; "
"pass --fields updated,source,events"
)
if path == "config/admin" and "done" in data and not fields:
raise SystemExit(
"refusing to overwrite config/admin.done without --fields"
)
In addition, caching the gcloud auth print-access-token call with @functools.lru_cache(maxsize=1) and enforcing a 15-second socket timeout cut multi-document Firestore sync latency by 80% and eliminated hanging background processes when switching Wi-Fi networks.
Layer 2: Per-Document Decode Isolation in Flutter Streams
In Cloud Firestore, a single malformed field inside one historical document in a collection query (collection('dayPlans').snapshots()) will normally throw a FormatException or TypeError inside .map(), killing the entire StreamProvider and replacing your UI with an error state.
During my audit pass, I hardened every repository to isolate decoding per document:
// lib/features/day_board/data/board_repository.dart
List<T> _decodeEach<T>(
QuerySnapshot<JsonMap> query,
T Function(JsonMap data, String id) decode,
) {
final results = <T>[];
for (final doc in query.docs) {
try {
results.add(decode(doc.data(), doc.id));
} on Object catch (error, stackTrace) {
developer.log(
'Skipping malformed document ${doc.reference.path}',
name: 'day_board.repository',
error: error,
stackTrace: stackTrace,
);
}
}
return results;
}
When saving multi-day edits (such as editing 7 days of focus blocks and habit notes in the This Week sheet), replacing 14 sequential await set() calls with a single atomic WriteBatch (saveWeekEditsBatch) eliminated partial-save race conditions on flaky mobile connections.
Layer 3: Self-Contained Web Bundles (--no-web-resources-cdn) & Service Worker Cleanup
When deploying Flutter Web to Firebase Hosting for daily use on iPhone, iPad, and desktop, CDN font/CanvasKit fetches can stall on airplane or train Wi-Fi. Building with --no-web-resources-cdn bundles CanvasKit and font assets directly into build/web/, paired with a custom web/offline-sw.js that races network against a 3.5-second timeout before falling back to cache:
// web/offline-sw.js
function networkFirst(request, timeoutMs) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('timeout')), timeoutMs);
fetch(request)
.then((response) => {
if (response && response.ok) {
caches.open(CACHE).then((cache) => cache.put(request, response.clone()));
}
resolve(response);
})
.catch(reject)
.finally(() => clearTimeout(timer));
}).catch(() => caches.match(request));
}
Notice the .finally(() => clearTimeout(timer)) cleanup: without it, every fast network response leaves an orphan timer running until timeoutMs expires.
Benchmarking: Cloud CI vs. Local Verification Gate
| Stage | GitHub Actions (ubuntu-latest) |
Local Apple Silicon Gate | Improvement |
|---|---|---|---|
Python CLI Unit Tests (68 tests) |
14.2s (with setup) | 0.02s | 710x faster |
Dart Format + Analyze + 466 Flutter tests
|
118.0s | 14.5s | 8.1x faster |
flutter build web --release --no-web-resources-cdn |
142.0s | 14.8s | 9.6x faster |
firebase deploy --only hosting:board |
34.0s | 3.1s | 11.0x faster |
| Total Wall-Clock Time | ~5m 08s (plus SDK cache) | ~32.4s | ~9.5x faster, $0 CI cost |
Table totals exclude the Flutter SDK download and Pub cache warm-up (~90s), which is what brings a full PR run to ~6.5 minutes.
Key Takeaways for Flutter & Firebase Engineers
-
Keep CI YAML for PRs when budget allows, but make your local gate identical. A single local command (
dart format -l 120 lib/ test/ && flutter analyze && python3 -m unittest discover -s tool -p '*_test.py' && flutter test) guarantees parity beforefirebase deploy. -
Use Firebase Hosting multi-site targets (
hosting:board,hosting:board-staging). Mapping targets in.firebaserclets you preview on staging or promote to live with--only hosting:boardwithout touching sibling sites on the same GCP project. -
Never let one bad Firestore document take down a collection stream. Wrap per-document
.fromJson()calls inside a helper that logs todart:developerand skips the corrupted record. -
Test accessibility contrast and 44pt tap targets in
flutter test, not in production. RunningtextContrastGuidelineandandroidTapTargetGuidelinewithGoogleFonts.config.allowRuntimeFetching = falsecatches neon text shadow contrast drops and 32pt icons in milliseconds.
The complete local release gate in one command:
dart format -l 120 lib/ test/ && \
flutter analyze && \
python3 -m unittest discover -s tool -p '*_test.py' && \
flutter test && \
flutter build web --release --no-web-resources-cdn && \
firebase deploy --only hosting:board --project esra-life-os
Top comments (1)
The local pipeline is impressively fast, but I’m curious how you handle a machine-specific issue that CI would normally catch. Do you keep a lightweight CI check for pull requests and reserve the full build and deployment for local runs?