From 92481185fc5306117c9f94ea72de65178e6578f9 Mon Sep 17 00:00:00 2001 From: bgard68 <30295154+bgard68@users.noreply.github.com> Date: Mon, 24 Aug 2026 09:56:37 -0500 Subject: [PATCH 1/2] ci: prune workflow runs on a schedule instead of by hand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep warm pings four times an hour through waking hours: about 64 runs a day, 450 a week. Left alone it buries everything else. The run list reached 881 entries, 426 of them pings, and a real Deploy failure sat in it unnoticed from 17 to 24 August — the noise was not cosmetic, it hid an outage. Not fixed by slowing the ping. keep-warm.yml already explains that fifteen minutes is the loose end of what beats a twenty-minute idle unload, so widening it trades away the thing it exists for. Keep the cadence; stop keeping the runs. Retention follows what a run is evidence of. A ping proves nothing after the next ping, so Keep warm is kept one day. CI, CodeQL, Gitleaks, Deploy and Dependency Review are the audit trail and are kept thirty days — the window where "when did this start failing?" is still answerable, which is exactly the question that went unanswered for a week. Runs still in progress are never deleted. workflow_dispatch takes a dry_run input that lists what would go without deleting anything. Uses only the gh CLI and github.token, so no third-party action and no allowlist entry. Co-Authored-By: Claude Opus 5 --- .github/workflows/prune-runs.yml | 105 +++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 .github/workflows/prune-runs.yml diff --git a/.github/workflows/prune-runs.yml b/.github/workflows/prune-runs.yml new file mode 100644 index 0000000..9574134 --- /dev/null +++ b/.github/workflows/prune-runs.yml @@ -0,0 +1,105 @@ +name: Prune workflow runs + +# Keep warm pings four times an hour through waking hours, so it produces about +# 64 runs a day and roughly 450 a week. Left alone it buries everything else: +# the run list reached 881 entries, 426 of them pings, and a genuine Deploy +# failure sat in that list unnoticed from 17 to 24 August. +# +# The fix is not a slower ping. keep-warm.yml explains why fifteen minutes is +# already the loose end of what beats a twenty-minute idle unload — widening it +# trades away the thing it exists to do. So keep the cadence and stop keeping +# the runs. +# +# Retention differs by what a run is evidence of: +# +# Keep warm 1 day A ping proves nothing after the next ping. +# everything 30 days CI, CodeQL, Gitleaks, Deploy and Dependency Review are +# the audit trail. Thirty days covers the window where +# "when did this start failing?" is still answerable — +# the question that went unanswered for a week here. +# +# Runs still in progress are never touched, whatever their age. + +on: + schedule: + - cron: "20 5 * * *" # 00:20 Central, outside the keep-warm window + workflow_dispatch: + inputs: + dry_run: + description: "List what would be deleted without deleting it" + type: boolean + default: false + +# Deleting a run is a write to the Actions API. Nothing else is needed: no +# checkout, no contents access. +permissions: + actions: write + +concurrency: + group: prune-runs + cancel-in-progress: false + +jobs: + prune: + name: Prune + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - name: Delete runs past their retention + shell: bash + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + DRY_RUN: ${{ inputs.dry_run }} + run: | + set -euo pipefail + + ping_cutoff=$(date -u -d '1 day ago' +%Y-%m-%dT%H:%M:%SZ) + other_cutoff=$(date -u -d '30 days ago' +%Y-%m-%dT%H:%M:%SZ) + echo "Keep warm older than $ping_cutoff; everything else older than $other_cutoff" + + # status!=in_progress is not a filter the API offers, so completion is + # checked per run below rather than in the query. + gh api "repos/$REPO/actions/runs?per_page=100" --paginate \ + --jq '.workflow_runs[] | [.id, .name, .created_at, .status] | @tsv' > runs.tsv + + echo "Runs found: $(wc -l < runs.tsv)" + + deleted=0 + kept=0 + + while IFS=$'\t' read -r id name created status; do + if [[ "$status" != "completed" ]]; then + kept=$((kept + 1)) + continue + fi + + if [[ "$name" == "Keep warm" ]]; then + cutoff="$ping_cutoff" + else + cutoff="$other_cutoff" + fi + + # ISO-8601 in UTC sorts lexicographically, so a string compare is a + # date compare here and needs no parsing. + if [[ "$created" < "$cutoff" ]]; then + if [[ "$DRY_RUN" == "true" ]]; then + echo "would delete: $created $name" + else + gh api -X DELETE "repos/$REPO/actions/runs/$id" --silent || true + fi + deleted=$((deleted + 1)) + else + kept=$((kept + 1)) + fi + done < runs.tsv + + if [[ "$DRY_RUN" == "true" ]]; then + echo "Dry run: $deleted would be deleted, $kept kept." + else + echo "Deleted $deleted, kept $kept." + fi + + # This workflow's own run is in `kept` — it is still in progress while + # it counts itself, which the status check above deliberately allows. From 269a3d77fed49a45de33c8bb2101876a1c205b4b Mon Sep 17 00:00:00 2001 From: bgard68 <30295154+bgard68@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:02:41 -0500 Subject: [PATCH 2/2] ci: scope the prune grant to its job, and record what it costs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The repository's own rules rejected the first version of prune-runs.yml: GHA002 against permissions: actions: write at workflow scope. The rule was right — a write granted there applies to every job the file ever grows. Moved to the one job that needs it, which is how deploy.yml is already written. That still reports, and correctly: actions: write is a real write, and unlike id-token it reaches the repository. There is no narrower grant for deleting a run, so it goes in the Accepted list with the cost written down rather than implied — the job can delete any run in the repository, including the audit trail it exists to keep readable. It checks out nothing and reads no secret. Accepted is exact: a second finding in the same file still fails, and an entry whose finding stops appearing fails too, so this cannot outlive its reason. 193 tests pass. Co-Authored-By: Claude Opus 5 --- .github/workflows/prune-runs.yml | 13 +++++++++---- .../RepositoryWorkflowsTests.cs | 9 +++++++++ 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/.github/workflows/prune-runs.yml b/.github/workflows/prune-runs.yml index 9574134..3116a8a 100644 --- a/.github/workflows/prune-runs.yml +++ b/.github/workflows/prune-runs.yml @@ -30,10 +30,10 @@ on: type: boolean default: false -# Deleting a run is a write to the Actions API. Nothing else is needed: no -# checkout, no contents access. -permissions: - actions: write +# Nothing at workflow scope: a write granted here would apply to every job the +# file ever grows, and this project's own GHA002 reports exactly that. The one +# job that needs it grants it to itself below. +permissions: {} concurrency: group: prune-runs @@ -45,6 +45,11 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 + # Deleting a run is a write to the Actions API, and it is all this needs: + # no checkout, no contents access. + permissions: + actions: write + steps: - name: Delete runs past their retention shell: bash diff --git a/tests/DevSecOpsSentinel.Infrastructure.Tests/RepositoryWorkflowsTests.cs b/tests/DevSecOpsSentinel.Infrastructure.Tests/RepositoryWorkflowsTests.cs index 9ee9451..4a4aad3 100644 --- a/tests/DevSecOpsSentinel.Infrastructure.Tests/RepositoryWorkflowsTests.cs +++ b/tests/DevSecOpsSentinel.Infrastructure.Tests/RepositoryWorkflowsTests.cs @@ -42,6 +42,15 @@ private static IReadOnlyList AllRules() => [ ("GHA002", 1, "pull-requests: write is the minimum for posting the review summary.") + ], + ["prune-runs.yml"] = + [ + ("GHA002", 1, + "actions: write is the minimum for deleting a workflow run, and there is " + + "no narrower grant. Accepted with the cost stated: it also permits " + + "deleting any run in the repository, so this job can destroy the audit " + + "trail it exists to keep readable. Held to one job in one workflow that " + + "checks out nothing and reads no secret.") ] };