Skip to content

Bulk Delete GitHub Deployments

Repositories that ship often, especially to GitHub Pages or similar environments, can rack up a long list of deployments. The UI is fine for spot checks, but it doesn't give you a fast way to clear old rows when you only care about what is current. The REST API deletes one deployment per request, so the practical approach is the same as workflow run cleanup: loop with the GitHub CLI (gh).

This post starts with a loop that deletes deployments whose latest status is inactive, then adds a variant that also removes error and failure without touching rows that still end in success.

Why You Might Want to Do This

A few common reasons to trim deployment history:

  • The Deployments view is noisy, and older inactive rows don't tell you much day to day.
  • You want less clutter after a rename, a default branch change, or a burst of failed attempts (error, failure).
  • You're aligning housekeeping with other API cleanup, similar to bulk-deleting workflow runs.

GitHub doesn't offer bulk delete in the UI for deployments, and the Deployments REST API deletes one deployment_id at a time. Scripting gh api is the straightforward path.

Prerequisites

You need the GitHub CLI on your PATH. jq is optional here because gh api can filter with --jq, but install it if you prefer piping JSON through jq like the workflow runs post.

Install the GitHub CLI

brew install gh
sudo apt update && sudo apt install -y gh
sudo dnf install -y gh
winget install GitHub.cli

After installation, authenticate with GitHub:

gh auth login

Run gh auth status to confirm your session is active before continuing.

Install jq (optional)

brew install jq
sudo apt update && sudo apt install -y jq
sudo dnf install -y jq
winget install jqlang.jq

The Script

Deployments don't expose a single active flag on the deployment object itself. Consumers report deployment statuses; the list is newest first, so per_page=1 returns the latest state. When that state is inactive, GitHub is already treating that row as superseded for typical environments. When it's error or failure, the last recorded outcome failed. Don't delete deployments whose latest state is still success unless you mean to remove live-looking history.

gh may page long JSON through less, which stops a loop until you quit the pager. Set GH_PAGER=cat (or gh config set pager cat) for unattended runs. Redirecting DELETE output to /dev/null keeps noise down.

Delete only inactive:

export GH_PAGER=cat

REPO="OWNER/REPO"

gh api --paginate "/repos/${REPO}/deployments" --jq '.[].id' | while read -r dep_id; do
  state="$(gh api "/repos/${REPO}/deployments/${dep_id}/statuses?per_page=1" --jq '.[0].state // empty')"
  [ "${state}" = "inactive" ] || continue
  echo "Deleting deployment ${dep_id} (${state})"
  gh api --method DELETE "/repos/${REPO}/deployments/${dep_id}" >/dev/null
done

Set REPO to your repository slug as owner/repo (not necessarily your local clone directory name).

How It Works

  1. gh api --paginate walks every page of GET /repos/{owner}/{repo}/deployments and prints each numeric id.
  2. For each id, GET .../deployments/{id}/statuses?per_page=1 reads the latest status. Use the integer id from the deployment object, not node_id and not the placeholder from docs examples. A literal bad id returns 404.
  3. When the latest state is inactive, the loop issues DELETE /repos/{owner}/{repo}/deployments/{id}. Other states are skipped.

Also Deleting error and failure Statuses

If you want failed attempts removed in the same pass, widen the match:

export GH_PAGER=cat

REPO="OWNER/REPO"

gh api --paginate "/repos/${REPO}/deployments" --jq '.[].id' | while read -r dep_id; do
  state="$(gh api "/repos/${REPO}/deployments/${dep_id}/statuses?per_page=1" --jq '.[0].state // empty')"
  case "${state}" in inactive|error|failure) ;; *) continue ;; esac
  echo "Deleting deployment ${dep_id} (${state})"
  gh api --method DELETE "/repos/${REPO}/deployments/${dep_id}" >/dev/null
done

Comment out the DELETE line and keep the echo first if you want a dry run.

Handling a Long History

Each deployment needs two API calls before a possible delete, so busy repos burn rate limit faster than a single list endpoint. If you hit limits, add a short sleep, run during off hours, or narrow the list.

To page until you're done, keep re-running the script while inactive, error, or failure rows remain, or wrap the loop in your own outer while that stops when a preview pass finds nothing to delete.

Targeting a Specific Environment

The list endpoint accepts environment. For GitHub Pages style noise, try the environment name you see on deployment objects (often github-pages):

export GH_PAGER=cat

REPO="OWNER/REPO"
ENVIRONMENT="github-pages"

gh api --paginate "/repos/${REPO}/deployments?environment=${ENVIRONMENT}" --jq '.[].id' | while read -r dep_id; do
  state="$(gh api "/repos/${REPO}/deployments/${dep_id}/statuses?per_page=1" --jq '.[0].state // empty')"
  [ "${state}" = "inactive" ] || continue
  echo "Deleting deployment ${dep_id} (${state})"
  gh api --method DELETE "/repos/${REPO}/deployments/${dep_id}" >/dev/null
done

Adjust ENVIRONMENT to match your repo’s deployment labels.

A Note on Permissions

Deleting deployments requires credentials that can write repository metadata for that repo. If you're using a fine-grained token or GitHub App, confirm it's allowed to manage deployments for the target repository. If the API returns 404 on a deployment or status URL you expect to exist, double-check the slug, the numeric deployment id, and whether the token can see that resource.

For workflow run cleanup with gh run delete, see Bulk Delete GitHub Actions Workflow Runs.