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 updating all go install commands to the latest specific semantic versions. This includes @latest, already-pinned versions, and commands without version specifiers.
# 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
Top comments (0)