Skip to content

Artifact cleanup

Artifact cleanup #3

name: Artifact cleanup
# Deletes GitHub Actions artifacts older than a threshold so storage doesn't
# accumulate and trip the account-wide quota.
#
# Three triggers:
# - schedule: Monday 06:00 UTC, same slot as branch-protection-sync,
# security, and dependabot for weekly-governance clustering.
# - workflow_dispatch: on-demand cleanup with inputs for threshold +
# dry-run safety.
# - push: not wired — cleanup doesn't need to fire on code changes.
#
# Uses the default GITHUB_TOKEN (built-in `actions: write` scope is
# sufficient for artifact deletion — no PAT needed). The scheduled run
# uses the hard-coded default (7 days, live delete); manual runs default
# to dry-run for safety.
on:
schedule:
- cron: "0 6 * * 1"
workflow_dispatch:
inputs:
days_to_keep:
description: "Delete artifacts older than this many days"
required: true
default: "7"
dry_run:
description: "List only, no deletion (safe to flip to false when ready)"
type: boolean
default: true
permissions:
actions: write # for artifact deletion
contents: read
concurrency:
group: artifact-cleanup
cancel-in-progress: false
jobs:
cleanup:
name: Prune old artifacts
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Compute threshold
id: threshold
env:
DAYS: ${{ inputs.days_to_keep || '7' }}
run: |
days="${DAYS}"
threshold_secs=$(( days * 86400 ))
echo "days=${days}" >> "$GITHUB_OUTPUT"
echo "secs=${threshold_secs}" >> "$GITHUB_OUTPUT"
echo "::notice::Pruning artifacts older than ${days} day(s)."
- name: List candidates
id: list
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
THRESHOLD_SECS: ${{ steps.threshold.outputs.secs }}
run: |
gh api --paginate \
"/repos/${GITHUB_REPOSITORY}/actions/artifacts?per_page=100" \
--jq ".artifacts[]
| select((now - (.created_at | fromdateiso8601)) > ${THRESHOLD_SECS})
| [.id, .created_at,
((.size_in_bytes/1024/1024*100|floor)/100|tostring),
(.workflow_run.name // \"<unknown>\"),
.name]
| @tsv" \
> candidates.tsv
count=$(wc -l < candidates.tsv | tr -d ' ')
echo "count=${count}" >> "$GITHUB_OUTPUT"
echo "::notice::Found ${count} artifact(s) older than ${{ steps.threshold.outputs.days }} day(s)."
- name: Summary (candidates)
if: always()
run: |
{
echo "### Artifact cleanup — dry_run=${{ inputs.dry_run || 'false (scheduled)' }}"
echo ""
echo "**Threshold:** older than ${{ steps.threshold.outputs.days }} day(s)"
echo "**Matched:** ${{ steps.list.outputs.count }} artifact(s)"
echo ""
echo "| id | created_at | size (MB) | workflow | name |"
echo "|---|---|---|---|---|"
awk -F'\t' '{ printf "| %s | %s | %s | %s | %s |\n", $1, $2, $3, $4, $5 }' \
candidates.tsv || true
} >> "$GITHUB_STEP_SUMMARY"
- name: Delete (skipped when dry_run=true)
if: ${{ inputs.dry_run == false || inputs.dry_run == 'false' || github.event_name == 'schedule' }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Individual 404s are expected — an artifact can expire between the
# list call and the delete call. A mixed outcome still passes. Only
# the every-attempt-failed signal escalates (token lost
# `actions: write`, API rate-limit, network flake).
deleted=0
failed=0
while IFS=$'\t' read -r id _rest; do
if [ -n "${id}" ]; then
if gh api -X DELETE "/repos/${GITHUB_REPOSITORY}/actions/artifacts/${id}" > /dev/null 2>&1; then
deleted=$((deleted + 1))
else
echo "::warning::failed to delete artifact ${id}"
failed=$((failed + 1))
fi
fi
done < candidates.tsv
echo "::notice::Deleted ${deleted} artifact(s); ${failed} failure(s)."
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "**Deleted:** ${deleted} artifact(s); ${failed} failure(s)." >> "$GITHUB_STEP_SUMMARY"
if [ "${failed}" -gt 0 ] && [ "${deleted}" -eq 0 ]; then
echo "::error::All ${failed} delete attempts failed. Check token scope (actions: write) or API rate limits."
exit 1
fi