Skip to content

Commit 5392bee

Browse files
authored
fix(golang): reduce required co-package updates to minimal necessary set (#63)
* feat(golang): reduce required co-package updates to minimal necessary set - Filter CheckTransitiveRequirements to only flag direct project deps; indirect deps are resolved automatically by Go's MVS and cannot cause API breakage in the project's own code - Add FindVersionGroupPackages to detect tightly-coupled module ecosystems (e.g. all go.opentelemetry.io/otel/* packages) using module family prefix heuristics, covering both co-released and version-drifted siblings - Add second API compat pass over discovered co-updates so community packages that import a co-updated dep (e.g. otelgrpc importing otel) are surfaced as API compat alerts * feat(golang): find minimum compatible version for API compat alerts When CheckAPICompatibilityWithCache flags a package (e.g. go-ldap/ldap/v3) that imports a dep being updated (e.g. go-ntlmssp), walk the package's version history to find the lowest release whose go.mod requires the updated dep at >= the target version. This converts API compat alerts from no-op suggestions (pkg@current) into actionable version recommendations (pkg@min-compatible), directly addressing build failures where a dep's API signature changed between versions. * feat(golang): add API surface diff to detect genuine breaking changes Add CheckAPIBreakingChanges which compares the exported API of a package between two versions using golang.org/x/exp/apidiff. This provides a precise signal for whether a dep version bump introduces incompatible changes (e.g. ProcessChallenge gaining a fourth argument in go-ntlmssp), as opposed to compatible additions (new functions, new fields). The function type-checks both versions using golang.org/x/tools/go/packages in isolated temp modules, then runs apidiff.Changes to classify each exported symbol change as compatible or incompatible. This can be used alongside findMinCompatibleVersion and CheckAPICompatibilityWithCache to distinguish false-positive compat alerts from confirmed breaking changes that require downstream packages to update. * fix(golang): skip main module and detect unavailable package versions - resolveAndFilterPackages now skips any package whose path matches the main module of the go.mod being analyzed, logging a warning instead of letting it reach gobump where it would fail with 'bumping the main module is not allowed' - checkMissingTransitiveDeps applies the same guard as a second line of defence for co-updates discovered through transitive checks - CheckAPIBreakingChanges now treats an unloadable new version (removed package or internally broken release) as a breaking change rather than returning an error, giving callers a clear signal not to bump to that version * chore(golang): fix golangci-lint violations - Add sentinel errors errPackageNotFound and errNoTypeInfo to replace dynamic fmt.Errorf calls in loadPackageTypes (err113) - Wrap packages.Error with %w in loadPackageTypes (errorlint) - Wrap defer os.RemoveAll in closure discarding return value (errcheck) - Fix stdlib import grouping for go/types and errors (gci, gofumpt) - Extract runCoUpdateAPICompatChecks helper from checkMissingTransitiveDeps to reduce cyclomatic complexity below threshold (gocyclo) * refactor(golang): extract mainModulePath helper to remove duplication * chore: go mod tidy * refactor(golang): consolidate co-update detection into shared detectCoUpdates Extract the co-update detection core from checkMissingTransitiveDeps into a new detectCoUpdates function so both the update path and analyzer path use identical logic. Previously checkTransitiveRequirementsForStrategy in analyzer.go had its own diverged implementation: it used the deprecated CheckAPICompatibility, had no version-group ecosystem detection, no findMinCompatibleVersion, no main module skip, and included indirect project deps in version checks. Both paths now share: FindVersionGroupPackages for ecosystem grouping, CheckAPICompatibilityWithCache with findMinCompatibleVersion for actionable version recommendations, main module skip, direct-only dep filtering, and the second API compat pass for co-update deps. * fix(golang): apply main module skip in analyze path RecommendStrategy was adding the main module to strategy.DirectUpdates without checking whether the package being updated is the go.mod's own module. The update path had this guard in resolveAndFilterPackages; now both paths produce consistent output. * fix(golang): surface API compat co-updates in all output types When findMinCompatibleVersion identifies a concrete minimum version for an API compat alert (e.g. otelgrpc@v0.68.0 when bumping otel to v1.43.0), add it to strategy.DirectUpdates so it appears in JSON, YAML, and deps file output — not just as a human-readable warning string. Packages already being updated are skipped to avoid duplicates. * chore(golang): add map capacity and eliminate redundant go.mod parse - Set initial capacity on allMissingDeps and apiCompatibilityAlerts in detectCoUpdates to avoid realloc when co-updates match update count - Parse go.mod once in RecommendStrategy and pass it to checkTransitiveRequirementsForStrategy to avoid reading the same file twice; fall back to parsing internally when caller passes nil * docs(golang): document why GONOSUMCHECK is set in loadPackageTypes * fix(golang): skip version group packages on different major version track Packages like go.opentelemetry.io/otel/exporters/* use a v0.x cadence while core otel uses v1.x. FindVersionGroupPackages was including them in the version group and recommending them at the wrong target version (e.g. v1.43.0 instead of v0.19.0). Skip version group candidates whose current major differs from the target major. The second-pass API compat check handles these packages correctly via findMinCompatibleVersion, which finds the right v0.x version. * test(golang): regression tests for cross-major version group handling Add TestDetectCoUpdates_CrossMajorVersionGroupSkipped to guard against recommending the wrong version for otel exporter packages. The otel exporter family (go.opentelemetry.io/otel/exporters/*) uses v0.x while core otel uses v1.x — the fix must find v0.19.0 via the API compat chain rather than v1.43.0 from the version group. Add a FindVersionGroupPackages test case documenting that the function itself returns cross-major family members; the filter lives in detectCoUpdates. * fix(golang): exclude cross-major packages from version group in FindVersionGroupPackages Move the cross-major version guard into FindVersionGroupPackages itself so that all callers are protected, not just detectCoUpdates. Previously, otel/exporters/prometheus@v0.60.0 would be included in the version group when bumping core otel to v1.43.0, and the function would return it with a target of v1.43.0 — a version that does not exist, causing go mod tidy to fail. The fix compares each candidate's semver major against the source package's current version major and excludes any mismatch. The second-pass API compat chain (findMinCompatibleVersion) then finds the correct v0.x version instead. Removes the now-redundant duplicate guard that was in detectCoUpdates. * fix(golang): actively resolve correct version for cross-major family packages When FindVersionGroupPackages returns a package on a different major version track (e.g. otel/exporters/prometheus v0.60.0 when targeting otel v1.43.0), detectCoUpdates now calls findMinCompatibleVersion to find the correct v0.x version (e.g. v0.65.0) rather than either applying the wrong v1.x target or relying on the second-pass API compat chain to catch it. FindVersionGroupPackages returns all family members regardless of major version — the caller is responsible for resolving the right version. * chore(golang): fix log message and reason string for cross-major co-updates - Move familyRoot declaration inside the cross-major branch where it is used - Remove duplicate familyRoot argument from log message - Use distinct Reason string for cross-major packages so it doesn't incorrectly say 'both at X' when the versions differ
1 parent 96a8f2e commit 5392bee

7 files changed

Lines changed: 877 additions & 124 deletions

File tree

go.mod

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ require (
1313
github.com/samber/lo v1.53.0
1414
github.com/spf13/cobra v1.10.2
1515
github.com/stretchr/testify v1.11.1
16-
golang.org/x/mod v0.34.0
16+
golang.org/x/exp v0.0.0-20231006140011-7918f672742d
17+
golang.org/x/mod v0.35.0
18+
golang.org/x/tools v0.44.0
1719
k8s.io/apimachinery v0.35.3
1820
sigs.k8s.io/release-utils v0.12.4
1921
)
@@ -40,9 +42,10 @@ require (
4042
github.com/rivo/uniseg v0.4.7 // indirect
4143
github.com/spf13/pflag v1.0.9 // indirect
4244
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
43-
golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect
44-
golang.org/x/sys v0.38.0 // indirect
45+
golang.org/x/sync v0.20.0 // indirect
46+
golang.org/x/sys v0.43.0 // indirect
4547
golang.org/x/text v0.31.0 // indirect
48+
golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated // indirect
4649
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect
4750
gopkg.in/yaml.v2 v2.4.0 // indirect
4851
gopkg.in/yaml.v3 v3.0.1 // indirect

go.sum

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -71,13 +71,21 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJu
7171
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
7272
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
7373
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
74-
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
75-
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
74+
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
75+
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
76+
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
77+
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
7678
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
77-
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
78-
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
79+
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
80+
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
7981
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
8082
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
83+
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
84+
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
85+
golang.org/x/tools/go/expect v0.1.0-deprecated h1:jY2C5HGYR5lqex3gEniOQL0r7Dq5+VGVgY1nudX5lXY=
86+
golang.org/x/tools/go/expect v0.1.0-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
87+
golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM=
88+
golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8=
8189
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
8290
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
8391
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=

pkg/languages/golang/analyzer.go

Lines changed: 92 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"fmt"
1212
"os"
1313
"path/filepath"
14+
"sort"
1415

1516
"github.com/chainguard-dev/clog"
1617
"github.com/chainguard-dev/omnibump/pkg/analyzer"
@@ -377,7 +378,23 @@ func (ga *GolangAnalyzer) RecommendStrategy(ctx context.Context, analysis *analy
377378
AffectedDependencies: make(map[string][]string),
378379
}
379380

381+
// Parse go.mod once here and pass it to checkTransitiveRequirementsForStrategy
382+
// to avoid a second disk read for the same file.
383+
var strategyModFile *modfile.File
384+
mainModule := ""
385+
if rootPath, ok := analysis.Metadata["moduleRoot"].(string); ok {
386+
if mf, _, err := ParseGoModfile(filepath.Join(rootPath, "go.mod")); err == nil {
387+
strategyModFile = mf
388+
mainModule = mainModulePath(mf)
389+
}
390+
}
391+
380392
for _, dep := range deps {
393+
if dep.Name == mainModule {
394+
log.Warnf("Skipping %s: it is the main module of this go.mod and cannot be bumped as a dependency", dep.Name)
395+
continue
396+
}
397+
381398
if depInfo, exists := analysis.Dependencies[dep.Name]; exists {
382399
// Check if this is a replaced dependency
383400
if replaced, ok := depInfo.Metadata["replaced"].(bool); ok && replaced {
@@ -399,8 +416,9 @@ func (ga *GolangAnalyzer) RecommendStrategy(ctx context.Context, analysis *analy
399416
log.Debugf("Will update %s to %s", dep.Name, dep.Version)
400417
}
401418

402-
// Check transitive requirements for all packages being updated
403-
ga.checkTransitiveRequirementsForStrategy(ctx, analysis, strategy)
419+
// Check transitive requirements for all packages being updated.
420+
// Pass the already-parsed modFile to avoid a redundant disk read.
421+
ga.checkTransitiveRequirementsForStrategy(ctx, analysis, strategy, strategyModFile)
404422

405423
// Deduplicate DirectUpdates — the same package can be added from multiple paths
406424
// (direct update, parent bump for an indirect dep, transitive co-update).
@@ -417,86 +435,36 @@ func (ga *GolangAnalyzer) checkTransitiveRequirementsForStrategy(
417435
ctx context.Context,
418436
analysis *analyzer.AnalysisResult,
419437
strategy *analyzer.Strategy,
438+
modFile *modfile.File,
420439
) {
421440
log := clog.FromContext(ctx)
422441

423-
// Get module root from analysis
424-
modRoot := "."
425-
if rootPath, ok := analysis.Metadata["moduleRoot"].(string); ok {
426-
modRoot = rootPath
427-
}
428-
429-
// Parse go.mod (if moduleRoot is set and file exists)
430-
modFilePath := filepath.Join(modRoot, "go.mod")
431-
modFile, _, err := ParseGoModfile(modFilePath)
432-
if err != nil {
433-
// If we can't parse go.mod, skip transitive checking
434-
// This can happen in tests or when analyzing remotely
435-
log.Debugf("Could not parse go.mod for transitive checking: %v", err)
436-
return
437-
}
438-
439-
// Build map of packages being updated
440-
packagesBeingUpdated := make(map[string]string)
441-
for _, dep := range strategy.DirectUpdates {
442-
packagesBeingUpdated[dep.Name] = dep.Version
443-
}
444-
445-
// Check each package for missing transitive requirements
446-
allMissingDeps := make(map[string]MissingDependency)
447-
448-
for _, dep := range strategy.DirectUpdates {
449-
missingDeps, err := CheckTransitiveRequirements(ctx, dep.Name, dep.Version, modFile)
450-
if err != nil {
451-
log.Warnf("Could not check transitive requirements for %s@%s: %v", dep.Name, dep.Version, err)
452-
continue
442+
// Caller may pass nil if go.mod could not be parsed (e.g. remote analysis without checkout).
443+
if modFile == nil {
444+
// Fall back to parsing go.mod ourselves if caller couldn't provide it.
445+
modRoot := "."
446+
if rootPath, ok := analysis.Metadata["moduleRoot"].(string); ok {
447+
modRoot = rootPath
453448
}
454-
455-
// Also check for potential API/schema incompatibilities in other packages
456-
apiIssues, err := CheckAPICompatibility(ctx, dep.Name, dep.Version, modFile)
449+
var err error
450+
modFile, _, err = ParseGoModfile(filepath.Join(modRoot, "go.mod"))
457451
if err != nil {
458-
log.Debugf("Could not check API compatibility for %s@%s: %v", dep.Name, dep.Version, err)
459-
} else if len(apiIssues) > 0 {
460-
// Collect API compatibility issues for grouped warning
461-
for _, issue := range apiIssues {
462-
strategy.Warnings = append(strategy.Warnings,
463-
fmt.Sprintf("API Compatibility Alert - %s imports %s which is being updated to %s (may require manual version bump)",
464-
issue.Package, dep.Name, dep.Version))
465-
log.Infof("API compatibility alert for %s", issue.Package)
466-
}
452+
log.Debugf("Could not parse go.mod for transitive checking: %v", err)
453+
return
467454
}
455+
}
468456

469-
// Only add missing deps that are NOT already being updated
470-
for _, missing := range missingDeps {
471-
// Skip if this dependency is already in the update list
472-
if targetVer, beingUpdated := packagesBeingUpdated[missing.Package]; beingUpdated {
473-
// Check if the version being updated is sufficient
474-
if semver.IsValid(targetVer) && semver.IsValid(missing.RequiredVersion) {
475-
if semver.Compare(targetVer, missing.RequiredVersion) >= 0 {
476-
log.Debugf("Dependency %s requirement satisfied by update to %s", missing.Package, targetVer)
477-
continue
478-
}
479-
}
480-
}
481-
482-
// Add to missing deps (deduplicate, keep highest required version)
483-
if existing, exists := allMissingDeps[missing.Package]; exists {
484-
if semver.IsValid(missing.RequiredVersion) && semver.IsValid(existing.RequiredVersion) {
485-
if semver.Compare(missing.RequiredVersion, existing.RequiredVersion) > 0 {
486-
allMissingDeps[missing.Package] = missing
487-
}
488-
}
489-
} else {
490-
allMissingDeps[missing.Package] = missing
491-
}
492-
}
457+
packagesToUpdate := make(map[string]string, len(strategy.DirectUpdates))
458+
for _, dep := range strategy.DirectUpdates {
459+
packagesToUpdate[dep.Name] = dep.Version
493460
}
494461

495-
// Add missing dependencies to DirectUpdates, skipping no-ops (where version isn't changing)
462+
allMissingDeps, apiCompatibilityAlerts := detectCoUpdates(ctx, packagesToUpdate, modFile)
463+
464+
// Add missing dependencies to DirectUpdates, skipping no-ops (where version isn't changing).
496465
if len(allMissingDeps) > 0 {
497466
log.Infof("Found %d additional dependencies that need co-updating", len(allMissingDeps))
498467
for _, missing := range allMissingDeps {
499-
// Skip no-op updates (where required version equals current version)
500468
if missing.CurrentVersion == missing.RequiredVersion {
501469
log.Debugf("Skipping no-op update for %s (already at %s)", missing.Package, missing.CurrentVersion)
502470
continue
@@ -515,6 +483,60 @@ func (ga *GolangAnalyzer) checkTransitiveRequirementsForStrategy(
515483
log.Infof("Adding co-update: %s@%s", missing.Package, missing.RequiredVersion)
516484
}
517485
}
486+
487+
// Surface API compatibility alerts. When detectCoUpdates determined a minimum
488+
// compatible version, add it as a DirectUpdate so it appears in all output types
489+
// (JSON, YAML, deps file). A warning is also emitted for human-readable context.
490+
// When no version could be determined, emit a warning only.
491+
for pkg, recommendedVer := range apiCompatibilityAlerts {
492+
// Skip packages that are already being updated — they're handled by DirectUpdates.
493+
if _, alreadyUpdating := packagesToUpdate[pkg]; alreadyUpdating {
494+
continue
495+
}
496+
currentVer := getVersion(modFile, pkg)
497+
importingPkg, importingVer := findImporterForAlert(pkg, packagesToUpdate, modFile)
498+
if recommendedVer != "" && recommendedVer != currentVer {
499+
strategy.DirectUpdates = append(strategy.DirectUpdates, analyzer.Dependency{
500+
Name: pkg,
501+
Version: recommendedVer,
502+
Metadata: map[string]any{
503+
"required_by": "api compatibility check",
504+
"reason": fmt.Sprintf("imports %s@%s which has breaking API changes", importingPkg, importingVer),
505+
},
506+
})
507+
log.Infof("Adding API compat co-update: %s@%s (imports %s)", pkg, recommendedVer, importingPkg)
508+
strategy.Warnings = append(strategy.Warnings,
509+
fmt.Sprintf("API Compatibility Alert - updating %s to %s (imports %s@%s with breaking changes)",
510+
pkg, recommendedVer, importingPkg, importingVer))
511+
continue
512+
}
513+
strategy.Warnings = append(strategy.Warnings,
514+
fmt.Sprintf("API Compatibility Alert - %s imports %s which is being updated to %s (may require manual version bump)",
515+
pkg, importingPkg, importingVer))
516+
}
517+
}
518+
519+
// findImporterForAlert returns a representative (package, version) being updated
520+
// that triggered the API compatibility alert for the given affected package.
521+
// Falls back to ("", "") when no concrete importer can be determined; in that case
522+
// callers should still emit the alert because the affected package is the most
523+
// actionable signal for the user.
524+
func findImporterForAlert(affectedPkg string, packagesToUpdate map[string]string, modFile *modfile.File) (string, string) {
525+
// Prefer a deterministic choice: walk packagesToUpdate in sorted order.
526+
names := make([]string, 0, len(packagesToUpdate))
527+
for name := range packagesToUpdate {
528+
names = append(names, name)
529+
}
530+
sort.Strings(names)
531+
for _, name := range names {
532+
// affectedPkg shouldn't be the importer itself.
533+
if name == affectedPkg {
534+
continue
535+
}
536+
return name, packagesToUpdate[name]
537+
}
538+
// Last resort: report the affected package's current version, with empty importer.
539+
return affectedPkg, getVersion(modFile, affectedPkg)
518540
}
519541

520542
// handleIndirectDependency resolves an indirect dependency to parent bumps.

0 commit comments

Comments
 (0)