| 1 | #!/usr/bin/env bash |
| 2 | # Delete GitHub Actions caches that no future run can use. |
| 3 | # |
| 4 | # Usage: |
| 5 | # prune-actions-caches.sh [--dry-run] --ref <git-ref> [--ref <git-ref> ...] |
| 6 | # prune-actions-caches.sh [--dry-run] --sweep |
| 7 | # |
| 8 | # --ref deletes every cache saved under that exact ref (for example |
| 9 | # refs/pull/123/merge or refs/tags/v1.2.3). |
| 10 | # |
| 11 | # --sweep deletes caches under refs/pull/N/* whose PR is closed, and caches |
| 12 | # under refs/tags/* last used more than a day ago (a finished release run |
| 13 | # never reads them again; the day keeps an in-flight release's own cache). |
| 14 | # Branch caches, including main, are never touched. |
| 15 | # |
| 16 | # A cache is only readable from its own ref and the default branch, so a |
| 17 | # closed PR's or a released tag's entries are dead weight that pushes live |
| 18 | # main entries out once the repo passes its 10 GiB cap. |
| 19 | # |
| 20 | # Needs GH_REPO=owner/name and gh authenticated with actions:write. |
| 21 | # PRUNE_CACHES_GH overrides the gh executable and PRUNE_CACHES_NOW the epoch |
| 22 | # clock (tests only). |
| 23 | set -euo pipefail |
| 24 | |
| 25 | gh_bin="${PRUNE_CACHES_GH:-gh}" |
| 26 | now="${PRUNE_CACHES_NOW:-$(date +%s)}" |
| 27 | tag_min_age_seconds=86400 |
| 28 | dry_run=false |
| 29 | sweep=false |
| 30 | refs=() |
| 31 | |
| 32 | while [[ $# -gt 0 ]]; do |
| 33 | case "$1" in |
| 34 | --dry-run) dry_run=true ;; |
| 35 | --sweep) sweep=true ;; |
| 36 | --ref) |
| 37 | [[ $# -ge 2 ]] || { echo "--ref needs a value" >&2; exit 2; } |
| 38 | refs+=("$2") |
| 39 | shift |
| 40 | ;; |
| 41 | *) echo "unknown argument: $1" >&2; exit 2 ;; |
| 42 | esac |
| 43 | shift |
| 44 | done |
| 45 | |
| 46 | repo="${GH_REPO:-}" |
| 47 | if ! [[ "${repo}" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ ]]; then |
| 48 | echo "GH_REPO must be owner/name, got '${repo}'" >&2 |
| 49 | exit 2 |
| 50 | fi |
| 51 | if [[ "${sweep}" == false && ${#refs[@]} -eq 0 ]]; then |
| 52 | echo "usage: $0 [--dry-run] (--ref <ref>... | --sweep)" >&2 |
| 53 | exit 2 |
| 54 | fi |
| 55 | for ref in ${refs[@]+"${refs[@]}"}; do |
| 56 | # Only PR and tag refs are prunable by name; never a branch. |
| 57 | if ! [[ "${ref}" =~ ^refs/pull/[0-9]+/(merge|head)$ || "${ref}" =~ ^refs/tags/[A-Za-z0-9._-]+$ ]]; then |
| 58 | echo "refusing to prune caches for '${ref}': only refs/pull/N/{merge,head} and refs/tags/<tag> are allowed" >&2 |
| 59 | exit 2 |
| 60 | fi |
| 61 | done |
| 62 | |
| 63 | deleted=0 |
| 64 | bytes=0 |
| 65 | |
| 66 | delete_cache() { |
| 67 | local id="$1" ref="$2" size="$3" |
| 68 | if [[ "${dry_run}" == true ]]; then |
| 69 | echo "would delete cache ${id} (${ref}, ${size} bytes)" |
| 70 | else |
| 71 | "${gh_bin}" api -X DELETE "repos/${repo}/actions/caches/${id}" >/dev/null |
| 72 | echo "deleted cache ${id} (${ref}, ${size} bytes)" |
| 73 | fi |
| 74 | deleted=$((deleted + 1)) |
| 75 | bytes=$((bytes + size)) |
| 76 | } |
| 77 | |
| 78 | # id, ref, size, last-accessed epoch (fractional seconds stripped for jq). |
| 79 | list_caches() { |
| 80 | local query="$1" |
| 81 | "${gh_bin}" api --paginate "repos/${repo}/actions/caches?per_page=100${query}" \ |
| 82 | --jq '.actions_caches[] | [.id, .ref, .size_in_bytes, (.last_accessed_at | sub("\\.[0-9]+"; "") | fromdateiso8601)] | @tsv' |
| 83 | } |
| 84 | |
| 85 | # Each listing is read in full before deleting, so deletes never shift the |
| 86 | # pages still to be fetched, and a failed listing stops the script (set -e). |
| 87 | for ref in ${refs[@]+"${refs[@]}"}; do |
| 88 | listing="$(list_caches "&ref=${ref}")" |
| 89 | while IFS=$'\t' read -r id cache_ref size _; do |
| 90 | [[ -n "${id}" ]] || continue |
| 91 | [[ "${cache_ref}" == "${ref}" ]] || continue |
| 92 | delete_cache "${id}" "${cache_ref}" "${size}" |
| 93 | done <<< "${listing}" |
| 94 | done |
| 95 | |
| 96 | if [[ "${sweep}" == true ]]; then |
| 97 | # "N=state" lines; bash 3.2 (macOS) has no associative arrays. |
| 98 | pr_states="" |
| 99 | listing="$(list_caches "")" |
| 100 | while IFS=$'\t' read -r id cache_ref size accessed; do |
| 101 | [[ -n "${id}" ]] || continue |
| 102 | if [[ "${cache_ref}" =~ ^refs/pull/([0-9]+)/(merge|head)$ ]]; then |
| 103 | pr="${BASH_REMATCH[1]}" |
| 104 | state="$(printf '%s' "${pr_states}" | sed -n "s/^${pr}=//p")" |
| 105 | if [[ -z "${state}" ]]; then |
| 106 | state="$("${gh_bin}" api "repos/${repo}/pulls/${pr}" --jq '.state')" |
| 107 | pr_states="${pr_states}${pr}=${state}"$'\n' |
| 108 | fi |
| 109 | if [[ "${state}" == "closed" ]]; then |
| 110 | delete_cache "${id}" "${cache_ref}" "${size}" |
| 111 | fi |
| 112 | elif [[ "${cache_ref}" =~ ^refs/tags/ ]]; then |
| 113 | if (( now - accessed > tag_min_age_seconds )); then |
| 114 | delete_cache "${id}" "${cache_ref}" "${size}" |
| 115 | fi |
| 116 | fi |
| 117 | done <<< "${listing}" |
| 118 | fi |
| 119 | |
| 120 | verb="deleted" |
| 121 | [[ "${dry_run}" == true ]] && verb="would delete" |
| 122 | echo "${verb} ${deleted} caches, ${bytes} bytes" |
| 123 |