Mass deletion of GitHub Actions workflow runs

| 3 min read

Implementing a simple cleanup script for workflow runs, using gh, jq, fzf and the GitHub API

Yesterday, while thinking aloud, I was wondering how best to mass-delete logs from GitHub Actions workflow runs. Such a feature isn't available in the Web based Actions UI and my lack of competence in the Actions area means that I have a lot of cruft from my trial and error approach to writing and executing workflows.

The GitHub Workflow Runs API

I knew the answer probably was in the GitHub API, and it was - in the form of the Workflow Runs API. There are various endpoints that follow a clean and logical design. Workflow runs are repo specific, and to list them, the following API endpoint is available to access via the GET method:

GET /repos/{owner}/{repo}/actions/runs

Following this straightforward URL-space design, a deletion is possible thus:

DELETE /repos/{owner}/{repo}/actions/runs/{run_id}

Incidentally, I like the use of "owner" here - because a repo can belong to an individual GitHub account (such as qmacro) or an organisation (such as SAP-samples), and "owner" is a generic term that covers both situations and has the right semantics.

Requesting the workflow run information with gh

To make use of these API endpoints, I used the excellent gh GitHub CLI, specifically the api facility. Once authenticated, it's super easy to make API calls; to retrieve the workflow runs for the qmacro/thinking-aloud repo, it's as simple as this (some pretty-printed output is also shown here):

; gh api /repos/qmacro/thinking-aloud/actions/runs
{
"total_count": 22,
"workflow_runs": [
{
"id": 686610826,
"name": "Generate Atom Feed",
"node_id": "MDExOldvcmtmbG93UnVuNjg2NjEwODI2",
"head_branch": "main",
"head_sha": "24822bfb34573c0dc2fb6b0f83c42a1752a324d9",
"run_number": 13,
"event": "issues",
"status": "completed",
"conclusion": "skipped",
...

Making sense of the response with jq

The response from the API has a JSON representation and a straightfoward but rich set of details. This is where jq comes in. I started with just pulling out values for a few properties like this:

; gh api /repos/qmacro/thinking-aloud/actions/runs \
> | jq -r '.workflow_runs[] | [.id, .conclusion, .name] | @tsv' \
> | head -5
686610826 skipped Generate Atom Feed
686610824 skipped Tweet new entry
686610823 skipped Render most recent entries
686471644 success Render most recent entries
686157878 success Render most recent entries

There's built-in support for pagination with gh api, with the --paginate switch, which is handy.

Breaking the jq invocation down, we have:

PartDescription
-rTells jq to output "raw" values, rather than JSON structures
.workflow_runs[]Process each of the entries in the workflow_runs array
[.id, .conclusion, .name]Show values for these three properties
@tsvConvert everything into tab separated values

Notice the use of the | symbol too - the output of .workflow_runs[] is piped into the selection of properties, and the output of that is piped further into the call to the builtin @tsv mechanism.

I ended up using this approach, but in a slightly expanded way, using a couple of helper functions:

  • one to make the values for the .created_at property easier to read (for example changing "2021-03-26T09:10:11Z" into "2021-03-26 09:10:11")
  • the other to convert the values for the .conclusion property into simpler and shorter terms
def symbol:
sub("skipped"; "SKIP") |
sub("success"; "GOOD") |
sub("failure"; "FAIL");

def tz:
gsub("[TZ]"; " ");

.workflow_runs[]
| [
(.conclusion | symbol),
(.created_at | tz),
.id,
.event,
.name
]
| @tsv

Presenting the list with fzf

Now all that was required was to present the list of workflow runs in a list, for me to choose which ones to delete. The wonderful fzf came to the rescue here. If you've not heard of fzf, go and read all about the command line fuzzy-finder right now. I've written a couple of posts on this very blog about fzf basics too:

This is how I combined the gh, jq and fzf invocations, inside a selectruns function:

gh api --paginate "/repos/$repo/actions/runs" \
| jq -r -f <(jqscript) \
| fzf --multi

With the --multi switch, fzf allows the selection of more than one item.

Then it was just a case of processing each selected item, and making use of that other API endpoint we saw earlier inside a deleterun function, like this:

local run id result
run=$1
id="$(cut -f 3 <<< "$run")"
gh api -X DELETE "/repos/$repo/actions/runs/$id"
[[ $? = 0 ]] && result="OK!" || result="BAD"
printf "%s\t%s\n" "$result" "$run")

The use of cut was to pick out the id property in the list, as presented to (and selected via) fzf; the list is tab separated (thanks to @tsv) and cut's default delimiter is tab too, which is nice.

The script in action

That's about it - here's the entire script in action:

And you can check out the script, as it was at the time of writing, in my dotfiles repository here: dwr.