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
29 changes: 29 additions & 0 deletions .github/workflows/go-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
10 changes: 5 additions & 5 deletions .github/workflows/selftest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 ./...
36 changes: 36 additions & 0 deletions covcheck/README.md
Original file line number Diff line number Diff line change
@@ -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.
92 changes: 92 additions & 0 deletions covcheck/main.go
Original file line number Diff line number Diff line change
@@ -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@<ref> -min 70 coverage.txt
//
// Flags:
//
// -min <pct> 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: <path>:<startLine.col>,<endLine.col> <numStatements> <count>
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
}
53 changes: 53 additions & 0 deletions covcheck/main_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
43 changes: 43 additions & 0 deletions footprint/README.md
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading