Skip to content

CI must be a strict superset of the local checks #670

Open

Description

@David-Noble-at-work

Ruling (USER, 2026-08-25): CI is a strict superset of the local checks by default, and a developer can opt into running everything locally — including the cross-platform checks, whose setup can be postponed.

Today neither gate contains the other. Both directions have gaps, and one of them is serious.

CI runs, make check does not

Check
make build-all compile every package under every supported GOOS
make vet-all vet under every GOOS
make lint-all lint under every GOOS
./build/star lint go ./... a different tool from make lint — see below
./build/star lint shell . vs make shell-lint, which runs .github/scripts/shell-lint.sh
make test-race vs make test, which has no race detector
make test-scenario end-to-end, the install path's only coverage

make check runs, CI does not

Check Why it matters
make complexity cyclomatic complexity ceiling of 20 — unenforced in CI
make verify-ldflags unenforced in CI, and see below
make lint bare golangci-lint run — a different invocation from CI's star lint go

The serious one

verify-ldflags exists because unstamped releases shipped for months, unnoticed — a -X flag naming a symbol that does not exist is silently ignored by the linker, and every release before 2026-08-16 went out that way. The check asserts that every build definition in the repository stamps the same package, because agreement between definitions is the only available proof.

No workflow runs it. It fires only when a developer types make check. The gate written in response to a defect that shipped silently is itself only enforced by habit.

complexity is in the same position, with lower stakes.

The lint mismatch, concretely

make lint is golangci-lint run. CI runs ./build/star lint go ./.... They are different invocations and they find different things.

This is not theoretical: on #668, make vet and make test passed locally while CI's star lint go failed on four unused symbols left behind by a refactor. Worse, make check's lint stage cannot run at all on a Go 1.27 toolchain — golangci-lint dies typechecking crypto/internal/randutil in the standard library — so the local gate had been silently degraded to "everything except lint" for anyone on that toolchain, while still reporting itself as make check.

