Go Modules skill for Codex

Clean go.mod, honest versions, no laptop paths. Codex treats go.mod as a release contract: it explains minimal version selection before upgrading, keeps replace directives out of libraries, handles /v2 path moves and retract lines instead of re-tagging, and wires private modules through GOPRIVATE and git auth without touching tokens. A bundled checker flags local replaces and tag/path major mismatches before you tag.

  • Languages
  • $go-modules
  • 199 lines
  • 3 files
  • ~2,450 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- go-modules

Installs to ~/.agents/skills/go-modules/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $go-modules, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Manages Go modules end to end - go.mod and go.sum hygiene, go get upgrades and downgrades, major-version /v2 paths, replace and retract directives, go.work multi-module workspaces, tool dependencies, and private modules via GOPRIVATE, GOPROXY and git auth. Use when go build fails with missing go.sum entries or ambiguous imports, when bumping or pinning a dependency, releasing a v2, wiring a monorepo with go.work, or fetching modules from a private GitHub or GitLab org.

Use it when

  • go build or go test fails on module resolution, go.sum or import path errors
  • You are bumping, pinning or removing a Go dependency
  • A Go library is about to be tagged, especially a new major version
  • Several Go modules in one repo need to be developed together
  • Modules live in a private GitHub, GitLab or internal proxy

Use something else when

  • The question is package layout, naming or error style inside the code: use go-idioms
  • You are tuning a CI pipeline rather than the module graph: use github-actions-workflows

How to check the result

go mod tidy -diff prints nothing, go build and go test pass with -mod=readonly, and scripts/check_gomod.py reports release-ready before any tag is created.

The mistake it prevents

Shipping a library whose go.mod relies on a replace directive; every consumer ignores it and gets a different, often broken, dependency.

~/.agents/skills/go-modules/SKILL.md

--- name: go-modules description: Manages Go modules end to end - go.mod and go.sum hygiene, go get upgrades and downgrades, major-version /v2 paths, replace and retract directives,… license: MIT ---

Done well means: go mod tidy -diff prints nothing, go build ./... and go test ./... pass with GOFLAGS=-mod=readonly, every version in go.mod is a tagged release or a deliberate pseudo-version, and nothing in go.mod points at a path on someone’s laptop.

Before changing anything

cat go.mod; ls go.work go.work.sum 2>/dev/null   # workspace mode changes resolution
go env GOVERSION GOTOOLCHAIN GOPROXY GOPRIVATE GOFLAGS GOWORK
go list -m                                       # main module(s)
git tag --list 'v*' --sort=-v:refname | head     # released versions, if this is a library

Read AGENTS.md for the project’s Go version policy and release process. Module downloads need network; in a Codex sandbox with network off, go build can still work from the module cache (go env GOMODCACHE) or a vendor/ directory, so check those before asking for approval. Never set GOSUMDB=off or GOINSECURE just to get past a checksum or TLS failure; find the cause.

How resolution works (the part people get wrong)

  • Go uses minimal version selection: the build gets the highest version any requirement asks for, not the newest available. Upgrading one dependency can raise others.
  • The go line is a minimum language and toolchain version (Go 1.21+). If the local toolchain is older, GOTOOLCHAIN=auto downloads a newer one (network). The optional toolchain line is only a preference for the main module.
  • replace and exclude apply only in the main module. A library’s replaces are ignored by everyone who imports it. Never ship a library that only builds because of a replace.
  • go.sum holds hashes for every module version the build may load. Commit it. It is not a lockfile of chosen versions; go.mod is.

Common tasks

