Cleanup Workflow #83
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # 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 api repos/{owner}/{repo}/actions/workflows \ | |
| --jq '.workflows[] | select(.path | startswith(".github")) | { name, node_id, path }' \ | |
| | jq -s '.') | |
| 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" | |
| # Performs the actual run deletion. | |
| # | |
| # This contains a lot of code, but the vast majority is just pretty-printing. | |
| - 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 }} | |
| shell: bash --noprofile --norc -euo pipefail {0} | |
| run: | | |
| if [ -z "$WORKFLOWS" ]; then | |
| echo "No workflows to delete." | |
| exit 0 | |
| fi | |
| # ================================================================================================ | |
| # Utility functions | |
| # ================================================================================================ | |
| # Fetches a page of workflow runs for a given workflow ID and cursor. | |
| # | |
| # We use github's graphql API here which allows us to paginate over workflow runs. | |
| # Unfortunately `gh run list` does not support pagination, so we use the graphql API instead. | |
| gh_workflow_run_page() { | |
| local id="$1" | |
| local cursor="$2" | |
| gh api graphql -F workflowId="$id" -F after="$cursor" \ | |
| -f query='query($workflowId: ID!, $after: String) { | |
| node(id: $workflowId) { | |
| ... on Workflow { | |
| runs(first: 100, after: $after) { | |
| pageInfo { hasNextPage endCursor } | |
| nodes { databaseId } | |
| } | |
| } | |
| } | |
| }' | |
| } | |
| # ================================================================================================ | |
| # Print helpers for nice progress and table display | |
| # ================================================================================================ | |
| # Column widths (table includes three spacers for ' | ' between columns) | |
| widths_index=9 | |
| widths_name=30 | |
| widths_count=14 | |
| widths_total=12 | |
| widths_table=$(( $widths_index + 3 + $widths_name + 3 + $widths_count + 3 + $widths_total )) | |
| # Repeats a character a given number of times. | |
| repeat_char() { | |
| local char=$1 | |
| local count=$2 | |
| printf "%0.s$char" $(seq 1 $count) | |
| } | |
| # Prints the given header as `==== <header> ====` to match the table layout. | |
| print_table_header() { | |
| local header="$1" | |
| local header_len=${#header} | |
| local left_pad=$(( ( $widths_table - header_len - 2) / 2 )) | |
| local right_pad=$(( $widths_table - header_len - 2 - left_pad )) | |
| printf " \n%s %s %s\n" $(repeat_char = $left_pad) "$header" $(repeat_char = $right_pad) | |
| } | |
| # Prints |---+---+---+---| with appropriate widths to accomodate the table headers. | |
| print_table_separator() { | |
| printf "%s+%s+%s+%s\n" \ | |
| "$(repeat_char - $((widths_index + 1)))" \ | |
| "$(repeat_char - $((widths_name + 2)))" \ | |
| "$(repeat_char - $((widths_count + 2)))" \ | |
| "$(repeat_char - $((widths_total + 1)))" | |
| } | |
| # Prints a row of the table (index, workflow name, workflow count, global total) | |
| print_table_row() { | |
| local index=$1 | |
| local name=$2 | |
| local count=$3 | |
| local total=$4 | |
| printf "%*s | %-*s | %*s | %*s\n" \ | |
| "$widths_index" "$index" \ | |
| "$widths_name" "$name" \ | |
| "$widths_count" "$count" \ | |
| "$widths_total" "$total" | |
| } | |
| # Alias for print_table_row() with empty index and total columns. | |
| print_summary_row() { | |
| local name=$1 | |
| local count=$2 | |
| print_table_row "" "$name" "$count" "" | |
| } | |
| # ================================================================================================ | |
| # Print progress table header | |
| # ================================================================================================ | |
| print_table_header "Workflow Cleanup Progress" | |
| print_table_row "Index" "Workflow" "Workflow Count" "Global Total" | |
| print_table_separator | |
| # ================================================================================================ | |
| # Core workflow loop, iterate over workflows | |
| # ================================================================================================ | |
| n_workflows=$(echo "$WORKFLOWS" | jq -r '. | length') | |
| total=0 | |
| summary=() | |
| index=0 | |
| mapfile -t WF_ARRAY < <(echo "$WORKFLOWS" | jq -c '.[]') | |
| for wf in "${WF_ARRAY[@]}"; do | |
| index=$((index + 1)) | |
| name=$(echo "$wf" | jq -r '.name') | |
| count=0 | |
| id=$(echo "$wf" | jq -r '.node_id') | |
| # Safety checks | |
| if [ -z "$name" ]; then | |
| echo "::error title=Workflow name empty::Resolved workflow name is empty at index $index" | |
| exit 1 | |
| fi | |
| if [ -z "$id" ]; then | |
| echo "::error title=Workflow ID missing::Workflow '$name' has no ID" | |
| exit 1 | |
| fi | |
| cursor="" | |
| # Paginate over workflow runs | |
| while true; do | |
| response=$(gh_workflow_run_page "$id" "$cursor") | |
| run_ids=$(echo "$response" | jq -r '.data.node.runs.nodes[].databaseId') | |
| has_next=$(echo "$response" | jq -r '.data.node.runs.pageInfo.hasNextPage') | |
| cursor=$(echo "$response" | jq -r '.data.node.runs.pageInfo.endCursor') | |
| [ -z "$run_ids" ] && break | |
| deleted=$(echo "$run_ids" | wc -l | tr -d ' ') | |
| count=$((count + deleted)) | |
| total=$((total + deleted)) | |
| # Print progress | |
| print_table_row "[$index/$n_workflows]" "$name" "$count" "$total" | |
| if [ "$MODE" = "execute" ]; then | |
| for run_id in $run_ids; do | |
| gh run delete "$run_id" >/dev/null | |
| done | |
| fi | |
| [ "$has_next" != "true" ] && break | |
| done | |
| summary+=("$name|$count") | |
| done | |
| # ================================================================================================ | |
| # Print a summary table | |
| # ================================================================================================ | |
| print_table_header "Workflow Cleanup Summary" | |
| print_summary_row "Workflow" "Runs" | |
| print_table_separator | |
| for entry in "${summary[@]}"; do | |
| wf="${entry%%|*}" | |
| count="${entry##*|}" | |
| print_summary_row "$wf" "$count" | |
| done | |
| # ================================================================================================ | |
| # Print totals as a footer | |
| # ================================================================================================ | |
| print_table_separator | |
| print_summary_row "TOTAL" "$total" | |
| if [ "$MODE" != "execute" ]; then | |
| echo "Dry run complete. No runs were deleted." | |
| else | |
| echo "Cleanup complete." | |
| fi |