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
inactiverows 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¶
After installation, authenticate with GitHub:
Run gh auth status to confirm your session is active before continuing.
Install jq (optional)¶
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¶
gh api --paginatewalks every page ofGET /repos/{owner}/{repo}/deploymentsand prints each numericid.- For each id,
GET .../deployments/{id}/statuses?per_page=1reads the latest status. Use the integeridfrom the deployment object, notnode_idand not the placeholder from docs examples. A literal bad id returns 404. - When the latest state is
inactive, the loop issuesDELETE /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.