GoalCommand
Add or upgrade one dependencygo get example.com/[email protected] (or @latest, @patch)
Upgrade direct and indirect deps of a packagego get -u ./... then go mod tidy
Patch-level upgrades onlygo get -u=patch ./...
Downgradego get example.com/[email protected] (may downgrade dependents too)
Remove a requirementgo get example.com/lib@none
Pin to an unreleased commitgo get example.com/lib@<commit-sha> (records a pseudo-version)
List available updatesgo list -m -u all
See why a module is neededgo mod why -m example.com/lib and go mod graph | grep lib
List versions of a modulego list -m -versions example.com/lib
Check go.mod/go.sum without writinggo mod tidy -diff (Go 1.23+; non-zero exit if dirty)
Verify cache integritygo mod verify
Track a dev tool (Go 1.24+)go get -tool golang.org/x/tools/cmd/stringer@latest, run with go tool stringer
Upgrade toolchain linego get [email protected] toolchain@none (raises the minimum for all users)

After any change: go mod tidy && go build ./... && go test ./.... Review the go.mod and go.sum diff; an upgrade that pulled in unrelated major bumps deserves a second look.

Raising the go line is a breaking change for library consumers on older toolchains. Do it on purpose, never as a side effect of go mod tidy run with a newer local Go.

Fixing common errors

ErrorCauseFix
missing go.sum entry for module providing packagego.sum incompletego mod tidy (or go mod download <mod>)
ambiguous import: found package X in multiple modulesA package moved between a module and its submoduleUpgrade both to versions where the split is complete; go get mod@latest submod@latest
module declares its path as A but was required as BFork or renamed modulereplace B => fork vX in the app only, or fix the import path
invalid version: module contains a go.mod file, so module path must match major versionTagged v2+ without /v2 in module pathSee major versions below
410 Gone / verifying module: checksum mismatchTag moved or private module checked against sum.golang.orgNever retag; set GOPRIVATE for private paths
go: updates to go.mod needed; to update it: go mod tidyRunning with -mod=readonly (default) after editsRun tidy locally and commit; CI should fail, not fix
go: downloading go1.x then failureGOTOOLCHAIN=auto with no networkInstall the toolchain or ask for network approval

Major versions and releasing a library

Semantic import versioning: v0 and v1 share the base path; v2+ must change it.

# Major-branch layout (most common): same repo, path gets /v2
go mod edit -module github.com/acme/widget/v2
# rewrite imports inside the module
grep -rl 'github.com/acme/widget' --include='*.go' . | xargs sed -i '' 's#github.com/acme/widget\([/"]\)#github.com/acme/widget/v2\1#g'
go build ./... && go test ./...
git tag v2.0.0   # tag only after user confirmation; pushing tags is public and permanent

(sed -i '' is BSD/macOS; use sed -i on GNU.) Check the rewrite did not double-apply (/v2/v2).

Release rules:

  • Tags are immutable. The proxy and checksum DB cache them forever. A bad release gets a new version plus a retract line, never a re-tag:

    retract (
        v1.4.0 // Panics on empty config; use v1.4.1.
        [v1.5.0, v1.5.2] // Wrong module path in generated code.
    )

    Publish the retraction in a newer version (for example v1.5.3) so go list -m -u sees it.

  • Submodules in a monorepo are tagged with a prefix: tools/lint/v0.3.0 for module github.com/acme/repo/tools/lint.

  • Ask the user before creating or pushing any tag; it publishes the version to the public proxy as soon as someone requests it.

Before tagging, run the bundled checker, which flags local-path replaces, major-version mismatches between tags and the module path, pseudo-version requirements and a committed go.work:

python3 scripts/check_gomod.py path/to/module   # exit 1 on errors, 0 when release-ready

Workspaces (go.work)

Use a workspace to develop several modules together without replace directives.

go work init ./api ./worker ./shared
go work use ./tools/lint          # add another module
go work sync                      # push workspace build list back into each go.mod
GOWORK=off go build ./...         # build one module exactly as consumers will
SituationChoice
Local hacking across reposgo.work, not committed (add to .gitignore)
Monorepo where all modules always release togetherCommit go.work; CI must still run GOWORK=off per module
One module, needs a forked dependencyreplace in the app’s go.mod, never in a library
Want a single version for everythingConsider one module instead of many; multiple modules add release cost

Each module still needs a correct go.mod on its own. CI that only builds with go.work hides missing requirements; run GOWORK=off go mod tidy -diff in each module directory.

