Skip to content

ci: enforce the CLI's documented pre-commit gate - #307

Merged
dusanstanojeviccs merged 3 commits into
mainfrom
ci/cli-lint-gate
Aug 26, 2026
Merged

ci: enforce the CLI's documented pre-commit gate#307
dusanstanojeviccs merged 3 commits into
mainfrom
ci/cli-lint-gate

Conversation

@FrameAutomata

@FrameAutomata FrameAutomata commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

CLAUDE.md calls cd cli && just check the pre-commit gate. Nothing ran it, and it was red on main: 19 lint issues plus a govulncheck failure.

Lint policy

All 19 were unactionable-error noise rather than defects, so most of this is a policy file. The exclusions are kept deliberately narrow: where the module already checks a function's error somewhere, the unactionable call sites get an explicit _ = (the idiom already used at ~40 sites) instead of a module-wide exclusion, so the linter keeps guarding the sites that matter.

That distinction turned out to be load-bearing. Two broader exclusions I first wrote were silently disarming real guards:

  • fmt.Fprintf/Fprintln covered 9 sites, but only 5 were the stderr diagnostics the rationale described. setup_apply.go:302-327 writes the "Next steps (deployment credentials)" block to stdout — the text telling the user which secret to add where. Unchecked, it can truncate silently while the command exits 0.
  • (*os.File).Close covered the 6 abandonment closes in the atomic-write paths, but being module-wide it also disarmed the three closes whose error decides whether the write reached disk before os.Rename publishes it (config.go:152, state.go:96, envfile.go:112).

