Maintenance · pull_request #7
Workflow file for this run
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
| # Generated by velnor-workflow. Regenerate; do not hand-edit. | |
| name: Maintenance | |
| run-name: Maintenance · ${{ github.event_name }} | |
| on: | |
| pull_request: | |
| types: [closed] | |
| schedule: | |
| - cron: '31 3 * * *' | |
| workflow_dispatch: | |
| inputs: | |
| pull_request_number: | |
| description: Optional closed PR number whose merge cache should be removed | |
| required: false | |
| type: string | |
| permissions: | |
| actions: write | |
| contents: read | |
| concurrency: | |
| group: maintenance-${{ github.repository }}-${{ github.event.pull_request.number || inputs.pull_request_number || github.run_id }} | |
| cancel-in-progress: false | |
| jobs: | |
| prune-pr-cache: | |
| name: Prune closed-PR cache | |
| if: ${{ github.event_name == 'pull_request' || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' && inputs.pull_request_number != '') }} | |
| runs-on: ubuntu-24.04 | |
| timeout-minutes: 15 | |
| steps: | |
| - name: Delete merge-ref cache namespace | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| PR_NUMBER: ${{ github.event.pull_request.number || inputs.pull_request_number }} | |
| run: | | |
| set -euo pipefail | |
| ref="refs/pull/$PR_NUMBER/merge" | |
| encoded="$(printf '%s' "$ref" | jq -sRr @uri)" | |
| cache_ids="$(gh api --paginate "repos/$GITHUB_REPOSITORY/actions/caches?ref=$encoded" --jq '.actions_caches[].id')" | |
| if [[ -z "$cache_ids" ]]; then | |
| echo "No merge-ref cache entries found for $ref" | |
| exit 0 | |
| fi | |
| failed=0 | |
| while IFS= read -r id; do | |
| [[ -z "$id" ]] && continue | |
| deleted=false | |
| for delay in 1 2 4 8; do | |
| if gh api --method DELETE "repos/$GITHUB_REPOSITORY/actions/caches/$id" >/dev/null 2>&1; then | |
| deleted=true | |
| break | |
| fi | |
| sleep "$delay" | |
| done | |
| if [[ "$deleted" != true ]]; then | |
| failed=$((failed + 1)) | |
| echo "::error::failed to delete cache id $id after retries" >&2 | |
| fi | |
| done <<< "$cache_ids" | |
| if (( failed > 0 )); then | |
| echo "::error::$failed closed-PR cache entries could not be deleted; rerun maintenance" >&2 | |
| exit 1 | |
| fi | |
| cache-budget: | |
| name: Cache retention | |
| if: ${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }} | |
| runs-on: ubuntu-24.04 | |
| timeout-minutes: 10 | |
| permissions: | |
| contents: read | |
| actions: write | |
| pull-requests: read | |
| steps: | |
| - name: Skip while CI producers are running | |
| id: retention-gate | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| set -euo pipefail | |
| for workflow in ci-main.yml nightly.yml; do | |
| if [[ "$(gh run list --workflow "$workflow" --status in_progress --limit 1 --json databaseId --jq 'length')" != "0" ]]; then | |
| echo "skip=true" >> "$GITHUB_OUTPUT" | |
| echo "$workflow is in_progress; skipping cache retention" >> "$GITHUB_STEP_SUMMARY" | |
| exit 0 | |
| fi | |
| done | |
| echo "skip=false" >> "$GITHUB_OUTPUT" | |
| - name: Set up Velnor workflow runtime | |
| if: ${{ steps.retention-gate.outputs.skip != 'true' && runner.environment == 'github-hosted' }} | |
| uses: tailrocks/velnor/.github/actions/setup-velnor-workflow@2678913a8e3db842a2f92e82e2e1119fae7dad35 | |
| with: | |
| rev: 2678913a8e3db842a2f92e82e2e1119fae7dad35 | |
| - name: Set trusted workflow policy revision | |
| if: steps.retention-gate.outputs.skip != 'true' | |
| run: echo "VELNOR_WORKFLOW_POLICY_REVISION=2678913a8e3db842a2f92e82e2e1119fae7dad35" >> "$GITHUB_ENV" | |
| - name: Sweep closed-PR merge-ref caches | |
| if: steps.retention-gate.outputs.skip != 'true' | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| set -euo pipefail | |
| failed=0 | |
| mapfile -t refs < <(gh api --paginate "repos/$GITHUB_REPOSITORY/actions/caches?per_page=100" \ | |
| --jq '.actions_caches[].ref' | grep -E '^refs/pull/[0-9]+/merge$' | sort -u) | |
| if ((${#refs[@]} == 0)); then | |
| echo "No merge-ref cache scopes found" | |
| exit 0 | |
| fi | |
| for ref in "${refs[@]}"; do | |
| pr="${ref#refs/pull/}" | |
| pr="${pr%/merge}" | |
| state="$(gh pr view "$pr" --json state --jq .state 2>/dev/null || echo unknown)" | |
| if [[ "$state" != "CLOSED" ]]; then | |
| continue | |
| fi | |
| encoded="$(printf '%s' "$ref" | jq -sRr @uri)" | |
| mapfile -t cache_ids < <(gh api --paginate "repos/$GITHUB_REPOSITORY/actions/caches?ref=$encoded" \ | |
| --jq '.actions_caches[].id') | |
| if ((${#cache_ids[@]} == 0)); then | |
| continue | |
| fi | |
| echo "Sweeping $ref ($pr): ${#cache_ids[@]} entries" | |
| for id in "${cache_ids[@]}"; do | |
| deleted=false | |
| for delay in 1 2 4 8; do | |
| if gh api --method DELETE "repos/$GITHUB_REPOSITORY/actions/caches/$id" >/dev/null 2>&1; then | |
| deleted=true | |
| break | |
| fi | |
| sleep "$delay" | |
| done | |
| if [[ "$deleted" != true ]]; then | |
| failed=$((failed + 1)) | |
| echo "::error::failed to delete cache id $id for $ref after retries" >&2 | |
| fi | |
| done | |
| done | |
| if (( failed > 0 )); then | |
| echo "::error::$failed closed-PR merge-ref cache entries could not be deleted; rerun maintenance" >&2 | |
| exit 1 | |
| fi | |
| - name: Collect Actions cache account | |
| if: steps.retention-gate.outputs.skip != 'true' | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| set -euo pipefail | |
| mkdir -p "$RUNNER_TEMP/cache-retention" | |
| gh api --paginate "repos/$GITHUB_REPOSITORY/actions/caches?per_page=100" \ | |
| --jq '.actions_caches[] | {id, key, size_in_bytes, created_at, last_accessed_at}' \ | |
| > "$RUNNER_TEMP/cache-retention/entries.jsonl" | |
| jq -s '.' "$RUNNER_TEMP/cache-retention/entries.jsonl" \ | |
| > "$RUNNER_TEMP/cache-retention/entries.json" | |
| velnor-workflow cache-plan --mode=budget --entries "$RUNNER_TEMP/cache-retention/entries.json" \ | |
| > "$RUNNER_TEMP/cache-retention/budget.json" | |
| # `gh api --paginate --jq` runs the filter once per page and | |
| # concatenates the outputs: summing inside the filter prints one | |
| # number per page, and the total silently understates the account. | |
| # Slurp the page stream first, then take one total over every entry. | |
| total="$(jq '.total_held_bytes' "$RUNNER_TEMP/cache-retention/budget.json")" | |
| count="$(jq 'length' "$RUNNER_TEMP/cache-retention/entries.json")" | |
| headroom="$(jq '.headroom_bytes' "$RUNNER_TEMP/cache-retention/budget.json")" | |
| if (( headroom >= 0 && headroom < 536870912 )); then | |
| echo "::warning::Actions cache headroom below 512 MiB ($headroom bytes remaining)" >&2 | |
| fi | |
| jq -n --argjson total "$total" --argjson count "$count" --argjson headroom "$headroom" \ | |
| --slurpfile budget "$RUNNER_TEMP/cache-retention/budget.json" \ | |
| --arg captured_at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ | |
| '{captured_at: $captured_at, cache_count: $count, total_bytes: $total, headroom_bytes: $headroom, classes: $budget[0].classes}' \ | |
| > "$RUNNER_TEMP/cache-retention/summary.json" | |
| { | |
| cat "$RUNNER_TEMP/cache-retention/summary.json" | |
| echo "Per-class totals:" | |
| jq -r '.classes[] | " \(.id): \(.entry_count) entries, \(.held_bytes) bytes (budget \(.budget_bytes))"' \ | |
| "$RUNNER_TEMP/cache-retention/budget.json" | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| - name: Plan retention evictions | |
| if: steps.retention-gate.outputs.skip != 'true' | |
| run: | | |
| set -euo pipefail | |
| # The plan is the generator's own retention policy, executed - never | |
| # a shell copy of it: per-class budgets, bounded generations per | |
| # variant class, and the protected classes (toolchain seeds, Cargo | |
| # source bundles, the Docker seed baseline) reserved before rolling | |
| # compiler snapshots are touched. An access timestamp is not a | |
| # lease. The newest generation of a variant stays out of reach | |
| # inside the producer window; a superseded generation of the same | |
| # variant is eligible, because the newer save is the producer | |
| # signal that the older entry is no longer being written. | |
| velnor-workflow cache-plan \ | |
| --now "$(date -u +%s)" \ | |
| --entries "$RUNNER_TEMP/cache-retention/entries.json" \ | |
| > "$RUNNER_TEMP/cache-retention/plan.json" | |
| if jq -e 'length > 0' "$RUNNER_TEMP/cache-retention/plan.json" > /dev/null; then | |
| { | |
| echo "Retention plan (evict oldest first: bound, class budget, global budget):" | |
| jq -r 'sort_by(.class, .reason) | group_by(.class, .reason)[] | " \(.[0].class) / \(.[0].reason): \(length) entries, \(map(.size_in_bytes) | add) bytes"' \ | |
| "$RUNNER_TEMP/cache-retention/plan.json" | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| else | |
| echo "Retention plan: nothing to evict" >> "$GITHUB_STEP_SUMMARY" | |
| fi | |
| - name: Apply retention evictions | |
| if: steps.retention-gate.outputs.skip != 'true' | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| set -euo pipefail | |
| evicted=0 | |
| freed=0 | |
| failed=0 | |
| # The plan is applied verbatim, in its own order: generations beyond | |
| # their class bound first, then classes over budget, then the global | |
| # sweep - which never touches a protected class. | |
| while IFS=$'\t' read -r class reason id size key; do | |
| if gh api --method DELETE "repos/$GITHUB_REPOSITORY/actions/caches/$id" >/dev/null; then | |
| evicted=$((evicted + 1)) | |
| freed=$((freed + size)) | |
| echo "evicted id=$id class=$class reason=$reason size=$size key=$key" | |
| else | |
| failed=$((failed + 1)) | |
| echo "::warning::failed to evict cache id $id (class $class, key $key)" >&2 | |
| fi | |
| done < <(jq -r '.[] | [.class, .reason, .id, .size_in_bytes, .key] | @tsv' \ | |
| "$RUNNER_TEMP/cache-retention/plan.json") | |
| # Every eviction is recorded under its cache class and its reason, | |
| # so a later cold run can be correlated with the eviction that | |
| # caused it. | |
| { | |
| echo "Evictions by cache class:" | |
| jq -r 'group_by(.class)[] | " \(.[0].class): \(length) evictions, \(map(.size_in_bytes) | add) bytes"' \ | |
| "$RUNNER_TEMP/cache-retention/plan.json" | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| jq -n --argjson evicted "$evicted" --argjson freed "$freed" --argjson failed "$failed" \ | |
| '{evicted_caches: $evicted, failed_evictions: $failed, freed_bytes: $freed}' \ | |
| >> "$GITHUB_STEP_SUMMARY" | |
| # A failed eviction is a loud failure, never a swallowed warning: | |
| # silent DELETE failures leave the account over budget while the | |
| # run reports success. | |
| if (( failed > 0 )); then | |
| echo "::error::$failed retention evictions failed; rerun maintenance" >&2 | |
| exit 1 | |
| fi | |
| - name: Publish retention evidence | |
| if: steps.retention-gate.outputs.skip != 'true' | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| with: | |
| name: cache-retention-${{ github.run_id }} | |
| path: ${{ runner.temp }}/cache-retention | |
| if-no-files-found: error | |
| retention-days: 14 | |
| - name: Enforce cache budget | |
| if: steps.retention-gate.outputs.skip != 'true' | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| set -euo pipefail | |
| # Re-query live state: the enforcement decision must reflect what the | |
| # account actually holds after eviction, not the pre-eviction | |
| # snapshot. The same page-streaming rule as collection applies: the | |
| # sum is taken after slurping every page. | |
| total="$(gh api --paginate "repos/$GITHUB_REPOSITORY/actions/caches?per_page=100" \ | |
| --jq '.actions_caches[].size_in_bytes' | jq -s 'add // 0')" | |
| budget="$(velnor-workflow cache-plan --mode=budget)" | |
| if (( total > budget )); then | |
| echo "::error::Actions cache account exceeds budget: $total > $budget bytes" >&2 | |
| exit 1 | |
| fi |