Private modules

go env -w GOPRIVATE='github.com/acme/*,gitlab.example.com'
# GOPRIVATE sets GONOPROXY and GONOSUMDB defaults: no public proxy, no public checksum DB

Authentication for the underlying git fetch (pick one, documented in AGENTS.md or README):

  • HTTPS with a token in ~/.netrc (machine github.com login x-access-token password <token>); ask the user to create it, never write a token yourself.
  • SSH rewrite: git config --global url."[email protected]:".insteadOf "https://github.com/".
  • Go 1.24+ also supports GOAUTH for credential helpers; see go help goauth.
  • Corporate proxy (Athens, Artifactory, GOPROXY-compatible): GOPROXY=https://goproxy.corp,direct and leave GONOSUMDB covering private paths.

In CI, set GOPRIVATE in the job env and inject the token as a secret; do not commit .netrc or tokens into Dockerfiles. For Docker builds use BuildKit secrets (RUN --mount=type=secret,id=netrc,target=/root/.netrc go mod download).

Vendoring

go mod vendor copies dependencies into vendor/; the go command then uses it automatically when the go line is 1.14+. Choose it only for hermetic or air-gapped builds. Re-run after every go.mod change; go build fails with “inconsistent vendoring” otherwise. Workspaces use go work vendor.

Gotchas

  • go get in a directory outside any module, or with GOFLAGS=-mod=mod in CI, silently rewrites go.mod. CI should use the default readonly mode.
  • go install pkg@version ignores the current module’s go.mod; use it for one-off tools, and the tool directive for tools the repo pins.
  • // indirect comments are maintained by tidy; do not hand-edit them.
  • A pseudo-version like v0.0.0-20250612093011-4f1c2a9e8b7d is fine during development, but releasing a library that requires one forces it on every consumer.
  • go mod tidy considers all build tags and platforms; a dependency used only under //go:build windows is still kept. That is correct; do not delete it by hand.
  • Module path case matters, and the proxy escapes uppercase letters (!acme); mismatched case between import and go.mod causes “case-insensitive import collision”.

Verification

go mod tidy -diff                 # empty output
go mod verify                     # all modules verified
GOFLAGS=-mod=readonly go build ./... && go test ./...
govulncheck ./...                 # if installed; needs network for the vuln DB

If tidy keeps changing files between runs, look for build-tag-specific imports or a go.work overriding versions (GOWORK=off and re-run).

Report template

**Change:** upgraded github.com/jackc/pgx/v5 v5.6.0 -> v5.7.4
**Side effects:** golang.org/x/crypto v0.31.0 -> v0.36.0 (required by pgx)
**go line:** unchanged (1.23)
**Checks:** go mod tidy -diff clean; go test ./... pass; govulncheck: no findings
**Needs decision:** none / tag v2.0.0 once you confirm

What the zip contains

Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "Go Modules"
  short_description: "Clean go.mod, honest versions, no laptop paths"
  default_prompt: "$go-modules go build says missing go.sum entry and ambiguous import, sort out our go.mod"
scripts/check_gomod.py140 lines, 4.9 KB
#!/usr/bin/env python3
"""Pre-release lint for a Go module directory.

Usage: python3 check_gomod.py [module_dir]

Errors (exit 1):
  - replace directive pointing at a local filesystem path
  - module path major suffix does not match the highest git tag for this module
  - missing module or go directive
Warnings (exit 0):
  - requirements pinned to pseudo-versions
  - go.work present in the module directory (check it is meant to be committed)
  - replace/exclude present (ignored by consumers of a library)
Standard library only; uses `git tag` when the directory is in a git repo.
"""
from __future__ import annotations

import re
import subprocess
import sys
from pathlib import Path

PSEUDO = re.compile(r"v\d+\.\d+\.\d+-(?:\d+\.)?(?:0\.)?\d{14}-[0-9a-f]{12}")
SEMVER_TAG = re.compile(r"^v(\d+)\.(\d+)\.(\d+)$")


