From db13d8fcd2396be9624f9f0a53765496979c6342 Mon Sep 17 00:00:00 2001 From: Jaro-c <75870284+Jaro-c@users.noreply.github.com> Date: Mon, 27 Jul 2026 22:20:32 -0500 Subject: [PATCH 1/2] feat(go-ci): add an optional per-package coverage floor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An aggregate threshold on a module whose packages differ in risk rewards covering the easy ones. authcore ran at 91% against a gate of 90 while its OIDC client sat at 87.7% and the package that persists its Ed25519 keys at 85.7% — the two with the most attack surface were the two thinnest, paid for by a fully covered clock helper. The new input asserts the floor holds in every package rather than on average. It defaults to 0, so no existing caller changes behaviour until it opts in. Measured before assuming: comparing isolated profiles against -coverpkg=./... moves those packages by +0.5 and +0.0, so the per-package numbers are honest rather than an artefact of helpers being exercised from a sibling package. Without that the gate would be enforcing noise. LC_ALL is pinned because awk formats decimals per locale, and a comma separator would truncate the comparison at the boundary. Signed-off-by: Jaro-c <75870284+Jaro-c@users.noreply.github.com> --- .github/workflows/go-ci.yml | 47 ++++++++++++++++++++++++++++++++++++- docs/reusables/README.md | 2 +- docs/reusables/go-ci.md | 3 ++- 3 files changed, 49 insertions(+), 3 deletions(-) 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/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. | --- From 7baf76a728d30218bf733d30b9c1de7240c0498f Mon Sep 17 00:00:00 2001 From: Jaro-c <75870284+Jaro-c@users.noreply.github.com> Date: Mon, 27 Jul 2026 22:20:32 -0500 Subject: [PATCH 2/2] feat(go-fuzz): keep the corpus between runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Short fuzz runs are only worth repeating if they add up, and these did not. setup-go restores GOCACHE — where Go keeps the fuzzing corpus — but on a cache hit it does not save it again, so everything a run discovered died with the runner and the next week re-explored the same ground from the seed corpus. Its key derives from go.sum as well, which a dependency bump changes, so even the accidental persistence reset every few days. Measured on authcore before changing anything: a cold 60s run of FuzzParseJWK finds 219 new interesting inputs and 300s finds 293, so a single run saturates well before its budget. The budget is not the constraint — starting from zero every week is. The corpus now has a cache key of its own, per target, with the run id in it so it never hits and is therefore always saved; restore-keys pulls the most recent one back. The path comes from `go env GOCACHE` rather than an assumed default. Signed-off-by: Jaro-c <75870284+Jaro-c@users.noreply.github.com> --- .github/workflows/go-fuzz.yml | 31 ++++++++++++++++++++++++++++--- docs/reusables/go-fuzz.md | 2 +- 2 files changed, 29 insertions(+), 4 deletions(-) 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/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