firebase / firebase/firebase-tools

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

Open Beginner friendly
#11,066 2 comments 0 reactions 0 assignees View on GitHub
api: functions reproducible type: bug
Dominant language
TypeScript
Stars
4.5k
Forks
1.3k
Avg merge
1d 12h
Merged PRs (30d)
84

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

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`:

```ts
const haveSecrets = await listSecrets(projectId, `labels.${FIREBASE_MANAGED}=true`);
```

But `src/gcp/secretManager.ts#L503` writes the product name when a secret is created:

```ts
export function labels(product: "functions" | "apphosting" = "functions"): Record {
return { [FIREBASE_MANAGED]: product }; // firebase-managed=functions
}
```

Every other call site carries a back-compat shim for both values — `src/gcp/secretManager.ts#L477`:

```ts
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.

Contributor guide

Open the contributing guide

Research direction

Start in src/functions/secrets.ts at pruneSecrets and compare its label query with isFunctionsManaged in src/gcp/secretManager.ts. Reproduce with functions:secrets:set and functions:secrets:prune, then verify that a CLI-created secret is found and stale versions are destroyed without reporting success for an empty result.

Written by the indexing model from the issue text.

Assessment

Tech stack
google-cloud, typescript
Domain
cli, cloud
Issue type
Bug
Difficulty
2/5
Estimated time
1-3 hours
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
90/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.