Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package updatecli for openSUSE:Factory checked in at 2026-08-28 19:51:49 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/updatecli (Old) and /work/SRC/openSUSE:Factory/.updatecli.new.1265 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "updatecli" Fri Aug 28 19:51:49 2026 rev:45 rq:1374260 version:0.120.1 Changes: -------- --- /work/SRC/openSUSE:Factory/updatecli/updatecli.changes 2026-08-06 16:27:38.683835969 +0200 +++ /work/SRC/openSUSE:Factory/.updatecli.new.1265/updatecli.changes 2026-08-28 19:53:42.464242298 +0200 @@ -1,0 +2,21 @@ +Fri Aug 28 07:26:48 UTC 2026 - Johannes Kastl <[email protected]> + +- Update to version 0.120.1: + * Bug Fixes + - fix: skip resource depends on skipped source (#9916) + - fix: go module cooldown with pseudo version (#9917) + - fix(yaml): pass keyonly condition with searchpattern when at + least one file matches (#9889) + - fix(temurin): include specificversion in ReportConfig (#9850) + * Maintenance + - deps: Bump Golang version to 1.26.6 (#9925) + - deps: bump Updatecli GH action to v3.5.0 (#9901) + - deps: bump Updatecli GH action to v0.120.0 (#9890) + - deps(updatecli/policies): bump all policies (#9877) + - deps(go): bump module go.opentelemetry.io/otel/trace to + v1.45.0 (#9861) + - chore(dockerfile): upgrade node version (#9822) + * Documentation + - Add checklist item for manual testing (#9905) + +------------------------------------------------------------------- Old: ---- updatecli-0.120.0.obscpio New: ---- updatecli-0.120.1.obscpio ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ updatecli.spec ++++++ --- /var/tmp/diff_new_pack.6axwXz/_old 2026-08-28 19:53:45.384344366 +0200 +++ /var/tmp/diff_new_pack.6axwXz/_new 2026-08-28 19:53:45.386344436 +0200 @@ -17,7 +17,7 @@ Name: updatecli -Version: 0.120.0 +Version: 0.120.1 Release: 0 Summary: A Declarative Dependency Management tool License: Apache-2.0 ++++++ _service ++++++ --- /var/tmp/diff_new_pack.6axwXz/_old 2026-08-28 19:53:45.466347233 +0200 +++ /var/tmp/diff_new_pack.6axwXz/_new 2026-08-28 19:53:45.474347512 +0200 @@ -3,7 +3,7 @@ <param name="url">https://github.com/updatecli/updatecli.git</param> <param name="scm">git</param> <param name="exclude">.git</param> - <param name="revision">refs/tags/v0.120.0</param> + <param name="revision">refs/tags/v0.120.1</param> <param name="versionformat">@PARENT_TAG@</param> <param name="versionrewrite-pattern">v(.*)</param> <param name="changesgenerate">enable</param> ++++++ _servicedata ++++++ --- /var/tmp/diff_new_pack.6axwXz/_old 2026-08-28 19:53:45.515348946 +0200 +++ /var/tmp/diff_new_pack.6axwXz/_new 2026-08-28 19:53:45.521349155 +0200 @@ -3,6 +3,6 @@ <param name="url">https://github.com/updatecli/updatecli</param> <param name="changesrevision">786ba9fbb8374d9c23e18b062f29ac1a95fd2b3c</param></service><service name="tar_scm"> <param name="url">https://github.com/updatecli/updatecli.git</param> - <param name="changesrevision">6dcc92d2b1d89d81449865291a38cbe858b78905</param></service></servicedata> + <param name="changesrevision">5609a74b86406126f41f74e4d50f1481dbe81fd4</param></service></servicedata> (No newline at EOF) ++++++ updatecli-0.120.0.obscpio -> updatecli-0.120.1.obscpio ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/updatecli-0.120.0/README.md new/updatecli-0.120.1/README.md --- old/updatecli-0.120.0/README.md 2026-08-05 07:03:11.000000000 +0200 +++ new/updatecli-0.120.1/README.md 2026-08-13 21:59:35.000000000 +0200 @@ -122,7 +122,7 @@ **Verify Container signature** ``` -cosign verify --certificate-identity-regexp "https://github.com/updatecli/updatecli" --certificate-oidc-issuer "https://token.actions.githubusercontent.com" ghcr.io/updatecli/updatecli:v0.119.0 +cosign verify --certificate-identity-regexp "https://github.com/updatecli/updatecli" --certificate-oidc-issuer "https://token.actions.githubusercontent.com" ghcr.io/updatecli/updatecli:v0.120.0 ``` ## Documentation diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/updatecli-0.120.0/go.mod new/updatecli-0.120.1/go.mod --- old/updatecli-0.120.0/go.mod 2026-08-05 07:03:11.000000000 +0200 +++ new/updatecli-0.120.1/go.mod 2026-08-13 21:59:35.000000000 +0200 @@ -1,6 +1,6 @@ module github.com/updatecli/updatecli -go 1.26.5 +go 1.26.6 retract v0.999.0 // Published accidentally. @@ -75,12 +75,12 @@ github.com/yuin/goldmark v1.8.5 github.com/zclconf/go-cty v1.19.0 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 - go.opentelemetry.io/otel v1.44.0 + go.opentelemetry.io/otel v1.45.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0 go.opentelemetry.io/otel/sdk v1.44.0 - go.opentelemetry.io/otel/trace v1.44.0 + go.opentelemetry.io/otel/trace v1.45.0 go.yaml.in/yaml/v3 v3.0.5 go.yaml.in/yaml/v4 v4.0.0-rc.4 golang.org/x/exp v0.0.0-20260603202125-055de637280b @@ -219,7 +219,7 @@ go.opentelemetry.io/contrib/detectors/gcp v1.44.0 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect - go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/metric v1.45.0 // indirect go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect @@ -267,7 +267,7 @@ github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/go-billy/v5 v5.9.0 // indirect github.com/go-gorp/gorp/v3 v3.1.0 // indirect - github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/logr v1.4.4 // indirect github.com/go-openapi/jsonpointer v0.23.1 // indirect github.com/go-openapi/jsonreference v0.21.6 // indirect github.com/go-openapi/swag v0.26.1 // indirect diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/updatecli-0.120.0/go.sum new/updatecli-0.120.1/go.sum --- old/updatecli-0.120.0/go.sum 2026-08-05 07:03:11.000000000 +0200 +++ new/updatecli-0.120.1/go.sum 2026-08-13 21:59:35.000000000 +0200 @@ -334,8 +334,8 @@ github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= -github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= @@ -816,8 +816,8 @@ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0/go.mod h1:D7J12YRapIekYyPWgGPlA/23pRmpSEZC5xJC/TTLI9U= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI= -go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= -go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU= +go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ= go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.19.0 h1:Dn8rkudDzY6KV9dr/D/bTUuWgqDf9xe0rr4G2elrn0Y= go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.19.0/go.mod h1:gMk9F0xDgyN9M/3Ed5Y1wKcx/9mlU91NXY2SNq7RQuU= go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0 h1:HIBTQ3VO5aupLKjC90JgMqpezVXwFuq6Ryjn0/izoag= @@ -842,8 +842,8 @@ go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0/go.mod h1:L0hRV50XdVIODHUfWEqGRCXQvj2rV82STVo12FMFBU0= go.opentelemetry.io/otel/log v0.19.0 h1:KUZs/GOsw79TBBMfDWsXS+KZ4g2Ckzksd1ymzsIEbo4= go.opentelemetry.io/otel/log v0.19.0/go.mod h1:5DQYeGmxVIr4n0/BcJvF4upsraHjg6vudJJpnkL6Ipk= -go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= -go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M= +go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s= go.opentelemetry.io/otel/metric/x v0.66.0 h1:YkCrx1zLOChi9ZcZ6euupOcsgzbVlec7D/xoEU1+cTA= go.opentelemetry.io/otel/metric/x v0.66.0/go.mod h1:d1+BDj9t96do0/1LoU1ayfCv79ZgNE41qbhBvnMOBZk= go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= @@ -852,8 +852,8 @@ go.opentelemetry.io/otel/sdk/log v0.19.0/go.mod h1:vFBowwXGLlW9AvpuF7bMgnNI95LiW10szrOdvzBHlAg= go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= -go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= -go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag= +go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc= go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/updatecli-0.120.0/pkg/core/pipeline/utils.go new/updatecli-0.120.1/pkg/core/pipeline/utils.go --- old/updatecli-0.120.0/pkg/core/pipeline/utils.go 2026-08-05 07:03:11.000000000 +0200 +++ new/updatecli-0.120.1/pkg/core/pipeline/utils.go 2026-08-13 21:59:35.000000000 +0200 @@ -51,6 +51,11 @@ if dependencyResult.Category == conditionCategory && dependencyResult.Result != result.SUCCESS { return true } + // A skipped source doesn't provide any value, so running its dependents + // would have them consume an empty source input. + if dependencyResult.Category == sourceCategory && dependencyResult.Result == result.SKIPPED { + return true + } if dependencyResult.Result == result.FAILURE { // And operator but dep is failed return true diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/updatecli-0.120.0/pkg/core/pipeline/utils_test.go new/updatecli-0.120.1/pkg/core/pipeline/utils_test.go --- old/updatecli-0.120.0/pkg/core/pipeline/utils_test.go 2026-08-05 07:03:11.000000000 +0200 +++ new/updatecli-0.120.1/pkg/core/pipeline/utils_test.go 2026-08-13 21:59:35.000000000 +0200 @@ -5,6 +5,7 @@ "testing" "github.com/stretchr/testify/require" + "github.com/updatecli/updatecli/pkg/core/result" ) func TestExtractDepsFromTemplate(t *testing.T) { @@ -46,3 +47,93 @@ }) } } + +func TestShouldSkipResource(t *testing.T) { + testdata := []struct { + Name string + Leaf Node + DepsResults map[string]*Node + ExpectedResult bool + }{ + { + Name: "target depending on a successful source runs", + Leaf: Node{ + ID: "target#mytarget", + Category: targetCategory, + DependsOn: []Dependency{{ID: "source#mysource", Operator: andBooleanOperator}}, + }, + DepsResults: map[string]*Node{ + "source#mysource": {ID: "source#mysource", Category: sourceCategory, Result: result.SUCCESS}, + }, + ExpectedResult: false, + }, + { + // A skipped source has no value to provide, so its dependents would otherwise + // consume an empty source input. + Name: "target depending on a skipped source is skipped", + Leaf: Node{ + ID: "target#mytarget", + Category: targetCategory, + DependsOn: []Dependency{{ID: "source#mysource", Operator: andBooleanOperator}}, + }, + DepsResults: map[string]*Node{ + "source#mysource": {ID: "source#mysource", Category: sourceCategory, Result: result.SKIPPED}, + }, + ExpectedResult: true, + }, + { + Name: "condition depending on a skipped source is skipped", + Leaf: Node{ + ID: "condition#mycondition", + Category: conditionCategory, + DependsOn: []Dependency{{ID: "source#mysource", Operator: andBooleanOperator}}, + }, + DepsResults: map[string]*Node{ + "source#mysource": {ID: "source#mysource", Category: sourceCategory, Result: result.SKIPPED}, + }, + ExpectedResult: true, + }, + { + Name: "target depending on a failed source is skipped", + Leaf: Node{ + ID: "target#mytarget", + Category: targetCategory, + DependsOn: []Dependency{{ID: "source#mysource", Operator: andBooleanOperator}}, + }, + DepsResults: map[string]*Node{ + "source#mysource": {ID: "source#mysource", Category: sourceCategory, Result: result.FAILURE}, + }, + ExpectedResult: true, + }, + { + // A skipped target only means it had nothing to change, which mustn't stop + // the resources depending on it. + Name: "target depending on a skipped target runs", + Leaf: Node{ + ID: "target#mytarget", + Category: targetCategory, + DependsOn: []Dependency{{ID: "target#myothertarget", Operator: andBooleanOperator}}, + }, + DepsResults: map[string]*Node{ + "target#myothertarget": {ID: "target#myothertarget", Category: targetCategory, Result: result.SKIPPED}, + }, + ExpectedResult: false, + }, + { + Name: "resource without any dependency runs", + Leaf: Node{ + ID: "target#mytarget", + Category: targetCategory, + }, + ExpectedResult: false, + }, + } + + p := Pipeline{} + + for _, tt := range testdata { + t.Run(tt.Name, func(t *testing.T) { + require.Equal(t, tt.ExpectedResult, p.shouldSkipResource(&tt.Leaf, tt.DepsResults)) + }) + } +} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/updatecli-0.120.0/pkg/plugins/resources/go/module/condition.go new/updatecli-0.120.1/pkg/plugins/resources/go/module/condition.go --- old/updatecli-0.120.0/pkg/plugins/resources/go/module/condition.go 2026-08-05 07:03:11.000000000 +0200 +++ new/updatecli-0.120.1/pkg/plugins/resources/go/module/condition.go 2026-08-13 21:59:35.000000000 +0200 @@ -5,7 +5,6 @@ "fmt" "os" "strings" - "time" "github.com/sirupsen/logrus" "github.com/updatecli/updatecli/pkg/core/pipeline/scm" @@ -47,18 +46,8 @@ return true, fmt.Sprintf("version %q available", versionToCheck), nil } case false: - releaseDate, err := time.Parse(time.RFC3339, versionInfo.Time) - if err != nil { - logrus.Debugf("failed to parse release date for version %q from proxy %q: %v\n", versionToCheck, proxy, err) - continue - } - if g.Spec.Age.Minimum != "" && g.Spec.Age.IsOlderThan(releaseDate, nil) { - logrus.Debugf("ignoring version %q from proxy %q because its age is below %q (released on %s)\n", versionToCheck, proxy, g.Spec.Age.Minimum, releaseDate) - continue - } - - if g.Spec.Age.Maximum != "" && g.Spec.Age.IsNewerThan(releaseDate, nil) { - logrus.Debugf("ignoring version %q from proxy %q because its age is above %q (released on %s)\n", versionToCheck, proxy, g.Spec.Age.Maximum, releaseDate) + if !isVersionMatchingAge(versionInfo, g.Spec.Age) { + logrus.Debugf("ignoring version %q from proxy %q because it doesn't match the age filter\n", versionToCheck, proxy) continue } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/updatecli-0.120.0/pkg/plugins/resources/go/module/main.go new/updatecli-0.120.1/pkg/plugins/resources/go/module/main.go --- old/updatecli-0.120.0/pkg/plugins/resources/go/module/main.go 2026-08-05 07:03:11.000000000 +0200 +++ new/updatecli-0.120.1/pkg/plugins/resources/go/module/main.go 2026-08-13 21:59:35.000000000 +0200 @@ -45,6 +45,17 @@ newFilter.Pattern = "*" } + /* + Init must run after the fallback above, as it defaults an empty kind to "latest" + which would silently override the semantic versioning default of that resource. + It also provides the default pattern of a partially specified filter, such as + "latest" for a filter only setting `kind: latest`. + */ + newFilter, err = newFilter.Init() + if err != nil { + return nil, err + } + if err = newSpec.Age.Validate(); err != nil { return nil, fmt.Errorf("wrong age spec %v", err) } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/updatecli-0.120.0/pkg/plugins/resources/go/module/source.go new/updatecli-0.120.1/pkg/plugins/resources/go/module/source.go --- old/updatecli-0.120.0/pkg/plugins/resources/go/module/source.go 2026-08-05 07:03:11.000000000 +0200 +++ new/updatecli-0.120.1/pkg/plugins/resources/go/module/source.go 2026-08-13 21:59:35.000000000 +0200 @@ -2,6 +2,7 @@ import ( "context" + "errors" "fmt" "github.com/updatecli/updatecli/pkg/core/result" @@ -9,8 +10,18 @@ // Source returns the latest go module version func (g *GoModule) Source(ctx context.Context, workingDir string, resultSource *result.Source) error { - version, _, err := g.versions(ctx) + version, err := g.versions(ctx) if err != nil { + /* + Every published version is still cooling down, which is an expected state of + the age filter rather than a failure, so the source is skipped instead. + */ + if errors.Is(err, ErrNoVersionMatchingAge) { + resultSource.Result = result.SKIPPED + resultSource.Description = fmt.Sprintf("no version of the GO module %q matches the age filter yet", g.Spec.Module) + return nil + } + return fmt.Errorf("searching go module version: %w", err) } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/updatecli-0.120.0/pkg/plugins/resources/go/module/source_test.go new/updatecli-0.120.1/pkg/plugins/resources/go/module/source_test.go --- old/updatecli-0.120.0/pkg/plugins/resources/go/module/source_test.go 2026-08-05 07:03:11.000000000 +0200 +++ new/updatecli-0.120.1/pkg/plugins/resources/go/module/source_test.go 2026-08-13 21:59:35.000000000 +0200 @@ -13,10 +13,11 @@ func TestSource(t *testing.T) { tests := []struct { - name string - spec Spec - expectedResult string - expectedError bool + name string + spec Spec + expectedResult string + expectedError bool + expectedSkipped bool }{ { spec: Spec{ @@ -84,7 +85,8 @@ Minimum: "100y", }, }, - expectedError: true, + // Every published version is still cooling down, which is a skip and not a failure + expectedSkipped: true, }, { spec: Spec{ @@ -110,7 +112,8 @@ Maximum: "1s", }, }, - expectedError: true, + // Every published version is too old, which is a skip and not a failure + expectedSkipped: true, }, } for _, tt := range tests { @@ -124,6 +127,12 @@ return } require.NoError(t, err) + if tt.expectedSkipped { + assert.Equal(t, result.SKIPPED, gotResult.Result) + assert.Empty(t, gotResult.Information) + return + } + assert.Equal(t, result.SUCCESS, gotResult.Result) assert.Equal(t, tt.expectedResult, gotResult.Information) }) } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/updatecli-0.120.0/pkg/plugins/resources/go/module/version.go new/updatecli-0.120.1/pkg/plugins/resources/go/module/version.go --- old/updatecli-0.120.0/pkg/plugins/resources/go/module/version.go 2026-08-05 07:03:11.000000000 +0200 +++ new/updatecli-0.120.1/pkg/plugins/resources/go/module/version.go 2026-08-13 21:59:35.000000000 +0200 @@ -3,6 +3,7 @@ import ( "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -19,6 +20,11 @@ "github.com/updatecli/updatecli/pkg/plugins/utils/version" ) +// ErrNoVersionMatchingAge is returned when a Golang module publishes versions but the age +// filter discarded all of them. It reports a cooldown still running, not a lookup failure, +// so callers are expected to skip rather than to fail. +var ErrNoVersionMatchingAge = errors.New("no version matching the age filter") + // versionInfo represents the structure of the version information returned by the Go proxy API. type versionInfo struct { Version string `json:"Version"` @@ -26,7 +32,7 @@ } // GetVersions fetch all versions of a Golang module -func (g *GoModule) versions(ctx context.Context) (v string, versions []string, err error) { +func (g *GoModule) versions(ctx context.Context) (v string, err error) { var GOPROXY string if g.Spec.Proxy != "" { @@ -37,90 +43,139 @@ GOPROXY = goModuleDefaultProxy } + // Tracks proxies which do publish versions for that module but none matching the age + // filter, so that a running cooldown isn't reported as a missing module. + heldBackByAge := false + for _, proxy := range strings.Split(GOPROXY, ",") { proxy = strings.TrimSpace(proxy) if !isSupportedGoProxy(proxy) { continue } - proxyVersions, err := getVersionsFromProxy(ctx, g.webClient, proxy, g.Spec.Module, g.Spec.Age) + publishedVersions, matchingVersions, err := getVersionsFromProxy(ctx, g.webClient, proxy, g.Spec.Module, g.Spec.Age) if err != nil { logrus.Debugf("skipping proxy %q due to %v\n", proxy, err) continue } - if proxyVersions == nil && isLatestVersionFilter(g.versionFilter) { - pseudoVersion, err := getLatestVersionFromProxy(ctx, g.webClient, proxy, g.Spec.Module) + /* + The module doesn't publish any version, so the only thing a Go proxy can report + is the pseudo version of its latest commit. + as explained on https://go.dev/ref/mod#goproxy-protocol + */ + if len(publishedVersions) == 0 { + if !isLatestVersionFilter(g.versionFilter) { + logrus.Debugf("no version published for module %q on proxy %q\n", g.Spec.Module, proxy) + continue + } + + latestVersion, err := getLatestVersionFromProxy(ctx, g.webClient, proxy, g.Spec.Module) if err != nil { logrus.Debugf("skipping proxy %q due to %v\n", proxy, err) continue } - if pseudoVersion != "" { + if latestVersion.Version == "" { + logrus.Debugf("no version published for module %q on proxy %q\n", g.Spec.Module, proxy) + continue + } - if !isPseudoVersionMatchingAge(pseudoVersion, g.Spec.Age) { - logrus.Debugf("ignoring pseudo version %q from proxy %q because it doesn't match the age filter\n", pseudoVersion, proxy) - continue - } + if !isVersionMatchingAge(latestVersion, g.Spec.Age) { + logrus.Debugf("ignoring version %q from proxy %q because it doesn't match the age filter\n", latestVersion.Version, proxy) + heldBackByAge = true + continue + } + + logrus.Debugf("no version published for module %q on proxy %q, fallback to version %q\n", g.Spec.Module, proxy, latestVersion.Version) - versions = append(versions, pseudoVersion) + g.Version = version.Version{ + ParsedVersion: latestVersion.Version, + OriginalVersion: latestVersion.Version, } - logrus.Debugf("no version published for module %q on proxy %q, fallback to pseudo version %q\n", g.Spec.Module, proxy, pseudoVersion) + return latestVersion.Version, nil + } - return pseudoVersion, versions, nil + // The module publishes versions but the age filter discarded every one of them, + // which means the version we would have returned is still cooling down. + if len(matchingVersions) == 0 { + logrus.Debugf("every version published for module %q on proxy %q is filtered out by the age filter\n", g.Spec.Module, proxy) + heldBackByAge = true + continue } + versions := versionNames(matchingVersions) + /* - The response should be a list of version separated by \n - as explained on https://go.dev/ref/mod#goproxy-protocol + A "latest" filter asks for the most recently published version, which the + lexicographic sort below can't identify since "v1.9.0" sorts after "v1.10.0". + Release dates are known whenever the age filter ran, so use them to fall back + on the most recent version that isn't cooling down anymore. */ - versions = append(versions, proxyVersions...) + if isNewestVersionFilter(g.versionFilter) && !g.Spec.Age.IsZero() { + newestVersion := newestPublishedVersion(matchingVersions) + if newestVersion != "" { + g.Version = version.Version{ + ParsedVersion: newestVersion, + OriginalVersion: newestVersion, + } + + return newestVersion, nil + } + } sort.Strings(versions) g.Version, err = g.versionFilter.Search(versions) if err != nil { - return "", nil, err + return "", err } - return g.Version.GetVersion(), versions, nil + return g.Version.GetVersion(), nil + + } + if heldBackByAge { + return "", fmt.Errorf("%w for GO module %q", ErrNoVersionMatchingAge, g.Spec.Module) } - return "", nil, fmt.Errorf("GO module %q not found on proxy %q", g.Spec.Module, GOPROXY) + return "", fmt.Errorf("GO module %q not found on proxy %q", g.Spec.Module, GOPROXY) } -// getVersionsFromProxy returns all versions of a Golang module from a proxy -func getVersionsFromProxy(ctx context.Context, client httpclient.HTTPClient, proxy, module string, releaseAge age.Spec) ([]string, error) { +// getFromProxy queries a Go module proxy endpoint and returns its raw response body. +// The endpoint is built by appending elems to the module path, as described on +// https://go.dev/ref/mod#goproxy-protocol +func getFromProxy(ctx context.Context, client httpclient.HTTPClient, proxy, module string, elems ...string) ([]byte, error) { URL, err := url.JoinPath( sanitizeGoProxy(proxy), - sanitizeGoModuleNameForProxy(module), - "@v", "list") + append([]string{sanitizeGoModuleNameForProxy(module)}, elems...)...) if err != nil { - logrus.Errorf("something went wrong while getting go module api data %q\n", err) - return nil, err + return nil, fmt.Errorf("building go module proxy url: %w", err) } // #nosec G704 req, err := http.NewRequestWithContext(ctx, "GET", URL, nil) if err != nil { - logrus.Errorf("something went wrong while getting go module api data %q\n", err) - return nil, err + return nil, fmt.Errorf("building go module proxy request: %w", err) } res, err := client.Do(req) if err != nil { - logrus.Errorf("something went wrong while getting go module api data %q\n", err) - return nil, err + return nil, fmt.Errorf("querying go module proxy: %w", err) } defer res.Body.Close() if res.StatusCode >= 400 { - logrus.Errorf("something went wrong while getting golang module data: proxy %q returned HTTP %d (%s)\n", proxy, res.StatusCode, res.Status) - logrus.Debugf("skipping proxy %q due to HTTP %d (%s)\n", proxy, res.StatusCode, res.Status) + /* + A proxy answering with an error status isn't necessarily a problem, GOPROXY may + list several proxies and the next one is expected to serve the module, so the + details are only reported at debug level. + */ + logrus.Debugf("proxy %q returned HTTP %d (%s) for module %q\n", proxy, res.StatusCode, res.Status, module) + body, err := httputil.DumpResponse(res, false) if err != nil { - logrus.Debugf("failed to dump proxy response for %q: %q\n", proxy, err) + logrus.Debugf("failed to dump proxy response for %q: %v\n", proxy, err) } else { logrus.Debugf("\n%v\n", string(body)) } @@ -130,159 +185,85 @@ data, err := io.ReadAll(res.Body) if err != nil { - logrus.Errorf("something went wrong while getting golang module api data%q\n", err) - return nil, err - } - - // The response should be a list of version separated by \n - // as explained on https://go.dev/ref/mod#goproxy-protocol - - dataStr := strings.TrimSpace(string(data)) - versions := strings.Split(dataStr, "\n") - - // Sanitize versions by filtering out versions that are too recent based on the MinimumReleaseAge filter - if !releaseAge.IsZero() { - sanitizedVersions := []string{} - - for v := range versions { - getVersionInfo, err := getVersionInfoFromProxy(ctx, client, proxy, module, versions[v]) - if err != nil { - logrus.Debugf("ignoring version %q from proxy %q due to %q\n", versions[v], proxy, err) - continue - } - - releaseDate, err := time.Parse(time.RFC3339, getVersionInfo.Time) - if err != nil { - logrus.Debugf("ignoring version %q from proxy %q due to invalid release date format: %q\n", versions[v], proxy, err) - continue - } - - if releaseAge.Minimum != "" && releaseAge.IsOlderThan(releaseDate, nil) { - logrus.Debugf("ignoring version %q from proxy %q because its age is below %q (released on %s)\n", versions[v], proxy, releaseAge.Minimum, releaseDate) - continue - } - - if releaseAge.Maximum != "" && releaseAge.IsNewerThan(releaseDate, nil) { - logrus.Debugf("ignoring version %q from proxy %q because its age is above %q (released on %s)\n", versions[v], proxy, releaseAge.Maximum, releaseDate) - continue - } - - sanitizedVersions = append(sanitizedVersions, versions[v]) - } - versions = sanitizedVersions - } - - if len(versions) == 0 { - return nil, nil + return nil, fmt.Errorf("reading go module proxy response: %w", err) } - if len(versions) == 1 && versions[0] == "" { - return nil, nil - } - - return versions, nil + return data, nil } -// getLatestVersionFromProxy returns the latest version of a Golang module from a proxy -func getLatestVersionFromProxy(ctx context.Context, client httpclient.HTTPClient, proxy, module string) (string, error) { - URL, err := url.JoinPath( - sanitizeGoProxy(proxy), - sanitizeGoModuleNameForProxy(module), - "@latest") - +// getVersionsFromProxy returns the versions of a Golang module published on a proxy. +// +// It returns both every published version and the subset matching the age filter, as +// telling "this module doesn't publish any version" from "every published version is +// still cooling down" requires very different handling from the caller. +// The matching subset carries release dates because the goproxy protocol doesn't +// guarantee any ordering of the published versions. +func getVersionsFromProxy(ctx context.Context, client httpclient.HTTPClient, proxy, module string, releaseAge age.Spec) (publishedVersions []string, matchingVersions []versionInfo, err error) { + data, err := getFromProxy(ctx, client, proxy, module, "@v", "list") if err != nil { - logrus.Errorf("something went wrong while getting go module api data %q\n", err) - return "", err + return nil, nil, err } - // #nosec G704 - req, err := http.NewRequestWithContext(ctx, "GET", URL, nil) - if err != nil { - logrus.Errorf("something went wrong while getting go module api data %q\n", err) - return "", err + // The response should be a list of version separated by \n + // as explained on https://go.dev/ref/mod#goproxy-protocol + dataStr := strings.TrimSpace(string(data)) + if dataStr == "" { + return nil, nil, nil } - res, err := client.Do(req) - if err != nil { - logrus.Errorf("something went wrong while getting go module api data %q\n", err) - return "", err - } + publishedVersions = strings.Split(dataStr, "\n") - defer res.Body.Close() - if res.StatusCode >= 400 { - body, err := httputil.DumpResponse(res, false) - logrus.Errorf("something went wrong while getting golang module data %q\n", err) - logrus.Debugf("skipping proxy %q due to %q\n", proxy, err) - logrus.Debugf("\n%v\n", string(body)) + // Without an age filter there is no reason to pay for one request per version. + if releaseAge.IsZero() { + matchingVersions = make([]versionInfo, 0, len(publishedVersions)) + for _, v := range publishedVersions { + matchingVersions = append(matchingVersions, versionInfo{Version: v}) + } - return "", fmt.Errorf("GO module %q not found on proxy %q", module, proxy) + return publishedVersions, matchingVersions, nil } - data, err := io.ReadAll(res.Body) - if err != nil { - logrus.Errorf("something went wrong while getting npm api data%q\n", err) - return "", err - } + for _, v := range publishedVersions { + vInfo, err := getVersionInfoFromProxy(ctx, client, proxy, module, v) + if err != nil { + logrus.Debugf("ignoring version %q from proxy %q due to %v\n", v, proxy, err) + continue + } - type JSONData struct { - Version string `json:"Version"` - Time string `json:"Time"` - } + if !isVersionMatchingAge(vInfo, releaseAge) { + continue + } - jsonData := JSONData{} - err = json.Unmarshal(data, &jsonData) - if err != nil { - return "", fmt.Errorf("something went wrong while parsing go module api data %q", err) + matchingVersions = append(matchingVersions, vInfo) } - return jsonData.Version, nil + return publishedVersions, matchingVersions, nil } -// getVersionInfoFromProxy returns the version information of a Golang module from a proxy -func getVersionInfoFromProxy(ctx context.Context, client httpclient.HTTPClient, proxy, module, version string) (versionInfo, error) { - URL, err := url.JoinPath( - sanitizeGoProxy(proxy), - sanitizeGoModuleNameForProxy(module), - "@v", - version+".info") - - if err != nil { - logrus.Errorf("something went wrong while getting go module api data %q\n", err) - return versionInfo{}, err - } - - // #nosec G704 - req, err := http.NewRequestWithContext(ctx, "GET", URL, nil) +// getLatestVersionFromProxy returns the latest version of a Golang module from a proxy +func getLatestVersionFromProxy(ctx context.Context, client httpclient.HTTPClient, proxy, module string) (versionInfo, error) { + data, err := getFromProxy(ctx, client, proxy, module, "@latest") if err != nil { - logrus.Errorf("something went wrong while getting go module api data %q\n", err) return versionInfo{}, err } - res, err := client.Do(req) - if err != nil { - logrus.Errorf("something went wrong while getting go module api data %q\n", err) - return versionInfo{}, err + vInfo := versionInfo{} + if err = json.Unmarshal(data, &vInfo); err != nil { + return versionInfo{}, fmt.Errorf("something went wrong while parsing go module api data %q", err) } - defer res.Body.Close() - if res.StatusCode >= 400 { - body, err := httputil.DumpResponse(res, false) - logrus.Errorf("something went wrong while getting golang module data %q\n", err) - logrus.Debugf("skipping proxy %q due to %q\n", proxy, err) - logrus.Debugf("\n%v\n", string(body)) - - return versionInfo{}, fmt.Errorf("GO module %q not found on proxy %q", module, proxy) - } + return vInfo, nil +} - data, err := io.ReadAll(res.Body) +// getVersionInfoFromProxy returns the version information of a Golang module from a proxy +func getVersionInfoFromProxy(ctx context.Context, client httpclient.HTTPClient, proxy, module, version string) (versionInfo, error) { + data, err := getFromProxy(ctx, client, proxy, module, "@v", version+".info") if err != nil { - logrus.Errorf("something went wrong while getting go module proxy response data%q\n", err) return versionInfo{}, err } vInfo := versionInfo{} - err = json.Unmarshal(data, &vInfo) - if err != nil { + if err = json.Unmarshal(data, &vInfo); err != nil { return versionInfo{}, fmt.Errorf("something went wrong while parsing go module api data %q", err) } @@ -311,30 +292,66 @@ return false } -func isPseudoVersionMatchingAge(pseudoVersion string, releaseAge age.Spec) bool { - // Pseudo versions have the format vX.0.0-yyyymmddhhmmss-abcdefabcdef - parts := strings.Split(pseudoVersion, "-") - if len(parts) < 3 { - logrus.Debugf("invalid pseudo version format: %q\n", pseudoVersion) - return false +// isNewestVersionFilter returns true if the version filter asks for the most recently +// published version rather than for a version matching a pattern. +func isNewestVersionFilter(versionfilter version.Filter) bool { + return versionfilter.Kind == version.LATESTVERSIONKIND && + versionfilter.Pattern == version.LATESTVERSIONKIND +} + +// isVersionMatchingAge returns true if a version falls inside the age window. +// Versions without a parsable release date are never considered as matching. +func isVersionMatchingAge(v versionInfo, releaseAge age.Spec) bool { + if releaseAge.IsZero() { + return true } - timestamp := parts[len(parts)-2] - releaseDate, err := time.Parse("20060102150405", timestamp) + releaseDate, err := time.Parse(time.RFC3339, v.Time) if err != nil { - logrus.Debugf("failed to parse release date from pseudo version %q: %q\n", pseudoVersion, err) + logrus.Debugf("ignoring version %q due to invalid release date %q: %v\n", v.Version, v.Time, err) return false } if releaseAge.Minimum != "" && releaseAge.IsOlderThan(releaseDate, nil) { - logrus.Debugf("ignoring pseudo version %q because its age is below %q (released on %s)\n", pseudoVersion, releaseAge.Minimum, releaseDate) + logrus.Debugf("ignoring version %q because its age is below %q (released on %s)\n", v.Version, releaseAge.Minimum, releaseDate) return false } if releaseAge.Maximum != "" && releaseAge.IsNewerThan(releaseDate, nil) { - logrus.Debugf("ignoring pseudo version %q because its age is above %q (released on %s)\n", pseudoVersion, releaseAge.Maximum, releaseDate) + logrus.Debugf("ignoring version %q because its age is above %q (released on %s)\n", v.Version, releaseAge.Maximum, releaseDate) return false } return true } + +// newestPublishedVersion returns the most recently published version among the provided +// ones, or an empty string when none of them carries a parsable release date. +func newestPublishedVersion(versions []versionInfo) string { + newestVersion := "" + newestReleaseDate := time.Time{} + + for _, v := range versions { + releaseDate, err := time.Parse(time.RFC3339, v.Time) + if err != nil { + continue + } + + if newestVersion == "" || releaseDate.After(newestReleaseDate) { + newestVersion = v.Version + newestReleaseDate = releaseDate + } + } + + return newestVersion +} + +// versionNames returns the version strings of the provided version information. +func versionNames(versions []versionInfo) []string { + names := make([]string, 0, len(versions)) + for _, v := range versions { + names = append(names, v.Version) + } + + return names +} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/updatecli-0.120.0/pkg/plugins/resources/go/module/version_test.go new/updatecli-0.120.1/pkg/plugins/resources/go/module/version_test.go --- old/updatecli-0.120.0/pkg/plugins/resources/go/module/version_test.go 1970-01-01 01:00:00.000000000 +0100 +++ new/updatecli-0.120.1/pkg/plugins/resources/go/module/version_test.go 2026-08-13 21:59:35.000000000 +0200 @@ -0,0 +1,292 @@ +package gomodule + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "path" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/updatecli/updatecli/pkg/plugins/utils/age" + "github.com/updatecli/updatecli/pkg/plugins/utils/version" +) + +const testModule string = "example.com/mymodule" + +// daysAgo returns a release date as reported by a Go module proxy. +func daysAgo(days int) string { + return time.Now().Add(-time.Duration(days) * 24 * time.Hour).Format(time.RFC3339) +} + +// goProxyStub serves the subset of the goproxy protocol used by that resource, as +// described on https://go.dev/ref/mod#goproxy-protocol +type goProxyStub struct { + // publishedVersions is served by the "@v/list" endpoint. + publishedVersions []string + // releaseDates maps a version to its release date, served by "@v/<version>.info". + releaseDates map[string]string + // latestVersion is served by the "@latest" endpoint. + latestVersion versionInfo + // requestedPaths records every requested path, to assert on the requests really sent. + requestedPaths []string +} + +// start returns a GoModule querying that stub instead of a real Go module proxy. +func (s *goProxyStub) start(t *testing.T, spec Spec) *GoModule { + t.Helper() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + s.requestedPaths = append(s.requestedPaths, r.URL.Path) + + switch { + case strings.HasSuffix(r.URL.Path, "/@v/list"): + fmt.Fprint(w, strings.Join(s.publishedVersions, "\n")) + + case strings.HasSuffix(r.URL.Path, "/@latest"): + if s.latestVersion.Version == "" { + http.NotFound(w, r) + return + } + assert.NoError(t, json.NewEncoder(w).Encode(s.latestVersion)) + + case strings.HasSuffix(r.URL.Path, ".info"): + requestedVersion := strings.TrimSuffix(path.Base(r.URL.Path), ".info") + releaseDate, ok := s.releaseDates[requestedVersion] + if !ok { + http.NotFound(w, r) + return + } + assert.NoError(t, json.NewEncoder(w).Encode(versionInfo{ + Version: requestedVersion, + Time: releaseDate, + })) + + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + + spec.Proxy = server.URL + spec.Module = testModule + + got, err := New(spec) + require.NoError(t, err) + got.webClient = http.DefaultClient + + return got +} + +// infoRequests returns the ".info" paths requested to the stub. +func (s *goProxyStub) infoRequests() []string { + requests := []string{} + for _, p := range s.requestedPaths { + if strings.HasSuffix(p, ".info") { + requests = append(requests, p) + } + } + + return requests +} + +func TestVersions(t *testing.T) { + tests := []struct { + name string + spec Spec + stub goProxyStub + // expectedVersion is the version returned by versions() + expectedVersion string + // expectedHeldBackByAge is true when the age filter is expected to discard every version + expectedHeldBackByAge bool + // expectedInfoRequests is the number of ".info" requests expected on the proxy + expectedInfoRequests int + }{ + { + name: "module without any published version falls back to its latest commit", + spec: Spec{ + VersionFilter: version.Filter{Kind: version.LATESTVERSIONKIND}, + Age: age.Spec{Minimum: "7d"}, + }, + stub: goProxyStub{ + latestVersion: versionInfo{ + Version: "v0.0.0-20260101120000-abcdefabcdef", + Time: daysAgo(60), + }, + }, + expectedVersion: "v0.0.0-20260101120000-abcdefabcdef", + // An empty "@v/list" must not be turned into a request for "@v/.info" + expectedInfoRequests: 0, + }, + { + name: "cooldown holds back the only commit of a module without any published version", + spec: Spec{ + VersionFilter: version.Filter{Kind: version.LATESTVERSIONKIND}, + Age: age.Spec{Minimum: "7d"}, + }, + stub: goProxyStub{ + latestVersion: versionInfo{ + Version: "v0.0.0-20260101120000-abcdefabcdef", + Time: daysAgo(1), + }, + }, + expectedHeldBackByAge: true, + expectedInfoRequests: 0, + }, + { + name: "latest endpoint answering a tagged version is accepted without any age filter", + spec: Spec{ + VersionFilter: version.Filter{Kind: version.LATESTVERSIONKIND}, + }, + stub: goProxyStub{ + latestVersion: versionInfo{Version: "v1.2.3", Time: daysAgo(60)}, + }, + expectedVersion: "v1.2.3", + expectedInfoRequests: 0, + }, + { + name: "cooldown holds back every published version instead of using the latest commit", + spec: Spec{ + VersionFilter: version.Filter{Kind: version.LATESTVERSIONKIND}, + Age: age.Spec{Minimum: "7d"}, + }, + stub: goProxyStub{ + publishedVersions: []string{"v1.0.0", "v1.1.0"}, + releaseDates: map[string]string{ + "v1.0.0": daysAgo(2), + "v1.1.0": daysAgo(1), + }, + // A module publishing versions must never fall back to a pseudo version + latestVersion: versionInfo{ + Version: "v0.0.0-20260101120000-abcdefabcdef", + Time: daysAgo(60), + }, + }, + expectedHeldBackByAge: true, + expectedInfoRequests: 2, + }, + { + name: "latest filter falls back to the most recently published version out of cooldown", + spec: Spec{ + VersionFilter: version.Filter{Kind: version.LATESTVERSIONKIND}, + Age: age.Spec{Minimum: "7d"}, + }, + stub: goProxyStub{ + publishedVersions: []string{"v1.9.0", "v1.10.0", "v1.11.0"}, + releaseDates: map[string]string{ + "v1.9.0": daysAgo(50), + "v1.10.0": daysAgo(10), + "v1.11.0": daysAgo(1), + }, + }, + // "v1.9.0" is the lexicographic maximum, "v1.10.0" the most recently published + expectedVersion: "v1.10.0", + expectedInfoRequests: 3, + }, + { + name: "semver filter keeps ordering versions semantically", + spec: Spec{ + VersionFilter: version.Filter{Kind: version.SEMVERVERSIONKIND, Pattern: "*"}, + Age: age.Spec{Minimum: "7d"}, + }, + stub: goProxyStub{ + publishedVersions: []string{"v1.9.0", "v1.10.0", "v1.11.0"}, + releaseDates: map[string]string{ + "v1.9.0": daysAgo(50), + "v1.10.0": daysAgo(10), + "v1.11.0": daysAgo(1), + }, + }, + expectedVersion: "v1.10.0", + expectedInfoRequests: 3, + }, + { + name: "no age filter means no release date lookup", + spec: Spec{ + VersionFilter: version.Filter{Kind: version.SEMVERVERSIONKIND, Pattern: "*"}, + }, + stub: goProxyStub{ + publishedVersions: []string{"v1.9.0", "v1.10.0"}, + }, + expectedVersion: "v1.10.0", + expectedInfoRequests: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.stub.start(t, tt.spec) + + gotVersion, err := got.versions(context.Background()) + + if tt.expectedHeldBackByAge { + require.ErrorIs(t, err, ErrNoVersionMatchingAge) + assert.Empty(t, gotVersion) + } else { + require.NoError(t, err) + assert.Equal(t, tt.expectedVersion, gotVersion) + } + + assert.Len(t, tt.stub.infoRequests(), tt.expectedInfoRequests) + }) + } +} + +func TestNewVersionFilter(t *testing.T) { + tests := []struct { + name string + versionFilter version.Filter + expectedFilter version.Filter + expectedNewErr bool + expectedIsLatest bool + }{ + { + name: "no filter falls back to semantic versioning", + expectedFilter: version.Filter{Kind: version.SEMVERVERSIONKIND, Pattern: "*"}, + expectedIsLatest: true, + }, + { + // A "latest" kind without any pattern used to be searched as the literal + // pattern "" and never matched anything. + name: "latest kind without pattern gets its default pattern", + versionFilter: version.Filter{Kind: version.LATESTVERSIONKIND}, + expectedFilter: version.Filter{Kind: version.LATESTVERSIONKIND, Pattern: version.LATESTVERSIONKIND}, + expectedIsLatest: true, + }, + { + name: "semver kind without pattern gets its default pattern", + versionFilter: version.Filter{Kind: version.SEMVERVERSIONKIND}, + expectedFilter: version.Filter{Kind: version.SEMVERVERSIONKIND, Pattern: "*"}, + expectedIsLatest: true, + }, + { + name: "explicit filter is left untouched", + versionFilter: version.Filter{Kind: version.SEMVERVERSIONKIND, Pattern: "1.0.x"}, + expectedFilter: version.Filter{Kind: version.SEMVERVERSIONKIND, Pattern: "1.0.x"}, + }, + { + name: "unsupported kind is rejected", + versionFilter: version.Filter{Kind: "notakind"}, + expectedNewErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := New(Spec{Module: testModule, VersionFilter: tt.versionFilter}) + if tt.expectedNewErr { + require.Error(t, err) + return + } + + require.NoError(t, err) + assert.Equal(t, tt.expectedFilter, got.versionFilter) + assert.Equal(t, tt.expectedIsLatest, isLatestVersionFilter(got.versionFilter)) + }) + } +} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/updatecli-0.120.0/pkg/plugins/resources/temurin/main.go new/updatecli-0.120.1/pkg/plugins/resources/temurin/main.go --- old/updatecli-0.120.0/pkg/plugins/resources/temurin/main.go 2026-08-05 07:03:11.000000000 +0200 +++ new/updatecli-0.120.1/pkg/plugins/resources/temurin/main.go 2026-08-13 21:59:35.000000000 +0200 @@ -143,6 +143,7 @@ ReleaseType: t.spec.ReleaseType, ImageType: t.spec.ImageType, FeatureVersion: t.spec.FeatureVersion, + SpecificVersion: t.spec.SpecificVersion, ReleaseLine: t.spec.ReleaseLine, Architecture: t.spec.Architecture, OperatingSystem: t.spec.OperatingSystem, diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/updatecli-0.120.0/pkg/plugins/resources/temurin/main_test.go new/updatecli-0.120.1/pkg/plugins/resources/temurin/main_test.go --- old/updatecli-0.120.0/pkg/plugins/resources/temurin/main_test.go 2026-08-05 07:03:11.000000000 +0200 +++ new/updatecli-0.120.1/pkg/plugins/resources/temurin/main_test.go 2026-08-13 21:59:35.000000000 +0200 @@ -7,6 +7,27 @@ "github.com/stretchr/testify/require" ) +// The source cache keys entries on ReportConfig, so every spec field that +// changes what the API returns must be reported (issue #9821). +func TestReportConfig(t *testing.T) { + spec := Spec{ + ReleaseLine: "lts", + ReleaseType: "ga", + FeatureVersion: 21, + SpecificVersion: "21.0.5+11", + Result: "installer_url", + Architecture: "x64", + ImageType: "jdk", + OperatingSystem: "windows", + Project: "jdk", + Platforms: []string{"windows/x64"}, + } + + got := (&Temurin{spec: spec}).ReportConfig() + + assert.Equal(t, spec, got) +} + func TestNew(t *testing.T) { tests := []struct { name string diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/updatecli-0.120.0/pkg/plugins/resources/yaml/condition.go new/updatecli-0.120.1/pkg/plugins/resources/yaml/condition.go --- old/updatecli-0.120.0/pkg/plugins/resources/yaml/condition.go 2026-08-05 07:03:11.000000000 +0200 +++ new/updatecli-0.120.1/pkg/plugins/resources/yaml/condition.go 2026-08-13 21:59:35.000000000 +0200 @@ -115,6 +115,13 @@ docs = append(docs, &doc) } + // Track whether the key was found in at least one document of this + // file. The yamlpath engine does not report an error when the key is + // missing: it returns an empty result set. For keyonly conditions we + // record a miss per file (the same way the go-yaml engine does) so the + // handling below can distinguish "at least one file contains the key" + // (searchpattern) from "no file contains it". + fileKeyFound := false for index, doc := range docs { if y.spec.DocumentIndex != nil { if index != *y.spec.DocumentIndex { @@ -138,6 +145,15 @@ for i := range founds { results = append(results, founds[i].Value) } + + if len(founds) > 0 { + fileKeyFound = true + } + } + + if !fileKeyFound && y.spec.KeyOnly { + errorMessages = append(errorMessages, + fmt.Errorf("%q - %w", originalFilePath, ErrKeyNotFound)) } default: @@ -145,6 +161,11 @@ } } + originalFilePaths := make([]string, 0, len(y.files)) + for i := range y.files { + originalFilePaths = append(originalFilePaths, y.files[i].originalFilePath) + } + if len(errorMessages) > 0 { if y.spec.KeyOnly { for i := range errorMessages { @@ -153,17 +174,20 @@ return false, "", errorsToError(errorMessages) } } + + // When a search pattern is used, the condition passes as soon as at + // least one file contains the key, aligning with the file plugin + // behavior. Otherwise every specified file must contain the key. + if y.spec.SearchPattern && len(results) > 0 { + return true, fmt.Sprintf("key %q found in yaml file(s) [%q]", y.spec.Key, strings.Join(originalFilePaths, ",")), nil + } + return false, "key not found in yaml file(s)", nil } return false, "", errorsToError(errorMessages) } - originalFilePaths := make([]string, len(y.files)) - for i := range y.files { - originalFilePaths = append(originalFilePaths, y.files[i].originalFilePath) - } - // When user want to only check the existence of a YAML key if y.spec.KeyOnly { if len(results) == len(y.files) { diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/updatecli-0.120.0/pkg/plugins/resources/yaml/condition_test.go new/updatecli-0.120.1/pkg/plugins/resources/yaml/condition_test.go --- old/updatecli-0.120.0/pkg/plugins/resources/yaml/condition_test.go 2026-08-05 07:03:11.000000000 +0200 +++ new/updatecli-0.120.1/pkg/plugins/resources/yaml/condition_test.go 2026-08-13 21:59:35.000000000 +0200 @@ -3,10 +3,13 @@ import ( "context" "fmt" + "os" + "path/filepath" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/updatecli/updatecli/pkg/core/pipeline/scm" "github.com/updatecli/updatecli/pkg/core/text" ) @@ -629,11 +632,161 @@ }, isResultWanted: true, }, + { + name: "Passing case with keyonly and searchpattern when at least one file contains the key (yamlpath)", + spec: Spec{ + Files: []string{ + "*.yaml", + }, + Key: "$.github.owner", + KeyOnly: true, + Engine: "yamlpath", + SearchPattern: true, + }, + files: map[string]file{ + "test.yaml": { + filePath: "test.yaml", + originalFilePath: "test.yaml", + }, + "too-much.yaml": { + filePath: "too-much.yaml", + originalFilePath: "too-much.yaml", + }, + }, + mockedContents: map[string]string{ + "test.yaml": `--- +github: + owner: olblak + repository: charts +`, + "too-much.yaml": `--- +name: github +`, + }, + isResultWanted: true, + }, + { + name: "Passing case with keyonly and searchpattern when at least one file contains the key (go-yaml)", + spec: Spec{ + Files: []string{ + "*.yaml", + }, + Key: "$.github.owner", + KeyOnly: true, + SearchPattern: true, + }, + files: map[string]file{ + "test.yaml": { + filePath: "test.yaml", + originalFilePath: "test.yaml", + }, + "too-much.yaml": { + filePath: "too-much.yaml", + originalFilePath: "too-much.yaml", + }, + }, + mockedContents: map[string]string{ + "test.yaml": `--- +github: + owner: olblak + repository: charts +`, + "too-much.yaml": `--- +name: github +`, + }, + isResultWanted: true, + }, + { + name: "Failing case with keyonly and searchpattern when no file contains the key", + spec: Spec{ + Files: []string{ + "*.yaml", + }, + Key: "$.github.owner", + KeyOnly: true, + Engine: "yamlpath", + SearchPattern: true, + }, + files: map[string]file{ + "test.yaml": { + filePath: "test.yaml", + originalFilePath: "test.yaml", + }, + "too-much.yaml": { + filePath: "too-much.yaml", + originalFilePath: "too-much.yaml", + }, + }, + mockedContents: map[string]string{ + "test.yaml": `--- +name: github +`, + "too-much.yaml": `--- +name: updatecli +`, + }, + isResultWanted: false, + }, + { + name: "Failing case with keyonly and multiple files when not all files contain the key (no searchpattern)", + spec: Spec{ + Files: []string{ + "test.yaml", + "too-much.yaml", + }, + Key: "$.github.owner", + KeyOnly: true, + Engine: "yamlpath", + }, + files: map[string]file{ + "test.yaml": { + filePath: "test.yaml", + originalFilePath: "test.yaml", + }, + "too-much.yaml": { + filePath: "too-much.yaml", + originalFilePath: "too-much.yaml", + }, + }, + mockedContents: map[string]string{ + "test.yaml": `--- +github: + owner: olblak + repository: charts +`, + "too-much.yaml": `--- +name: github +`, + }, + isResultWanted: false, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + mockedContents := tt.mockedContents + var scmHandler scm.ScmHandler + + // When a search pattern is used, initFiles resolves the pattern + // against the working directory, so we need real files on disk. + if tt.spec.SearchPattern { + tempDir := t.TempDir() + for fileName := range tt.files { + if err := os.WriteFile(filepath.Join(tempDir, fileName), []byte{}, 0600); err != nil { + t.Fatalf("failed to create temp file: %v", err) + } + } + scmHandler = &scm.MockScm{WorkingDir: tempDir} + + updatedContents := make(map[string]string) + for fileName, content := range tt.mockedContents { + updatedContents[filepath.Join(tempDir, fileName)] = content + } + mockedContents = updatedContents + } + mockedText := text.MockTextRetriever{ - Contents: tt.mockedContents, + Contents: mockedContents, Err: tt.mockedError, } @@ -643,7 +796,7 @@ assert.NoError(t, err) - gotResult, _, gotErr := y.Condition(context.Background(), tt.inputSourceValue, nil) + gotResult, _, gotErr := y.Condition(context.Background(), tt.inputSourceValue, scmHandler) if tt.isErrorWanted { assert.Error(t, gotErr) return diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/updatecli-0.120.0/updatecli-compose.yaml new/updatecli-0.120.1/updatecli-compose.yaml --- old/updatecli-0.120.0/updatecli-compose.yaml 2026-08-05 07:03:11.000000000 +0200 +++ new/updatecli-0.120.1/updatecli-compose.yaml 2026-08-13 21:59:35.000000000 +0200 @@ -55,7 +55,7 @@ - name: Update Updatecli version in GitHub action id: updatecli_github_action - policy: ghcr.io/updatecli/policies/updatecli/githubaction:0.9.0@sha256:5d21b1af9a9c3c1ed0c05bf0e12b4e3e6d98050d3d6a65bda0f12f7adce94c69 + policy: ghcr.io/updatecli/policies/updatecli/githubaction:0.9.1@sha256:49c07d724474ff7976f7ad5ecb0db9aa46448b456d08f82717913b403a0f02d8 valuesinline: spec: rootdir: ".github" ++++++ updatecli.obsinfo ++++++ --- /var/tmp/diff_new_pack.6axwXz/_old 2026-08-28 19:53:46.769392779 +0200 +++ /var/tmp/diff_new_pack.6axwXz/_new 2026-08-28 19:53:46.774392954 +0200 @@ -1,5 +1,5 @@ name: updatecli -version: 0.120.0 -mtime: 1785906191 -commit: 6dcc92d2b1d89d81449865291a38cbe858b78905 +version: 0.120.1 +mtime: 1786651175 +commit: 5609a74b86406126f41f74e4d50f1481dbe81fd4 ++++++ vendor.tar.gz ++++++ /work/SRC/openSUSE:Factory/updatecli/vendor.tar.gz /work/SRC/openSUSE:Factory/.updatecli.new.1265/vendor.tar.gz differ: char 36, line 1
