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:
- The deploy script refuses to run if the resolved
prodproject ID equals the known dev project ID. Catches a misconfigured.firebaserc. - Production deploys require an explicit
--confirm-productionflag. No accidental prod pushes from muscle memory. - 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:
- Create the Firebase projects. Dev and prod, register the apps in each,
fill in
.firebaserc. - Get it running locally against dev. Commit
dart_defines/dev.json, since it’s non-secret. - Add
validate.ymlfirst. Green PR checks with zero secrets involved. - Set up Android release signing locally. Confirm
flutter build appbundle --release --flavor prodworks on your machine before asking CI to do it. - Wire up the Play Store deploy. Service account, secrets,
deploy-mobile.yml, then verify one push-to-main actually lands in the internal track. - Backend, once it exists. Deploy service account or Workload Identity
Federation,
tool/firebase_deploy.sh,deploy.yml. - Web admin app, once it exists. Hosting service account, build helpers,
deploy-admin-web.yml. - 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.