DEV Community

Cover image for Shipping Flutter Web to Firebase When Your GitHub Actions Budget Hits Zero

Shipping Flutter Web to Firebase When Your GitHub Actions Budget Hits Zero

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:

  1. Spinning up an ubuntu-latest VM.
  2. Pulling the Flutter SDK and warming the Pub cache (~90s).
  3. Installing Python 3 and running 68 CLI sync unit tests (~15s).
  4. Running flutter analyze and 466 widget and accessibility tests (~110s).
  5. Compiling flutter build web --release --no-web-resources-cdn (~140s).
  6. Authenticating via Workload Identity Federation (google-github-actions/auth@v2) and deploying to Firebase Hosting target board (~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:

  1. Explicit schema validation on every collection (dayPlans, stats, config, finance, plan).
  2. 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"
    )
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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));
}
Enter fullscreen mode Exit fullscreen mode

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

  1. 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 before firebase deploy.
  2. Use Firebase Hosting multi-site targets (hosting:board, hosting:board-staging). Mapping targets in .firebaserc lets you preview on staging or promote to live with --only hosting:board without touching sibling sites on the same GCP project.
  3. Never let one bad Firestore document take down a collection stream. Wrap per-document .fromJson() calls inside a helper that logs to dart:developer and skips the corrupted record.
  4. Test accessibility contrast and 44pt tap targets in flutter test, not in production. Running textContrastGuideline and androidTapTargetGuideline with GoogleFonts.config.allowRuntimeFetching = false catches 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
Enter fullscreen mode Exit fullscreen mode

Top comments (1)

Collapse
 
mohsen_jr profile image
Mohsen jr •

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?