Repository navigation
functions:secrets:set and :prune never destroy anything: pruneSecrets filters firebase-managed=true, but labels() writes firebase-managed=functions #11066
Description
Environment info
firebase-tools: 15.28.1, and still present on main / 15.30.0
Platform: macOS 15 (Darwin 25.5.0)
Test case
functions:secrets:set never destroys the stale version, and functions:secrets:prune never finds anything to prune, for any secret the CLI itself created. pruneSecrets filters on firebase-managed=true, but labels() writes firebase-managed=functions.
This is the same user-visible symptom as #6074, which was closed in 2023 after the destroy step was rebuilt. The rebuilt destroy is fine; it is just always handed an empty list.
Steps to reproduce
firebase functions:secrets:set MY_KEYand deploy, so the CLI creates the secret.- Rotate it:
firebase functions:secrets:set MY_KEY, answerYto "re-deploy the functions and destroy the stale version". gcloud secrets versions list MY_KEY— the superseded version is stillENABLED.gcloud secrets describe MY_KEY --format="value(labels)"→firebase-managed=functions.firebase functions:secrets:prune→All secrets are in use. Nothing to prune today.
Expected behavior
The superseded version is DESTROYED, or at minimum the CLI does not report success.
Actual behavior
Nothing is destroyed, and both commands print a success-shaped message.
functions:secrets:set prints the removal line with an empty list, which reads as a no-op that succeeded:
Removing secret versions:
functions:secrets:prune takes the pruned.length === 0 branch and issues a clean bill of health over a set it never examined:
All secrets are in use. Nothing to prune today.
Cause
src/functions/secrets.ts#L241 — pruneSecrets only selects secrets whose label value is the string true:
const haveSecrets = await listSecrets(projectId, `labels.${FIREBASE_MANAGED}=true`);But src/gcp/secretManager.ts#L503 writes the product name when a secret is created:
export function labels(product: "functions" | "apphosting" = "functions"): Record<string, string> {
return { [FIREBASE_MANAGED]: product }; // firebase-managed=functions
}Every other call site carries a back-compat shim for both values — src/gcp/secretManager.ts#L477:
export function isFunctionsManaged(secret: Secret): boolean {
return secret.labels[FIREBASE_MANAGED] === "true" || secret.labels[FIREBASE_MANAGED] === "functions";
}pruneSecrets is the only place that hardcodes =true, so the list query returns zero secrets, prunedSecrets stays empty, and both callers act on an empty array:
src/commands/functions-secrets-set.ts#L156-L162src/commands/functions-secrets-prune.ts#L38-L42
This cannot self-heal. Because isFunctionsManaged accepts functions, ensureSecret already treats the secret as managed and never offers the "Would you like to have your secret managed by Cloud Functions for Firebase?" relabel prompt. So a project created by a recent CLI is permanently invisible to prune, with no path back and no warning.
Suggested fix
Use the existing shim in the query rather than a literal, e.g. list once and filter with isFunctionsManaged, or issue both label filters. A one-line change to pruneSecrets.
Note for anyone finding this while auditing their own project
The obvious workaround — gcloud secrets update MY_KEY --update-labels=firebase-managed=true — is worth thinking twice about. pruneSecrets selects versions with NOT state: DESTROYED, so DISABLED versions are prune candidates. If you have disabled superseded versions as a reversible first step (rather than destroying them), relabelling will destroy all of them on your next unrelated secrets:set, irreversibly and without a dry run.
Activity
IzaakGough commentedon Sep 10, 2026
Hi @zuhairizainuddin thanks for raising this. We will review as soon as possible.
IzaakGough commentedon Sep 10, 2026
Hi @zuhairizainuddin, I was able to reproduce this. I think your suggested fix is the right approach and I will look at getting a fix out asap.
This one will probably need some checking internally first. Prune has been dead since 13.6.1 in April 2024, so projects have a backlog of unused versions, and the first run after the fix destroys the lot. Disabled versions go too, since the query is
NOT state: DESTROYED. The bigger risk is thatfunctions:secrets:setruns the same code without listing anything or asking, so this can happen during a routine rotation.