helm another operation is in progress is a status, not a lock
A helm another operation is in progress failure is Helm reading the status field of your release's most recent revision and finding it set to one of three pending values, which is a stored fact rather than a lock anybody holds. Nothing expires it and nothing releases it, so waiting is the one response that cannot work, and the command prefix on the line tells you which of Helm's entry points hit it.

What this error means
A deploy step fails immediately, with no wait and no Kubernetes activity, on a line built from three pieces: Error: from the command runner, UPGRADE FAILED: from the upgrade command, and the sentence itself from deep inside Helm. helm status <release> then shows the release as pending-install, pending-upgrade or pending-rollback, while kubectl get pods shows nothing happening. That combination is the whole diagnosis: a pending status with no live operation means a previous Helm process was killed between marking the release pending and marking it done, and the record it left behind is what the next run is reading.
Error: UPGRADE FAILED: another operation (install/upgrade/rollback) is in progressOne branch, in one file, reachable from one command
The sentence is defined once. In pkg/action/action.go it reads errPending = errors.New("another operation (install/upgrade/rollback) is in progress"), and a grep of the whole Helm v3.19.0 tree finds it used in exactly one other place: prepareUpgrade in pkg/action/upgrade.go, guarded by if lastRelease.Info.Status.IsPending(). The comment above that guard calls it a pessimistic lock, which is accurate about intent and misleading about mechanism, because there is no lock object anywhere. IsPending in pkg/release/status.go is a three-way comparison against pending-install, pending-upgrade and pending-rollback.
That single call site has a useful consequence. helm install cannot produce this message; a name already in use gives you a different error entirely. helm rollback and helm uninstall do not reference errPending at all. So if your log carries this sentence, the command that ran was helm upgrade, including the helm upgrade --install form once a release record exists, and you can stop looking at the other steps in the pipeline.
The prefix is added later and by a different file. In Helm 3 the upgrade command wraps the returned error with errors.Wrap(err, "UPGRADE FAILED"); in Helm 4 the same place uses fmt.Errorf("UPGRADE FAILED: %w", err). The install command uses INSTALLATION FAILED instead, which is worth knowing because in the --install fallback path the upgrade command returns the install error without adding a prefix at all. Read the prefix as a signal of which entry point spoke, not as part of the error.
| Release status | What Helm did last | Does an upgrade proceed? |
|---|---|---|
deployed | Finished an install or upgrade successfully. | Yes. |
failed | Finished, unsuccessfully. The record is complete. | Yes. This is not a pending status. |
pending-install | Began a first install and never recorded an end. | No. IsPending is true. |
pending-upgrade | Began an upgrade and never recorded an end. | No. IsPending is true. |
pending-rollback | Began a rollback and never recorded an end. | No. IsPending is true. |
superseded | An older revision, replaced by a newer one. | Yes. Helm reads the latest revision. |
Common causes
A deploy job was cancelled mid-operation
Someone pushed again and the concurrency group cancelled the running job, or the workflow hit its timeout, or a maintainer pressed cancel. Helm had already written the pending status and had not yet written the final one, so the record is stuck exactly where the signal arrived. This is by a distance the most common cause in CI and it is also the one most likely to recur, because the behaviour that caused it is a setting somebody chose.
Two pipelines deploy the same release at once
A release deployed from both a branch pipeline and a tag pipeline, or from a workflow and a person's laptop, produces a genuine race. The comment in the Helm source calls this out as the case the guard exists for. Here the message is correct rather than stale: another operation really is in progress, and the right response is to make that impossible rather than to clear the status.
The Helm process was killed by the kernel or the runner
A chart with a large rendering step, a Helm binary running alongside a memory-hungry test suite, or a runner that lost its connection mid-step all end the process without warning. The pending status is left set the same way a cancellation leaves it, so the symptom is identical and the fix is identical, but the prevention is different: the operation has to stop dying rather than stop being cancelled.
A timeout ended the wait, not the operation
A helm upgrade --wait --timeout that expires returns control while the underlying rollout continues, and how the release record ends up depends on what the run did next. In our experience this is the version that produces a pending status on a job that reported an error rather than a cancellation, which makes it easy to blame the chart instead of the timeout.
How to fix it
Serialize deploys per release so the race cannot happen
- Put a concurrency group on the deploy job, keyed on the release and namespace rather than on the branch, so two branches deploying the same release still queue.
- Set
cancel-in-progress: false. Cancelling a running deploy is the single largest producer of this failure, and queueing costs minutes where cancelling costs an outage window. - Give the job a timeout longer than the chart's own
--timeout, so the job cannot be killed while Helm is still inside its wait.
concurrency:
group: helm-${{ inputs.namespace }}-${{ inputs.release }}
cancel-in-progress: false
jobs:
deploy:
timeout-minutes: 30Use --atomic so an interrupted upgrade cleans up after itself
With --atomic, an upgrade that fails or times out rolls back automatically, which turns a pending status into a settled one without anybody intervening. It implies --wait, so pair it with a --timeout you have actually measured against your slowest rollout rather than the default.
helm upgrade --install "$RELEASE" ./chart \
-n "$NS" \
--atomic \
--timeout 10m \
--set image.tag="$GITHUB_SHA"Detect and clear the pending status at the start of the deploy
A preflight that reads the status and recovers only from the two safe cases turns a manual intervention into a no-op. Keep it narrow: recover from pending-upgrade and pending-rollback by rolling back to the last deployed revision, and leave pending-install to a human, because clearing that one means deleting a release.
- name: Recover a stuck release
run: |
set -euo pipefail
s=$(helm status "$RELEASE" -n "$NS" -o json | jq -r .info.status || echo none)
if [ "$s" = "pending-upgrade" ] || [ "$s" = "pending-rollback" ]; then
rev=$(helm history "$RELEASE" -n "$NS" -o json | jq -r '[.[] | select(.status=="deployed")] | last | .revision // empty')
if [ -n "$rev" ]; then helm rollback "$RELEASE" "$rev" -n "$NS" --wait; fi
fiAlert on the status rather than on the error
Because the record never expires, a release left pending stays pending until somebody deploys again and fails. Checking the status of your releases on a schedule finds it in the hour it happened rather than the next time a developer pushes, and the check is one command per namespace.
helm list -n "$NS" -o json | jq -r '.[] | select(.status | startswith("pending")) | "\(.name) \(.status)"'Clearing it, in the order that loses least
The record lives in a Kubernetes Secret named after the release and revision, so every option here is a way of changing which revision is latest or what it says. Start with the one that keeps history intact. helm history shows you the revisions and their statuses; if any of them is deployed, rolling back to it writes a new revision with a settled status and the next upgrade proceeds.
When there is no deployed revision, which is the case for a first install that was cancelled, there is nothing to roll back to and helm rollback will tell you so. Then the honest move is to uninstall the half-created release and install again, accepting that whatever the cancelled run created will be deleted. Doing that in CI needs a guard, because an uninstall aimed at a healthy production release by a workflow that misread the situation is a much worse day than a failed deploy.
Deleting the Secret by hand is the fourth option and it is the one to reach for last. It removes Helm's record of a revision while leaving whatever that revision created in the cluster, so the next install can conflict with resources Helm no longer believes it owns. It works, and it leaves you a different problem.
set -euo pipefail
status=$(helm status "$RELEASE" -n "$NS" -o json | jq -r .info.status)
case "$status" in
pending-upgrade|pending-rollback)
rev=$(helm history "$RELEASE" -n "$NS" -o json | jq -r '[.[] | select(.status=="deployed")] | last | .revision // empty')
[ -n "$rev" ] && helm rollback "$RELEASE" "$rev" -n "$NS" --wait
;;
pending-install)
helm uninstall "$RELEASE" -n "$NS" --wait
;;
esacWhy this page carries no recorded run
Reproducing this needs a Kubernetes cluster, a chart, and a Helm process killed in a window that lasts as long as one API write. All three are easy to arrange and none of them tells you anything the source does not: the status values are an enumeration, the guard is one comparison, and the message has exactly one call site. A log of that experiment would be a photograph of a constant.
What is worth being careful about instead is the line itself, and this page is careful about it. The sentence, the prefix and the Error: at the front are assembled by three different layers of Helm, so the whole line is a literal in no source file, and a search for it will come back empty. That is a property of the line, not a sign that somebody made it up, and the label on the block says which piece came from where. content/heal-evidence.mjs has no record for this slug, so nothing on this page claims Latchkey repairs it.
How to prevent it
- Set cancel-in-progress to false on deploy jobs. Cancellation is the main source of pending releases.
- Deploy each release from exactly one pipeline, and key the concurrency group on the release rather than the branch.
- Use --atomic with a timeout you have measured, so an interrupted upgrade settles itself.
- Check for pending releases on a schedule, since nothing clears the status on its own.
Frequently asked questions
How long until a Helm pending-upgrade clears itself?
Which Helm commands can produce "another operation is in progress"?
helm upgrade, including helm upgrade --install when a release record already exists. In the Helm v3.19.0 tree the error value appears in just two files: its definition in pkg/action/action.go and its single use in prepareUpgrade in pkg/action/upgrade.go. helm install on a name already in use reports a different error, and rollback and uninstall do not reference it.Should I delete the Helm release Secret to unstick it?
Why does the error say UPGRADE FAILED when I ran helm upgrade --install?
--install takes the install path, the upgrade command returns that error unwrapped, so you get no prefix at all; when a release record already exists it stays on the upgrade path and the wrapper applies. The prefix tells you which path ran, which is useful, and it is not part of the underlying message.