# helm another operation is in progress is a status, not a lock

> A helm another operation is in progress failure reads a stored status field, not a live lock. Nothing times out, so learn which record to change.

Source: https://latchkey.dev/learn/failures/helm-another-operation-in-progress-in-ci  
Updated: 2026-09-21

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.

```Reconstructed composite: the sentence is errPending in pkg/action/action.go, the UPGRADE FAILED prefix is added in cmd/helm/upgrade.go, Helm v3.19.0
Error: UPGRADE FAILED: another operation (install/upgrade/rollback) is in progress
```

## 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

1. 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.
2. 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.
3. 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.

```.github/workflows/deploy.yml
concurrency:
  group: helm-${{ inputs.namespace }}-${{ inputs.release }}
  cancel-in-progress: false

jobs:
  deploy:
    timeout-minutes: 30
```

### Use --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.

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

```.github/workflows/deploy.yml
- 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
    fi
```

### Alert 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.

```Terminal
helm list -n "$NS" -o json | jq -r '.[] | select(.status | startswith("pending")) | "\(.name) \(.status)"'
```

## 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.

## One 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. |

> A `failed` release is not blocked. The three pending values are transitions and `failed` is a resting state, which is why a deploy that errored cleanly lets the next one through and a deploy that was cancelled does not.

## 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.

```Terminal
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
    ;;
esac
```

## Why 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.

## FAQ

### How long until a Helm pending-upgrade clears itself?

It does not. The pending value is a field on the stored release record, written before the operation starts and overwritten when it ends, so a process that dies in between leaves it set indefinitely. There is no lease, no expiry and no timeout attached to it. Every fix works by writing a new record or removing the old one.

### Which Helm commands can produce "another operation is in progress"?

Only `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?

Only as a last resort. The Secret is Helm's record of a revision, and deleting it leaves whatever that revision created running in the cluster with no owner Helm knows about, so the next install can collide with resources it does not expect. Rolling back to the last deployed revision, or uninstalling a stuck first install, keeps Helm and the cluster agreeing with each other.

### Why does the error say UPGRADE FAILED when I ran helm upgrade --install?

Because the prefix is added by the upgrade command rather than by the code that raised the error. When `--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.

## References

- [helm/helm: errPending in pkg/action/action.go](https://github.com/helm/helm/blob/v3.19.0/pkg/action/action.go)
- [helm/helm: the single IsPending guard in prepareUpgrade](https://github.com/helm/helm/blob/v3.19.0/pkg/action/upgrade.go)
- [helm/helm: the release status enumeration and IsPending](https://github.com/helm/helm/blob/v3.19.0/pkg/release/status.go)
- [Helm docs: helm upgrade, including --atomic and --timeout](https://helm.sh/docs/helm/helm_upgrade/)

---

Latchkey runs CI/CD that repairs its own failures. Agent entry points: https://latchkey.dev/agent.txt, https://latchkey.dev/openapi.json, https://latchkey.dev/llms.txt
