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_KEY and deploy, so the CLI creates the secret.
- Rotate it:
firebase functions:secrets:set MY_KEY, answer Y to "re-deploy the functions and destroy the stale version".
gcloud secrets versions list MY_KEY — the superseded version is still ENABLED.
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-L162
src/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.
Related: #6074, #4459.
Environment info
firebase-tools: 15.28.1, and still present on
main/ 15.30.0Platform: macOS 15 (Darwin 25.5.0)
Test case
functions:secrets:setnever destroys the stale version, andfunctions:secrets:prunenever finds anything to prune, for any secret the CLI itself created.pruneSecretsfilters onfirebase-managed=true, butlabels()writesfirebase-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.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:setprints the removal line with an empty list, which reads as a no-op that succeeded:functions:secrets:prunetakes thepruned.length === 0branch and issues a clean bill of health over a set it never examined:Cause
src/functions/secrets.ts#L241—pruneSecretsonly selects secrets whose label value is the stringtrue:But
src/gcp/secretManager.ts#L503writes the product name when a secret is created:Every other call site carries a back-compat shim for both values —
src/gcp/secretManager.ts#L477:pruneSecretsis the only place that hardcodes=true, so the list query returns zero secrets,prunedSecretsstays empty, and both callers act on an empty array:src/commands/functions-secrets-set.ts#L156-L162src/commands/functions-secrets-prune.ts#L38-L42This cannot self-heal. Because
isFunctionsManagedacceptsfunctions,ensureSecretalready 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 topruneSecrets.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.pruneSecretsselects versions withNOT state: DESTROYED, soDISABLEDversions 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 unrelatedsecrets:set, irreversibly and without a dry run.Related: #6074, #4459.