Spec Driven Development with agents: a Flutter + Firebase pipeline

Both of the apps I’m building independently right now are developed the same way: spec first, and executed through agentic workflows I designed myself. Kleio is in open testing on Google Play, and Adepha is a food intake tracker with an admin web app for nutritionists. Not a stock assistant asked to write features, but a deliberate setup where the spec is the contract, custom skills and subagents do scoped work against it, and a harness I built decides what the agent is allowed to touch.

This post is about applying that to the least glamorous part of either project: the deployment pipeline. I picked it on purpose. Feature work is where AI-assisted development looks impressive in a demo; infrastructure is where it either holds up or quietly produces something you can’t ship twice.

Most of my deployment pipelines used to be archaeology. They worked, but the knowledge of why they worked lived in three places: my shell history, a half-remembered Play Console permissions screen, and whatever the last CI failure had taught me. Starting the same setup on a second project meant rediscovering all of it.

So on the last two I did it the other way around. I wrote the pipeline as a spec first, a document precise enough that following it produces a working pipeline, with the scripts and workflows written out in full, and only then built it. The spec came out of Adepha, from a repo I call macro_cntr, and then I ran the same document again on Kleio. That second run is the real test: a spec you can only execute once is just documentation.

What “Spec Driven Development” means here

Not a methodology with a capital “M” just an inversion of the usual order:

  • The deliverable is a document that can be executed, not a description of something already built.
  • Every value that varies between projects is a named placeholder, not a fact buried in a YAML file.
  • Assumptions are stated up front, so the reader knows immediately whether the spec applies to them.
  • Sections are independently droppable. No web app yet? Delete that section and its secrets. The spec has to degrade cleanly or nobody will use it on a smaller project.

The last point matters more than it sounds. Adepha has a mobile app, an admin web app, and a Cloud Functions backend. Kleio started with only the mobile app. A spec that assumes all three surfaces exist is useless on day one of a new project, which is exactly when you want it.

Why this shape suits an agent

Those four properties weren’t chosen for human readers. They’re what makes a document an agent can execute without supervision.

The spec is the contract, not the generated code. The agent implements against the document; the document is what I review. That inverts the usual failure mode of AI-assisted work, where you end up reviewing a large diff with no stated intent behind it and no way to tell a deliberate choice from a plausible-looking guess. If the output disagrees with the spec, the output is wrong. If the spec was wrong, I fix the spec and re-run, and the fix carries to the next project instead of evaporating into a chat log.

Named placeholders are what make it re-runnable. REPLACE_WITH_DEV_PROJECT_ID is not a nicety for the reader. It’s the difference between a spec an agent can execute against a fresh project and one that quietly bakes in Adepha’s values when pointed at Kleio.

Stated assumptions are a precondition check. Listing “one Firebase project per environment” up front means the run stops at the top when it doesn’t hold, rather than producing a pipeline that half-works against a shared project.

Droppable sections keep scope honest. Kleio had no backend and no web app on day one. Sections that can be deleted cleanly are sections a subagent can be told to skip without unravelling the rest.

The workflow itself

The loop I settled on, for both apps:

  • Custom skills carry the conventions I don’t want to restate: how these repos are laid out, how environments resolve, what a guarded deploy script has to check. Written once, loaded whenever relevant.
  • Subagents take scoped pieces against the spec: one section, one workflow file, one script. Scoped work is reviewable work, and a subagent that fails a narrow task fails visibly instead of drifting.
  • The harness is the part I care most about and the part most people skip. It decides what tooling is reachable, what gets touched without asking, and what has to come back for review. Anything that ships to the Play Store or writes a credential is in the second category, permanently.

The guardrails in the pipeline below and the guardrails in the workflow that produced it are the same instinct applied twice. A deploy script that refuses to run against the wrong Firebase project, and a harness that won’t let an agent push to production unreviewed, are answering the same question: what is the damage if this is confidently wrong?

That question is why the technical judgement matters more here, not less. An agent will write a deploy script very quickly. It will not tell you that upload and commit are authorized separately in the Play Console, because it has never watched a release fail at the commit step at 11pm. That’s further down.

The assumptions the spec makes

Everything below only holds if these are true:

  • Flutter app(s) using build flavors (dev / prod) with matching entry points (lib/main_dev.dart, lib/main_prod.dart).
  • One Firebase project per environment. Dev and prod are never the same project.
  • Secrets never committed. Public Firebase client config (API keys, app IDs) can be committed once an app is registered; that identifies a public client, it is not a secret.
  • GitHub Actions is the runner.

Start with the environment matrix

This is the part I now write before any code. Every script and workflow refers back to it, and disagreements about naming surface here instead of at 2am during a release.

Surface Development Production
Firebase alias dev prod
Firebase project <app>-dev-xxxxx <app>-prod-id
Android applicationId com.yourorg.app.dev com.yourorg.app
iOS bundle ID com.yourorg.app.dev com.yourorg.app
Entry point lib/main_dev.dart lib/main_prod.dart

