Fixing Helm's "another operation is in progress" Without Nuking the Release
The CI job hit its ten minute limit and the runner was killed. Nobody thought much about it, the pods looked fine, so the next commit went out an hour later. That one failed in four seconds:
Error: UPGRADE FAILED: another operation (install/upgrade/rollback) is in progress
And now nothing deploys. Not the fix, not a rollback, not the same chart with no changes. The cluster is running fine, the workloads are healthy, and Helm has decided you are not allowed to touch anything.
This is not Helm being buggy. It is Helm's lock working exactly as designed, with no unlock command. The comment sitting next to the check in Helm's own source says so plainly: concurrent upgrades fail here, and "this should act as a pessimistic lock". A pessimistic lock held by a process that no longer exists is just a lock.
Helm stores each release revision as a Kubernetes Secret. An interrupted operation leaves the newest revision stuck at pending-upgrade, and Helm refuses every subsequent operation until that changes.
helm history <release> -n <ns>to find the pending revision and confirm a good one exists.- If a
deployedrevision is still listed:helm rollback <release> <that-revision> -n <ns>. - If that also refuses, delete only the pending revision's Secret:
kubectl delete secret sh.helm.release.v1.<release>.v<N> -n <ns>, then re-run the upgrade.
Never delete the last deployed revision's Secret, and never kubectl delete the workloads. Prevent the next one with --atomic --timeout 10m and a CI timeout that is longer than the Helm timeout.
The exact error
Error: UPGRADE FAILED: another operation (install/upgrade/rollback) is in progress
You may also see the install-time variant, or this one if you go on to try a rollback:
Error: INSTALL FAILED: another operation (install/upgrade/rollback) is in progress
Error: release: already exists
Why this happens
Helm 3 and Helm 4 both keep release state inside the cluster, as Secrets of type helm.sh/release.v1, one per revision, in the release's own namespace. Those are ordinary namespace-scoped Secrets with all the usual consequences, which is why every command below needs -n. The name pattern is sh.helm.release.v1.<release>.v<revision>. Inside, the release field is the release object serialised to JSON, gzipped, base64 encoded, and then base64 encoded again by Kubernetes itself. That double encoding is why the decode command later in this article looks so silly.
An upgrade runs in three phases from the client's point of view:
- Create a new revision Secret with status
pending-upgrade. - Apply the manifests, run hooks, and if
--waitor--atomicis set, block until the resources report ready. - Flip the new revision to
deployedand the previous one tosuperseded.
Phase 3 happens in the client. There is no controller in the cluster finishing the job on your behalf. If the client dies between phase 1 and phase 3, because a CI runner was killed, a network link dropped, a laptop lid closed, or someone pressed Ctrl+C during a slow --wait, the Secret stays at pending-upgrade forever.
On the next run, Helm loads the most recent revision and checks its status. The check reads the status from the decoded release object, not from a label, and pending statuses are rejected outright. That distinction matters, and it is why one very popular piece of advice does not work. More on that below.
The fix, in order
Step 1: Look at the history before touching anything
helm history my-app -n prod
REVISION UPDATED STATUS CHART APP VERSION DESCRIPTION
5 Mon Jul 20 09:14:02 2026 superseded api-2.4.1 1.14.0 Upgrade complete
6 Mon Jul 20 11:02:37 2026 deployed api-2.4.2 1.15.0 Upgrade complete
7 Tue Jul 21 08:31:19 2026 pending-upgrade api-2.4.3 1.16.0 Preparing upgrade
Two things to read off this. Revision 7 is the stuck one. Revision 6 is still marked deployed, which means Helm never got as far as superseding it and you have a valid rollback target. If no revision says deployed, skip to step 3, because rollback will fail with has no deployed releases.
Step 2: Confirm no operation is genuinely running
This is the step everyone skips, and it is the only one that can cause damage if you get it wrong. Look at the timestamp on the pending revision. If it is thirty seconds old, a colleague or a pipeline may be mid-upgrade right now, and yanking the Secret out from under them produces a genuinely corrupt release. If it is an hour old and no job is running, it is dead.
kubectl get secret -n prod -l owner=helm,name=my-app \
--sort-by=.metadata.creationTimestamp
NAME TYPE DATA AGE
sh.helm.release.v1.my-app.v5 helm.sh/release.v1 1 3d
sh.helm.release.v1.my-app.v6 helm.sh/release.v1 1 3d
sh.helm.release.v1.my-app.v7 helm.sh/release.v1 1 67m
To read the status straight out of the Secret rather than trusting the label, decode it. Note the two base64 -d calls, which is not a typo:
kubectl get secret sh.helm.release.v1.my-app.v7 -n prod \
-o jsonpath='{.data.release}' | base64 -d | base64 -d | gzip -d | jq -r '.info.status'
pending-upgrade
Step 3: Roll back to the last deployed revision
If step 1 showed a deployed revision, try this first. It is the least invasive option, because it leaves the full revision history intact:
helm rollback my-app 6 -n prod --wait --timeout 10m
Rollback was a success! Happy Helming!
This creates revision 8 with the contents of revision 6 and marks it deployed, which clears the block. If it refuses with the same pending error, go to step 4.
Step 4: Delete the pending revision's Secret, and only that one
kubectl delete secret sh.helm.release.v1.my-app.v7 -n prod
secret "sh.helm.release.v1.my-app.v7" deleted
Deleting that Secret makes revision 6 the newest one Helm can see, and revision 6 is deployed, so the lock is gone. Nothing in the cluster is touched by this: the Deployments, Services and ConfigMaps that revision 7 may have already applied stay exactly where they are. Helm will reconcile them on the next upgrade.
What you must not do is delete ...v6. That is the last known-good record of what Helm believes is running. Delete it and you get has no deployed releases, at which point your options narrow to reinstalling.
Step 5: Re-run the upgrade so it cannot happen again
helm upgrade my-app ./charts/api -n prod \
--install --atomic --timeout 10m
--atomic rolls the release back automatically if the upgrade fails, and it turns on --wait for you. --timeout defaults to five minutes and applies to individual Kubernetes operations such as waiting on hooks. The important part is the relationship between the two timeouts: your CI job timeout must be comfortably longer than the Helm timeout. If the runner is killed at ten minutes while Helm is still patiently waiting on a fifteen minute timeout, you are back where you started, and --atomic will not save you because the rollback it would have performed also runs in the dead client.
Verification
helm status my-app -n prod | head -5
helm history my-app -n prod
You want STATUS: deployed and a history where the newest revision is deployed and every older one is superseded. No revision should still read pending-anything. Then confirm no orphaned Secret survived:
kubectl get secret -n prod -l owner=helm,name=my-app,status=pending-upgrade
No resources found in prod namespace.
Finally, prove the lock is actually released rather than just looking released, by running a no-op upgrade with the same values. It should complete and produce a new revision.
What people get wrong
Patching the status label on the Secret. This is the most repeated fix on the internet, usually written as kubectl patch secret ... -p '{"metadata":{"labels":{"status":"deployed"}}}'. The label is metadata used for filtering. The status Helm actually tests is inside the gzipped, base64 encoded release payload in .data.release. Patching the label alone leaves the payload saying pending-upgrade, and you now have a Secret whose label and contents disagree, which makes the next person's diagnosis harder. If you want to edit the payload you have to decode it, edit the JSON, gzip it, base64 it twice and put it back, which is a lot of ceremony to achieve what deleting the Secret achieves in one command.
kubectl delete on the release's workloads. This is the genuinely dangerous one. It causes downtime, and it does not help, because the lock lives in the release Secret and not in the workloads. Worse, if you delete some resources and not others, Helm's next upgrade computes a three-way merge against a state that no longer exists and can leave you with orphaned objects it no longer tracks.
helm uninstall then reinstall. Effective and almost always excessive. It deletes your Services, so it drops every connection, it deletes PersistentVolumeClaims unless the reclaim policy protects them, and it loses the entire revision history. Keep it for a release you can afford to lose.
Re-running helm upgrade in a retry loop. A pending status is not transient. It will never clear on its own, so the loop is guaranteed to fail every time and mostly serves to bury the original error in scrollback.
When it is still broken
Error: UPGRADE FAILED: "my-app" has no deployed releases. Different error, related cause: the very first install failed, so there has never been adeployedrevision to roll back to. Delete thepending-installorfailedSecrets for that release and runhelm upgrade --installagain, which will treat it as a fresh install.- The release is managed by Flux or Argo CD. Their controllers re-drive Helm on a reconcile loop and will happily recreate the pending state seconds after you clear it. Suspend reconciliation first (
flux suspend helmrelease my-app -n prod, or disable auto-sync on the Argo application), fix the release, then resume. - It goes pending again on every attempt. Then the upgrade is genuinely failing, and the interruption is a symptom. Run without
--atomicso the failed state survives for inspection, thenkubectl get events -n prod --sort-by=.lastTimestamp | tail -30andkubectl describethe workload. Usually it is an image that will not pull, a readiness probe that never passes, or a pre-upgrade hook Job that hangs. - Secrets are not where you expect. If your cluster was configured with
HELM_DRIVER=configmaporsql, the release records live somewhere else entirely and the Secret commands above will find nothing.helm env | grep HELM_DRIVERtells you which backend is in use.
The uncomfortable conclusion is that Helm has no first-class command to release its own lock, which is why every team eventually writes the same three-line unstick script. Put yours in the repository next to the deploy script, with the helm history check baked in so nobody runs the delete blind, and keep the kubeconfig and registry credentials it needs out of the repository itself using something like a SOPS and age workflow. It will get used more often than you would like.
Frequently asked questions
- Why does Helm say another operation is in progress when nothing is running?
- Helm records each revision as a Kubernetes Secret and marks it pending-upgrade while the operation runs, then flips it to deployed or failed from the client at the end. If the client is killed by a CI timeout, a dropped connection or Ctrl+C, nothing in the cluster finishes that transition, so the Secret stays pending forever and Helm treats it as a held lock.
- Is it safe to delete the sh.helm.release.v1 secret?
- It is safe to delete the Secret for the pending revision only, after confirming no upgrade is genuinely running. That Secret is a record of state, not a running workload, so deleting it changes nothing in the cluster. Never delete the Secret for the revision marked deployed, because that is the last known-good record and losing it produces the has no deployed releases error.
- Does patching the status label on the Helm release secret fix it?
- Usually not. The label is only metadata used for filtering, while the status Helm actually checks lives inside the release payload in the data.release field, which is JSON, gzipped and base64 encoded twice. Patching the label leaves the payload still saying pending-upgrade and creates a Secret whose label contradicts its contents.
- How do I stop Helm releases getting stuck in pending-upgrade?
- Run upgrades with --atomic and an explicit --timeout, and make sure the CI job timeout is comfortably longer than the Helm timeout so the client is never killed mid-operation. --atomic also enables --wait and rolls the release back automatically if the upgrade fails, which converts a stuck pending state into a clean failed one.