def strip_comment(line: str) -> str:
    return line.split("//", 1)[0].strip()


def parse_gomod(text: str):
    module = go = None
    requires, replaces, excludes = [], [], []
    block = None
    for raw in text.splitlines():
        line = strip_comment(raw)
        if not line:
            continue
        if block:
            if line == ")":
                block = None
                continue
            entry = line
            kind = block
        else:
            m = re.match(r"^(\w+)\s*\($", line)
            if m:
                block = m.group(1)
                continue
            parts = line.split(None, 1)
            kind, entry = parts[0], parts[1] if len(parts) > 1 else ""
        if kind == "module":
            module = entry.strip('"')
        elif kind == "go":
            go = entry
        elif kind == "require":
            requires.append(entry)
        elif kind == "replace":
            replaces.append(entry)
        elif kind == "exclude":
            excludes.append(entry)
    return module, go, requires, replaces, excludes


def git_tags(mod_dir: Path) -> list[tuple[int, int, int]]:
    try:
        top = subprocess.run(["git", "-C", str(mod_dir), "rev-parse", "--show-toplevel"],
                             capture_output=True, text=True, check=True).stdout.strip()
        out = subprocess.run(["git", "-C", str(mod_dir), "tag", "--list"],
                             capture_output=True, text=True, check=True).stdout
    except (OSError, subprocess.CalledProcessError):
        return []
    rel = mod_dir.resolve().relative_to(Path(top).resolve()).as_posix()
    tag_prefix = "" if rel == "." else rel + "/"
    versions = []
    for tag in out.split():
        if not tag.startswith(tag_prefix):
            continue
        m = SEMVER_TAG.match(tag[len(tag_prefix):])
        if m:
            versions.append(tuple(int(x) for x in m.groups()))
    return sorted(versions)


def main() -> int:
    mod_dir = Path(sys.argv[1] if len(sys.argv) > 1 else ".")
    gomod = mod_dir / "go.mod"
    if not gomod.is_file():
        print(f"error: {gomod} not found")
        return 1
    module, go, requires, replaces, excludes = parse_gomod(gomod.read_text())
    errors, warnings = [], []

    if not module:
        errors.append("no module directive")
    if not go:
        errors.append("no go directive (add one with: go mod edit -go=<version>)")

    for r in replaces:
        target = r.split("=>", 1)[1].strip() if "=>" in r else ""
        if target.startswith(("./", "../", "/")) or re.match(r"^[A-Za-z]:\\", target):
            errors.append(f"local-path replace: {r}")
    if replaces:
        warnings.append(f"{len(replaces)} replace directive(s): consumers of this module ignore them")
    if excludes:
        warnings.append(f"{len(excludes)} exclude directive(s): consumers of this module ignore them")

    for r in requires:
        if PSEUDO.search(r):
            warnings.append(f"pseudo-version requirement: {r}")

    if (mod_dir / "go.work").exists():
        warnings.append("go.work present: build each module with GOWORK=off before release")

    if module:
        m = re.search(r"/v(\d+)$", module)
        path_major = int(m.group(1)) if m else 1
        tags = git_tags(mod_dir)
        if tags:
            tag_major = max(1, tags[-1][0])
            if path_major == tag_major + 1:
                warnings.append(f"module path is v{path_major} but no v{path_major} tag yet "
                                "(expected while preparing a new major release)")
            elif tag_major != path_major:
                errors.append(
                    f"highest tag is v{'.'.join(map(str, tags[-1]))} but module path "
                    f"'{module}' implies major v{path_major}")
            print(f"latest tag: v{'.'.join(map(str, tags[-1]))}")

    print(f"module: {module}  go: {go}  requires: {len(requires)}")
    for w in warnings:
        print(f"warn:  {w}")
    for e in errors:
        print(f"error: {e}")
    print("result:", "NOT release-ready" if errors else "release-ready")
    return 1 if errors else 0


if __name__ == "__main__":
    sys.exit(main())

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc