firebase deploy said "Deploy complete". It shipped three-day-old code.
I deployed a new API route to Cloud Functions. firebase deploy --only functions printed:
✔ Deploy complete!
The route returned 404. I deployed again. Same success message, same 404. The new function didn't exist in the console either.
There were two separate bugs stacked on top of each other, and the first one is the reason "successful" deploys can ship code from three days ago without telling you.
Trap 1: a build script that can't fail
Two things were true at once.
firebase.json had no predeploy hook for functions. So the CLI packaged up whatever was already sitting in functions/lib/ — the compiled output from the last time anyone happened to run tsc locally. In my case, three days old.
The cloud-side build couldn't fail either. package.json had a gcp-build script, which Google Cloud runs when it installs your function source. But the build script was:
"build": "tsc || echo 'Skipping TypeScript compile'"
That || echo is the whole problem. It converts every compile error into exit code 0. A tsc failure becomes a cheerful log line, the build "passes", and the pre-existing lib/ ships untouched.
Somebody added that fallback so a transient failure wouldn't block a deploy. What it actually built was a pipeline where a broken build and a working build are indistinguishable from the outside.
Both layers had to be wrong for this to happen, and both were wrong in the same direction: fail quietly, keep going, report success.
The fix is unglamorous:
// firebase.json
"functions": {
"predeploy": ["npm --prefix \"$RESOURCE_DIR\" run build"]
}
// package.json
"build": "tsc" // let it fail
Trap 2: the deploy that then refused to analyze
With predeploy in place, a genuinely fresh lib/ went up — and the deploy stopped succeeding:
Error: Functions codebase could not be analyzed
That message means the CLI loaded your entry module in order to enumerate your exported functions, and loading it threw.
The cause was in a new shared module I'd added. At module scope, it did:
const db = admin.firestore(); // runs at import time
admin.initializeApp() lives in the body of index.ts — which, in the emitted CommonJS, runs after the imports at the top of the file. So the new module ran admin.firestore() before the app existed, and threw during load.
Why the existing modules were fine
This is the part that made it confusing: a dozen other modules did roughly the same thing and had worked for months.
Reading the emitted lib/index.js explained it. The old modules were pulled in via re-export statements sitting at the bottom of index.ts:
admin.initializeApp();
// ...
export { aiFeedback } from './aiFeedback'; // last lines of the file
TypeScript emits the require for a re-export at the position of that statement, while a top-of-file import becomes a require at the top. So the old modules were required after initializeApp() ran, and the new one — a plain top-of-file import — was required before.
Nobody designed that. The codebase had been accidentally relying on statement ordering in generated JavaScript.
The robust fix is to stop depending on load order at all. In any shared module:
if (!admin.apps.length) admin.initializeApp();
const db = admin.firestore();
Better still, don't do work at module scope. Resolve admin.firestore() inside the handler, where the app is guaranteed to exist.
The check I now run every time
The lesson isn't really about Firebase. It's that "Deploy complete" is a statement about the upload, not about your code. Verify the artifact, then verify production:
# 1. Does the compiled entry point even load?
node -e "require('./lib/index.js'); console.log('LOAD OK')"
# 2. After deploying, does the new behaviour actually exist?
curl -s -o /dev/null -w '%{http_code}\n' https://<host>/api/<new-route>
Step 1 catches trap 2 locally in under a second — it's the same thing the deploy analyzer does, and you can run it before you wait five minutes for an upload.
Step 2 is the one I'd been skipping. A deploy that reports success and changes nothing is indistinguishable from a deploy that never ran, unless you ask production a question only the new code can answer.
If you have || echo, || true, or a bare continue-on-error anywhere in a build path, go look at it now. That's where this class of bug lives.