This content originally appeared on DEV Community and was authored by kuro
Have you ever wondered why your CI pipeline suddenly broke even though you didn’t change any code? Or why a teammate’s local build produces different results than yours? The culprit might be lurking in your go install commands with @latest tags.
gopin is a CLI tool that automatically pins versions of go install commands in your codebase, ensuring reproducible builds and enhanced security.
The Problem with @latest
Using @latest in go install commands creates several issues:
Reproducibility Issues
.PHONY: lint
lint:
go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
golangci-lint run
Today it installs v2.6.0, tomorrow it might install v2.6.2. Builds become non-deterministic.
Security Risk
Unpinned versions increase supply chain attack risk. A compromised version could be installed without your knowledge.
CI/CD Instability
Different runners may install different versions, causing inconsistent test results and build failures.
Debugging Difficulty
Reproducing the exact environment from weeks or months ago becomes impossible with @latest.
Introducing gopin
gopin is a CLI tool that solves these problems by automatically converting @latest to specific semantic versions.
# Before
go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
# After running gopin
go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.6.2
Architecture: A Three-Stage Pipeline
gopin follows a clean, modular architecture with three core stages:
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Detector │ ───> │ Resolver │ ───> │ Rewriter │
└─────────────┘ └─────────────┘ └─────────────┘
│ │ │
│ │ │
Scan files Query versions Replace text
with regex from proxy.golang.org in-place
1. Detection Phase
The detector scans files for go install patterns using regex:
// From pkg/detector/detector.go
var GoInstallPattern = regexp.MustCompile(
`go\s+install\s+` + // go install
`(?:-[a-zA-Z0-9_=,]+\s+)*` + // Optional flags
`([^\s@#]+)` + // Module path
`(?:@([^\s#]+))?`, // Version (optional)
)
This pattern handles various edge cases:
- Flags:
go install -v -trimpath github.com/tool@latest - Subpackages:
github.com/org/repo/cmd/tool@latest - No version:
go install github.com/tool(implicitly @latest) - Special characters:
gopkg.in/yaml.v3@latest
2. Resolution Phase
The resolver queries proxy.golang.org for the latest version:
GET https://proxy.golang.org/github.com/golangci/golangci-lint/v2/@latest
Response:
{
"Version": "v2.6.2",
"Time": "2024-12-01T10:30:00Z"
}
Key Design: Resolver Chain Pattern
Resolvers are composed using the decorator pattern:
CachedResolver
→ FallbackResolver
→ ProxyResolver (primary)
→ GoListResolver (fallback)
This design provides:
- Caching for repeated module lookups
- Fallback to
go listfor private modules - Flexibility to add new resolution strategies
// From pkg/cli/app.go
func createResolver(cfg *config.Config) resolver.Resolver {
var res resolver.Resolver
switch cfg.Resolver.Type {
case "golist":
res = resolver.NewGoListResolver()
default:
res = resolver.NewProxyResolver(cfg.Resolver.ProxyURL, cfg.Resolver.Timeout)
}
if cfg.Resolver.Fallback {
res = resolver.NewFallbackResolver(res, resolver.NewGoListResolver())
}
return resolver.NewCachedResolver(res)
}
3. Rewriting Phase
The rewriter replaces version strings in-place with change tracking.
Key Design: Backward Processing
Matches are processed in reverse order (last line first, rightmost column first) to prevent offset shifts:
// From pkg/rewriter/rewriter.go
sort.Slice(matches, func(i, j int) bool {
if matches[i].Line != matches[j].Line {
return matches[i].Line > matches[j].Line // Descending
}
return matches[i].StartColumn > matches[j].StartColumn
})
This prevents offset shifts – modifying line 5 doesn’t affect line 10’s position. Forward processing would require recalculating offsets after each change.
Real-World Example
Let’s see gopin in action with a typical Makefile:
Before:
.PHONY: install-tools
install-tools:
go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
go install golang.org/x/tools/cmd/goimports@latest
go install honnef.co/go/tools/cmd/staticcheck@latest
go install github.com/securego/gosec/v2/cmd/gosec@latest
Running gopin:
$ gopin run --diff
--- Makefile
+++ Makefile
- go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
+ go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.6.2
- go install golang.org/x/tools/cmd/goimports@latest
+ go install golang.org/x/tools/cmd/goimports@v0.39.0
After:
.PHONY: install-tools
install-tools:
go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.6.2
go install golang.org/x/tools/cmd/goimports@v0.39.0
go install honnef.co/go/tools/cmd/staticcheck@v0.5.1
go install github.com/securego/gosec/v2/cmd/gosec@v2.22.0
Default Target Files
By default, gopin scans these file patterns:
-
.github/**/*.ymland.github/**/*.yaml– GitHub Actions workflows -
Makefile,makefile,GNUmakefile– Make build files -
*.mk– Make include files
You can customize target files using a .gopin.yaml configuration file.
Conclusion
Version pinning helps ensure reproducible builds and reduces security risks. gopin automates this process for go install commands across your codebase using a clean three-stage architecture: detection, resolution, and rewriting.
Get started:
go install github.com/nnnkkk7/gopin/cmd/gopin@latest
cd your-project
gopin run --dry-run # Preview changes
gopin run # Apply changes
Repository: github.com/nnnkkk7/gopin
This content originally appeared on DEV Community and was authored by kuro