Skip to content

Cleanup Workflow

Cleanup Workflow #58

# 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: Filter for deleted workflows
id: deleted
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
# Union of `main` and `next` workflows as a JSON array of strings (paths)
EXISTING=$(printf "%s\n%s\n" \
"${{ steps.main.outputs.workflows }}" \
"${{ steps.next.outputs.workflows }}" \
)
EXISTING=$(echo "$EXISTING" | sort -u | jq -R . | jq -s .)
echo "Existing workflows:"
echo "$EXISTING"
# Get workflows currently on GitHub as JSON array of objects
GITHUB=$(gh workflow list --all --json path,name,id)
GITHUB=$(echo "$GITHUB" | jq -c '[.[] | select(.path | startswith(".github/"))]')
echo "Workflows on GitHub:"
echo "$GITHUB"
# Find deleted workflows: present on GitHub but not in main/next
DELETED=$(echo "$GITHUB" | jq -c \
--argjson existing "$EXISTING" '
map(select(.path as $p | $existing | index($p) | not))
'
)
echo "Deleted workflows:"
echo "$DELETED"
# Output to GitHub Actions
{
echo "workflows<<EOF"
echo "$DELETED"
echo "EOF"
} >> "$GITHUB_OUTPUT"
- name: Delete runs from deleted workflows
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
MODE: ${{ inputs.mode }}
WORKFLOWS: ${{ steps.deleted.outputs.workflows }}
OWNER: ${{ github.repository_owner }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
if [ -z "$WORKFLOWS" ]; then
echo "No workflows to delete."
exit 0
fi
TOTAL_AFFECTED=0
SUMMARY=()
CURRENT_INDEX=0
# Column widths
INDEX_WIDTH=9
MAX_WF_LENGTH=30
WORKFLOW_COUNT_WIDTH=14
GLOBAL_TOTAL_WIDTH=12
TOTAL_WIDTH=$(( INDEX_WIDTH + 3 + MAX_WF_LENGTH + 3 + WORKFLOW_COUNT_WIDTH + 3 + GLOBAL_TOTAL_WIDTH ))
# Count total workflows for progress display
TOTAL_WORKFLOWS=$(echo "$WORKFLOWS" | jq -r '. | length')
# 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))"
# Loop over deleted workflows JSON
echo "$WORKFLOWS" | jq -c '.[]' | while read -r wf; do
CURRENT_INDEX=$((CURRENT_INDEX + 1))
WORKFLOW_NAME=$(echo "$wf" | jq -r '.name')
WORKFLOW_ID=$(echo "$wf" | jq -r '.databaseId')
WORKFLOW_PATH=$(echo "$wf" | jq -r '.path')
# Safety checks
if [ -z "$WORKFLOW_NAME" ]; then
echo "::error title=Workflow name empty::Resolved workflow name is empty at index $CURRENT_INDEX"
exit 1
fi
if [ -z "$WORKFLOW_ID" ]; then
echo "::error title=Workflow ID missing::Workflow '$WORKFLOW_NAME' (path: $WORKFLOW_PATH) has no ID"
exit 1
fi
WORKFLOW_COUNT=0
AFTER_CURSOR=null
# Paginate over workflow runs
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.node.runs.nodes[].databaseId')
HAS_NEXT=$(echo "$RESPONSE" | jq -r '.data.node.runs.pageInfo.hasNextPage')
AFTER_CURSOR=$(echo "$RESPONSE" | jq -r '.data.node.runs.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
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
gh run delete "$RUN_ID" >/dev/null
done
fi
[ "$HAS_NEXT" != "true" ] && break
# TEMPORARY safety break
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_WIDTH" "$(printf '%.0s-' $(seq 1 $WORKFLOW_COUNT_WIDTH))" \
"$GLOBAL_TOTAL_WIDTH" "$(printf '%.0s-' $(seq 1 $GLOBAL_TOTAL_WIDTH))"
# Print summary rows
for entry in "${SUMMARY[@]}"; do
wf="${entry%%|*}"
count="${entry##*|}"
printf "%*s | %-*s | %*s | %-*s\n" \
"$INDEX_WIDTH" "" \
"$MAX_WF_LENGTH" "$wf" \
"$WORKFLOW_COUNT_WIDTH" "$count" \
"$GLOBAL_TOTAL_WIDTH" ""
done
# Footer separator
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_WIDTH" "$(printf '%.0s-' $(seq 1 $WORKFLOW_COUNT_WIDTH))" \
"$GLOBAL_TOTAL_WIDTH" "$(printf '%.0s-' $(seq 1 $GLOBAL_TOTAL_WIDTH))"
# TOTAL row
printf "%*s | %-*s | %*s | %-*s\n" \
"$INDEX_WIDTH" "" \
"$MAX_WF_LENGTH" "TOTAL" \
"$WORKFLOW_COUNT_WIDTH" "$TOTAL_AFFECTED" \
"$GLOBAL_TOTAL_WIDTH" ""
if [ "$MODE" = "dry run" ]; then
echo "Dry run complete. No runs were deleted."
else
echo "Cleanup complete."
fi