diff --git a/.github/workflows/go-ci.yml b/.github/workflows/go-ci.yml index 98f1fad..dfa774f 100644 --- a/.github/workflows/go-ci.yml +++ b/.github/workflows/go-ci.yml @@ -8,7 +8,16 @@ on: type: string default: "." coverage-threshold: - description: Minimum coverage percentage (0 disables the gate) + description: Minimum coverage percentage across the module (0 disables the gate) + type: number + default: 0 + per-package-coverage-threshold: + description: >- + Minimum coverage percentage for every individual package (0 disables + the gate). An aggregate threshold on a module whose packages differ in + risk rewards covering the easy ones — a fully covered helper package + pays for a thin one that parses untrusted input. This asserts the + floor holds everywhere rather than on average. type: number default: 0 @@ -58,3 +67,39 @@ jobs: PCT=$(go tool cover -func=coverage.out | awk '/^total:/ {gsub(/%/, "", $3); print $3}') echo "### Coverage: ${PCT}% (threshold: ${THRESHOLD}%)" >> "$GITHUB_STEP_SUMMARY" awk -v p="$PCT" -v t="$THRESHOLD" 'BEGIN { exit (p >= t) ? 0 : 1 }' + + - name: Per-package coverage gate + if: inputs.per-package-coverage-threshold > 0 + env: + THRESHOLD: ${{ inputs.per-package-coverage-threshold }} + # awk formats decimals per locale; C keeps the separator a dot so the + # comparison below cannot quietly truncate at the boundary. + LC_ALL: C + run: | + set -euo pipefail + # Profile lines are "/.go: ", + # so summing per directory gives the per-package figure the aggregate + # hides. Written once to a file: piping awk into tee would swallow its + # exit status. + awk ' + /^mode:/ { next } + { loc = $1; sub(/\/[^\/]*$/, "", loc); total[loc] += $2; if ($3 > 0) covered[loc] += $2 } + END { for (p in total) printf "%.1f %s\n", (total[p] ? 100 * covered[p] / total[p] : 100), p } + ' coverage.out | sort -n > per-package-coverage.txt + + { + echo "### Per-package coverage (floor: ${THRESHOLD}%)" + echo + echo '```' + cat per-package-coverage.txt + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + + under=$(awk -v t="$THRESHOLD" '$1 < t' per-package-coverage.txt) + if [ -n "$under" ]; then + echo "$under" | while read -r pct pkg; do + echo "::error::${pkg} is at ${pct}%, under the ${THRESHOLD}% floor" + done + exit 1 + fi + echo "Every package clears ${THRESHOLD}%." diff --git a/.github/workflows/go-fuzz.yml b/.github/workflows/go-fuzz.yml index 4a9883f..249cb41 100644 --- a/.github/workflows/go-fuzz.yml +++ b/.github/workflows/go-fuzz.yml @@ -1,9 +1,16 @@ name: Go fuzz (reusable) # Scheduled fuzzing for Go modules: runs `go test -fuzz` against each declared -# target for a short, bounded time to surface parser panics and edge cases on a -# fixed tree. Callers wire the schedule (e.g. weekly cron) and pass the target -# list in their own workflow. This is a smoke run, not a corpus-building soak. +# target for a short, bounded time to surface parser panics and edge cases. +# Callers wire the schedule (e.g. weekly cron) and pass the target list in their +# own workflow. +# +# The corpus persists between runs, which is what makes short runs add up. +# `setup-go` restores GOCACHE — where Go keeps the fuzzing corpus — but on a +# cache hit it does not save it again, so every input a run discovered used to +# die with the runner and the next week re-explored the same ground from the +# seeds. Its key also derives from go.sum, which a dependency bump changes. +# The corpus therefore gets a key of its own below. on: workflow_call: @@ -51,6 +58,24 @@ jobs: go-version-file: ${{ inputs.working-directory }}/go.mod cache-dependency-path: ${{ inputs.working-directory }}/go.sum + # The corpus lives inside GOCACHE, so ask the toolchain where that is + # rather than assuming the default path. + - name: Locate the fuzz corpus + id: corpus + run: echo "path=$(go env GOCACHE)/fuzz" >> "$GITHUB_OUTPUT" + + # The key carries the run id, so it never hits and the cache is always + # saved; restore-keys then pulls the most recent corpus for this target. + # That is the rolling-cache shape, and it is what lets one target's + # discoveries compound across weeks instead of resetting every run. + - name: Restore and extend the corpus + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ${{ steps.corpus.outputs.path }} + key: go-fuzz-corpus-${{ matrix.package }}-${{ matrix.func }}-${{ github.run_id }} + restore-keys: | + go-fuzz-corpus-${{ matrix.package }}-${{ matrix.func }}- + - name: Fuzz ${{ matrix.func }} (${{ matrix.package }}) env: FUNC: ${{ matrix.func }} diff --git a/docs/reusables/README.md b/docs/reusables/README.md index 358ac2e..6112dc4 100644 --- a/docs/reusables/README.md +++ b/docs/reusables/README.md @@ -11,7 +11,7 @@ which blocks every pull request until someone works out why. | [`bun-ci`](bun-ci.md) | 1 | 0 | 3 | | [`dco`](dco.md) | 1 | 0 | 0 | | [`go-audit`](go-audit.md) | 2 | 0 | 4 | -| [`go-ci`](go-ci.md) | 1 | 0 | 2 | +| [`go-ci`](go-ci.md) | 1 | 0 | 3 | | [`go-fuzz`](go-fuzz.md) | 1 | 0 | 3 | | [`installer-contract`](installer-contract.md) | 1 | 0 | 3 | | [`line-limit`](line-limit.md) | 1 | 0 | 4 | diff --git a/docs/reusables/go-ci.md b/docs/reusables/go-ci.md index c28e2d2..56bf650 100644 --- a/docs/reusables/go-ci.md +++ b/docs/reusables/go-ci.md @@ -31,7 +31,8 @@ a required check whose name nothing emits blocks every pull request. | Input | Type | Default | Required | Description | |---|---|---|---|---| | `working-directory` | string | `.` | no | Directory containing the Go module | -| `coverage-threshold` | number | `0` | no | Minimum coverage percentage (0 disables the gate) | +| `coverage-threshold` | number | `0` | no | Minimum coverage percentage across the module (0 disables the gate) | +| `per-package-coverage-threshold` | number | `0` | no | Minimum coverage percentage for every individual package (0 disables the gate). An aggregate threshold on a module whose packages differ in risk rewards covering the easy ones — a fully covered helper package pays for a thin one that parses untrusted input. This asserts the floor holds everywhere rather than on average. | --- diff --git a/docs/reusables/go-fuzz.md b/docs/reusables/go-fuzz.md index 574cd12..bb994ad 100644 --- a/docs/reusables/go-fuzz.md +++ b/docs/reusables/go-fuzz.md @@ -1,6 +1,6 @@ # go-fuzz -Scheduled fuzzing for Go modules: runs `go test -fuzz` against each declared target for a short, bounded time to surface parser panics and edge cases on a fixed tree. Callers wire the schedule (e.g. weekly cron) and pass the target list in their own workflow. This is a smoke run, not a corpus-building soak. +Scheduled fuzzing for Go modules: runs `go test -fuzz` against each declared target for a short, bounded time to surface parser panics and edge cases. Callers wire the schedule (e.g. weekly cron) and pass the target list in their own workflow. The corpus persists between runs, which is what makes short runs add up. `setup-go` restores GOCACHE — where Go keeps the fuzzing corpus — but on a cache hit it does not save it again, so every input a run discovered used to die with the runner and the next week re-explored the same ground from the seeds. Its key also derives from go.sum, which a dependency bump changes. The corpus therefore gets a key of its own below. ## Calling it