diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 00000000..34f3dd55 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,76 @@ +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'] }} + # 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