Publishing Go Modules — Hands-on Tasks¶
Practical exercises from easy to hard. Each task says what to build, what success looks like, and a hint or expected outcome. Solutions are at the end.
Easy¶
Task 1 — Tiny library, first tag¶
Create a directory tinylib. Inside, run go mod init github.com/<you>/tinylib. Add a single exported function Greet(name string) string. Initialise git, commit, and tag v0.1.0. Push the tag.
- Confirm
git tag --listshowsv0.1.0. - Confirm
go list -m -versions github.com/<you>/tinylib(after the proxy catches up) listsv0.1.0.
Goal. Walk through the minimum publish flow: code, commit, tag, push.
Task 2 — LICENSE and README¶
Take the module from Task 1. Add a LICENSE file (MIT or Apache-2.0) and a README.md that includes:
- One-paragraph description.
- An install line:
go get github.com/<you>/tinylib@latest. - A minimal usage example fenced with
```go.
Commit and tag v0.1.1. Confirm the README renders correctly on the repo's web view.
Goal. Make the module discoverable and legally usable.
Task 3 — A godoc Example¶
Add an example_test.go next to Greet with a function ExampleGreet:
Run go test ./... and confirm the example runs and passes. Push and check that pkg.go.dev/github.com/<you>/tinylib shows the example after indexing.
Goal. Connect testing and documentation in the canonical Go way.
Task 4 — Bumping v0.1.0 to v0.2.0¶
Add a second function Farewell(name string) string. Commit. Tag v0.2.0. Push the tag. Confirm:
go get github.com/<you>/tinylib@latestfrom a fresh consumer module pullsv0.2.0.go.sumin the consumer records the new version.
Goal. Master the routine minor-version bump.
Task 5 — Crossing the v1 boundary¶
Decide that the API in v0.2.0 is stable. Tag v1.0.0 without changing the module path (v0 and v1 share the path). Push.
- Confirm
go list -m github.com/<you>/tinylib@latestreturnsv1.0.0. - Read the SemVer spec section on v1 and note one line about backward-compatibility commitments.
Goal. Understand that v0 and v1 share a path; v1 starts the public API contract.
Medium¶
Task 6 — Bumping to v2 (rename module path)¶
You decide v2 needs a breaking change to Greet's signature. Walk through:
- Edit
go.mod: change the module line togithub.com/<you>/tinylib/v2. - Update internal imports (none in this tiny case, but practise grepping).
- Tag
v2.0.0and push. - Confirm
go list -m github.com/<you>/tinylib/v2@v2.0.0resolves.
In a fresh consumer, go get github.com/<you>/tinylib/v2@latest and use the new API.
Goal. Internalise the v2 path-rename rule.
Task 7 — Retracting a release¶
You publish v2.1.0 and discover a memory leak within an hour. Add to go.mod (in your repo's main branch):
Tag v2.1.1 (which contains the retraction directive and the fix). Push. Confirm:
go list -m -retracted -versions github.com/<you>/tinylib/v2flagsv2.1.0as retracted.- A consumer running
go get github.com/<you>/tinylib/v2@latestskipsv2.1.0. go get github.com/<you>/tinylib/v2@v2.1.0still works (retract is advisory, not deletion).
Goal. Practise the retract workflow under realistic pressure.
Task 8 — Vanity URL, static page¶
You want consumers to import csv.example.com/csvkit instead of github.com/<you>/csvkit. Without owning a real domain, simulate the static page locally.
Create vanity/csvkit/index.html:
<!DOCTYPE html>
<html><head>
<meta name="go-import" content="csv.example.com/csvkit git https://github.com/<you>/csvkit">
<meta name="go-source" content="csv.example.com/csvkit https://github.com/<you>/csvkit https://github.com/<you>/csvkit/tree/main{/dir} https://github.com/<you>/csvkit/blob/main{/dir}/{file}#L{line}">
</head><body>
go get csv.example.com/csvkit
</body></html>
Serve the directory locally with python3 -m http.server. Use curl http://localhost:8000/csvkit/?go-get=1 to verify the meta tag.
Goal. Read the meta-tag protocol and produce a valid file.
Task 9 — GoReleaser config¶
Add a .goreleaser.yaml to a CLI module that:
- Builds for
linux/amd64,linux/arm64,darwin/amd64,darwin/arm64,windows/amd64. - Embeds version, commit, and date via
-ldflags. - Produces a
.tar.gzfor unix and.zipfor windows. - Generates a
checksums.txt.
Run goreleaser release --snapshot --clean and inspect dist/.
Goal. Get one goreleaser config working end-to-end before wiring it to CI.
Task 10 — Multi-module monorepo, prefix tags¶
Set up a repo:
mono/
├── shared/
│ └── go.mod (module github.com/<you>/mono/shared)
└── cli/
└── go.mod (module github.com/<you>/mono/cli)
Tag the shared module with the prefix-tag form: shared/v0.1.0. Tag cli with cli/v0.1.0. Confirm:
go list -m -versions github.com/<you>/mono/sharedshowsv0.1.0.go list -m -versions github.com/<you>/mono/clishowsv0.1.0.- An external consumer can
go get github.com/<you>/mono/shared@v0.1.0andgo get github.com/<you>/mono/cli@v0.1.0independently.
Goal. Master the <dir>/vX.Y.Z tag form for monorepos.
Hard¶
Task 11 — GitHub Actions release pipeline¶
Build a workflow .github/workflows/release.yml that triggers on push of tags matching v* and:
- Sets up Go using
go-version-file: go.mod. - Runs
go test ./.... - Runs
goreleaser release --clean. - Uploads artefacts to the GitHub Release that
goreleasercreates.
Test by tagging v0.3.0 on a sandbox repo and confirming the release page lists every artefact.
Goal. A minimum production-grade release pipeline.
Task 12 — Cosign-signing your release artefacts¶
Extend the workflow from Task 11 to sign every artefact with sigstore/cosign keyless signing (using GitHub OIDC). For each artefact foo.tar.gz, produce foo.tar.gz.sig and foo.tar.gz.cert.
Verify after release:
cosign verify-blob \
--certificate foo.tar.gz.cert \
--signature foo.tar.gz.sig \
--certificate-identity-regexp 'https://github.com/<you>/.*' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
foo.tar.gz
Goal. Add supply-chain signatures consumers can verify.
Task 13 — v1 maintenance branch alongside v2¶
You've shipped v2.0.0 and the module path has moved to .../v2. A large customer needs v1 patches.
- Create branch
release/v1from the last v1 tag. - Apply a security fix.
- Tag
v1.4.3on therelease/v1branch. - Push the branch and the tag.
Confirm with git branch --contains v1.4.3 that the tag is on the release/v1 branch, not on main. Confirm both go get .../tinylib@v1.4.3 and go get .../tinylib/v2@latest resolve correctly.
Goal. Run the two-track maintenance workflow.
Task 14 — A runnable Example_complex¶
Write an Example_processFile (or similar named example for an unexported scenario) that:
- Creates a temp file via
os.CreateTemp. - Writes sample CSV input.
- Calls your library's parser.
- Prints structured output.
- Has a
// Output:block that matches exactly.
Run go test -run Example_processFile -v and confirm. Push and check that pkg.go.dev displays it under the "Examples" heading.
Goal. Produce non-trivial, runnable, indexed documentation.
Task 15 — Coordinated security release¶
Simulate CVE coordination. Choose a vulnerability scenario (e.g., an input parser that is exponentially slow on crafted input).
- Open a private GitHub Security Advisory in your repo.
- Prepare the fix on a private branch.
- Coordinate an embargo date with at least one downstream consumer (you can simulate this by making a second repo that depends on the library).
- On embargo day: merge fix, tag
v2.4.7, publish the advisory, and submit the GHSA to the Go vulnerability database (go-vulndb) via PR. - Confirm
govulncheckflags the vulnerable versions.
Goal. Walk through the full coordinated-disclosure flow.
Bonus / Stretch¶
Task 16 — Migrating to a vanity path mid-life¶
You have github.com/<you>/csvkit@v1.5.0. You buy csv.example.com and want consumers to use csv.example.com/csvkit.
- Set up the vanity static page (Task 8) for real.
- In the existing repo, do not rename the module yet — first add a deprecation notice to the README pointing to the new path.
- Cut a new repo or tag a fresh major (
v2) atcsv.example.com/csvkit/v2. - Document the migration: shim file, deprecation timeline, recommended
go.modreplacefor transition.
Goal. Renaming a published module without breaking everyone.
Task 17 — Reproducing the proxy-zip byte-identically¶
The Go proxy serves https://proxy.golang.org/<module>/@v/<version>.zip. Pick one of your tagged versions. Build the zip locally with go mod download -x plus inspection, and compare it to the proxy's copy using sha256sum.
If they differ, identify why (file ordering, mtimes, included/excluded files, line endings, hidden directories).
Goal. Understand the module-zip format at byte level.
Task 18 — Private Athens proxy¶
Stand up gomods/athens locally (Docker is fine). Configure your client:
Use it for two days of normal Go work. Verify by killing your network and confirming go build still works because Athens has cached the modules.
Add private modules from a local git server and verify Athens proxies them too.
Goal. Operate a private module proxy.
Task 19 — Breaking-change scanner¶
Write a Go program that, given two tags vA and vB of a module, lists every breaking API change between them. Detect at least:
- Removed exported identifier.
- Changed exported function signature.
- Removed exported struct field.
- Added required interface method (i.e., interface widened).
Use golang.org/x/tools/go/packages to load both. Run it on two non-trivial versions of one of your published modules.
Bonus: emit an exit code of 1 if any breaking changes are found without a major-version bump.
Goal. Build the tool you wish your CI had before every release.
Task 20 — Deprecation timeline document¶
Pick one of your published modules. Write a DEPRECATION.md that includes:
- The deprecated symbol or path.
- The version in which deprecation was announced.
- The version in which it will be removed (no sooner than two minor releases later).
- A migration recipe (find/replace or
gofmt -r). - Communication channels: README banner, release notes,
// Deprecated:doc comments, GitHub issue label.
Add // Deprecated: use Bar instead. doc comments to the deprecated symbols. Confirm go vet (or staticcheck's SA1019) flags consumers' use of them.
Goal. Produce a humane deprecation experience.
Solutions (sketched)¶
Solution 1¶
mkdir tinylib && cd tinylib
go mod init github.com/<you>/tinylib
# write greet.go with func Greet
git init && git add . && git commit -m "v0.1.0"
git tag v0.1.0
git remote add origin git@github.com:<you>/tinylib.git
git push -u origin main --tags
Solution 2¶
LICENSE — copy the official MIT or Apache-2.0 text. README must include the install line and a fenced Go example. Re-tag v0.1.1 only after git commit-ing both files.
Solution 3¶
The example function name must match ExampleGreet. The // Output: comment is parsed by the test framework — exact whitespace match required. go test ./... runs it like any test.
Solution 4¶
Consumer side:Solution 5¶
v0 → v1 is not a path rename. SemVer says v1.0.0 commits to backward-compatibility within the v1 line.Solution 6¶
Then: The/v2 path suffix is the rule for v2+. Solution 7¶
retract block in go.mod:
v2.1.1 after committing the retract directive. Module-aware tools read retractions from the latest version's go.mod. Solution 8¶
The static page must be served at the import-path prefix and respond to ?go-get=1. The <meta name="go-import"> tag is mandatory; go-source is optional but improves pkg.go.dev.
Solution 9¶
Minimum .goreleaser.yaml:
project_name: tinycli
builds:
- env: [CGO_ENABLED=0]
goos: [linux, darwin, windows]
goarch: [amd64, arm64]
ignore:
- {goos: windows, goarch: arm64}
ldflags:
- -s -w
- -X main.version={{.Version}}
- -X main.commit={{.Commit}}
- -X main.date={{.Date}}
archives:
- format_overrides:
- {goos: windows, format: zip}
checksum:
name_template: checksums.txt
Solution 10¶
The<subdir>/vX.Y.Z form is the only correct way to version a sub-module in a monorepo. Solution 11¶
on:
push:
tags: ['v*']
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: {fetch-depth: 0}
- uses: actions/setup-go@v5
with: {go-version-file: go.mod}
- run: go test ./...
- uses: goreleaser/goreleaser-action@v6
with: {args: release --clean}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Solution 12¶
Add to GoReleaser:
signs:
- cmd: cosign
artifacts: all
output: true
args:
- sign-blob
- --yes
- --output-signature=${signature}
- --output-certificate=${certificate}
- ${artifact}
id-token: write permission for OIDC. Solution 13¶
git checkout -b release/v1 v1.4.2
# apply fix
git commit -am "fix: backport CVE-XXXX"
git tag v1.4.3
git push origin release/v1 v1.4.3
main. The Go proxy resolves any reachable tag. Solution 14¶
func Example_processFile() {
f, _ := os.CreateTemp("", "in*.csv")
f.WriteString("a,b\n1,2\n")
f.Close()
rows, _ := csvkit.Parse(f.Name())
for _, r := range rows { fmt.Println(r) }
// Output:
// [a b]
// [1 2]
}
Solution 15¶
- GitHub: Security tab → Advisories → New draft.
- Private fork or branch for fix; tag only after embargo lifts.
- After release, open a PR to
github.com/golang/vulndbadding a YAML report. - Verify with
govulncheck ./...against a consumer pinned to the vulnerable version.
Solution 16¶
README banner:
> NOTICE: this module is moving to csv.example.com/csvkit/v2.
> github.com/<you>/csvkit will receive security fixes only until 2027-05-01.
replace: Solution 17¶
GOPROXY=off go mod download -x github.com/<you>/tinylib@v1.0.0
sha256sum $GOPATH/pkg/mod/cache/download/.../v1.0.0.zip
curl -sL https://proxy.golang.org/github.com/<you>/tinylib/@v/v1.0.0.zip -o proxy.zip
sha256sum proxy.zip
golang.org/x/mod/zip to compare programmatically. Solution 18¶
docker run -d -p 3000:3000 \
-v $PWD/athens-storage:/var/lib/athens \
-e ATHENS_DISK_STORAGE_ROOT=/var/lib/athens \
-e ATHENS_STORAGE_TYPE=disk \
gomods/athens:latest
export GOPROXY=http://localhost:3000,direct
go build ./...
Solution 19¶
Skeleton:
import "golang.org/x/tools/go/packages"
cfg := &packages.Config{Mode: packages.NeedTypes | packages.NeedTypesInfo}
old, _ := packages.Load(cfg, "<modA>/...")
new, _ := packages.Load(cfg, "<modB>/...")
// Walk old's exported objects; for each, find counterpart in new.
// Compare type signatures; report removals and incompatible changes.
git tag --list to detect missing major bumps. Solution 20¶
# Deprecation: Greet(name string)
Announced: v2.4.0 (2026-04-01)
Removal: v3.0.0 (no sooner than 2026-08-01)
Migration:
Greet(x) --> GreetCtx(ctx, x)
// Deprecated: doc comments. Static analysers will flag callers. Checkpoints¶
After completing the easy tasks: you can publish a v0 library, tag releases, and write godoc examples that the proxy and pkg.go.dev pick up. After completing the medium tasks: you can run major-version bumps, retract bad releases, set up vanity URLs, and ship reproducible builds with GoReleaser across a monorepo. After completing the hard tasks: you can drive a real release pipeline with signing, run a v1 maintenance branch alongside v2, write professional examples, and coordinate a security release end-to-end. After completing the bonus tasks: you can rename modules without breaking ecosystems, operate your own proxy, audit your own releases for breakage, and run deprecations that your downstream consumers can actually act on.
In this topic