# How I Moved My Google AI Studio App to GitHub CI 

Kitaaben started life in Google AI Studio. No shame in that — it's genuinely the fastest way I know to get a full-stack app with Firebase wired up and live. But "fast to start" and "where your code should live forever" are two different things.

I wanted GitHub to be the source of truth. Real commit history. Real pull requests. A deploy pipeline I could reason about without a proprietary UI sitting in the middle. The service was already live on Cloud Run, so the plan was simple: point it at the GitHub repo and be done by lunch.

It took the whole afternoon. Just not the afternoon I expected.

## The plan that should have worked

Cloud Run has a built-in feature for exactly this — *Continuously deploy from a repository*. Connect a GitHub repo, pick a branch, and it auto-generates a Cloud Build trigger that builds and deploys on every push. No YAML. Click, connect, push.

**Snag 1.** The very first build failed, but for a boring reason. My repo had two lockfiles — `package-lock.json` and a stray `pnpm-lock.yaml` that got committed by accident at some point. Google's Buildpacks saw the pnpm lockfile and built with pnpm. And recent pnpm versions block dependency install scripts by default as a supply-chain protection. So esbuild's postinstall script (the one that downloads its platform binary) never ran, and the build died before it even reached my code.

Fix: delete the stray lockfile. The project was never pnpm-based anyway. Quick, forgettable, not the story.

## The error that refused to explain itself

Next push got further — all the way to the deploy step — and then this:

```plaintext
ERROR: (gcloud.run.services.update)
spec.template.metadata.annotations[run.googleapis.com/sources]:
Source annotation has sources that are not referenced by a container.
```

Read that sentence a few times. Nothing in it tells you what to do. No stack trace, no file, no line number. Just a Cloud Run validator rejecting a deploy against its own service definition.

### Wrong theory #1: strip the annotation

The obvious read — some stale annotation is sitting on the live service, left over from however AI Studio originally provisioned it. So just remove it:

```plaintext
$ gcloud run services update kitaaben --region us-west1 \
    --remove-annotations=run.googleapis.com/sources
 
ERROR: unrecognized arguments: --remove-annotations=run.googleapis.com/sources
(did you mean '--remove-env-vars'?)
```

Turns out `gcloud run services update` has no generic "edit any annotation" flag. Most of Cloud Run's raw Knative-style metadata isn't meant to be hand-edited through convenience flags — precisely to stop people from doing what I was about to do.

### Wrong theory #2: ditch Buildpacks entirely

New theory. The `run.googleapis.com/sources` annotation is part of Cloud Run's source-deploy tracking — it maps source paths to containers so the console can show "this revision was built from commit X." A plain prebuilt Docker image needs none of that. So: write a Dockerfile, sidestep Buildpacks and its source-tracking machinery completely.

I wrote a multi-stage Dockerfile. Built it locally, ran it, confirmed it booted and served traffic. Pushed.

And this is where it got genuinely confusing. The build went green. The live site didn't change.

The build log explained why. Adding a Dockerfile made Cloud Build quietly switch strategies. Instead of the four-step Buildpacks template it had been using (build → pull → push → deploy), it fell back to Google's generic "there's a Dockerfile here, build and push an image" default, which has **no deploy step at all**. Green build. No deploy. Nobody tells you.

Fix for this part: a handwritten `cloudbuild.yaml` with three explicit steps. `docker build`, `docker push`, `gcloud run deploy --image`. Three commands, in order, in the repo. No more guessing what the pipeline does.

### Same error, completely different deploy method

With a real deploy step finally in place, I pushed again — and got the *exact same annotation error*. This time from a plain `gcloud run deploy --image` call that had nothing to do with source builds or Buildpacks at all.

That was the moment the real shape of the problem clicked. It was never about *how* I was deploying. Buildpacks, Dockerfile, source-based, image-based — didn't matter. Something was wrong with the live service's own persisted state, and every deploy method was stepping on the same landmine.

## Exporting the body

Time to look at what the service actually thought it was running:

```plaintext
$ gcloud run services describe kitaaben --region us-west1 \
    --format export > service.yaml
```

Buried in that YAML: the container's `image` field wasn't a real image reference. It was the literal string `scratch` — Cloud Run's internal placeholder for services that resolve their actual container indirectly, through that same `sources` annotation I'd been fighting all afternoon.

The service was *born* this way. AI Studio provisioned it through its own source-deploy pipeline on day one, and it had been carrying that placeholder ever since. A ghost container, quietly broken since birth, and it only mattered the day I tried to deploy any other way.

So every attempt was failing one of two ways: either the deploy tried to update the sources annotation and Cloud Run rejected the mismatch, or the leftover `scratch` placeholder had nothing left to resolve against.

## The actual fix

Fix both in one edit, apply atomically:

```plaintext
# in service.yaml:
#   1. delete the run.googleapis.com/sources annotation line
#   2. change image: scratch to the real image path
 
$ gcloud run services replace service.yaml
Applying new configuration to Cloud Run service [kitaaben]...
Deploying... done.
```

That was it. Not a flag. Not a build strategy. Not a YAML rewrite of the pipeline. A genuinely stuck field in the service's own state that no amount of retrying — from any deploy method — was ever going to clear.

## If you're doing this migration

*   If AI Studio (or any source-deploy tool) originally provisioned your Cloud Run service, don't assume a clean deploy method inherits a clean service. `describe --format export` and *read the spec* before you spend a day guessing.
    
*   `run.googleapis.com/sources` and `image: scratch` travel together. See one, check for the other.
    
*   Cloud Build silently changes strategy based on what's in your repo. Dockerfile present vs absent produces different pipelines. If a build goes green but nothing deploys, check whether the deploy step still exists.
    
*   Not every Cloud Run field has a convenience flag. When `--remove-X` doesn't exist, the fallback is always: export the spec, hand-edit, `services replace`. Haven't touched the pipeline since. It's a Cloud Build trigger watching `main`, building a Docker image, deploying it. No mystery annotations.
    

GitHub is the source of truth now. AI Studio is where [Kitaaben](https://kitaaben.com) was born, not where it lives.

That’s all, folks! I hope you found this helpful. If you enjoyed this, check out more articles on my Blog, [**https://blog.nandan.dev/**](https://blog.nandan.dev/)

Feel free to comment, email me at [**connect@nandan.dev**](mailto:connect@nandan.dev), or connect with me on Twitter, Instagram, or GitHub. Don’t forget to subscribe to my newsletter for regular updates on JavaScript topics!

[**Twitter**](https://twitter.com/_sirius93_) | [**Instagram**](https://www.instagram.com/nandandotdev) | [**Github**](https://github.com/sirius93) | [**Website**](https://nandan.dev/)
