Skip to content

Cleanup Workflow

Cleanup Workflow #41

# Manual workflow to cleanup deleted workflows runs.
#
# Github keeps workflows runs around even if the workflow is deleted.
# This has the side effect that these still display in the UI which gets cluttered.
# Once the runs of a workflow are deleted, they also get removed from the UI.
name: Cleanup Workflow
on:
workflow_dispatch:
inputs:
mode:
description: "Choose 'dry run' to preview or 'execute' to delete runs"
required: true
default: "dry run"
type: choice
options:
- "dry run"
- "execute"
jobs:
cleanup:
name: Cleanup deleted workflows
runs-on: ubuntu-latest
permissions:
actions: write # required for deleting workflow runs
contents: read
steps:
- name: Checkout repo
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Workflows on main
id: main
run: |
git fetch origin main
WORKFLOWS=$(git ls-tree -r origin/main --name-only | grep '^.github/workflows/')
printf "%s\n" $WORKFLOWS
{
echo "workflows<<EOF"
echo "$WORKFLOWS"
echo "EOF"
} >> "$GITHUB_OUTPUT"
- name: Workflows on next
id: next
run: |
git fetch origin next
WORKFLOWS=$(git ls-tree -r origin/next --name-only | grep '^.github/workflows/')
printf "%s\n" $WORKFLOWS
{
echo "workflows<<EOF"
echo "$WORKFLOWS"
echo "EOF"
} >> "$GITHUB_OUTPUT"
- name: Workflows on github
id: github
env:
GH_TOKEN: ${{ github.token }}
run: |
# Note that we filter by `.github` path prefix to ensure we only get locally defined workflows.
#
# Examples of non-local workflows are `dependabot` and `copilot` which have paths:
# - dynamic/dependabot/dependabot-updates
# - dynamic/copilot-pull-request-reviewer/copilot-pull-request-reviewer
WORKFLOWS=$(gh workflow list \
--all \
--json path \
--jq '.[] | select(.path | startswith(".github")) | .path' \
)
printf "%s\n" $WORKFLOWS
{
echo "workflows<<EOF"
echo "$WORKFLOWS"
echo "EOF"
} >> "$GITHUB_OUTPUT"
- name: Filter for deleted workflows
id: deleted
run: |
# Union of `main` and `next` workflows.
EXISTING_FILES=$( \
printf "%s\n%s\n" \
"${{ steps.main.outputs.workflows }}" \
"${{ steps.next.outputs.workflows }}" \
)
EXISTING_FILES=$(echo "$EXISTING_FILES" | sort -u)
printf "%s\n" $EXISTING_FILES
# Find deleted workflows as the items in `WORKFLOWS` but not in the union of main and next.
# This assumes that _all_ items in main and next are present in `WORKFLOWS`.
DELETED_FILES=$( \
printf "%s\n%s\n" \
"$EXISTING_FILES" \
"${{ steps.github.outputs.workflows }}" \
)
DELETED_FILES=$(echo "$DELETED_FILES" | sort | uniq -u)
printf "%s\n" $DELETED_FILES
{
echo "workflows<<EOF"
echo "$DELETED_FILES"
echo "EOF"
} >> "$GITHUB_OUTPUT"
- name: Delete runs from deleted workflows
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
MODE: ${{ inputs.mode }}
DELETED_WORKFLOWS: ${{ steps.deleted.outputs.workflows }}
OWNER: ${{ github.repository_owner }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
TOTAL_AFFECTED=0
SUMMARY=()
# Read workflows into an array for indexing
mapfile -t WORKFLOWS_ARRAY <<< "$DELETED_WORKFLOWS"
TOTAL_WORKFLOWS=${#WORKFLOWS_ARRAY[@]}
# Convert workflow paths to workflow names
WORKFLOW_NAMES_ARRAY=()
for wf_path in "${WORKFLOWS_ARRAY[@]}"; do
# Extract 'name' from YAML, fallback to filename
name=$(yq -r '.name // ""' "$wf_path" 2>/dev/null || true)
if [ -z "$name" ]; then
name=$(basename "$wf_path")
fi
WORKFLOW_NAMES_ARRAY+=("$name")
done
# Determine max workflow name length for alignment
MAX_WF_LENGTH=0
for wf in "${WORKFLOW_NAMES_ARRAY[@]}"; do
len=${#wf}
(( len > MAX_WF_LENGTH )) && MAX_WF_LENGTH=$len
done
(( MAX_WF_LENGTH < 30 )) && MAX_WF_LENGTH=30 # minimum width
# Column widths
INDEX_WIDTH=9
WORKFLOW_COUNT_WIDTH=14
GLOBAL_TOTAL_WIDTH=12
# Total table width
TOTAL_WIDTH=$(( INDEX_WIDTH + 3 + MAX_WF_LENGTH + 3 + WORKFLOW_COUNT_WIDTH + 3 + GLOBAL_TOTAL_WIDTH ))
# Function to print dynamic table headers padded with '=' and spaces
print_header() {
local name="$1"
local name_len=${#name}
local pad=$(( (TOTAL_WIDTH - name_len - 2) / 2 ))
local left_pad=$pad
local right_pad=$(( TOTAL_WIDTH - name_len - 2 - left_pad ))
left_str=$(printf '=%0.s' $(seq 1 $left_pad))
right_str=$(printf '=%0.s' $(seq 1 $right_pad))
printf "\n%s %s %s\n" "$left_str" "$name" "$right_str"
}
# === Progress Header ===
print_header "Workflow Cleanup Progress"
printf "%*s | %-*s | %-*s | %-*s\n" \
"$INDEX_WIDTH" "Index" \
"$MAX_WF_LENGTH" "Workflow" \
"$WORKFLOW_COUNT_WIDTH" "Workflow Count" \
"$GLOBAL_TOTAL_WIDTH" "Global Total"
printf "%*s-+-%-*s-+-%-*s-+-%-*s\n" \
"$INDEX_WIDTH" "---------" \
"$MAX_WF_LENGTH" "$(printf '%.0s-' $(seq 1 $MAX_WF_LENGTH))" \
"$WORKFLOW_COUNT_WIDTH" "$(printf '%.0s-' $(seq 1 $WORKFLOW_COUNT_WIDTH))" \
"$GLOBAL_TOTAL_WIDTH" "$(printf '%.0s-' $(seq 1 $GLOBAL_TOTAL_WIDTH))"
# Process each workflow by name
for i in "${!WORKFLOW_NAMES_ARRAY[@]}"; do
WORKFLOW_NAME=${WORKFLOW_NAMES_ARRAY[$i]}
[ -z "$WORKFLOW_NAME" ] && continue
CURRENT_INDEX=$((i + 1))
WORKFLOW_COUNT=0
WORKFLOW_ID=$(gh workflow list --all --json id,name \
--jq ".[] | select(.name==\"$WORKFLOW_NAME\") | .id")
# GraphQL pagination variables
AFTER_CURSOR=null
while true; do
RESPONSE=$(gh api graphql -F workflowId="$WORKFLOW_ID" -F after="$AFTER_CURSOR" \
-f query='query($workflowId: ID!, $after: String) {
node(id: $workflowId) {
... on Workflow {
runs(first: 100, after: $after) {
pageInfo {
hasNextPage
endCursor
}
nodes {
databaseId
}
}
}
}
}')
RUN_IDS=$(echo "$RESPONSE" | jq -r '.data.repository.workflowRuns.nodes[].databaseId')
HAS_NEXT=$(echo "$RESPONSE" | jq -r '.data.repository.workflowRuns.pageInfo.hasNextPage')
AFTER_CURSOR=$(echo "$RESPONSE" | jq -r '.data.repository.workflowRuns.pageInfo.endCursor')
[ -z "$RUN_IDS" ] && break
BATCH_COUNT=$(echo "$RUN_IDS" | wc -l | tr -d ' ')
WORKFLOW_COUNT=$((WORKFLOW_COUNT + BATCH_COUNT))
TOTAL_AFFECTED=$((TOTAL_AFFECTED + BATCH_COUNT))
# Print progress line
printf "%*s | %-*s | %*s | %*s\n" \
"$INDEX_WIDTH" "[$CURRENT_INDEX/$TOTAL_WORKFLOWS]" \
"$MAX_WF_LENGTH" "$WORKFLOW_NAME" \
"$WORKFLOW_COUNT_WIDTH" "$WORKFLOW_COUNT" \
"$GLOBAL_TOTAL_WIDTH" "$TOTAL_AFFECTED"
if [ "$MODE" = "execute" ]; then
for RUN_ID in $RUN_IDS; do
echo | gh run delete "$RUN_ID" >/dev/null
done
fi
[ "$HAS_NEXT" != "true" ] && break
# TEMPORARY break for testing large workflows
if [ "$WORKFLOW_COUNT" -gt 200 ]; then
echo " ⚠️ Temporary break: workflow count exceeded 200, stopping early."
break
fi
done
SUMMARY+=("$WORKFLOW_NAME|$WORKFLOW_COUNT")
done
# === Summary Header ===
print_header "Workflow Cleanup Summary"
printf "%*s | %-*s | %-*s | %-*s\n" \
"$INDEX_WIDTH" "" \
"$MAX_WF_LENGTH" "Workflow" \
"$WORKFLOW_COUNT_WIDTH" "Runs" \
"$GLOBAL_TOTAL_WIDTH" ""
printf "%*s-+-%-*s-+-%-*s-+-%-*s\n" \
"$INDEX_WIDTH" "$(printf '%.0s-' $(seq 1 $INDEX_WIDTH))" \
"$MAX_WF_LENGTH" "$(printf '%.0s-' $(seq 1 $MAX_WF_LENGTH))" \
"$WORKFLOW_COUNT_WIDT