Skip to content

Maintenance · pull_request #7

Maintenance · pull_request

Maintenance · pull_request #7

Workflow file for this run

# 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