Also closed two scope gaps: test/smoke (13 files behind //go:build smoke) was invisible to the linter and had an unchecked write of its own, and golangci-lint's default max-same-issues: 3 was hiding real hits — not what a gate should do.

The staticcheck list is spelled out rather than all minus one, because bare all is wider than the default: it enables ST1003, which wanted to rename IdID across the client's wire structs.

One thing worth recording, because it looks like a bug and isn't. defer enc.Close() in RenderYAML discards an error — the textbook shape of "returns nil while emitting truncated output". It isn't: yaml.v3's Encode flushes each document in full (measured at 0 extra bytes and 0 extra writes at Close for a 32KB document and a multi-document stream). The call site says so now.

Go toolchain

govulncheck failed on 4 stdlib CVEs — net/url, crypto/tls, encoding/asn1, net/http — all fixed in go1.26.6, against go 1.26.2.

Fixed with a toolchain directive, not by raising the go line. pkg/client is documented as importable by other Go programs, and since Go 1.21 the go line is a hard floor propagated to consumers — forcing every importer up over a CVE in the build toolchain is the wrong lever. toolchain go1.26.6 pins what builds the binary and leaves the consumer floor at 1.26.2.

This mattered beyond local tooling: release-cli.yml builds published binaries from this same go-version-file.

CI

Own workflow rather than more jobs in cli.yml, because the triggers genuinely differ — cli.yml runs on backend/** so its contract tests can catch backend drift, but nothing in backend/ can change the result of linting the CLI module. Over the last 300 commits, 42% touched backend/ against 6% touching cli/.

  • A daily schedule + workflow_dispatch. Without it the vulncheck job was structurally unable to do its job: both triggers were path-filtered, and a CVE landing in the database produces neither a push nor a PR, so a new advisory could sit unreported for weeks on a module that changes 6% of the time.
  • Both tools pinned. My first version left govulncheck at @latest reasoning that freshness required it — that was wrong about the mechanism. govulncheck -version prints DB: https://vuln.go.dev; the advisory database is fetched at run time regardless of binary version, so the schedule provides freshness and the pin removes an unreviewed upstream fetch on every PR.
  • golangci-lint-action in its default prebuilt-binary mode, rather than go install. Compiling it from source pulled 320 modules and ~950MB of sources plus build objects on every run (26-32s measured on an 8-core box, slower on a 2-4 vCPU runner), and wrote those artifacts into the cache setup-go saves under a cli/go.sum key.

Verification

  • just check green end to end (lint, 322 tests, vulncheck, skill drift, contract tests), exit 0
  • Both pinned tools installed and run for real at the exact versions CI uses
  • The toolchain directive confirmed to build with 1.26.6 while leaving the module floor at 1.26.2
  • gofmt gating confirmed by reverting the one unformatted file, watching it fail, then pass

Known gap, deliberately not in this PR

backend/go.mod is still go 1.26.2, and release-traceway.yml:167 builds the shipped traceway-runner binaries from it — so those still carry the same 4 stdlib CVEs, two of which (crypto/tls, net/http) are directly on the runner's outbound long-poll path. There is no govulncheck anywhere outside cli/.

Left out because the backend has three build-tag combinations and CGO/DuckDB, so it deserves its own change with its own verification rather than riding along here. Tracked as #308.

🤖 Generated with Claude Code

FrameAutomata and others added 3 commits August 25, 2026 16:29
CLAUDE.md calls `cd cli && just check` the pre-commit gate. Nothing ran it,
and it was red on main: 19 lint issues, plus a govulncheck failure.

## Lint

All 19 were unactionable-error noise rather than defects, so this is a
policy file, not a bug hunt. The exclusions are narrow and each says why:
writes to stderr, cleanup of a temp file already renamed away, and Close on
a handle being abandoned. Two call sites are fixed in code instead, because
the repo already had a convention for them -- `_, _ = w.Write(...)` is what
every other httptest handler in pkg/client does.

The staticcheck list is spelled out rather than `all` minus one: bare `all`
is wider than golangci-lint's default and turns on ST1003, which would
rename Id -> ID across the client's wire structs.

Worth recording for the next person, since it looks like a bug and isn't:
`defer enc.Close()` in RenderYAML discards an error that cannot occur.
yaml.v3's Encode flushes each document in full -- measured at 0 extra bytes
and 0 extra writes on Close for a 32KB document and for a multi-document
stream -- so Close has nothing buffered left to fail on.

## Go toolchain

govulncheck failed on 4 stdlib CVEs (net/url, crypto/tls, encoding/asn1,
net/http), all fixed in go1.26.6. go.mod pinned 1.26.2.

This is not just a local-tooling detail: release-cli.yml builds released
binaries with `go-version-file: cli/go.mod`, so published CLI binaries
carried all four. Both module files move together -- test/contract replaces
../.., so a split pin fails to build.

## CI

Two jobs rather than one, and separate from build-test: a lint nit and a
stdlib CVE are different signals, and neither should mask a test failure.
golangci-lint is pinned so an unrelated PR never goes red on a linter
release; govulncheck is deliberately unpinned, since a red run there is the
signal we are buying.

Verified with the same golangci-lint binary CI installs (go install, v2.13.1,
0 issues) and `just check` green end to end.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review follow-ups to the previous commit.

Triggers were wrong. cli.yml also runs on backend/** so its contract tests
can catch backend drift, but nothing in backend/ can change the result of
linting or scanning the cli module. Over the last 300 commits, 42% touched
backend/ against 6% touching cli/, so the jobs would have paid full cost on
roughly seven times more pushes than can affect them. Own workflow, cli/**
only -- which also matches how cli-contract.yml is already split out.

golangci-lint now comes from golangci-lint-action in its default binary
install mode instead of `go install`. Compiling it from source pulled 320
modules and ~950MB of sources plus build objects on every run, measured at
26-32s on an 8-core box and slower on a 2-4 vCPU runner. That also stops the
tool's build artifacts from being written into the module/build cache that
setup-go saves under a cli/go.sum key, where they were both oversized and
evicted by any unrelated dependency bump.

Dropped the fmt.Fprint errcheck exclusion: every bare fmt.Fprint call in the
module already discards its result explicitly, so the entry matched nothing.
Verified by removing it and re-running -- still 0 issues, while removing any
of the other five surfaces real findings.

Left alone deliberately: the two jobs stay separate rather than becoming a
matrix, since distinct job names are the signal; and RenderYAML keeps
`defer func() { _ = enc.Close() }()` rather than moving to a
(*gopkg.in/yaml.v3.Encoder).Close exclusion, which would put the reasoning in
config, away from the call site it explains.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review follow-ups.

The vulncheck job could not do the job its comment claimed. Both triggers
were path-filtered to cli/**, and a CVE landing in the database produces
neither a push nor a PR -- so on a module that sees 6% of commits, a new
advisory could sit unreported for weeks. Adds a daily schedule and a
workflow_dispatch, and skips lint on the scheduled run since a pinned linter
re-run is only noise.

govulncheck is now pinned like the linter. The "deliberately unpinned" note
was wrong about its own mechanism: govulncheck fetches the advisory database
from vuln.go.dev at run time (its -version output prints the DB URL), so
pinning the binary costs no freshness while removing an unreviewed upstream
fetch that could redden an unrelated PR.

Two errcheck exclusions were doing more than their comments described:

  - fmt.Fprintf/Fprintln covered 9 sites, and only 5 were the stderr
    diagnostics the comment justified. setup_apply.go:302-327 writes the
    "Next steps (deployment credentials)" block to stdout, which told the
    user which secret to add where and could be truncated silently while the
    command exited 0.
  - (*os.File).Close covered the 6 abandonment closes in the atomic-write
    paths, but being module-wide it also disarmed the guard on the three
    closes whose error decides whether the write reached disk before
    os.Rename publishes it (config.go:152, state.go:96, envfile.go:112).

Both are replaced by the explicit `_ =` the module already uses at ~40 sites,
so the linter keeps guarding everything else. Also drops the dead
`fmt.Fprint` entry, which matched nothing.

go.mod now uses a `toolchain` directive rather than raising the `go` line.
pkg/client is documented as importable by other programs, and the `go` line
is a hard floor for consumers -- forcing every importer to go1.26.6 over a
CVE in the *build* toolchain is the wrong lever. `toolchain go1.26.6` pins
what builds the binary and leaves the consumer floor at 1.26.2.

Two scope gaps closed: test/smoke (13 files behind //go:build smoke) was
invisible to the linter and had an unchecked write of its own; and the
default max-same-issues cap of 3 was hiding real hits, which is not what a
gate should do.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
FrameAutomata pushed a commit that referenced this pull request Aug 26, 2026
Cleanup pass over the previous two commits.

Removed three comments citing `.github/workflows/cli-lint.yml`, which does
not exist on main and never has -- it is added by the still-open #307. They
claimed cli/ was already gated (it is not; the CLI's only vulncheck is the
local `just vulncheck`) and described a cron stagger against a schedule that
does not exist. This one is the worst of the set: it disguises a real gap
rather than just being noise.

Dropped the `if [ -n "$TAGS" ]` branch. Verified against this module that
`govulncheck -tags "" ./...` is identical to the untagged form -- 0 called
vulnerabilities, exit 0 -- so the conditional was dead code with two paths,
one of which two of the three matrix legs never took.

Added `cli/**` to the path filters. backend/go.mod carries
`replace github.com/tracewayapp/traceway/cli => ../cli`, so the scan compiles
working-tree CLI source; a cli-only PR could change what is reachable and
never trigger this gate. cli.yml already carries the reciprocal filter.

Added `concurrency` (the repo already uses it in four workflows) and
`timeout-minutes: 15`. govulncheck's advisory-database client has no HTTP
timeout, and its fetch is the first thing it does, so a network hang would
otherwise sit at GitHub's 360-minute default across three legs on an
unattended cron.

Corrected two overclaims. "These three are the only combinations that
compile" is true of the storage axis only -- the orthogonal `oxc` symbolicator
tag also builds and is not scanned. And the toolchain pin does not reach the
Docker images at all: the official golang bases set GOTOOLCHAIN=local, so a
`toolchain` directive is ignored there and the images build with whatever the
floating golang:1.26-* tag ships. That is still >= what CI scans, so the
images are not at risk, but the comment claimed coverage it does not have.

Comments trimmed 42/104 -> 29/93, toward the repo's convention (cli.yml 0/52,
cli-contract.yml 4/41, release-traceway.yml 9/344).

README.md and docs/pages/learn/contributing.mdx still said Go 1.25, below the
module's own floor -- contributing.mdx states it as an install prerequisite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LW8APoZZwVdoHkPeUYGnwW
FrameAutomata pushed a commit that referenced this pull request Aug 26, 2026
CI caught this: both `contract` and `build-test` failed on
`go: updates to go.mod needed; to update it: go mod tidy`.

cli/test/contract requires `github.com/tracewayapp/traceway/backend v0.0.0`
through a local `replace`, so it records the backend's transitive
requirements in its own go.mod. Bumping grpc, x/text, quic-go and the AWS SDK
in the backend left those stale, which is a hard error rather than an
auto-fix in a build.

`go mod tidy` propagates the same versions and nothing else. The `go 1.26.2`
line is unchanged and no `toolchain` directive is added here, so this stays
off the line #307 edits in this file.

Verified: `cd cli/test/contract && go test ./...` passes (ok, 5.5s -- it
boots the backend in SQLite mode and asserts the wire contract), and
`go mod tidy -diff` is clean afterwards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LW8APoZZwVdoHkPeUYGnwW
@dusanstanojeviccs
dusanstanojeviccs merged commit 33849e8 into main Aug 26, 2026
4 checks passed
dusanstanojeviccs pushed a commit that referenced this pull request Aug 26, 2026
* deps: pin backend toolchain to go1.26.6 and bump 5 vulnerable modules

govulncheck against backend/ at the current `go 1.26.2` pin reports 21
reachable vulnerabilities: 16 in the standard library and 5 in required
modules. The shipped artifact is the worst of it -- `./cmd/traceway-runner`
alone has 9 reachable stdlib vulnerabilities, and that binary runs on user
infrastructure with an outbound HTTPS long-poll as its main loop, so
crypto/tls, crypto/x509, net/http and net/textproto are all on its hot path.

Toolchain: `toolchain go1.26.6`, not a raise of the `go` line. Since Go 1.21
the `go` line is a hard floor propagated to consumers, and the backend is
importable as tracewaybackend; forcing every importer up over a CVE in the
build toolchain is the wrong lever. Same reasoning as the CLI module.
go1.26.6 is the highest fixed-in version across all 16 stdlib findings.

setup-go@v5 reads only the `go` line and never sets GOTOOLCHAIN, so it
installs the 1.26.2 floor and the go command then switches to 1.26.6 on
first use in the module. release-traceway.yml builds the runner binaries
through exactly that path.

Modules, which no toolchain bump can fix:

  GO-2026-6061  google.golang.org/grpc              v1.81.1 -> v1.82.1
  GO-2026-5970  golang.org/x/text                   v0.37.0 -> v0.39.0
  GO-2026-5764  aws-sdk-go-v2/service/s3            v1.96.0 -> v1.97.3
                aws-sdk-go-v2/.../eventstream       v1.7.4  -> v1.7.8
  GO-2026-5676  github.com/quic-go/quic-go          v0.54.0 -> v0.59.1
  GO-2025-4233  github.com/quic-go/quic-go          (same bump)

Verified: all three supported build-tag combinations (default dual-SQLite,
telemetry_duckdb with CGO, transactional_pg telemetry_ch) build, test and
scan clean -- govulncheck reports 0 reachable vulnerabilities and exits 0
for each. The one failing package under telemetry_duckdb
(app/controllers, a telemetry-migration dialect error in setup_test.go)
fails identically before this change and is untouched by it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LW8APoZZwVdoHkPeUYGnwW

* ci: add a govulncheck gate for the backend module

Nothing scanned backend/ -- the only govulncheck in the repo was the CLI's,
and backend/ is the module that ships a binary to users.

One job per supported build-tag combination rather than a single default
scan. The tags are not cosmetic: they select which db/ and repositories/
packages compile in, and each backend brings its own driver stack. Measured
on this tree, `go list -deps ./cmd/traceway` returns 0 ClickHouse packages
on the default build and 11 under transactional_pg,telemetry_ch, so a
default-only scan would leave the driver stacks the published Docker images
actually run completely unscanned. The three in the matrix are the only
combinations that compile; app/db/guard_*.go rejects the rest.

A daily schedule plus workflow_dispatch, because both push and pull_request
are path-filtered and a CVE lands in the database without anyone pushing a
commit. Staggered off the CLI workflow's 06:17. govulncheck is pinned:
freshness comes from the schedule, since the advisory database is fetched
at run time regardless of binary version.

No -test flag -- the gate is about what ships, and failing a release on a
test-only dependency would train people to ignore the job.

Scope is stated in the workflow rather than left implied: this scans
linux/amd64 source, while release also cross-compiles the runner for darwin
and windows. Every finding so far is either a stdlib symbol the toolchain
pin resolves for all targets or a platform-independent module version, so a
GOOS axis is a deliberate follow-up rather than an oversight.

Verified green for all three combinations on this tree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LW8APoZZwVdoHkPeUYGnwW

* ci: correct and tighten the backend vulncheck workflow

Cleanup pass over the previous two commits.

Removed three comments citing `.github/workflows/cli-lint.yml`, which does
not exist on main and never has -- it is added by the still-open #307. They
claimed cli/ was already gated (it is not; the CLI's only vulncheck is the
local `just vulncheck`) and described a cron stagger against a schedule that
does not exist. This one is the worst of the set: it disguises a real gap
rather than just being noise.

Dropped the `if [ -n "$TAGS" ]` branch. Verified against this module that
`govulncheck -tags "" ./...` is identical to the untagged form -- 0 called
vulnerabilities, exit 0 -- so the conditional was dead code with two paths,
one of which two of the three matrix legs never took.

Added `cli/**` to the path filters. backend/go.mod carries
`replace github.com/tracewayapp/traceway/cli => ../cli`, so the scan compiles
working-tree CLI source; a cli-only PR could change what is reachable and
never trigger this gate. cli.yml already carries the reciprocal filter.

Added `concurrency` (the repo already uses it in four workflows) and
`timeout-minutes: 15`. govulncheck's advisory-database client has no HTTP
timeout, and its fetch is the first thing it does, so a network hang would
otherwise sit at GitHub's 360-minute default across three legs on an
unattended cron.

Corrected two overclaims. "These three are the only combinations that
compile" is true of the storage axis only -- the orthogonal `oxc` symbolicator
tag also builds and is not scanned. And the toolchain pin does not reach the
Docker images at all: the official golang bases set GOTOOLCHAIN=local, so a
`toolchain` directive is ignored there and the images build with whatever the
floating golang:1.26-* tag ships. That is still >= what CI scans, so the
images are not at risk, but the comment claimed coverage it does not have.

Comments trimmed 42/104 -> 29/93, toward the repo's convention (cli.yml 0/52,
cli-contract.yml 4/41, release-traceway.yml 9/344).

README.md and docs/pages/learn/contributing.mdx still said Go 1.25, below the
module's own floor -- contributing.mdx states it as an install prerequisite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LW8APoZZwVdoHkPeUYGnwW

* deps: tidy cli/test/contract after the backend dependency bumps

CI caught this: both `contract` and `build-test` failed on
`go: updates to go.mod needed; to update it: go mod tidy`.

cli/test/contract requires `github.com/tracewayapp/traceway/backend v0.0.0`
through a local `replace`, so it records the backend's transitive
requirements in its own go.mod. Bumping grpc, x/text, quic-go and the AWS SDK
in the backend left those stale, which is a hard error rather than an
auto-fix in a build.

`go mod tidy` propagates the same versions and nothing else. The `go 1.26.2`
line is unchanged and no `toolchain` directive is added here, so this stays
off the line #307 edits in this file.

Verified: `cd cli/test/contract && go test ./...` passes (ok, 5.5s -- it
boots the backend in SQLite mode and asserts the wire contract), and
`go mod tidy -diff` is clean afterwards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LW8APoZZwVdoHkPeUYGnwW

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants