diff --git a/.github/workflows/go-ci.yml b/.github/workflows/go-ci.yml index 35db27a..c8466aa 100644 --- a/.github/workflows/go-ci.yml +++ b/.github/workflows/go-ci.yml @@ -37,6 +37,21 @@ on: required: false type: boolean default: false + cov-min: + description: "Minimum total test coverage percent required, ratchet floor (0 = no gate)." + required: false + type: number + default: 0 + footprint: + description: "Measure the module's marginal binary-size footprint (badge + bloat gate)." + required: false + type: boolean + default: false + footprint-max-mb: + description: "Fail if the stripped binary-footprint delta exceeds this many MB (0 = report only)." + required: false + type: number + default: 0 secrets: CODECOV_TOKEN: required: false @@ -111,6 +126,20 @@ jobs: run: go run github.com/1set/meta/doccov@master . - name: Test run: make ci + # Test-coverage gate (ratchet): covcheck parses the coverage.txt that + # `make ci` wrote and fails if total coverage drops below cov-min. Floor + # leg. Gated on cov-min > 0 so repos opt in with their ratchet floor. + - name: Coverage gate + if: ${{ fromJSON(env.IS_REPORT) && inputs.cov-min > 0 }} + run: go run github.com/1set/meta/covcheck@master -min ${{ inputs.cov-min }} coverage.txt + # Binary footprint (opt-in): measure the marginal binary size the module + # adds over a bare starlet host; fails if the stripped delta exceeds + # footprint-max-mb (0 = report only). Floor leg for comparable numbers. + - name: Binary footprint + if: ${{ fromJSON(env.IS_REPORT) && inputs.footprint }} + run: | + modpath=$(awk '/^module /{print $2; exit}' go.mod) + go run github.com/1set/meta/footprint@master -modpath "$modpath" -dir . -max-mb ${{ inputs.footprint-max-mb }} # govulncheck is informational (continue-on-error): it surfaces dependency # CVEs in the run log without gating merges. Promote to gating once the # ecosystem floor lets known findings be fixed in-place. diff --git a/.github/workflows/selftest.yml b/.github/workflows/selftest.yml index 514177c..75ca9f9 100644 --- a/.github/workflows/selftest.yml +++ b/.github/workflows/selftest.yml @@ -20,10 +20,10 @@ jobs: working-directory: "selftest" secrets: inherit - # Tests the doccov gate itself (meta's own tool, in the root module). The - # doc-coverage step in go-ci.yml fetches this via `go run`, so it must stay green. - doccov: - name: doccov tool + # Tests meta's own gate tools (doccov / covcheck / footprint, in the root + # module). go-ci.yml fetches these via `go run`, so they must stay green. + tools: + name: meta tools runs-on: ubuntu-22.04 permissions: contents: read @@ -32,4 +32,4 @@ jobs: - uses: actions/setup-go@v6 with: go-version: "1.19.x" - - run: go test -race ./doccov/ + - run: go test -race ./... diff --git a/covcheck/README.md b/covcheck/README.md new file mode 100644 index 0000000..2f94c22 --- /dev/null +++ b/covcheck/README.md @@ -0,0 +1,36 @@ +# covcheck + +The test-coverage gate for the Star\* ecosystem. + +`make ci` writes a Go coverage profile (`go test -coverprofile=coverage.txt`). +covcheck parses it, computes the **total statement coverage**, and **fails when +it is below a required minimum** โ€” a reliable, self-contained merge gate that +does not depend on an external coverage service posting a commit status. + +## Usage + +```bash +go run github.com/1set/meta/covcheck@master -min 70 coverage.txt +``` + +| Flag | Default | Meaning | +|------|---------|---------| +| `-min` | `0` | minimum total coverage percent required (`0` = report only) | + +The total is computed the same way as `go tool cover -func` (covered +statements รท total statements), using only the standard library. Exit status is +non-zero when coverage is below `-min` or the profile is missing/empty. + +## In CI + +The reusable workflow `1set/meta/.github/workflows/go-ci.yml` runs this on the +coverage (floor) leg when a repo sets a **ratchet floor**: + +```yaml +with: + cov-min: 78 # this repo's current coverage minus a small margin +``` + +The ecosystem policy is a **ratchet**: each repo's `cov-min` is set just below +its current coverage, so coverage can only hold or improve, never regress. +Codecov upload stays on for the dashboard and the README coverage badge. diff --git a/covcheck/main.go b/covcheck/main.go new file mode 100644 index 0000000..dd31d13 --- /dev/null +++ b/covcheck/main.go @@ -0,0 +1,92 @@ +// Command covcheck is the test-coverage gate for the Star* ecosystem. +// +// `make ci` writes a Go coverage profile (go test -coverprofile=coverage.txt). +// covcheck parses that profile, computes the total statement coverage, and fails +// when it is below a required minimum โ€” a reliable, self-contained merge gate +// that does not depend on an external coverage service posting a commit status. +// +// Usage: +// +// covcheck [flags] [coverage.txt] # file defaults to coverage.txt +// go run github.com/1set/meta/covcheck@ -min 70 coverage.txt +// +// Flags: +// +// -min minimum total coverage percent required (default 0 = report only) +// +// The total is computed the same way as `go tool cover -func` (covered +// statements / total statements), using only the standard library. Exit status +// is non-zero when coverage is below -min or the profile is missing/empty. +package main + +import ( + "bufio" + "flag" + "fmt" + "os" + "strconv" + "strings" +) + +func main() { + min := flag.Float64("min", 0, "minimum total coverage percent required") + flag.Parse() + + path := "coverage.txt" + if flag.NArg() > 0 { + path = flag.Arg(0) + } + + pct, err := totalCoverage(path) + if err != nil { + fmt.Fprintln(os.Stderr, "covcheck: "+err.Error()) + os.Exit(1) + } + + fmt.Printf("covcheck: total coverage %.1f%% (min %.1f%%)\n", pct, *min) + if pct+1e-9 < *min { + fmt.Fprintf(os.Stderr, "covcheck: coverage %.1f%% is below the required minimum %.1f%%\n", pct, *min) + os.Exit(1) + } +} + +// totalCoverage parses a Go coverage profile and returns the total statement +// coverage percent: covered statements / total statements * 100. +func totalCoverage(path string) (float64, error) { + f, err := os.Open(path) + if err != nil { + return 0, fmt.Errorf("cannot read coverage profile %s: %v", path, err) + } + defer f.Close() + + var total, covered int64 + sc := bufio.NewScanner(f) + sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" || strings.HasPrefix(line, "mode:") { + continue + } + // Format: :, + fields := strings.Fields(line) + if len(fields) < 3 { + continue + } + numStmt, err1 := strconv.ParseInt(fields[len(fields)-2], 10, 64) + count, err2 := strconv.ParseInt(fields[len(fields)-1], 10, 64) + if err1 != nil || err2 != nil { + continue + } + total += numStmt + if count > 0 { + covered += numStmt + } + } + if err := sc.Err(); err != nil { + return 0, fmt.Errorf("reading %s: %v", path, err) + } + if total == 0 { + return 0, fmt.Errorf("no statements found in %s (empty or not a coverage profile)", path) + } + return 100 * float64(covered) / float64(total), nil +} diff --git a/covcheck/main_test.go b/covcheck/main_test.go new file mode 100644 index 0000000..f0e6b4c --- /dev/null +++ b/covcheck/main_test.go @@ -0,0 +1,53 @@ +// Tests for the covcheck gate: parsing a coverage profile into a total percent, +// and the missing/empty-profile error paths. +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func writeProfile(t *testing.T, body string) string { + t.Helper() + p := filepath.Join(t.TempDir(), "coverage.txt") + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + return p +} + +func TestTotalCoverage(t *testing.T) { + // 3 + 2 = 5 statements; 3 covered (count>0) => 60.0% + p := writeProfile(t, "mode: atomic\n"+ + "pkg/a.go:1.1,2.10 3 1\n"+ + "pkg/a.go:3.1,4.10 2 0\n") + got, err := totalCoverage(p) + if err != nil { + t.Fatalf("totalCoverage: %v", err) + } + if got < 59.9 || got > 60.1 { + t.Fatalf("coverage = %.2f, want 60.0", got) + } +} + +func TestTotalCoverageAllCovered(t *testing.T) { + p := writeProfile(t, "mode: set\npkg/a.go:1.1,2.10 4 1\n") + got, err := totalCoverage(p) + if err != nil || got < 99.9 { + t.Fatalf("coverage = %.2f, err %v, want 100.0", got, err) + } +} + +func TestTotalCoverageMissingFile(t *testing.T) { + if _, err := totalCoverage(filepath.Join(t.TempDir(), "nope.txt")); err == nil { + t.Fatal("missing profile should error") + } +} + +func TestTotalCoverageEmpty(t *testing.T) { + p := writeProfile(t, "mode: atomic\n") + if _, err := totalCoverage(p); err == nil { + t.Fatal("a profile with no statements should error") + } +} diff --git a/footprint/README.md b/footprint/README.md new file mode 100644 index 0000000..6cbb2a5 --- /dev/null +++ b/footprint/README.md @@ -0,0 +1,43 @@ +# footprint + +The binary-size gate & badge for the Star\* ecosystem. + +footprint measures the **marginal binary size a module adds**. It builds two +tiny programs against the module: a BASELINE that only spins up a starlet host, +and a WITH program that additionally constructs the module +(`NewModule().LoadModule()`), forcing the module and its transitive SDKs to +link. The footprint is `with โˆ’ baseline`, for both the default and the stripped +(`-ldflags="-s -w"`) build. + +## Usage + +```bash +go run github.com/1set/meta/footprint@master -modpath github.com/starpkg/sqlite -dir . +# footprint github.com/starpkg/sqlite +# default : baseline 13.0 MB with 20.2 MB delta +7.2 MB (+55%) +# stripped: baseline 9.0 MB with 13.7 MB delta +4.8 MB (+53%) +``` + +| Flag | Default | Meaning | +|------|---------|---------| +| `-modpath` | (required) | the module's import path, e.g. `github.com/starpkg/sqlite` | +| `-dir` | `.` | the local module directory | +| `-json` | off | emit a shields.io endpoint JSON (for the README badge) | +| `-max-mb` | `0` | fail if the stripped delta exceeds this many MB (`0` = report only) | + +Run it on the repo's go floor for comparable numbers. Exit status is non-zero on +a build failure or when `-max-mb` is exceeded. + +## In CI + +The reusable workflow runs this on the floor leg when a repo opts in: + +```yaml +with: + footprint: true + footprint-max-mb: 7 # current stripped delta + a small headroom +``` + +This both surfaces the footprint and **fails if a dependency silently bloats the +binary past the ceiling**. The `-json` output feeds a shields.io endpoint badge +in the README so the size cost is visible on the front page. diff --git a/footprint/main.go b/footprint/main.go new file mode 100644 index 0000000..c921708 --- /dev/null +++ b/footprint/main.go @@ -0,0 +1,179 @@ +// Command footprint measures the binary-size cost a Star* module adds. +// +// It builds two tiny programs against the module under test: a BASELINE that +// only spins up a starlet host, and a WITH program that additionally constructs +// the module (NewModule().LoadModule()), forcing the module and its transitive +// SDKs to link. The marginal footprint is (with - baseline), reported for both +// the default and the stripped (-ldflags="-s -w") build. +// +// Usage: +// +// footprint -modpath github.com/starpkg/ [-dir .] [flags] +// +// Flags: +// +// -modpath the module's import path (required) +// -dir the local module directory (default ".") +// -json emit a shields.io endpoint JSON (badge) on stdout +// -max-mb fail if the stripped delta exceeds n MB (0 = no gate) +// +// Build environment (Go toolchain, module cache) is inherited; run it on the +// repo's go floor for comparable numbers. Exit status is non-zero on build +// failure or when -max-mb is exceeded. +package main + +import ( + "flag" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +func main() { + modpath := flag.String("modpath", "", "module import path, e.g. github.com/starpkg/sqlite") + dir := flag.String("dir", ".", "local module directory") + jsonOut := flag.Bool("json", false, "emit shields.io endpoint JSON") + maxMB := flag.Float64("max-mb", 0, "fail if the stripped delta exceeds this many MB (0 = no gate)") + flag.Parse() + + if *modpath == "" { + fmt.Fprintln(os.Stderr, "footprint: -modpath is required") + os.Exit(2) + } + absDir, err := filepath.Abs(*dir) + if err != nil { + fmt.Fprintln(os.Stderr, "footprint: "+err.Error()) + os.Exit(1) + } + + r, err := measure(*modpath, absDir) + if err != nil { + fmt.Fprintln(os.Stderr, "footprint: "+err.Error()) + os.Exit(1) + } + + if *jsonOut { + // shields.io endpoint schema + fmt.Printf(`{"schemaVersion":1,"label":"binary footprint","message":"+%.1f MB","color":"blue"}`+"\n", mb(r.strippedDelta)) + return + } + + fmt.Printf("footprint %s\n", *modpath) + fmt.Printf(" default : baseline %.1f MB with %.1f MB delta +%.1f MB (+%.0f%%)\n", + mb(r.defBase), mb(r.defWith), mb(r.defDelta), pct(r.defDelta, r.defBase)) + fmt.Printf(" stripped: baseline %.1f MB with %.1f MB delta +%.1f MB (+%.0f%%)\n", + mb(r.stripBase), mb(r.stripWith), mb(r.strippedDelta), pct(r.strippedDelta, r.stripBase)) + + if *maxMB > 0 && mb(r.strippedDelta) > *maxMB+1e-9 { + fmt.Fprintf(os.Stderr, "footprint: stripped delta +%.1f MB exceeds the ceiling %.1f MB\n", mb(r.strippedDelta), *maxMB) + os.Exit(1) + } +} + +type result struct { + defBase, defWith, defDelta int64 + stripBase, stripWith, strippedDelta int64 +} + +func measure(modpath, absDir string) (*result, error) { + tmp, err := os.MkdirTemp("", "footprint-") + if err != nil { + return nil, err + } + defer os.RemoveAll(tmp) + + baseline := "package main\n\nimport \"github.com/1set/starlet\"\n\nfunc main() { _ = starlet.NewDefault() }\n" + with := "package main\n\nimport (\n\t\"github.com/1set/starlet\"\n\tmod \"" + modpath + "\"\n)\n\nfunc main() {\n\t_ = starlet.NewDefault()\n\t_ = mod.NewModule().LoadModule()\n}\n" + + // Require only the module under test (replaced to the local dir); `go mod + // tidy` then resolves starlet and the rest from the module's own go.mod. + goDirective := readGoDirective(filepath.Join(absDir, "go.mod")) + gomod := "module footprintprobe\n\ngo " + goDirective + "\n\nrequire " + modpath + " v0.0.0\n\nreplace " + modpath + " => " + absDir + "\n" + + mustWrite := func(rel, content string) error { + p := filepath.Join(tmp, rel) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + return err + } + return os.WriteFile(p, []byte(content), 0o644) + } + if err := mustWrite("go.mod", gomod); err != nil { + return nil, err + } + if err := mustWrite("baseline/main.go", baseline); err != nil { + return nil, err + } + if err := mustWrite("with/main.go", with); err != nil { + return nil, err + } + + // Resolve dependencies through the local module's go.mod (the replace). + if out, err := run(tmp, "go", "mod", "tidy"); err != nil { + return nil, fmt.Errorf("go mod tidy: %v\n%s", err, out) + } + + build := func(pkg, out string, strip bool) (int64, error) { + args := []string{"build", "-trimpath", "-o", out} + if strip { + args = append(args, "-ldflags=-s -w") + } + args = append(args, "./"+pkg) + if o, err := run(tmp, "go", args...); err != nil { + return 0, fmt.Errorf("go build %s: %v\n%s", pkg, err, o) + } + fi, err := os.Stat(filepath.Join(tmp, out)) + if err != nil { + return 0, err + } + return fi.Size(), nil + } + + r := &result{} + if r.defBase, err = build("baseline", "b_def", false); err != nil { + return nil, err + } + if r.defWith, err = build("with", "w_def", false); err != nil { + return nil, err + } + if r.stripBase, err = build("baseline", "b_str", true); err != nil { + return nil, err + } + if r.stripWith, err = build("with", "w_str", true); err != nil { + return nil, err + } + r.defDelta = r.defWith - r.defBase + r.strippedDelta = r.stripWith - r.stripBase + return r, nil +} + +// readGoDirective returns the `go` version from a go.mod (default "1.19"). +func readGoDirective(gomodPath string) string { + data, err := os.ReadFile(gomodPath) + if err != nil { + return "1.19" + } + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "go ") { + return strings.TrimSpace(strings.TrimPrefix(line, "go ")) + } + } + return "1.19" +} + +func run(dir, name string, args ...string) (string, error) { + cmd := exec.Command(name, args...) + cmd.Dir = dir + out, err := cmd.CombinedOutput() + return strings.TrimSpace(string(out)), err +} + +func mb(b int64) float64 { return float64(b) / (1024 * 1024) } +func pct(d, base int64) float64 { + if base == 0 { + return 0 + } + return 100 * float64(d) / float64(base) +} diff --git a/footprint/main_test.go b/footprint/main_test.go new file mode 100644 index 0000000..5fa1166 --- /dev/null +++ b/footprint/main_test.go @@ -0,0 +1,36 @@ +// Tests for footprint's pure helpers. The build-based measurement itself needs +// a module + toolchain + network and is exercised in CI, not here. +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func TestReadGoDirective(t *testing.T) { + dir := t.TempDir() + gomod := filepath.Join(dir, "go.mod") + if err := os.WriteFile(gomod, []byte("module example.com/x\n\ngo 1.23\n\nrequire foo v1.0.0\n"), 0o644); err != nil { + t.Fatal(err) + } + if got := readGoDirective(gomod); got != "1.23" { + t.Fatalf("readGoDirective = %q, want 1.23", got) + } + // Missing file falls back to the floor. + if got := readGoDirective(filepath.Join(dir, "nope.mod")); got != "1.19" { + t.Fatalf("readGoDirective(missing) = %q, want 1.19", got) + } +} + +func TestMBAndPct(t *testing.T) { + if mb(1024*1024) != 1.0 { + t.Fatalf("mb(1MiB) = %v, want 1.0", mb(1024*1024)) + } + if got := pct(50, 100); got != 50.0 { + t.Fatalf("pct(50,100) = %v, want 50.0", got) + } + if got := pct(5, 0); got != 0 { + t.Fatalf("pct(_,0) = %v, want 0 (no divide-by-zero)", got) + } +}