From f9608949c10b8a88d8d6d1eac0542a2eaec631c5 Mon Sep 17 00:00:00 2001 From: Jake Willmsen <42048994+jdwillmsen@users.noreply.github.com> Date: Sat, 29 Aug 2026 04:54:16 +0000 Subject: [PATCH 1/2] chore(ci): move CodeQL to a checked-in advanced-setup workflow Default-setup CodeQL writes its Java and Go dependency cache under a content-derived key that never repeats, so every analysis added a 250-300 MiB Actions cache entry and prune-actions-cache.yml ran hourly to keep the pnpm, Gradle and Trivy caches from being evicted. Nothing in the repository controlled that key. Replace default setup with codeql.yml, which mirrors the existing configuration (actions, go, java-kotlin, javascript-typescript; default suite; weekly schedule; code-scanning and code-quality analysis kinds) and leaves dependency-caching off. With no cache writes there is nothing to prune, so the prune workflow is removed. Default setup must be disabled once this merges; until then GitHub rejects CodeQL uploads from the workflow. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016XRddumw4ZNvFqHSd9KSf6 --- .github/workflows/codeql.yml | 79 ++++++++++ .github/workflows/prune-actions-cache.yml | 166 ---------------------- 2 files changed, 79 insertions(+), 166 deletions(-) create mode 100644 .github/workflows/codeql.yml delete mode 100644 .github/workflows/prune-actions-cache.yml diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 00000000..d0eb2201 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,79 @@ +name: CodeQL + +# Checked-in replacement for GitHub's default-setup CodeQL. Default setup +# turns dependency caching on unconditionally and writes each Java/Go +# dependency archive under a content-derived key that never repeats, so every +# analysis added another ~250-300 MiB Actions cache entry and the repository +# needed an hourly prune job to keep pnpm/Gradle/Trivy caches from being +# evicted. Advanced setup leaves dependency-caching off unless asked, which +# removes the write at its source. Languages, query suite, threat model and +# weekly schedule mirror the default-setup configuration this replaces, and +# the analysis category is kept at /language: so alerts keep the same +# analysis origin across the switch. + +on: + push: + branches: [main] + pull_request: + schedule: + - cron: '17 4 * * 1' + workflow_dispatch: + +permissions: {} + +# Superseded PR pushes are cancelled; main and schedule runs queue so every +# default-branch commit still uploads its results. +concurrency: + group: codeql-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + runs-on: ubuntu-latest + # A single default-setup language job peaks around 2.5 minutes; anything + # past this is a hung extractor, not a slow one. + timeout-minutes: 30 + permissions: + contents: read + security-events: write + strategy: + fail-fast: false + matrix: + include: + - language: actions + build-mode: none + - language: go + build-mode: autobuild + # Default setup analyses Java without a build; the extractor + # resolves Gradle dependencies itself, so no JDK or Gradle setup is + # needed here. + - language: java-kotlin + build-mode: none + - language: javascript-typescript + build-mode: none + steps: + - name: Checkout Codebase + uses: actions/checkout@v7.0.1 + # autobuild compiles the go.work modules, so the toolchain must match + # the one the workspace declares rather than whatever the runner ships. + - if: ${{ matrix.language == 'go' }} + name: Setup Go + uses: actions/setup-go@v7.0.0 + with: + go-version-file: go.work + - name: Initialize CodeQL + uses: github/codeql-action/init@v4.37.9 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix['build-mode'] }} + # Both kinds ran as separate default-setup workflows; one job per + # language now produces both. + analysis-kinds: code-scanning,code-quality + # Never turn this on: the content-hash keys it writes are what the + # retired prune workflow existed to clean up. + dependency-caching: false + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v4.37.9 + with: + category: '/language:${{ matrix.language }}' diff --git a/.github/workflows/prune-actions-cache.yml b/.github/workflows/prune-actions-cache.yml deleted file mode 100644 index e5cf8209..00000000 --- a/.github/workflows/prune-actions-cache.yml +++ /dev/null @@ -1,166 +0,0 @@ -name: Prune Actions Cache - -# CodeQL writes its dependency cache under a content-derived key that never -# repeats: the packed archive differs by a few hundred bytes every run, so each -# run adds another ~332 MiB entry instead of replacing one, and the total climbs -# until it passes the cap and GitHub starts LRU-evicting the pnpm, Gradle and -# Trivy caches that this repository actually depends on. -# -# These entries are NOT dead. An exact-key hit is impossible, but CodeQL falls -# back to a restore-keys PREFIX match and picks up the most recent entry sharing -# that prefix, so the accumulated entries are observably read back -- some of -# them days after they were written. Retention must therefore stay >= 1: the -# newest entry per prefix is precisely the one the next run's prefix restore -# will select, and dropping to 0 would cost every Java and Go analysis a full -# dependency re-resolve. Everything behind the newest is what this job reclaims, -# because a prefix restore can never reach past the most recent match. -# -# Nothing in the repo controls that key -- code scanning runs as a dynamic -# default-setup workflow -- so eviction is the only lever available here. - -on: - schedule: - - cron: '25 * * * *' - workflow_dispatch: - inputs: - retain: - description: 'CodeQL entries to keep per ref and key family' - type: number - default: 1 - dry_run: - description: 'Report what would be deleted without deleting it' - type: boolean - default: false - -permissions: - actions: write - pull-requests: read - -concurrency: - group: prune-actions-cache - cancel-in-progress: false - -jobs: - prune: - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - name: Prune stale caches - env: - GH_TOKEN: ${{ github.token }} - REPO: ${{ github.repository }} - RETAIN: ${{ inputs.retain || 1 }} - DRY_RUN: ${{ inputs.dry_run || false }} - run: | - set -euo pipefail - - # Totals come from the listing rather than from actions/cache/usage: - # that endpoint is eventually consistent and was observed still - # reporting the pre-purge figure long after 40 deletes had landed, - # which would make every run report a bogus "after". - list_caches() { - local page=1 resp - : > "$1" - while :; do - resp=$(gh api "repos/$REPO/actions/caches?per_page=100&page=$page") - jq -c '.actions_caches[]' <<<"$resp" >> "$1" - if [ "$(jq '.actions_caches | length' <<<"$resp")" -lt 100 ]; then - break - fi - page=$((page + 1)) - done - } - - list_caches caches.ndjson - before_bytes=$(jq -s 'map(.size_in_bytes) | add // 0' caches.ndjson) - before_count=$(wc -l < caches.ndjson) - - # A closed pull request can never restore its own caches again, so - # every entry on its ref is dead regardless of which tool wrote it. - : > closed.txt - jq -r 'select(.ref | test("^refs/pull/[0-9]+/")) | .ref | capture("^refs/pull/(?[0-9]+)/").n' \ - caches.ndjson | sort -u > pr-numbers.txt - while read -r n; do - [ -n "$n" ] || continue - state=$(gh api "repos/$REPO/pulls/$n" --jq '.state' 2>/dev/null || echo unknown) - if [ "$state" = "closed" ]; then - printf '%s\n' "$n" >> closed.txt - fi - done < pr-numbers.txt - closed_json=$(jq -Rs 'split("\n") | map(select(length > 0))' closed.txt) - - jq -r --argjson closed "$closed_json" ' - select(.ref | test("^refs/pull/[0-9]+/")) - | (.ref | capture("^refs/pull/(?[0-9]+)/").n) as $n - | select($closed | index($n)) - | [.id, .size_in_bytes, "closed-pr", .ref, .key] | @tsv - ' caches.ndjson > candidates.tsv - - # Group by ref plus a key family with the volatile segments -- content - # hashes, commit SHAs and run ids -- normalised away, then keep only - # the newest few per family. Restores match by prefix, so the newest - # entry is the only one with any chance of being useful. - jq -s -r --argjson retain "$RETAIN" ' - map(select(.key | startswith("codeql-"))) - | group_by(.ref + " " + (.key | gsub("-[0-9a-f]{8,}"; "") | gsub("-[0-9]{6,}"; ""))) - | map(sort_by(.created_at) | reverse | .[$retain:]) - | flatten - | .[] | [.id, .size_in_bytes, "codeql-retention", .ref, .key] | @tsv - ' caches.ndjson >> candidates.tsv - - sort -u -k1,1n candidates.tsv > delete.tsv - - deleted=0 - freed=0 - failed=0 - while IFS=$'\t' read -r id size reason ref key; do - [ -n "${id:-}" ] || continue - # Only an exact "false" arms the destructive branch, so a malformed - # or unset value reports instead of deleting. The counters stay on - # the branch that actually performed the work -- a dry run that - # reports deletions it did not make is worse than no rehearsal. - if [ "$DRY_RUN" != "false" ]; then - echo "would delete $id ($size bytes, $reason) $ref $key" - continue - fi - if gh api -X DELETE "repos/$REPO/actions/caches/$id" --silent 2>/dev/null; then - echo "deleted $id ($size bytes, $reason) $ref $key" - freed=$((freed + size)) - deleted=$((deleted + 1)) - else - # A concurrent job can evict an entry between listing and delete. - echo "skipped $id (already gone or not deletable) $ref $key" - failed=$((failed + 1)) - fi - done < delete.tsv - - list_caches after.ndjson - after_bytes=$(jq -s 'map(.size_in_bytes) | add // 0' after.ndjson) - after_count=$(wc -l < after.ndjson) - selected=$(wc -l < delete.tsv) - - { - echo "### Actions cache prune" - echo - echo "| | entries | bytes | GiB |" - echo "|---|---:|---:|---:|" - awk -v c="$before_count" -v b="$before_bytes" 'BEGIN{printf "| before | %d | %d | %.2f |\n", c, b, b/1073741824}' - awk -v c="$after_count" -v b="$after_bytes" 'BEGIN{printf "| after | %d | %d | %.2f |\n", c, b, b/1073741824}' - echo - echo "dry_run=\`$DRY_RUN\` retain=\`$RETAIN\` selected=\`$selected\` deleted=\`$deleted\` skipped=\`$failed\`" - } >> "$GITHUB_STEP_SUMMARY" - - echo "prune complete: selected=$selected deleted=$deleted skipped=$failed freed=$freed bytes" - echo "listed total: $before_bytes -> $after_bytes bytes ($before_count -> $after_count entries)" - - # Alert below the cap rather than at it. GitHub holds total usage under - # the limit on its own, by evicting least-recently-accessed entries as - # new ones are written, so a post-prune total measured against the cap - # is close to unreachable -- and in the window where it could trip, - # GitHub has already evicted the pnpm, Gradle and Trivy caches this job - # exists to protect. Firing while headroom remains is what makes this a - # warning instead of a post-mortem. - if [ "$DRY_RUN" = "false" ] && [ "$after_bytes" -gt 8589934592 ]; then - echo "::error::cache above the 8 GiB alert threshold after pruning ($after_bytes bytes); the prune is not keeping up with the write rate" - exit 1 - fi From dce16fc939793d6133e9c9ff03e7a9b1cb42c6eb Mon Sep 17 00:00:00 2001 From: Jake Willmsen <42048994+jdwillmsen@users.noreply.github.com> Date: Sat, 29 Aug 2026 04:55:57 +0000 Subject: [PATCH 2/2] chore(ci): drop the GitHub-internal analysis-kinds input from codeql.yml The action rejects it in custom workflows. Code quality stays a separate default-setup toggle for the reviewer to decide on. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016XRddumw4ZNvFqHSd9KSf6 --- .github/workflows/codeql.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index d0eb2201..34f3dd55 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -67,9 +67,6 @@ jobs: with: languages: ${{ matrix.language }} build-mode: ${{ matrix['build-mode'] }} - # Both kinds ran as separate default-setup workflows; one job per - # language now produces both. - analysis-kinds: code-scanning,code-quality # Never turn this on: the content-hash keys it writes are what the # retired prune workflow existed to clean up. dependency-caching: false