And in the other direction: with those four fixed, the local star lint go reports four staticcheck SA4023 findings in cmd/writ that CI does not (#669).

So "green locally" and "green in CI" are currently two different claims, and a developer cannot tell which one they have.

The shape to build

  1. CI runs everything the local gate runs, plus the cross-platform matrix. complexity and verify-ldflags join a workflow.
  2. The two gates share one definition rather than two lists that drift — CI invoking a make target rather than restating the commands, so a check added locally is automatically in CI.
  3. One lint tool. Either make lint becomes star lint go, or CI stops running star lint go. Two tools reporting different sets is the root of both failures above.
  4. A local opt-in for the full matrix — make check-all, or similar — accepting that the cross-platform pieces need setup and can be postponed.

Item 3 is the one worth doing first: it is small, and it is what actually bit.

Related

Activity

  1. David-Noble-at-work commented on Aug 25, 2026

    @David-Noble-at-work
    ContributorAuthor

    Correction: item 3 was misdiagnosed, and so was the cause of #668

    I filed this saying make lint (bare golangci-lint run) and CI's star lint go are "different invocations that find different things," and that the local one "cannot run at all on a Go 1.27 toolchain."

    Both claims are wrong. Measured just now:

    golangci-lint run          → exit 1, 4 issues (staticcheck, cmd/writ)
    ./build/star lint go ./... → exit 1, 4 issues (staticcheck, cmd/writ)
    

    The two tools agree. The stdlib typecheck failure I built the diagnosis on happened once, early, and I never re-ran it — many make clean cycles later it does not reproduce.

    What actually caused #668's dead code to reach CI

    Not a tool mismatch. I stopped running lint after one failure and did not try again, then treated "lint is unavailable locally" as established and verified with vet + test for the rest of the session — neither of which can see unused code. The gap was in the habit, not the tooling.

    What the real divergence is

    Local reports 8 issues: 4 in mem (now fixed) and 4 in cmd/writ (#669). CI reported only the 4 in mem.

    So the same tools disagree across environments — a toolchain difference, not a tool difference. Likely the golangci-lint version, its staticcheck vintage, or the Go version driving type inference.

    Restated items

    1. complexity and verify-ldflags join CI. Unchanged, and still the most serious gap — verify-ldflags exists because unstamped releases shipped for months, and no workflow runs it.
    2. One definition, not two lists. Unchanged. CI invokes make targets so a check added locally is automatically in CI.
    3. One lint tool → Pin the linter. The tools agree; the environments do not. Pin the golangci-lint version in CI and locally so both report the same set, and decide which vintage is authoritative. Until then "green in CI" is weaker than "green locally" by an unknown margin.
    4. make check-all for the local opt-in. Scope corrected below.

    Scope correction on item 4

    Most of the matrix needs no setup at all. Measured on darwin:

    Check Result
    make build-all works — every package under linux/darwin/windows, 12s
    make vet-all works, 5s
    make lint-all runs, 59s, fails only on the #669 findings

    Cross-compilation is pure Go, so all three static sweeps already run locally. What needs real machines is cross-execution — test, test-race, and test-scenario on linux and windows — which is the "build once, test many" thread in #598.

    So make check-all can land now covering the static matrix, with the execution matrix deferred behind the connection points.

    Also noted: lint-all invokes bare golangci-lint per GOOS while lint now invokes star lint go. If item 3 settles on a pinned linter, both should route through the same entry point.

  2. David-Noble-at-work commented on Aug 26, 2026

    @David-Noble-at-work
    ContributorAuthor

    The divergence, pinpointed (from #669, closed 2026-08-25)

    #669 produced a worked example of the two gates disagreeing, and measurement identifies the cause. It is
    version skew, not tool choice.

    Local CI
    Go 1.27.0 1.26 (ci.yaml:32, :130, :168)
    golangci-lint v2.13.1 (built with go1.27.0) v2.12.2, pinned (ci.yaml:73)

    CI already runs both linters:

    • ./build/star lint go ./... — ci.yaml:89
    • make lint-all — ci.yaml:95, golangci-lint across linux, darwin, and windows

    Local make check runs neither of those: its lint target is a bare golangci-lint run, host GOOS only.

    The gaps run in both directions

    1. CI is weaker on toolchain. v2.12.2 on Go 1.26 missed four SA4023 findings that v2.13.1 on Go 1.27
      reports. Analysis on staticcheck flags a possible typed-nil interface guard in writ — locally, but not in CI #669 confirmed all four were real dead code, not false positives — so CI let genuine
      defects through, and will begin failing on them at the next toolchain bump.
    2. make check is weaker on GOOS. It runs lint (host only) where CI runs lint-all (three platforms).
      A developer can be green locally and red in CI on a platform-conditional finding.

    What that implies for this issue's requirement

    "CI is a strict superset of local checks by default" needs two things, not one:

    • make check must call what CI calls — star lint go plus lint-all, not the bare host-only lint
    • The toolchain must be pinned on both sides. CI pins golangci-lint and Go; local pins neither. A pin on one
      side only guarantees they drift, and drift in the direction observed here means CI is the weaker gate — the
      opposite of the stated requirement.

    The second point is the one this issue did not previously name: pinning CI alone cannot make CI a superset.
    Whichever side runs the newer analyzer finds more, so the pin has to be shared or the comparison is undefined.

  3. David-Noble-at-work commented on Aug 26, 2026

    @David-Noble-at-work
    ContributorAuthor

    Second concrete instance of this gap, worth recording because it cost a red CI run.

    make check runs test, not test-race, and no scenarios. On #697 that meant every green reported locally was green on a strict subset of what CI runs — and the thing it did not run is exactly what failed: pkg/op/claimcheck timed out under -race on darwin-arm64 and windows-amd64 while passing make check cleanly every time.

    Measured just now on this machine, same test:

    Run Platforms swept Time
    make test (no race) 4 6.0s
    make test-race 1 (after the fix) passed within the 120s ceiling
    make test-race 4 (before the fix) timed out at 120s on darwin/windows CI

    A ~25x race multiplier on a type-checking load is not something make check can hint at, because it never builds with -race at all. Any developer following the documented gate would have shipped this.

    Suggests check should either include test-race, or say out loud that it is a subset and name what CI adds.

  4. David-Noble-at-work commented on Aug 26, 2026

    @David-Noble-at-work
    ContributorAuthor

    Related and now filed separately: #702. make check being a subset of CI is one failure mode; the other is that CI itself does not run deterministically — codegen is mtime-gated, so generator validations fire on some legs and not others. Both produce a green that means nothing, from opposite directions.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

Epic:QualityCoverageCode quality, coverage, and the lint gatestaskImplementation work item

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions