All skills
wdm0006 avatar

/projects

@2643c9a

Sets up and maintains Go projects with a CI gate that actually gates — module-path correctness, golangci-lint config that matches the installed major version, deterministic gofmt, a pinned toolchain, and meaningful test/lint jobs. Use when creating a Go module, wiring GitHub Actions for Go, debugging a golangci-lint or gofmt CI failure on an unrelated PR, or reviewing a Go CLI that shells out to git/gh.

  • 1 file
  • 9.3 KB
  • Updated 3 months ago
  • GitHub

Use this Skill: https://skilld.dev/gh/wdm0006/python-skills/projects

This session only. Nothing lands on disk.

SKILL.md

≈105 tokens always: the name and description. ≈2.3k when used: this file.

Go Project Setup & CI

Go's CI is deceptively easy to get green and hard to get correct. The traps below all share one failure mode: a job that reports success while proving nothing, or a config that breaks on a PR that never touched it. Each rule here comes from a real green-but-wrong (or red-on-unrelated-PR) gate.

Module path must match the repo URL

go.mod's module line and every internal import must use the path users actually go install/go get. A stale owner or renamed repo compiles and tests locally but breaks go install github.com/<owner>/<repo>@latest for everyone.

module github.com/owner/project   // MUST equal the canonical repo URL

When you rename it, rename every internal import ref too (grep -rl old/path). It's a mechanical string rename; go build/go vet/gofmt stay clean after.

Pin the toolchain — GOTOOLCHAIN=auto makes a version matrix lie

With the default GOTOOLCHAIN=auto, Go silently downloads and switches to the version in go.mod's go directive whenever the installed toolchain is older. So a CI matrix of ['1.21','1.22','1.23'] against go 1.24.2 in go.mod runs 1.24.2 in every cell — the matrix tests nothing it claims to.

Keep them consistent: the go directive, the setup-go version(s), and the matrix must agree.

# ci.yml — matrix versions must be >= the go.mod directive
strategy:
  matrix:
    go: ['1.24', '1.25']
steps:
  - uses: actions/setup-go@v5
    with: { go-version: '${{ matrix.go }}' }

gofmt runs under the setup-go SDK version, not the runtime toolchain. Even if go build/go test auto-upgrade via GOTOOLCHAIN, gofmt uses whatever setup-go installed. Bumping setup-go can change gofmt's output (e.g. newer gofmt realigns single-line declarations), turning a formatting check red on a PR that changed no code. Run gofmt locally under the same version CI installs, commit the reformat once, and pin setup-go so it doesn't drift again.

golangci-lint: the config's version key must match the installed major

golangci-lint's lint job fails at the config verify step — before a single line is linted — when .golangci.yml doesn't match the installed major version. The rule of thumb:

  • v1 (installed by golangci-lint-action@v6): no top-level version: key. Use disable-all / exclude-use-default. v1's schema rejects version as an unknown property.
  • v2 (installed by golangci-lint-action@v8): version: "2" (a quoted string), linters.default: none, enable: [...]. v1-only keys like disable-all fail verify.

A config with version: 2 (numeric) plus v1-only keys fails verify on both majors. Pick a lane and keep the action version and config in lockstep:

# v2 lane
- uses: golangci/golangci-lint-action@v8
# .golangci.yml (v2)
version: "2"
linters:
  default: none
  enable: [govet]
run:
  timeout: 5m   # v2 removed the --timeout CLI flag; set it HERE, not in args:

Do not pass args: --timeout=... to the v2 action — the flag was removed and the job errors. Put the timeout under run.timeout in the config instead.

A test job with no tests is a no-op gate

go test -race ./... exits 0 when there are zero *_test.go files. A repo can advertise a race-detector CI job and have it prove nothing. Green here means "nothing failed," not "behavior is verified."

Add real tests for the pure functions first — parsers, mappers, aggregators, state counters are trivially unit-testable and give the gate teeth. If a test job is the only quality gate, confirm it actually executes assertions, not just that it's green.

Deterministic output: never range a map into serialized output

Ranging a Go map yields keys in randomized order. Building a slice, JSON file, or any committed/compared artifact by ranging a map produces a different byte order every run — churny, unreviewable diffs (especially painful in a repo whose value is clean history). Sort before emitting:

keys := make([]string, 0, len(m))
for k := range m { keys = append(keys, k) }
sort.Strings(keys)
for _, k := range keys { /* emit m[k] in stable order */ }

Dry-run must simulate state transitions, not skip them

