Skip to content

functions:secrets:set and :prune never destroy anything: pruneSecrets filters firebase-managed=true, but labels() writes firebase-managed=functions #11066

Open

Description

@zuhairizainuddin

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

  1. firebase functions:secrets:set MY_KEY and deploy, so the CLI creates the secret.
  2. Rotate it: firebase functions:secrets:set MY_KEY, answer Y to "re-deploy the functions and destroy the stale version".
  3. gcloud secrets versions list MY_KEY — the superseded version is still ENABLED.
  4. gcloud secrets describe MY_KEY --format="value(labels)" → firebase-managed=functions.
  5. 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.

Activity

  1. IzaakGough commented on Sep 10, 2026

    @IzaakGough
    Contributor

    Hi @zuhairizainuddin thanks for raising this. We will review as soon as possible.

  2. IzaakGough commented on Sep 10, 2026

    @IzaakGough
    Contributor

    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 that functions:secrets:set runs the same code without listing anything or asking, so this can happen during a routine rotation.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions