diff --git a/cmd/agents/deps.go b/cmd/agents/deps.go index f85c39930..37182292c 100644 --- a/cmd/agents/deps.go +++ b/cmd/agents/deps.go @@ -13,10 +13,10 @@ import ( "github.com/codefly-dev/cli/cmd/common" "github.com/codefly-dev/cli/pkg/cli" + "github.com/codefly-dev/cli/pkg/dependencypins" "github.com/codefly-dev/core/shared" "github.com/spf13/cobra" "golang.org/x/mod/modfile" - "golang.org/x/mod/module" ) // DepsCmd manages an agent's build dependencies: the local↔published core @@ -414,44 +414,12 @@ func pinModule(ctx context.Context, dir string, env []string, version, label str // Select pins before any mutation so a typo cannot produce a partial update or // add a dependency to modules that never used it. func dependencyPins(dirs, dependencies []string) (map[string][]string, error) { - pins := make(map[string][]string) - selected := make(map[string]string) for _, dependency := range dependencies { - path, version, found := strings.Cut(dependency, "@") - if !found || version == "" || strings.ContainsAny(version, "@ \t\r\n") || module.CheckPath(path) != nil { - return nil, fmt.Errorf("dependency %q must be module@version", dependency) - } - if path == coreModule { + if strings.HasPrefix(dependency, coreModule+"@") { return nil, fmt.Errorf("select Core through --pin, not --dependency") } - if _, exists := selected[path]; exists { - return nil, fmt.Errorf("dependency %q is selected more than once", path) - } - selected[path] = dependency - } - matched := make(map[string]bool) - for _, dir := range dirs { - data, err := os.ReadFile(filepath.Join(dir, "go.mod")) - if err != nil { - return nil, err - } - file, err := modfile.Parse("go.mod", data, nil) - if err != nil { - return nil, err - } - for _, requirement := range file.Require { - if dependency, exists := selected[requirement.Mod.Path]; exists { - pins[dir] = append(pins[dir], dependency) - matched[requirement.Mod.Path] = true - } - } - } - for path := range selected { - if !matched[path] { - return nil, fmt.Errorf("dependency %q is not required by the agent or its base fixtures", path) - } } - return pins, nil + return dependencypins.Plan(dirs, dependencies) } // baseFixtureDirs returns every base-fixture Go module under the agent diff --git a/cmd/update/deps.go b/cmd/update/deps.go index 0f99902fa..ecdb34e7b 100644 --- a/cmd/update/deps.go +++ b/cmd/update/deps.go @@ -14,6 +14,7 @@ import ( "github.com/codefly-dev/cli/cmd/common" "github.com/codefly-dev/cli/cmd/companion" "github.com/codefly-dev/cli/pkg/cli" + "github.com/codefly-dev/cli/pkg/dependencypins" coreaudit "github.com/codefly-dev/core/agents/services/audit" "github.com/spf13/cobra" ) @@ -30,6 +31,7 @@ var ( depsAudit bool depsStaleDays int depsGo string + depsSelections []string ) // DepsCmd implements `codefly update deps` — the "update all packages" @@ -53,6 +55,10 @@ For each Go module under --dir (default: current directory), update deps: - go mod tidy - re-audit with govulncheck (unless --audit=false) +With --dependency module@version (repeatable), pin only those existing external +requirements, including indirect dependencies, and leave other selections alone. +All selections are validated before changing any module. + With --companions, also rebuilds every companion image with 'docker build --pull' so floating base tags (golang:1.26-alpine, …) pick up upstream patch releases — clearing base-image CVEs like the Go stdlib advisories. @@ -60,6 +66,7 @@ patch releases — clearing base-image CVEs like the Go stdlib advisories. Examples: codefly update deps # update Go deps under cwd + audit codefly update deps --dir . # same, explicit + codefly update deps --dependency golang.org/x/net@v0.62.0 codefly update deps --companions # also rebuild companion images (--pull) codefly update deps --audit=false # skip the post-update audit`, Args: cobra.NoArgs, @@ -89,6 +96,15 @@ Examples: if len(modDirs) == 0 { return fmt.Errorf("no go.mod found under %s", root) } + for _, selection := range depsSelections { + if strings.HasPrefix(selection, internalModulePrefix) { + return fmt.Errorf("first-party dependency %q is not managed by update deps", selection) + } + } + pins, err := dependencypins.Plan(modDirs, depsSelections) + if err != nil { + return err + } cli.Header(1, "Updating dependencies across %d module(s)", len(modDirs)) var failures []error @@ -123,7 +139,10 @@ Examples: failures = append(failures, err) break } - if err := updateModule(ctx, md); err != nil { + if len(depsSelections) > 0 && len(pins[md]) == 0 { + continue + } + if err := updateModule(ctx, md, pins[md]...); err != nil { cli.Error("update %s: %v", rel(root, md), err) failures = append(failures, fmt.Errorf("update %s: %w", rel(root, md), err)) } @@ -172,24 +191,31 @@ Examples: // updateModule bumps every outdated external dependency in the module at // dir to its latest safe version, then tidies. Internal codefly-dev // modules are skipped (see internalModulePrefix). -func updateModule(ctx context.Context, dir string) error { - res, err := coreaudit.Golang(ctx, dir, true) - if err != nil { - return fmt.Errorf("list outdated: %w", err) - } +func updateModule(ctx context.Context, dir string, selections ...string) error { var bumped int - for _, o := range res.Outdated { - if strings.HasPrefix(o.Package, internalModulePrefix) { - continue + if len(selections) > 0 { + if err := runGo(ctx, dir, append([]string{"get"}, selections...)...); err != nil { + return err } - if o.LatestSafe == "" || o.LatestSafe == o.Current { - continue + bumped = len(selections) + } else { + res, err := coreaudit.Golang(ctx, dir, true) + if err != nil { + return fmt.Errorf("list outdated: %w", err) } - if err := runGo(ctx, dir, "get", o.Package+"@"+o.LatestSafe); err != nil { - return fmt.Errorf("go get %s@%s: %w", o.Package, o.LatestSafe, err) + for _, o := range res.Outdated { + if strings.HasPrefix(o.Package, internalModulePrefix) { + continue + } + if o.LatestSafe == "" || o.LatestSafe == o.Current { + continue + } + if err := runGo(ctx, dir, "get", o.Package+"@"+o.LatestSafe); err != nil { + return fmt.Errorf("go get %s@%s: %w", o.Package, o.LatestSafe, err) + } + cli.Info(" %s %s → %s", o.Package, o.Current, o.LatestSafe) + bumped++ } - cli.Info(" %s %s → %s", o.Package, o.Current, o.LatestSafe) - bumped++ } // `go mod tidy` ignores go.work: it resolves every import from the // module graph (cache/network), not from workspace-local sibling @@ -314,6 +340,7 @@ func rel(root, p string) string { } func init() { + DepsCmd.Flags().StringArrayVar(&depsSelections, "dependency", nil, "Pin only selected existing external module@version requirements, including indirect dependencies (repeatable)") DepsCmd.Flags().StringVar(&depsDir, "dir", "", "Root to update (default: current directory; recurses into every go.mod)") DepsCmd.Flags().BoolVar(&depsCompanions, "companions", false, "Also rebuild companion Docker images with fresh base layers (docker build --pull)") DepsCmd.Flags().BoolVar(&depsAudit, "audit", true, "Run the govulncheck audit after updating") diff --git a/cmd/update/deps_selected_test.go b/cmd/update/deps_selected_test.go new file mode 100644 index 000000000..ce177973b --- /dev/null +++ b/cmd/update/deps_selected_test.go @@ -0,0 +1,61 @@ +package update + +import ( + "os" + "path/filepath" + "testing" + + "github.com/codefly-dev/cli/cmd/common" + "github.com/codefly-dev/cli/pkg/dependencypins" + "github.com/stretchr/testify/require" + "golang.org/x/mod/modfile" +) + +func TestSelectedDependencyUpdatesIndirectWithoutMovingOthers(t *testing.T) { + t.Setenv("GOWORK", "off") + t.Setenv("GOPROXY", "off") + root := t.TempDir() + files := map[string]string{ + "go.mod": "module example.test/app\ngo 1.23\nrequire (\nexample.test/direct v1.0.0\nexample.test/indirect v1.0.0 // indirect\n)\nreplace example.test/direct => ./direct\nreplace example.test/indirect => ./indirect\n", + "app.go": "package app\nimport \"example.test/direct\"\nvar Value = direct.Value\n", + "direct/go.mod": "module example.test/direct\ngo 1.23\nrequire example.test/indirect v1.0.0\nreplace example.test/indirect => ../indirect\n", + "direct/direct.go": "package direct\nimport \"example.test/indirect\"\nvar Value = indirect.Value\n", + "indirect/go.mod": "module example.test/indirect\ngo 1.23\n", + "indirect/indirect.go": "package indirect\nconst Value = 42\n", + } + for name, data := range files { + path := filepath.Join(root, name) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, []byte(data), 0o644)) + } + // The existing module resolver is real; local replacements keep the fixture offline. + pins, err := dependencypins.Plan([]string{root, filepath.Join(root, "indirect")}, []string{"example.test/indirect@v1.0.1"}) + require.NoError(t, err) + require.Empty(t, pins[filepath.Join(root, "indirect")], "do not inject a requirement into a module that never used it") + oldDir, oldAudit, oldSelections := depsDir, depsAudit, depsSelections + t.Cleanup(func() { depsDir, depsAudit, depsSelections = oldDir, oldAudit, oldSelections }) + depsDir, depsAudit = root, false + depsSelections = []string{"example.test/indirect@v1.0.1"} + ctx, done := common.NewContext() + defer done() + require.NoError(t, DepsCmd.RunE(DepsCmd, nil)) + data, err := os.ReadFile(filepath.Join(root, "go.mod")) + require.NoError(t, err) + parsed, err := modfile.Parse("go.mod", data, nil) + require.NoError(t, err) + versions := map[string]string{} + for _, r := range parsed.Require { + versions[r.Mod.Path] = r.Mod.Version + } + require.Equal(t, "v1.0.1", versions["example.test/indirect"]) + require.Equal(t, "v1.0.0", versions["example.test/direct"]) + require.NoError(t, runGo(ctx, root, "build", "./...")) + require.NoError(t, runGo(ctx, root, "mod", "tidy", "-diff")) + before := string(data) + depsSelections = []string{"example.test/indirect@v1.0.2", "example.test/missing@v1.0.1"} + err = DepsCmd.RunE(DepsCmd, nil) + require.Error(t, err) + data, err = os.ReadFile(filepath.Join(root, "go.mod")) + require.NoError(t, err) + require.Equal(t, before, string(data), "all selections must validate before mutation") +} diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 4c0df52ed..4ee0b7ecc 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -5281,6 +5281,10 @@ For each Go module under --dir (default: current directory), update deps: - go mod tidy - re-audit with govulncheck (unless --audit=false) +With --dependency module@version (repeatable), pin only those existing external +requirements, including indirect dependencies, and leave other selections alone. +All selections are validated before changing any module. + With --companions, also rebuilds every companion image with 'docker build --pull' so floating base tags (golang:1.26-alpine, …) pick up upstream patch releases — clearing base-image CVEs like the Go stdlib advisories. @@ -5288,6 +5292,7 @@ patch releases — clearing base-image CVEs like the Go stdlib advisories. Examples: codefly update deps # update Go deps under cwd + audit codefly update deps --dir . # same, explicit + codefly update deps --dependency golang.org/x/net@v0.62.0 codefly update deps --companions # also rebuild companion images (--pull) codefly update deps --audit=false # skip the post-update audit ``` @@ -5299,11 +5304,12 @@ codefly update deps [flags] Flags: ``` - --audit Run the govulncheck audit after updating (default true) - --companions Also rebuild companion Docker images with fresh base layers (docker build --pull) - --dir string Root to update (default: current directory; recurses into every go.mod) - --go string Pin the Go toolchain to this version across every go.mod + go.work (e.g. 1.26.4) — clears stdlib CVEs in standalone/CI builds - --stale-days int Audit: fail when a suppression's reviewed date is older than this many days (0 disables) (default 45) + --audit Run the govulncheck audit after updating (default true) + --companions Also rebuild companion Docker images with fresh base layers (docker build --pull) + --dependency stringArray Pin only selected existing external module@version requirements, including indirect dependencies (repeatable) + --dir string Root to update (default: current directory; recurses into every go.mod) + --go string Pin the Go toolchain to this version across every go.mod + go.work (e.g. 1.26.4) — clears stdlib CVEs in standalone/CI builds + --stale-days int Audit: fail when a suppression's reviewed date is older than this many days (0 disables) (default 45) ``` ## `codefly update service` diff --git a/docs/commands.md b/docs/commands.md index 15350f9e2..1bde38e37 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -2431,6 +2431,7 @@ codefly update workspace --agent-override codefly.dev/go-grpc=0.1.47-dev.abc123d codefly update service api # one service to its latest compatible release codefly update service api --agent-version 0.1.47-dev.abc123def456 # pin one service exactly codefly update deps # Go dependencies under the current directory +codefly update deps --dependency golang.org/x/net@v0.62.0 # only this existing external requirement, including indirect pins ``` Without flags, `update workspace` and `update service` move to the agent's diff --git a/docs/runbooks/cut-a-release.md b/docs/runbooks/cut-a-release.md index aef7d3bd8..7efd382dc 100644 --- a/docs/runbooks/cut-a-release.md +++ b/docs/runbooks/cut-a-release.md @@ -145,3 +145,24 @@ codefly self check-update # sees the new sta - [ ] `release.yaml` green end to end - [ ] `codefly version --json` reports the tag - [ ] `brew` install/upgrade works; `self check-update` sees it + +## Dependency fixes in companion images + +The October 2026 refresh selects `golang.org/x/net` 0.62.0 for +GO-2026-6860 and GO-2026-6866, and gRPC 1.85.0 supersedes the old 1.84.0 +hold. The dependency update command's automatic outdated list contains direct +dependencies; verify indirect dependencies such as `x/net` explicitly too. +Use `codefly update deps --dependency golang.org/x/net@v0.62.0` to select an +existing direct or indirect external requirement without moving unrelated +versions. The repeatable flag validates every selection across the selected +modules before mutation, then uses the same Go resolver, tidy and audit path as +the automatic update. Modules without the selected requirement are not changed; +unknown or duplicate selections and first-party selections are refused. Exact +selections also retain a version required by a compatibility gate without +silently undoing that hold during an unrelated security update. + +Companion publication compiles this checkout into the images. Merge the CLI +fix before dispatching `companions.yaml` against Core's exact merged commit +with new companion versions. A CLI merge does not refresh existing images: +inspect and scan the published executable, record each index digest, and +rebuild the services consuming it. Do not overwrite previously published tags. diff --git a/go.mod b/go.mod index 0a8df19b6..7e74a23d1 100644 --- a/go.mod +++ b/go.mod @@ -45,23 +45,15 @@ require ( go.uber.org/goleak v1.3.0 golang.org/x/crypto v0.58.0 golang.org/x/mod v0.42.0 - golang.org/x/oauth2 v0.36.0 + golang.org/x/oauth2 v0.37.0 golang.org/x/sync v0.24.0 golang.org/x/sys v0.49.0 golang.org/x/term v0.47.0 - google.golang.org/genproto/googleapis/api v0.0.0-20261019180522-ce908d29299b - // HELD at v1.83.2: v1.84.0 is affected by GO-2026-6443 (server panic via a - // missing :authority/Host header) and the advisory's only fix for the 1.84 - // line is an unreleased v1.85.0-dev pseudo-version, so there is nothing to - // bump to. v1.83.2 is itself the patched release for the 1.83 line. The - // `bootstrap-audit` gate fails on this as an actionable finding, which is - // correct -- do NOT add a .govulncheck.yaml suppression to get past it. - // Raise this to v1.85.0 (or a patched v1.84.x) once one ships; see - // .github/dependabot.yml, which ignores only the known-bad v1.84.0. - google.golang.org/grpc v1.84.0 + google.golang.org/genproto/googleapis/api v0.0.0-20261026165753-2ffab32bce7e + google.golang.org/grpc v1.85.0 google.golang.org/protobuf v1.36.12 gopkg.in/yaml.v3 v3.0.1 - oras.land/oras-go/v2 v2.6.2 + oras.land/oras-go/v2 v2.7.0 sigs.k8s.io/kustomize/api v0.21.1 sigs.k8s.io/kustomize/kyaml v0.21.1 ) @@ -242,10 +234,10 @@ require ( go.yaml.in/yaml/v3 v3.0.5 // indirect go.yaml.in/yaml/v4 v4.0.0-rc.6 // indirect golang.org/x/exp v0.0.0-20261014143641-f0251eb4aa3e // indirect - golang.org/x/net v0.61.0 // indirect + golang.org/x/net v0.62.0 // indirect golang.org/x/text v0.43.0 // indirect golang.org/x/time v0.16.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20261012220534-4341edf3f941 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20261019180522-ce908d29299b // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad // indirect diff --git a/go.sum b/go.sum index 31175fa7a..5a622a262 100644 --- a/go.sum +++ b/go.sum @@ -117,8 +117,6 @@ github.com/codefly-dev/authority/libraries/hop/go v0.0.0-20261026175541-3afad722 github.com/codefly-dev/authority/libraries/hop/go v0.0.0-20261026175541-3afad7224855/go.mod h1:rfn41uiORQ36RrOeFEjXHPLC9NUi0j14AoYJtcovsn4= github.com/codefly-dev/authority/libraries/resources/go v0.0.0-20261026023425-000d44495d93 h1:4EygH7/J0E6aM7hUPdbQGfit1rsLUNCtWJ8kMh2b9PE= github.com/codefly-dev/authority/libraries/resources/go v0.0.0-20261026023425-000d44495d93/go.mod h1:1rmjrVyr0h7zOuwOHS9i+BoeS9dr3XNvtupLZFwY6As= -github.com/codefly-dev/core v0.19.10 h1:rTeh1vph0xGtpiCCt2Dl1AH2Rz9hzIYt1YGcbv6bLYE= -github.com/codefly-dev/core v0.19.10/go.mod h1:aTJY3Rw0OSVkbWRThgDzSkqieoF9mW4MiPRXM8Vzxww= github.com/codefly-dev/core v0.19.11-0.20261028153419-de19eba80550 h1:IBur1pFM7LelM+9ecMgl2/Mxgd+04rgesSVFm3R1k0E= github.com/codefly-dev/core v0.19.11-0.20261028153419-de19eba80550/go.mod h1:aTJY3Rw0OSVkbWRThgDzSkqieoF9mW4MiPRXM8Vzxww= github.com/codefly-dev/golor v0.1.3 h1:xmo+ceyJFRYZdvpWE2fNd0jeaadp/Ibm1BnganiGKOc= @@ -558,10 +556,10 @@ golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw= -golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI= -golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= -golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/net v0.62.0 h1:18ELalfM5+XNRwzM2nYqXsZQ9ByS1uhj2O/U7+5jqRY= +golang.org/x/net v0.62.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI= +golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98= +golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58= golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA= golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -594,12 +592,12 @@ golang.org/x/tools v0.52.0 h1:ryxg4U63oaUYk7uOLTF37n43f4qgtGvqV4Ioit1BaEs= golang.org/x/tools v0.52.0/go.mod h1:ItcP5UNaVGG+Yc4eIYmaFLNJ4soEopkx5Y2d4lcEurs= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/genproto/googleapis/api v0.0.0-20261019180522-ce908d29299b h1:nB3spRIaSPtxJpxb0k41yljROvm2Jrwfs2pSNdKS5y8= -google.golang.org/genproto/googleapis/api v0.0.0-20261019180522-ce908d29299b/go.mod h1:9N4vtjEwvgQKEdlFVQO0M44ErhecQ9DCSQfGoZ51M6w= -google.golang.org/genproto/googleapis/rpc v0.0.0-20261012220534-4341edf3f941 h1:QG6hS4zFplRZvOQedzei4t2pT/zkQc3VR1u+S9/rcAw= -google.golang.org/genproto/googleapis/rpc v0.0.0-20261012220534-4341edf3f941/go.mod h1:OaIUM3+LpYcK2GXM4FTmhWoIq371Owdr+Cc7/BsYHHc= -google.golang.org/grpc v1.84.0 h1:soMyaPJ8pAak5PIQ0DGBUir0XRo2fRoMqhNWMLlLxO0= -google.golang.org/grpc v1.84.0/go.mod h1:ljCht0DrxQrXBDRTZp52Qxh3Ffk8CdYm2sj4O2QN2C0= +google.golang.org/genproto/googleapis/api v0.0.0-20261026165753-2ffab32bce7e h1:e789sLZjeTdc02ZCQGTSzHOu0fR73+dwPQVKUp67Z88= +google.golang.org/genproto/googleapis/api v0.0.0-20261026165753-2ffab32bce7e/go.mod h1:vHV3wJM9WZyfV7GyB+OQGq5dWVrFwCDxapPK4WlRD88= +google.golang.org/genproto/googleapis/rpc v0.0.0-20261019180522-ce908d29299b h1:6WNWHZk91dp4gZ25642OALkuG1UVNZbwENJD19DnB8k= +google.golang.org/genproto/googleapis/rpc v0.0.0-20261019180522-ce908d29299b/go.mod h1:OaIUM3+LpYcK2GXM4FTmhWoIq371Owdr+Cc7/BsYHHc= +google.golang.org/grpc v1.85.0 h1:BPBCfuVRQ1qvBhKDOq9uvQllMvO59rKsYhXpp7Dd8vw= +google.golang.org/grpc v1.85.0/go.mod h1:0rQrOD58yjal/0EaVbGBANindixsCZNQPDV3VKtaKuQ= google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -620,8 +618,8 @@ gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad h1:oXImqH8mQNk7PmvzKhmN3ddJoY6OnyM225MXwGHPm0A= k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad/go.mod h1:0/mqHCVhlumdJ3BhCfnjSZQE037nAhNodh1/hK0T8/I= -oras.land/oras-go/v2 v2.6.2 h1:N04RXngAp1LJKTG6ifz3xHPipasEkWr+hFmInja5YKo= -oras.land/oras-go/v2 v2.6.2/go.mod h1:PlTtg4JTDJkDe8yVHpM2wz7/YDc00GVas+i4jAW2TZ4= +oras.land/oras-go/v2 v2.7.0 h1:0bXRBBSqYgPSjZRrhJbT71Rb465c+rdlft2xdQPxLtQ= +oras.land/oras-go/v2 v2.7.0/go.mod h1:imXEFiYkTZUVVYkfv/r5GVmWeiLkGtNQA8FU4F/sjKs= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/kustomize/api v0.21.1 h1:lzqbzvz2CSvsjIUZUBNFKtIMsEw7hVLJp0JeSIVmuJs= diff --git a/pkg/dependencypins/pins.go b/pkg/dependencypins/pins.go new file mode 100644 index 000000000..3c3f017a0 --- /dev/null +++ b/pkg/dependencypins/pins.go @@ -0,0 +1,52 @@ +// Package dependencypins selects existing Go requirements before a pin mutates locks. +package dependencypins + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "golang.org/x/mod/modfile" + "golang.org/x/mod/module" +) + +// Plan validates selections across every module before returning each module's pins. +// Requirements may be direct or indirect; absent requirements are never introduced. +func Plan(dirs, dependencies []string) (map[string][]string, error) { + pins := make(map[string][]string) + selected := make(map[string]string) + for _, dependency := range dependencies { + path, version, found := strings.Cut(dependency, "@") + if !found || version == "" || strings.ContainsAny(version, "@ \t\r\n") || module.CheckPath(path) != nil { + return nil, fmt.Errorf("dependency %q must be module@version", dependency) + } + if _, exists := selected[path]; exists { + return nil, fmt.Errorf("dependency %q is selected more than once", path) + } + selected[path] = dependency + } + matched := make(map[string]bool) + for _, dir := range dirs { + data, err := os.ReadFile(filepath.Join(dir, "go.mod")) + if err != nil { + return nil, err + } + file, err := modfile.Parse("go.mod", data, nil) + if err != nil { + return nil, err + } + for _, requirement := range file.Require { + if dependency, exists := selected[requirement.Mod.Path]; exists { + pins[dir] = append(pins[dir], dependency) + matched[requirement.Mod.Path] = true + } + } + } + for path := range selected { + if !matched[path] { + return nil, fmt.Errorf("dependency %q is not required by any selected module", path) + } + } + return pins, nil +}