Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package apko for openSUSE:Factory checked in at 2026-05-06 19:19:39 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/apko (Old) and /work/SRC/openSUSE:Factory/.apko.new.30200 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "apko" Wed May 6 19:19:39 2026 rev:112 rq:1351146 version:1.2.11 Changes: -------- --- /work/SRC/openSUSE:Factory/apko/apko.changes 2026-05-05 15:16:47.926694866 +0200 +++ /work/SRC/openSUSE:Factory/.apko.new.30200/apko.changes 2026-05-06 19:23:43.819519734 +0200 @@ -1,0 +2,19 @@ +Wed May 06 09:10:19 UTC 2026 - Johannes Kastl <[email protected]> + +- Update to version 1.2.11: + * Tweak solver's same-origin heuristic (#2208) + * build(deps): bump k8s.io/apimachinery from 0.35.4 to 0.36.0 + (#2189) + * build(deps): bump google.golang.org/api from 0.276.0 to 0.277.0 + (#2212) + * build(deps): bump github.com/klauspost/compress from 1.18.5 to + 1.18.6 (#2211) + * build(deps): bump github/codeql-action from 4.35.2 to 4.35.3 + (#2213) + * build(deps): bump step-security/harden-runner from 2.19.0 to + 2.19.1 (#2214) + * build(deps): bump chainguard-dev/actions from 1.6.15 to 1.6.17 + (#2215) + * retry package fetch+expand on transient errors (#2210) + +------------------------------------------------------------------- Old: ---- apko-1.2.10.obscpio New: ---- apko-1.2.11.obscpio ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ apko.spec ++++++ --- /var/tmp/diff_new_pack.JDt60y/_old 2026-05-06 19:23:44.775559173 +0200 +++ /var/tmp/diff_new_pack.JDt60y/_new 2026-05-06 19:23:44.779559338 +0200 @@ -17,7 +17,7 @@ Name: apko -Version: 1.2.10 +Version: 1.2.11 Release: 0 Summary: Build OCI images from APK packages directly without Dockerfile License: Apache-2.0 @@ -27,7 +27,7 @@ BuildRequires: bash-completion BuildRequires: fish BuildRequires: zsh -BuildRequires: golang(API) >= 1.25 +BuildRequires: golang(API) >= 1.26 %description Build and publish OCI container images built from apk packages. ++++++ _service ++++++ --- /var/tmp/diff_new_pack.JDt60y/_old 2026-05-06 19:23:44.835561648 +0200 +++ /var/tmp/diff_new_pack.JDt60y/_new 2026-05-06 19:23:44.839561813 +0200 @@ -3,7 +3,7 @@ <param name="url">https://github.com/chainguard-dev/apko.git</param> <param name="scm">git</param> <param name="exclude">.git</param> - <param name="revision">refs/tags/v1.2.10</param> + <param name="revision">refs/tags/v1.2.11</param> <param name="versionformat">@PARENT_TAG@</param> <param name="versionrewrite-pattern">v(.*)</param> <param name="changesgenerate">enable</param> ++++++ _servicedata ++++++ --- /var/tmp/diff_new_pack.JDt60y/_old 2026-05-06 19:23:44.863562803 +0200 +++ /var/tmp/diff_new_pack.JDt60y/_new 2026-05-06 19:23:44.871563133 +0200 @@ -3,6 +3,6 @@ <param name="url">https://github.com/chainguard-dev/apko</param> <param name="changesrevision">861f83f69e6fa9114405a2f7bb5cf6585ad00421</param></service><service name="tar_scm"> <param name="url">https://github.com/chainguard-dev/apko.git</param> - <param name="changesrevision">eebbe627f86c584c3ff9df826411a2b33dca5ca6</param></service></servicedata> + <param name="changesrevision">bfd6776788292e020d8cbee9928f441781af72c0</param></service></servicedata> (No newline at EOF) ++++++ apko-1.2.10.obscpio -> apko-1.2.11.obscpio ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/apko-1.2.10/go.mod new/apko-1.2.11/go.mod --- old/apko-1.2.10/go.mod 2026-05-04 00:41:58.000000000 +0200 +++ new/apko-1.2.11/go.mod 2026-05-05 21:57:53.000000000 +0200 @@ -1,6 +1,6 @@ module chainguard.dev/apko -go 1.25.7 +go 1.26.0 require ( chainguard.dev/sdk v0.1.54 @@ -13,7 +13,7 @@ github.com/hashicorp/go-cleanhttp v0.5.2 github.com/hashicorp/go-retryablehttp v0.7.8 github.com/invopop/jsonschema v0.14.0 - github.com/klauspost/compress v1.18.5 + github.com/klauspost/compress v1.18.6 github.com/klauspost/pgzip v1.2.6 github.com/package-url/packageurl-go v0.1.5 github.com/pavlo-v-chernykh/keystore-go/v4 v4.5.0 @@ -30,10 +30,10 @@ golang.org/x/sync v0.20.0 golang.org/x/sys v0.43.0 golang.org/x/time v0.15.0 - google.golang.org/api v0.276.0 + google.golang.org/api v0.277.0 gopkg.in/ini.v1 v1.67.1 gopkg.in/yaml.v3 v3.0.1 - k8s.io/apimachinery v0.35.4 + k8s.io/apimachinery v0.36.0 sigs.k8s.io/release-utils v0.12.4 ) @@ -81,7 +81,7 @@ github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.15 // indirect github.com/googleapis/gax-go/v2 v2.22.0 // indirect github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 // indirect github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 // indirect @@ -130,9 +130,9 @@ golang.org/x/net v0.53.0 // indirect golang.org/x/text v0.36.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4 // indirect google.golang.org/grpc v1.80.0 // indirect - google.golang.org/protobuf v1.36.11 // indirect + google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect gopkg.in/warnings.v0 v0.1.2 // indirect ) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/apko-1.2.10/go.sum new/apko-1.2.11/go.sum --- old/apko-1.2.10/go.sum 2026-05-04 00:41:58.000000000 +0200 +++ new/apko-1.2.11/go.sum 2026-05-05 21:57:53.000000000 +0200 @@ -120,8 +120,8 @@ github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8= -github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= +github.com/googleapis/enterprise-certificate-proxy v0.3.15 h1:xolVQTEXusUcAA5UgtyRLjelpFFHWlPQ4XfWGc7MBas= +github.com/googleapis/enterprise-certificate-proxy v0.3.15/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4= github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY= github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 h1:QGLs/O40yoNK9vmy4rhUGBVyMf1lISBGtXRpsu/Qu/o= @@ -146,8 +146,8 @@ github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= -github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= -github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU= github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= @@ -179,8 +179,8 @@ github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= -github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= +github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= +github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= @@ -276,7 +276,6 @@ go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= -go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v4 v4.0.0-rc.2 h1:/FrI8D64VSr4HtGIlUtlFMGsm7H7pWTbj6vOLVZcA6s= go.yaml.in/yaml/v4 v4.0.0-rc.2/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= @@ -351,18 +350,18 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/api v0.276.0 h1:nVArUtfLEihtW+b0DdcqRGK1xoEm2+ltAihyztq7MKY= -google.golang.org/api v0.276.0/go.mod h1:Fnag/EWUPIcJXuIkP1pjoTgS5vdxlk3eeemL7Do6bvw= +google.golang.org/api v0.277.0 h1:HJfyJUiNeBBUMai7ez8u14wkp/gH/I4wpGbbO9o+cSk= +google.golang.org/api v0.277.0/go.mod h1:B9TqLBwJqVjp1mtt7WeoQwWRwvu/400y5lETOql+giQ= google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0= google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I= google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA= google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4 h1:tEkOQcXgF6dH1G+MVKZrfpYvozGrzb91k6ha7jireSM= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= -google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= -google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= @@ -378,8 +377,8 @@ gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= -k8s.io/apimachinery v0.35.4 h1:xtdom9RG7e+yDp71uoXoJDWEE2eOiHgeO4GdBzwWpds= -k8s.io/apimachinery v0.35.4/go.mod h1:NNi1taPOpep0jOj+oRha3mBJPqvi0hGdaV8TCqGQ+cc= +k8s.io/apimachinery v0.36.0 h1:jZyPzhd5Z+3h9vJLt0z9XdzW9VzNzWAUw+P1xZ9PXtQ= +k8s.io/apimachinery v0.36.0/go.mod h1:FklypaRJt6n5wUIwWXIP6GJlIpUizTgfo1T/As+Tyxc= pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= sigs.k8s.io/release-utils v0.12.4 h1:kuG6WTWGCKx5uUrJwl2uFErOKOw+4Ba8WrPmOQh5J3g= diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/apko-1.2.10/pkg/apk/apk/package_getter.go new/apko-1.2.11/pkg/apk/apk/package_getter.go --- old/apko-1.2.10/pkg/apk/apk/package_getter.go 2026-05-04 00:41:58.000000000 +0200 +++ new/apko-1.2.11/pkg/apk/apk/package_getter.go 2026-05-05 21:57:53.000000000 +0200 @@ -20,12 +20,16 @@ "crypto/sha1" //nolint:gosec // this is what apk tools is using "encoding/base64" "encoding/hex" + "errors" "fmt" "io" + "net" "net/http" "os" "path/filepath" "strings" + "syscall" + "time" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" @@ -39,6 +43,60 @@ "github.com/chainguard-dev/clog" ) +const ( + // maxFetchRetries is the number of additional attempts after the first failure. + maxFetchRetries = 2 + // retryBaseDelay is the base delay between retry attempts (scaled linearly by attempt number). + retryBaseDelay = 1 * time.Second +) + +// isRetryableError reports whether err is a transient error that warrants retrying +// the fetch+expand pipeline from scratch. +func isRetryableError(err error) bool { + if err == nil { + return false + } + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return false + } + if errors.Is(err, io.ErrUnexpectedEOF) { + return true + } + if errors.Is(err, syscall.ECONNRESET) || errors.Is(err, syscall.ECONNABORTED) { + return true + } + var opErr *net.OpError + if errors.As(err, &opErr) { + return true + } + var httpErr *httpStatusError + if errors.As(err, &httpErr) { + return httpErr.statusCode >= 500 || httpErr.statusCode == http.StatusTooManyRequests + } + msg := err.Error() + for _, substr := range []string{ + "unexpected EOF", + "connection reset", + "broken pipe", + } { + if strings.Contains(msg, substr) { + return true + } + } + return false +} + +// httpStatusError represents a non-OK HTTP response status. +type httpStatusError struct { + statusCode int + status string + url string +} + +func (e *httpStatusError) Error() string { + return fmt.Sprintf("unable to get package apk at %s: %s", e.url, e.status) +} + // PackageGetter abstracts how packages are fetched and expanded. type PackageGetter interface { // GetPackage fetches and returns an expanded package. @@ -152,12 +210,6 @@ } } - rc, err := d.fetchPackage(ctx, pkg) - if err != nil { - return nil, fmt.Errorf("fetching package %q: %w", pkg.PackageName(), err) - } - defer rc.Close() - var expandOpts []expandapk.Option if d.apkControlMaxSize != 0 { expandOpts = append(expandOpts, expandapk.WithMaxControlSize(d.apkControlMaxSize)) @@ -165,6 +217,54 @@ if d.apkDataMaxSize != 0 { expandOpts = append(expandOpts, expandapk.WithMaxDataSize(d.apkDataMaxSize)) } + + exp, err := d.fetchExpandAndVerify(ctx, pkg, cacheDir, expandOpts) + if err != nil { + return nil, err + } + + // If we don't have a cache, we're done. + if d.cache == nil { + return exp, nil + } + + return d.cachePackage(ctx, pkg, exp, cacheDir) +} + +// fetchExpandAndVerify fetches, expands, and verifies a package, retrying on transient errors. +func (d *defaultPackageGetter) fetchExpandAndVerify(ctx context.Context, pkg InstallablePackage, cacheDir string, expandOpts []expandapk.Option) (*expandapk.APKExpanded, error) { + var lastErr error + for attempt := range maxFetchRetries + 1 { + if attempt > 0 { + delay := time.Duration(attempt) * retryBaseDelay + clog.FromContext(ctx).Warnf("retrying fetch of %s (attempt %d/%d) after error: %v", pkg.PackageName(), attempt+1, maxFetchRetries+1, lastErr) + select { + case <-ctx.Done(): + return nil, context.Cause(ctx) + case <-time.After(delay): + } + } + + exp, err := d.doFetchExpandAndVerify(ctx, pkg, cacheDir, expandOpts) + if err == nil { + return exp, nil + } + if !isRetryableError(err) { + return nil, err + } + lastErr = err + } + return nil, fmt.Errorf("after %d attempts: %w", maxFetchRetries+1, lastErr) +} + +// doFetchExpandAndVerify performs a single fetch+expand+verify cycle for a package. +func (d *defaultPackageGetter) doFetchExpandAndVerify(ctx context.Context, pkg InstallablePackage, cacheDir string, expandOpts []expandapk.Option) (*expandapk.APKExpanded, error) { + rc, err := d.fetchPackage(ctx, pkg) + if err != nil { + return nil, fmt.Errorf("fetching package %q: %w", pkg.PackageName(), err) + } + defer rc.Close() + exp, err := expandapk.ExpandApkWithOptions(ctx, rc, cacheDir, expandOpts...) if err != nil { return nil, fmt.Errorf("expanding %s: %w", pkg.PackageName(), err) @@ -200,12 +300,7 @@ return nil, fmt.Errorf("package %q data hash mismatch: expected %x, got %x", pkg.PackageName(), expectedDataHash, exp.PackageHash) } - // If we don't have a cache, we're done. - if d.cache == nil { - return exp, nil - } - - return d.cachePackage(ctx, pkg, exp, cacheDir) + return exp, nil } // sha1File returns the SHA-1 of the file at path. @@ -268,7 +363,7 @@ } if res.StatusCode != http.StatusOK { res.Body.Close() - return nil, fmt.Errorf("unable to get package apk at %s: %v", u, res.Status) + return nil, &httpStatusError{statusCode: res.StatusCode, status: res.Status, url: u} } return res.Body, nil default: diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/apko-1.2.10/pkg/apk/apk/package_getter_test.go new/apko-1.2.11/pkg/apk/apk/package_getter_test.go --- old/apko-1.2.10/pkg/apk/apk/package_getter_test.go 2026-05-04 00:41:58.000000000 +0200 +++ new/apko-1.2.11/pkg/apk/apk/package_getter_test.go 2026-05-05 21:57:53.000000000 +0200 @@ -3,14 +3,18 @@ import ( "context" "encoding/base64" + "errors" "fmt" "io" + "net" "net/http" "net/http/httptest" "net/url" "os" "path/filepath" "strings" + "sync/atomic" + "syscall" "testing" "github.com/stretchr/testify/require" @@ -366,3 +370,125 @@ require.Contains(t, err.Error(), "data hash mismatch") }) } + +func TestIsRetryableError(t *testing.T) { + tests := []struct { + name string + err error + want bool + }{ + {"nil", nil, false}, + {"context canceled", context.Canceled, false}, + {"context deadline", context.DeadlineExceeded, false}, + {"unexpected EOF", io.ErrUnexpectedEOF, true}, + {"wrapped unexpected EOF", fmt.Errorf("expanding foo: %w", io.ErrUnexpectedEOF), true}, + {"connection reset", syscall.ECONNRESET, true}, + {"connection aborted", syscall.ECONNABORTED, true}, + {"net.OpError", &net.OpError{Op: "read", Err: errors.New("reset")}, true}, + {"string unexpected EOF", errors.New("something unexpected EOF happened"), true}, + {"string connection reset", errors.New("connection reset by peer"), true}, + {"string broken pipe", errors.New("write: broken pipe"), true}, + {"http 500", &httpStatusError{statusCode: 500, status: "500 Internal Server Error", url: "https://example.com/pkg.apk"}, true}, + {"http 502", &httpStatusError{statusCode: 502, status: "502 Bad Gateway", url: "https://example.com/pkg.apk"}, true}, + {"http 429", &httpStatusError{statusCode: 429, status: "429 Too Many Requests", url: "https://example.com/pkg.apk"}, true}, + {"http 404", &httpStatusError{statusCode: 404, status: "404 Not Found", url: "https://example.com/pkg.apk"}, false}, + {"wrapped http 503", fmt.Errorf("fetching package: %w", &httpStatusError{statusCode: 503, status: "503 Service Unavailable", url: "https://example.com/pkg.apk"}), true}, + {"checksum mismatch", errors.New("control hash mismatch: expected abc, got def"), false}, + {"generic error", errors.New("some other error"), false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := isRetryableError(tt.err) + require.Equal(t, tt.want, got) + }) + } +} + +// truncatingTransport serves a valid APK file but truncates the response on the first N requests. +type truncatingTransport struct { + root string + failCount int32 // number of remaining requests to truncate + attempts atomic.Int32 +} + +func (t *truncatingTransport) RoundTrip(req *http.Request) (*http.Response, error) { + attempt := t.attempts.Add(1) + + filename := filepath.Base(req.URL.Path) + data, err := os.ReadFile(filepath.Join(t.root, filename)) + if err != nil { + return &http.Response{ + StatusCode: http.StatusNotFound, + Body: io.NopCloser(strings.NewReader("not found")), + }, nil + } + + if attempt <= atomic.LoadInt32(&t.failCount) { + // Return a truncated response to simulate unexpected EOF during decompression. + truncated := data[:len(data)/2] + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(string(truncated))), + }, nil + } + + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(string(data))), + }, nil +} + +func TestGetPackage_RetryOnTransientError(t *testing.T) { + repo := Repository{URI: fmt.Sprintf("%s/%s", testAlpineRepos, testArch)} + repoWithIndex := repo.WithIndex(&APKIndex{Packages: []*Package{&testPkg}}) + pkg := NewRepositoryPackage(&testPkg, repoWithIndex) + ctx := context.Background() + + tr := &truncatingTransport{ + root: testPrimaryPkgDir, + failCount: 1, // fail once, then succeed + } + + getter := newDefaultPackageGetter( + &http.Client{Transport: tr}, + nil, + auth.DefaultAuthenticators, + ) + + exp, err := getter.GetPackage(ctx, pkg) + require.NoError(t, err, "expected retry to succeed") + require.NotNil(t, exp) + _ = exp.Close() + + // Should have taken 2 attempts (1 failure + 1 success). + require.Equal(t, int32(2), tr.attempts.Load(), "expected exactly 2 fetch attempts") +} + +func TestGetPackage_NoRetryOnPermanentError(t *testing.T) { + tampered := testPkg + tampered.Checksum = make([]byte, len(testPkg.Checksum)) // wrong checksum + ctx := context.Background() + + repo := Repository{URI: fmt.Sprintf("%s/%s", testAlpineRepos, testArch)} + repoWithIndex := repo.WithIndex(&APKIndex{Packages: []*Package{&tampered}}) + pkg := NewRepositoryPackage(&tampered, repoWithIndex) + + // Use a transport that always serves the real APK data so we can count attempts. + tr := &truncatingTransport{ + root: testPrimaryPkgDir, + failCount: 0, // never truncate — always serve valid data + } + + getter := newDefaultPackageGetter( + &http.Client{Transport: tr}, + nil, + auth.DefaultAuthenticators, + ) + + _, err := getter.GetPackage(ctx, pkg) + require.Error(t, err) + require.Contains(t, err.Error(), "control hash mismatch") + + // Should have attempted exactly once — permanent errors must not be retried. + require.Equal(t, int32(1), tr.attempts.Load(), "expected exactly 1 fetch attempt for permanent error") +} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/apko-1.2.10/pkg/apk/apk/repo.go new/apko-1.2.11/pkg/apk/apk/repo.go --- old/apko-1.2.10/pkg/apk/apk/repo.go 2026-05-04 00:41:58.000000000 +0200 +++ new/apko-1.2.11/pkg/apk/apk/repo.go 2026-05-05 21:57:53.000000000 +0200 @@ -569,11 +569,11 @@ func (p *PkgResolver) GetPackageWithDependencies(ctx context.Context, pkgName string, existing map[string]*RepositoryPackage, dq map[*RepositoryPackage]string) (*RepositoryPackage, []*RepositoryPackage, []string, error) { parents := make(map[string]bool) localExisting := make(map[string]*RepositoryPackage, len(existing)) - existingOrigins := map[string]bool{} + existingOrigins := map[string]string{} for k, v := range existing { localExisting[k] = v if v != nil && v.Origin != "" { - existingOrigins[v.Origin] = true + existingOrigins[v.Origin] = v.Version } } @@ -712,7 +712,7 @@ // It might change the order of install. // In other words, this _should_ be a DAG (acyclical), but because the packages // are just listing dependencies in text, it might be cyclical. We need to be careful of that. -func (p *PkgResolver) getPackageDependencies(ctx context.Context, pkg *RepositoryPackage, allowPin string, parents map[string]bool, existing map[string]*RepositoryPackage, existingOrigins map[string]bool, dq map[*RepositoryPackage]string) (dependencies []*RepositoryPackage, conflicts []string, err error) { +func (p *PkgResolver) getPackageDependencies(ctx context.Context, pkg *RepositoryPackage, allowPin string, parents map[string]bool, existing map[string]*RepositoryPackage, existingOrigins map[string]string, dq map[*RepositoryPackage]string) (dependencies []*RepositoryPackage, conflicts []string, err error) { if err := ctx.Err(); err != nil { return nil, nil, context.Cause(ctx) } @@ -902,7 +902,9 @@ conflicts = append(conflicts, confs...) for _, dep := range subDeps { existing[dep.Name] = dep - existingOrigins[dep.Origin] = true + if dep.Origin != "" { + existingOrigins[dep.Origin] = dep.Version + } } } return dependencies, conflicts, nil @@ -944,11 +946,11 @@ // For example, if the original search was for package "a", then pkgs may contain some that // are named "a", but others that provided "a". In that case, we should look not at the // version of the package, but the version of "a" that the package provides. -func (p *PkgResolver) sortPackages(pkgs []*repositoryPackage, compare *RepositoryPackage, name string, existing map[string]*RepositoryPackage, existingOrigins map[string]bool, pin string) { +func (p *PkgResolver) sortPackages(pkgs []*repositoryPackage, compare *RepositoryPackage, name string, existing map[string]*RepositoryPackage, existingOrigins map[string]string, pin string) { slices.SortFunc(pkgs, p.comparePackages(compare, name, existing, existingOrigins, pin)) } -func (p *PkgResolver) comparePackages(compare *RepositoryPackage, name string, existing map[string]*RepositoryPackage, existingOrigins map[string]bool, pin string) func(a, b *repositoryPackage) int { //nolint:gocyclo +func (p *PkgResolver) comparePackages(compare *RepositoryPackage, name string, existing map[string]*RepositoryPackage, existingOrigins map[string]string, pin string) func(a, b *repositoryPackage) int { //nolint:gocyclo return func(a, b *repositoryPackage) int { // determine versions iVersionStr := p.getDepVersionForName(a, name) @@ -990,9 +992,12 @@ } // both matched, so keep looking - // see if an origin already is installed - iOriginMatched := existingOrigins[a.Origin] - jOriginMatched := existingOrigins[b.Origin] + // Prefer a candidate whose origin we've already pulled in, but only at + // the version we pulled in. Otherwise this heuristic would keep us on + // an older version of an origin (e.g. when a package moves origins at + // a newer version, or an old binary lingers in the index after rebuild). + iOriginMatched := a.Origin != "" && existingOrigins[a.Origin] == a.Version + jOriginMatched := b.Origin != "" && existingOrigins[b.Origin] == b.Version if iOriginMatched && !jOriginMatched { return -1 } @@ -1052,7 +1057,7 @@ } } -func (p *PkgResolver) bestPackage(pkgs []*repositoryPackage, compare *RepositoryPackage, name string, existing map[string]*RepositoryPackage, existingOrigins map[string]bool, pin string) *repositoryPackage { +func (p *PkgResolver) bestPackage(pkgs []*repositoryPackage, compare *RepositoryPackage, name string, existing map[string]*RepositoryPackage, existingOrigins map[string]string, pin string) *repositoryPackage { if len(pkgs) == 0 { return nil } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/apko-1.2.10/pkg/apk/apk/repo_test.go new/apko-1.2.11/pkg/apk/apk/repo_test.go --- old/apko-1.2.10/pkg/apk/apk/repo_test.go 2026-05-04 00:41:58.000000000 +0200 +++ new/apko-1.2.11/pkg/apk/apk/repo_test.go 2026-05-05 21:57:53.000000000 +0200 @@ -764,7 +764,7 @@ pkgs []*RepositoryPackage pkg *RepositoryPackage existing = map[string]*RepositoryPackage{} - existingOrigins = map[string]bool{} + existingOrigins = map[string]string{} ) for _, pkg := range tt.pkgs { // we cheat and use the InstalledSize for the preferred order, so that it gets carried around. @@ -778,7 +778,7 @@ } for _, pkg := range tt.existing { existing[pkg.pkg.Name] = NewRepositoryPackage(pkg.pkg, &RepositoryWithIndex{Repository: &Repository{URI: pkg.repo}}) - existingOrigins[pkg.pkg.Origin] = true + existingOrigins[pkg.pkg.Origin] = pkg.pkg.Version } namedPkgs := testNamedPackageFromPackages(pkgs) pr := NewPkgResolver(context.Background(), []NamedIndex{}) @@ -900,6 +900,43 @@ } } +// When an origin is bumped to a new version that no longer ships one of its +// old subpackages, but the old subpackage still lingers in the index providing +// some so:, the resolver must not prefer that stale package over a fresh +// provider in a different origin just because we already pulled the origin at +// a different version. +// +// As a contrived example, if we want to drop libcrypt1 from glibc's origin, +// it was difficult because we would prefer the libcrypt.so.1 provider due to +// that same-origin heuristic. This tests that the heuristic does not activate +// if the origins match but the versions don't. +func TestProviderAcrossOriginVersionBump(t *testing.T) { + repo := Repository{} + index := repo.WithIndex(&APKIndex{ + Packages: []*Package{ + {Name: "glibc", Version: "1", Origin: "glibc"}, + {Name: "libcrypt1", Version: "1", Origin: "glibc", + Provides: []string{"so:libcrypt.so.1=1"}}, + {Name: "glibc", Version: "2", Origin: "glibc"}, + {Name: "libxcrypt", Version: "2", Origin: "libxcrypt", + Provides: []string{"so:libcrypt.so.1=1"}}, + {Name: "consumer", Version: "1", + Dependencies: []string{"so:libcrypt.so.1"}}, + }, + }) + resolver := NewPkgResolver(context.Background(), testNamedRepositoryFromIndexes([]*RepositoryWithIndex{index})) + pkgs, _, err := resolver.GetPackagesWithDependencies(context.Background(), []string{"consumer", "glibc=2"}, nil) + require.NoError(t, err) + + got := make([]string, 0, len(pkgs)) + for _, p := range pkgs { + got = append(got, p.Filename()) + } + require.NotContains(t, got, "libcrypt1-1.apk", "should not pull stale libcrypt1 from old glibc origin") + require.Contains(t, got, "libxcrypt-2.apk", "should select libxcrypt for so:libcrypt.so.1") + require.Contains(t, got, "glibc-2.apk") +} + func TestConstrains(t *testing.T) { providers := map[string][]string{ "ld-linux=2.38-r10": {"so:ld-linux-aarch64.so.1=1.0"}, diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/apko-1.2.10/pkg/apk/apk/version_test.go new/apko-1.2.11/pkg/apk/apk/version_test.go --- old/apko-1.2.10/pkg/apk/apk/version_test.go 2026-05-04 00:41:58.000000000 +0200 +++ new/apko-1.2.11/pkg/apk/apk/version_test.go 2026-05-05 21:57:53.000000000 +0200 @@ -910,10 +910,10 @@ found := filterPackages(pkgs, map[*RepositoryPackage]string{}, withVersion(tt.version, tt.compare), withPreferPin(tt.pin), withInstalledPackage(tt.installed)) // add the existing in, if any existing := make(map[string]*RepositoryPackage) - existingOrigins := make(map[string]bool) + existingOrigins := make(map[string]string) if tt.installed != nil { existing[tt.installed.Name] = tt.installed - existingOrigins[tt.installed.Origin] = true + existingOrigins[tt.installed.Origin] = tt.installed.Version } pkg := pr.bestPackage(found, nil, "", existing, existingOrigins, tt.pin) if tt.want == "" { ++++++ apko.obsinfo ++++++ --- /var/tmp/diff_new_pack.JDt60y/_old 2026-05-06 19:23:45.571592011 +0200 +++ /var/tmp/diff_new_pack.JDt60y/_new 2026-05-06 19:23:45.575592176 +0200 @@ -1,5 +1,5 @@ name: apko -version: 1.2.10 -mtime: 1777848118 -commit: eebbe627f86c584c3ff9df826411a2b33dca5ca6 +version: 1.2.11 +mtime: 1778011073 +commit: bfd6776788292e020d8cbee9928f441781af72c0 ++++++ vendor.tar.gz ++++++ /work/SRC/openSUSE:Factory/apko/vendor.tar.gz /work/SRC/openSUSE:Factory/.apko.new.30200/vendor.tar.gz differ: char 133, line 1
