Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 46 additions & 1 deletion .github/workflows/go-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 "<import/path>/<file>.go:<range> <stmts> <count>",
# 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}%."
31 changes: 28 additions & 3 deletions .github/workflows/go-fuzz.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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 }}
Expand Down
2 changes: 1 addition & 1 deletion docs/reusables/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
3 changes: 2 additions & 1 deletion docs/reusables/go-ci.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |

---

Expand Down
2 changes: 1 addition & 1 deletion docs/reusables/go-fuzz.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
Loading