firebase / firebase/firebase-tools
functions:secrets:set and :prune never destroy anything: pruneSecrets filters firebase-managed=true, but labels() writes firebase-managed=functions
- 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
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