Skip to content

Commit 5909f59

Browse files
ci: install golangci-lint (Tier-2 quality) (#133)
* ci: install golangci-lint (Tier-2 quality) Adds golangci-lint workflow + conservative initial config to surface Go code-quality issues (errcheck, ineffassign, gocyclo, unused, staticcheck, misspell). Runs on PR + push-to-master + weekly schedule. Sibling-checkout pattern matches existing codeql.yml for replace-directive resolution. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ci(golangci-lint): bump action v6 → v8 + migrate config to v2 (Go 1.25) Action v6 resolved to golangci-lint v1.64.8 (built with Go 1.24), which fails to load configs targeting Go 1.25. Action v8 ships golangci-lint v2.x which is Go 1.25-compatible. Config migrated to v2 format: removed gosimple (folded into staticcheck), moved exclude-rules under linters.exclusions, added version: "2" header. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ci(golangci-lint): baseline gocyclo threshold + targeted staticcheck exclusions - gocyclo min-complexity 20 -> 69: ratchet baseline just above the largest pre-existing offender (StackHandler.New, complexity 68) so introducing the linter does not force 33 risky production-handler refactors. Lower over time. - Exclude SA1019 in MinIO provider (local-dev-only; deprecated-API swap is behavior-risky on the credential path). - Exclude QF1001 in resource.go (De Morgan on two SQL-injection identifier guards; inverting security boolean logic mechanically is unsafe). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ci(golangci-lint): fix ineffassign, unused, and safe staticcheck issues ineffassign (2): - auth.go emitAuthLoginAudit: drop ineffectual email clone (never read in bg goroutine) - mongo_test.go: remove dead first token assignment overwritten on next line unused (3): removed genuinely-dead code with no test references: - handlers-pkg readBody, presignOKEnvelope, capNetBindService const staticcheck (28, all behavior-preserving): - QF1002 admin_customers.go: switch{case x==""} -> switch x {case ""} - S1016 email_webhooks.go / export_bvwave_test.go / export_test.go: struct-literal copy -> type conversion - QF1008 internal_backup_refund.go / middleware/auth.go / crypto+razorpaybilling tests: drop embedded-field selectors - S1008 magic_link.go: collapse to return strings.Contains(...) - S1039 isolation_test.go: drop obsolete fmt.Sprint keep-import hack - QF1003 idempotency_fingerprint_test.go: if/else-if -> tagged switch - S1005 deployment_failure_test.go: drop unnecessary blank identifier - QF1001 cli_auth_coverage_test.go: De Morgan (test assertion, not a security guard) - QF1012 auth_final2_test / auth_oauth_coverage_test: Write([]byte(Sprintf)) -> Fprintf - SA5001 admin_promos_audit_residual_test.go: check sqlmock err before defer Close - SA9003 provisioner/client_cov_test.go: empty branch -> _ = br.Allow() - QF1011 run_test.go: omit redundant func() error type (inferred from run) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ci(golangci-lint): fix all 90 errcheck issues + final QF1001 reduction errcheck (90, behavior-preserving): - 71 deferred closers (rows.Close / resp.Body.Close / stream.Close / *.Shutdown across handlers/models/providers/email/main.go/testhelpers): defer X.Close() -> defer func() { _ = X.Close() }() - manual post-loop / scan-error-path rows.Close() in admin_customers.go and admin_promo_codes.go: assigned to _ ('result set fully consumed') - idempotency.go fingerprint-hash f.Close(): assigned to _ (read-only) - stack.go tarball-read f.Close() after io.ReadAll: assigned to _ (in memory) - k8s/client.go extractTarGz write f.Close(): assigned to _ (best-effort, loop continues) - queue/local.go NATS health-check resp.Body.Close(): assigned to _ (StatusCode only) - app_github_connection.go tx.Rollback(): defer func() { _ = tx.Rollback() }() (the prior em-dash //nolint form was not a valid directive) - testhelpers cleanup closures: db.Close / rdb.Close / app.Shutdown assigned to _ staticcheck: cli_auth_coverage_test.go QF1001 rewritten as an explicit isHex bool so staticcheck no longer suggests further De Morgan reduction. golangci-lint run --timeout=5m -> 0 issues. go build ./... + go vet ./... clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d14c2af commit 5909f59

60 files changed

Lines changed: 235 additions & 173 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
name: golangci-lint
2+
3+
on:
4+
push:
5+
branches: [master, main]
6+
pull_request:
7+
branches: [master, main]
8+
schedule:
9+
- cron: "23 6 * * 1"
10+
11+
permissions:
12+
contents: read
13+
pull-requests: read
14+
15+
jobs:
16+
lint:
17+
name: lint
18+
runs-on: ubuntu-latest
19+
timeout-minutes: 10
20+
steps:
21+
- uses: actions/checkout@v4
22+
with:
23+
path: api
24+
# Sibling checkouts (proto/common) for repos with replace directives.
25+
# No-op for repos that do not need them.
26+
- uses: actions/checkout@v4
27+
if: ${{ hashFiles('api/go.mod') != '' }}
28+
with:
29+
repository: InstaNode-dev/common
30+
path: common
31+
continue-on-error: true
32+
- uses: actions/checkout@v4
33+
with:
34+
repository: InstaNode-dev/proto
35+
path: proto
36+
continue-on-error: true
37+
- uses: actions/setup-go@v5
38+
with:
39+
go-version-file: api/go.mod
40+
- uses: golangci/golangci-lint-action@v8
41+
with:
42+
version: latest
43+
working-directory: api
44+
args: --timeout=5m

.golangci.yml

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# golangci-lint v2 config — start conservative, expand once baseline is clean
2+
version: "2"
3+
4+
run:
5+
timeout: 5m
6+
tests: true
7+
8+
linters:
9+
# Default linter set is govet+errcheck+ineffassign+staticcheck+unused.
10+
# We explicitly add misspell + gocyclo on top. gosimple folded into staticcheck in v2.
11+
enable:
12+
- errcheck # checks unchecked errors
13+
- govet # standard vet
14+
- ineffassign # ineffective assignments
15+
- staticcheck # bug detection (subsumes gosimple in v2)
16+
- unused # unused code
17+
- misspell # spelling
18+
- gocyclo # cyclomatic complexity
19+
settings:
20+
gocyclo:
21+
# Baseline threshold for an existing mature codebase. The largest pre-existing
22+
# offender at the time golangci-lint was introduced (StackHandler.New) measured
23+
# cyclomatic complexity 68; this is set just above it so the linter does not force
24+
# 33 risky refactors of production request handlers as a precondition for adoption.
25+
# This is a ratchet baseline: lower it incrementally as functions are decomposed in
26+
# follow-up work, never raise it.
27+
min-complexity: 69
28+
exclusions:
29+
rules:
30+
- path: _test\.go
31+
linters:
32+
- errcheck
33+
- gocyclo
34+
# SA1019 (deprecated madmin.New / SetPolicy) in the MinIO provider. MinIO is a
35+
# local-dev-only object-store backend; the suggested replacements
36+
# (NewWithOptions / AttachPolicy) have different call shapes and semantics, so a
37+
# mechanical swap would risk a behavior change on the credential-issuance path.
38+
# Excluded as a targeted, file-scoped suppression rather than disabling SA1019
39+
# globally; revisit when the madmin dependency is next upgraded.
40+
- path: internal/providers/storage/minio/minio\.go
41+
linters:
42+
- staticcheck
43+
text: "SA1019"
44+
# QF1001 (De Morgan's law) fires on two SQL-injection identifier validators in
45+
# resource.go (the unsafe-identifier and rotatePostgresPassword username guards).
46+
# Mechanically inverting the boolean logic of a security guard is exactly the
47+
# class of change that introduces subtle off-by-one acceptance bugs; the current
48+
# !(...) form is intentional and readable in context. Suppressed by check, not by
49+
# disabling staticcheck.
50+
- path: internal/handlers/resource\.go
51+
linters:
52+
- staticcheck
53+
text: "QF1001"
54+
55+
issues:
56+
max-issues-per-linter: 0
57+
max-same-issues: 0

internal/crypto/coverage_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -342,7 +342,7 @@ func TestSignJWT_AutoStampsIssuedAt(t *testing.T) {
342342
got, err := crypto.VerifyJWT([]byte(coverageJWTSecret), tok)
343343
require.NoError(t, err)
344344
require.NotNil(t, got.IssuedAt)
345-
assert.True(t, !got.IssuedAt.Time.Before(before) && !got.IssuedAt.Time.After(after),
345+
assert.True(t, !got.IssuedAt.Before(before) && !got.IssuedAt.After(after),
346346
"IssuedAt must be auto-stamped to ~now")
347347
}
348348

internal/email/email.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -471,7 +471,7 @@ func (p *brevoProvider) Send(ctx context.Context, to, subject, plainText, htmlBo
471471
)
472472
return fmt.Errorf("email.brevo.do: %w", err)
473473
}
474-
defer resp.Body.Close()
474+
defer func() { _ = resp.Body.Close() }()
475475

476476
// Brevo: 201 Created on success. 400 surfaces sender-not-verified, 401
477477
// is bad api-key, 4xx generally are payload problems. Surface the

internal/handlers/admin_customers.go

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -326,7 +326,7 @@ func (h *AdminCustomersHandler) List(c *fiber.Ctx) error {
326326
return respondError(c, fiber.StatusServiceUnavailable, "db_failed",
327327
"Failed to list customers")
328328
}
329-
defer rows.Close()
329+
defer func() { _ = rows.Close() }()
330330

331331
out := make([]CustomerListItem, 0, limit)
332332
var total int
@@ -481,13 +481,13 @@ func (h *AdminCustomersHandler) Detail(c *fiber.Ctx) error {
481481
var u CustomerDetailUser
482482
var id uuid.UUID
483483
if err := userRows.Scan(&id, &u.Email, &u.Role, &u.CreatedAt); err != nil {
484-
userRows.Close()
484+
_ = userRows.Close() // result set fully consumed; close error irrelevant
485485
return respondError(c, fiber.StatusServiceUnavailable, "db_failed", "Failed to scan user row")
486486
}
487487
u.ID = id.String()
488488
out.Users = append(out.Users, u)
489489
}
490-
userRows.Close()
490+
_ = userRows.Close() // result set fully consumed; close error irrelevant
491491

492492
// Resource summary.
493493
resRows, err := h.db.QueryContext(c.Context(), `
@@ -504,12 +504,12 @@ func (h *AdminCustomersHandler) Detail(c *fiber.Ctx) error {
504504
for resRows.Next() {
505505
var rs CustomerDetailResourceSummary
506506
if err := resRows.Scan(&rs.ResourceType, &rs.Count, &rs.StorageBytes); err != nil {
507-
resRows.Close()
507+
_ = resRows.Close() // result set fully consumed; close error irrelevant
508508
return respondError(c, fiber.StatusServiceUnavailable, "db_failed", "Failed to scan resource row")
509509
}
510510
out.Resources = append(out.Resources, rs)
511511
}
512-
resRows.Close()
512+
_ = resRows.Close() // result set fully consumed; close error irrelevant
513513

514514
// Deployment count.
515515
deployCount, err := models.CountActiveDeploymentsByTeam(c.Context(), h.db, teamID)
@@ -536,7 +536,7 @@ func (h *AdminCustomersHandler) Detail(c *fiber.Ctx) error {
536536
var id uuid.UUID
537537
var meta sql.NullString
538538
if err := auditRows.Scan(&id, &ai.Actor, &ai.Kind, &ai.Summary, &meta, &ai.CreatedAt); err != nil {
539-
auditRows.Close()
539+
_ = auditRows.Close() // result set fully consumed; close error irrelevant
540540
return respondError(c, fiber.StatusServiceUnavailable, "db_failed", "Failed to scan audit row")
541541
}
542542
ai.ID = id.String()
@@ -545,7 +545,7 @@ func (h *AdminCustomersHandler) Detail(c *fiber.Ctx) error {
545545
}
546546
out.RecentAudit = append(out.RecentAudit, ai)
547547
}
548-
auditRows.Close()
548+
_ = auditRows.Close() // result set fully consumed; close error irrelevant
549549

550550
return c.JSON(fiber.Map{
551551
"ok": true,
@@ -755,8 +755,8 @@ func (h *AdminCustomersHandler) cancelOnDemote(c *fiber.Ctx, teamID uuid.UUID, t
755755
SubscriptionID: subID,
756756
}
757757

758-
switch {
759-
case subID == "":
758+
switch subID {
759+
case "":
760760
// No subscription on file. Still emit an audit row so the BI/Loops
761761
// consumer sees the demote transition uniformly — but with
762762
// cancel_attempted=false so the email template knows nothing was

internal/handlers/admin_promos_audit_residual_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,9 @@ func notesAppWithDB(t *testing.T, db *sql.DB, callerEmail string) *fiber.App {
108108
func TestAdminNotes_CreateFailed_Sqlmock(t *testing.T) {
109109
t.Setenv("ADMIN_EMAILS", adminCallerEmail)
110110
db, mock, err := sqlmockNewRegexp(t)
111+
if err != nil {
112+
t.Fatalf("sqlmockNewRegexp: %v", err)
113+
}
111114
defer db.Close()
112115
tid := uuid.New()
113116
mock.ExpectQuery(`SELECT .* FROM teams WHERE id`).WithArgs(tid).WillReturnRows(adminTeamRow(tid, "hobby"))

internal/handlers/audit.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -450,7 +450,7 @@ func lookupMaskedEmails(ctx context.Context, db *sql.DB, events []*models.AuditE
450450
slog.Warn("audit.email_lookup_failed", "error", err)
451451
return out
452452
}
453-
defer rows.Close()
453+
defer func() { _ = rows.Close() }()
454454
for rows.Next() {
455455
var id, email string
456456
if err := rows.Scan(&id, &email); err != nil {

internal/handlers/auth.go

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -171,10 +171,11 @@ func emitAuthLoginAudit(db *sql.DB, teamID, userID uuid.UUID, email, provider, i
171171
// c.Get("User-Agent") results, whose backing bytes live inside the
172172
// fasthttp request Ctx. fiber recycles that Ctx into a pool the instant
173173
// the handler returns, so the background goroutine below MUST read
174-
// heap-owned copies, never aliases into the recycled Ctx. email/provider
175-
// are already heap-owned (DB column / package const) but cloned for
176-
// symmetry; teamID/userID are value types.
177-
email = strings.Clone(email)
174+
// heap-owned copies, never aliases into the recycled Ctx. provider is
175+
// already heap-owned (DB column / package const) but cloned for symmetry;
176+
// teamID/userID are value types. email is accepted for call-site symmetry
177+
// but is not read in the background goroutine below, so it is intentionally
178+
// not cloned (cloning it was an ineffectual assignment).
178179
provider = strings.Clone(provider)
179180
ip = strings.Clone(ip)
180181
userAgent = strings.Clone(userAgent)
@@ -502,7 +503,7 @@ func exchangeGitHubCode(ctx context.Context, clientID, clientSecret, code string
502503
if err != nil {
503504
return nil, fmt.Errorf("github token exchange: %w", err)
504505
}
505-
defer resp.Body.Close()
506+
defer func() { _ = resp.Body.Close() }()
506507

507508
var tokenResp struct {
508509
AccessToken string `json:"access_token"`
@@ -524,7 +525,7 @@ func exchangeGitHubCode(ctx context.Context, clientID, clientSecret, code string
524525
if err != nil {
525526
return nil, fmt.Errorf("github user fetch: %w", err)
526527
}
527-
defer userResp.Body.Close()
528+
defer func() { _ = userResp.Body.Close() }()
528529

529530
var profile struct {
530531
ID int `json:"id"`
@@ -541,7 +542,7 @@ func exchangeGitHubCode(ctx context.Context, clientID, clientSecret, code string
541542
emailReq.Header.Set("Authorization", "Bearer "+tokenResp.AccessToken)
542543
emailResp, err := client.Do(emailReq)
543544
if err == nil {
544-
defer emailResp.Body.Close()
545+
defer func() { _ = emailResp.Body.Close() }()
545546
body, _ := io.ReadAll(emailResp.Body)
546547
var emails []struct {
547548
Email string `json:"email"`
@@ -668,7 +669,7 @@ func verifyGoogleIDToken(ctx context.Context, clientID, idToken string) (*google
668669
if err != nil {
669670
return nil, fmt.Errorf("google token verify: %w", err)
670671
}
671-
defer resp.Body.Close()
672+
defer func() { _ = resp.Body.Close() }()
672673

673674
if resp.StatusCode != 200 {
674675
return nil, fmt.Errorf("google token invalid (status %d)", resp.StatusCode)
@@ -717,7 +718,7 @@ func exchangeGoogleAuthorizationCode(ctx context.Context, clientID, clientSecret
717718
if err != nil {
718719
return "", fmt.Errorf("google token exchange: %w", err)
719720
}
720-
defer resp.Body.Close()
721+
defer func() { _ = resp.Body.Close() }()
721722

722723
var tokenResp struct {
723724
AccessToken string `json:"access_token"`
@@ -748,7 +749,7 @@ func fetchGoogleUserInfoOAuth2V2(ctx context.Context, accessToken string) (*goog
748749
if err != nil {
749750
return nil, fmt.Errorf("google userinfo: %w", err)
750751
}
751-
defer resp.Body.Close()
752+
defer func() { _ = resp.Body.Close() }()
752753

753754
if resp.StatusCode != http.StatusOK {
754755
body, _ := io.ReadAll(resp.Body)

internal/handlers/auth_final2_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ func startEmptyNameGoogleOAuth(t *testing.T, sub, email string) {
146146
mux := http.NewServeMux()
147147
mux.HandleFunc("/g/tokeninfo", func(w http.ResponseWriter, r *http.Request) {
148148
w.Header().Set("Content-Type", "application/json")
149-
_, _ = w.Write([]byte(fmt.Sprintf(`{"sub":%q,"email":%q,"name":"","aud":"g-client"}`, sub, email)))
149+
_, _ = fmt.Fprintf(w, `{"sub":%q,"email":%q,"name":"","aud":"g-client"}`, sub, email)
150150
})
151151
srv := httptest.NewServer(mux)
152152
t.Cleanup(srv.Close)

internal/handlers/auth_oauth_coverage_test.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -85,15 +85,15 @@ func (f *fakeOAuthServer) handler() http.Handler {
8585
if id == "" {
8686
id = "424242"
8787
}
88-
_, _ = w.Write([]byte(fmt.Sprintf(`{"id":%s,"login":"octocat","email":%q}`, id, f.ghEmail)))
88+
_, _ = fmt.Fprintf(w, `{"id":%s,"login":"octocat","email":%q}`, id, f.ghEmail)
8989
})
9090
mux.HandleFunc("/gh/emails", func(w http.ResponseWriter, r *http.Request) {
9191
w.Header().Set("Content-Type", "application/json")
92-
_, _ = w.Write([]byte(fmt.Sprintf(`[{"email":%q,"primary":true,"verified":true}]`, f.ghPrimaryEmail)))
92+
_, _ = fmt.Fprintf(w, `[{"email":%q,"primary":true,"verified":true}]`, f.ghPrimaryEmail)
9393
})
9494
mux.HandleFunc("/g/tokeninfo", func(w http.ResponseWriter, r *http.Request) {
9595
w.Header().Set("Content-Type", "application/json")
96-
_, _ = w.Write([]byte(fmt.Sprintf(`{"sub":%q,"email":%q,"name":"G User","aud":%q}`, f.gSubOr(), f.gEmailOr(), f.gAud)))
96+
_, _ = fmt.Fprintf(w, `{"sub":%q,"email":%q,"name":"G User","aud":%q}`, f.gSubOr(), f.gEmailOr(), f.gAud)
9797
})
9898
mux.HandleFunc("/g/token", func(w http.ResponseWriter, r *http.Request) {
9999
w.Header().Set("Content-Type", "application/json")
@@ -105,7 +105,7 @@ func (f *fakeOAuthServer) handler() http.Handler {
105105
})
106106
mux.HandleFunc("/g/userinfo", func(w http.ResponseWriter, r *http.Request) {
107107
w.Header().Set("Content-Type", "application/json")
108-
_, _ = w.Write([]byte(fmt.Sprintf(`{"id":%q,"email":%q,"name":"G User"}`, f.gSubOr(), f.gEmailOr())))
108+
_, _ = fmt.Fprintf(w, `{"id":%q,"email":%q,"name":"G User"}`, f.gSubOr(), f.gEmailOr())
109109
})
110110
return mux
111111
}

0 commit comments

Comments
 (0)