The aliases resolve through a .firebaserc per Flutter app, so no script ever hardcodes a project ID:

{
  "projects": {
    "dev": "REPLACE_WITH_DEV_PROJECT_ID",
    "prod": "REPLACE_WITH_PROD_PROJECT_ID"
  }
}

Repo layout

tool/
  firebase_deploy.sh          # guarded manual deploy — dev/prod, per-component
  admin_web_build.sh          # optional — build + verify web bundle
  check_web_environment.sh    # optional — sanity-check built web output
.github/workflows/
  validate.yml                # PR/CI: analyze, test, debug builds — no secrets
  deploy-mobile.yml           # push-to-main: build + release Android
  deploy.yml                  # workflow_dispatch: manual Firebase deploy
  deploy-admin-web.yml        # optional — push-to-main: build + deploy hosting
<app>/.firebaserc             # alias "dev"/"prod" -> real Firebase project IDs
<app>/dart_defines/
  dev.json                    # committed, non-secret dev config
  prod.local.json             # gitignored — local file or CI-injected

The principle that holds the whole thing together: CI never invents secrets. It decodes them from GitHub Actions secrets into the exact gitignored local files a developer would otherwise create by hand. Local dev and CI read configuration through identical paths and formats, so there is exactly one way the app resolves its config, which means “works on my machine” and “works in CI” stop being different questions.

Guardrails, written into the spec as requirements

These are the three rules I’d have written down after getting burned. Writing them as spec requirements meant I never got burned on Kleio:

  1. The deploy script refuses to run if the resolved prod project ID equals the known dev project ID. Catches a misconfigured .firebaserc.
  2. Production deploys require an explicit --confirm-production flag. No accidental prod pushes from muscle memory.
  3. A post-build check greps the built output for the other environment’s project ID and fails if it finds one. Catches “built dev, deployed like prod.”

Here is the deploy script those rules produce:

#!/usr/bin/env bash
set -euo pipefail

environment="${1:-}"
component="${2:-}"
confirmation="${3:-}"

case "$environment" in
  dev|prod) ;;
  *) echo "Usage: $0 <dev|prod> <hosting|functions|rules|indexes|backend|all> [--confirm-production]" >&2; exit 2 ;;
esac

case "$component" in
  hosting)   only="hosting" ;;
  functions) only="functions" ;;
  rules)     only="firestore:rules" ;;
  indexes)   only="firestore:indexes" ;;
  backend)   only="functions,firestore:rules,firestore:indexes" ;;
  all)       only="hosting,functions,firestore:rules,firestore:indexes" ;;
  *) echo "Unknown component: $component" >&2; exit 2 ;;
esac

project_id="$(node -e "const fs=require('fs'); const c=JSON.parse(fs.readFileSync('.firebaserc','utf8')); process.stdout.write(c.projects['$environment'] || '')")"

if [[ -z "$project_id" || "$project_id" == REPLACE_* ]]; then
  echo "The $environment Firebase alias is not configured in .firebaserc." >&2
  exit 2
fi

if [[ "$environment" == "prod" ]]; then
  if [[ "$project_id" == "REPLACE_WITH_DEV_PROJECT_ID" ]]; then
    echo "Refusing deployment: prod resolves to the development project." >&2
    exit 1
  fi
  if [[ "$confirmation" != "--confirm-production" ]]; then
    echo "Production deployment requires --confirm-production." >&2
    exit 2
  fi
fi

echo "Environment: $environment"
echo "Firebase project: $project_id"
echo "Components: $only"

firebase deploy --project "$environment" --only "$only"

The workflows

validate.yml runs on every PR and push to main, and touches zero secrets. That separation is deliberate: contributors and forks get useful CI feedback without any credential ever entering the job.

name: Validate
on:
  pull_request:
  push:
    branches: [main]

jobs:
  flutter:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        package: [mobile_app]
    steps:
      - uses: actions/checkout@v4
      - uses: subosito/flutter-action@v2
        with:
          flutter-version: "3.44.8" # pin exact version, not "channel: stable"
          cache: true
      - working-directory: ${{ matrix.package }}
        run: flutter pub get
      - working-directory: ${{ matrix.package }}
        run: flutter analyze --no-fatal-infos
      - working-directory: ${{ matrix.package }}
        run: flutter test

deploy-mobile.yml runs on push to main and ships the Android bundle to the Play Store internal track. Note how signing config is written into the same files a local release build would read:

- name: Set up Android signing & defines
  run: |
    echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > android/app/upload-keystore.jks
    cat <<EOF > android/key.properties
    storePassword=${{ secrets.KEYSTORE_PASSWORD }}
    keyPassword=${{ secrets.KEY_PASSWORD }}
    keyAlias=${{ secrets.KEY_ALIAS }}
    storeFile=upload-keystore.jks
    EOF
    echo '${{ secrets.PROD_MOBILE_DART_DEFINES }}' > dart_defines/prod.local.json