A dry-run should suppress external writes, not the in-memory changes used to decide what would be written. Gating a preparatory state transition on !dryRun makes the preview compute from stale state and under-report the real operation:

// BAD — dry-run previews incremental work, while the real rebuild clears state
// first and performs the full operation.
if opts.Rebuild && !opts.DryRun {
    state.ClearProcessed()
    rewriteHistory()
}
work := plan(state)

Split simulation from side effects. Apply the same in-memory transition in both modes, and gate only persistence or destructive external operations:

if opts.Rebuild {
    state.ClearProcessed() // changes planning only; safe in memory
    if !opts.DryRun {
        rewriteHistory()
    }
}

work := plan(state)
if !opts.DryRun {
    execute(work)
    save(state)
}

Test preview fidelity by starting from identical state and comparing the planned items from dry-run with the items attempted by a real run using a recording fake. Also assert that dry-run performed no filesystem, network, or subprocess writes. Do not settle for testing only that it "didn't write": a quiet dry-run that reports the wrong plan is still broken.

Bound text without splitting UTF-8

Go string indexes and slices are byte-based. A limit such as body[:500] can cut through a multi-byte code point, producing invalid UTF-8 that is later replaced, rejected, or corrupted when sent as JSON. This commonly appears when bounding API response bodies, changelog excerpts, or LLM context.

If the limit is a byte budget, retreat to the previous rune boundary:

func truncateUTF8(s string, maxBytes int) string {
    if maxBytes <= 0 {
        return ""
    }
    if len(s) <= maxBytes {
        return s
    }

    end := maxBytes
    for end > 0 && !utf8.RuneStart(s[end]) {
        end--
    }
    return s[:end]
}

This assumes the input string is valid UTF-8; validate untrusted raw bytes at the ingestion boundary. If the product requirement is a character limit rather than a byte limit, truncate by runes instead. Keep one shared limiter for every path that constructs the same kind of context so one caller cannot remain unbounded while another truncates.

Test the boundary with multi-byte text, not only ASCII:

func TestTruncateUTF8DoesNotSplitRune(t *testing.T) {
    got := truncateUTF8("abc🙂def", 5) // the emoji occupies bytes 3..6
    if got != "abc" || !utf8.ValidString(got) {
        t.Fatalf("got %q, want valid UTF-8 %q", got, "abc")
    }
}

Shelling out to git/gh: inject a runner, don't string-match stderr

CLIs that wrap git/gh by exec'ing them and classifying failures with strings.Contains(stderr, "404") / "auth login" are brittle (tool output wording changes between versions) and effectively untestable — there's no seam to inject a fake. Define a small runner interface so tests can supply canned output and error paths:

type Runner interface {
    Run(ctx context.Context, name string, args ...string) (stdout, stderr string, err error)
}

Depend on Runner, not os/exec directly. Prefer structured output where the tool offers it (gh api, --json) over scraping human-readable stderr.

Outbound HTTP: always set a timeout and User-Agent

Scrapers/clients built on the default http.Get have no timeout (a hung peer hangs the process forever) and no User-Agent (some services throttle or block that). Use an explicit client:

client := &http.Client{Timeout: 15 * time.Second}
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
req.Header.Set("User-Agent", "project/1.0 (+https://github.com/owner/project)")
resp, err := client.Do(req)

Regex/markup-based scraping is inherently fragile — a timeout + UA is the minimum robustness; treat parse failures as errors, not silent empties.

Checklist

Go project health:
- [ ] go.mod module path == canonical repo URL; internal imports match
- [ ] go directive, setup-go version(s), and CI matrix all consistent
- [ ] gofmt run under the same version setup-go installs (reformat committed once)
- [ ] .golangci.yml version key matches the installed golangci-lint major (v1: none; v2: "2")
- [ ] golangci-lint-action version paired with the config lane; timeout in run.timeout, not args
- [ ] test job actually has *_test.go files with assertions (race gate isn't a no-op)
- [ ] no map ranged directly into serialized/committed output (sort first)
- [ ] dry-run applies planning-state transitions and suppresses only external writes
- [ ] bounded text is truncated on UTF-8 rune boundaries through one shared helper
- [ ] git/gh wrappers depend on an injectable Runner, not os/exec + stderr string-matching
- [ ] outbound HTTP sets Timeout and User-Agent

Source: SKILL.md on GitHub

No third-party reports yet.

Signed by skilld at 2643c9a. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub 2 weeks ago.

Activeupdated 3 months ago

README badge

README badge for wdm0006/python-skills/projects