- name: Build app bundle
  run: |
    flutter build appbundle --release --flavor prod -t lib/main_prod.dart \
      --dart-define-from-file=dart_defines/prod.local.json

- name: Upload to Play Store
  uses: r0adkll/upload-google-play@v1
  with:
    serviceAccountJsonPlainText: ${{ secrets.PLAY_STORE_SERVICE_ACCOUNT_JSON }}
    packageName: com.yourorg.app
    releaseFiles: mobile_app/build/app/outputs/bundle/prodRelease/app-prod-release.aab
    track: internal
    status: completed

The iOS job lives in the same file behind if: ${{ false }}. It needs an Apple Developer account, an App Store Connect API key, and fastlane match before it can be unblocked. Keeping it gated means Android ships independently instead of a half-working iOS job blocking CI. Documented, disabled, honest.

deploy.yml is workflow_dispatch only, taking environment and component as choice inputs, and ends by calling the same tool/firebase_deploy.sh a developer runs locally. One code path, two entry points.

The gotcha that justified writing any of this down

The Play Store service account is where I lost the most time, and it’s the clearest argument for a spec over a memory.

In Play Console → Users and permissions → your service account → Manage app permissions, you must explicitly grant Release apps to testing tracks. Without that specific checkbox, the build and the upload both succeed, and then the action fails at “Committing the Edit” with:

Error: The caller does not have permission

Upload and commit are authorized separately. Worse, a named role like “Release Manager” doesn’t reliably map to these granular checkboxes, so “I gave it the right role” is not a verification; you have to look at the boxes. And if you later add a production or beta track, testing-track access alone won’t cover it; you need Release apps to production, exclude devices, and use Play App Signing as well.

That paragraph is worth more than every YAML file above. It is exactly the kind of knowledge that evaporates between projects, and exactly what a spec is for.

Pin the Flutter version. Exactly.

Not channel: stable. An exact version, in every subosito/flutter-action@v2 step and in each package’s .fvmrc:

{ "flutter": "3.44.8" }

A stable-channel bump can silently break CI with a new lint or assertion that doesn’t exist on your machine yet. When bumping, update every .fvmrc, every workflow’s flutter-version:, and the version in your docs together, in one change.

Order of operations for a new project

The spec ends with the sequence, because ordering is most of the value:

  1. Create the Firebase projects. Dev and prod, register the apps in each, fill in .firebaserc.
  2. Get it running locally against dev. Commit dart_defines/dev.json, since it’s non-secret.
  3. Add validate.yml first. Green PR checks with zero secrets involved.
  4. Set up Android release signing locally. Confirm flutter build appbundle --release --flavor prod works on your machine before asking CI to do it.
  5. Wire up the Play Store deploy. Service account, secrets, deploy-mobile.yml, then verify one push-to-main actually lands in the internal track.
  6. Backend, once it exists. Deploy service account or Workload Identity Federation, tool/firebase_deploy.sh, deploy.yml.
  7. Web admin app, once it exists. Hosting service account, build helpers, deploy-admin-web.yml.
  8. iOS last.

Steps 6 and 7 didn’t exist when I ran this on Kleio. That’s the point.

On stored keys

For a fresh project, prefer Workload Identity Federation over a downloaded service account JSON. Use google-github-actions/auth@v2 with workload_identity_provider and service_account, so no long-lived credential sitting in your repo secrets waiting to be rotated.

One honest caveat: FirebaseExtended/action-hosting-deploy@v0 only accepts a raw service-account key and doesn’t support WIF. If you want to avoid stored keys entirely for Hosting, skip that action and run firebase deploy --only hosting directly under WIF-issued credentials.

Was it worth it?

The first run, on Adepha, cost more than just building the pipeline would have: maybe half a day of extra writing. The second run, on Kleio, took an afternoon instead of a week, and I didn’t hit the Play Console permissions wall a second time.

That’s the case for spec driven development on infrastructure work. The spec isn’t overhead you pay for tidiness. It’s the artifact that makes the second project cheap, and it only earns that by being precise enough to actually execute: placeholders instead of your project’s real values, assumptions stated, and sections you can delete without the rest falling apart.

The agentic half is what makes the first run affordable enough to bother. Left to hand-writing, a document this detailed is a weekend I’d rather spend shipping features, so it never gets written and the knowledge stays in my shell history. With skills carrying the conventions, subagents doing the scoped drafting, and a harness keeping anything irreversible in front of me, the spec becomes a normal cost of starting a project.

Neither half works alone. Agents without a spec produce a large diff and no stated intent. A spec without agents is a document you write once, congratulate yourself for, and never run again. The pairing is what turned “how did I set this up last time?” into a document I’ve now executed twice, and it’s how both Kleio and Adepha get built, not just how their pipelines got configured.

← All posts