From 32f6865de845244828f96a15d4e2536d7297f4d7 Mon Sep 17 00:00:00 2001 From: Charlie Le Date: Wed, 19 Aug 2026 14:16:50 -0700 Subject: [PATCH] Resolve the latest release image to a published version integration/util.go derived the "latest release" image straight from the VERSION file. That holds on master, where VERSION is the last GA, but not on a release branch: VERSION is bumped to the version being prepared (e.g. 1.22.0-rc.0) long before the deploy job publishes that tag, and the integration job is a dependency of deploy. So the query fuzz leg would pull an image that does not exist yet. Resolve a pre-release version to the release preceding it instead, and add CORTEX_LATEST_RELEASE_IMAGE as an escape hatch for the cases the version math cannot cover (a major pre-release). The preload step in test-build-deploy.yml mirrors the same rule. Signed-off-by: Charlie Le --- .github/workflows/test-build-deploy.yml | 32 ++++++++- integration/util.go | 83 ++++++++++++++++++++-- integration/util_test.go | 94 +++++++++++++++++++++++++ 3 files changed, 204 insertions(+), 5 deletions(-) create mode 100644 integration/util_test.go diff --git a/.github/workflows/test-build-deploy.yml b/.github/workflows/test-build-deploy.yml index 6950325c94..e446933562 100644 --- a/.github/workflows/test-build-deploy.yml +++ b/.github/workflows/test-build-deploy.yml @@ -317,6 +317,36 @@ jobs: done } + # Mirror of latestReleaseVersion() in integration/util.go: VERSION names the version + # being prepared, which on a release branch is not published yet, so a pre-release + # resolves to the release preceding it. Keep the two implementations in sync. + latest_release_image() { + if [ -n "${CORTEX_LATEST_RELEASE_IMAGE:-}" ]; then + echo "$CORTEX_LATEST_RELEASE_IMAGE" + return 0 + fi + + local version major minor patch + version=$(cat testdata/VERSION) + case "$version" in + *-*) + IFS='.' read -r major minor patch <<< "${version%%-*}" + if [ "$patch" -gt 0 ]; then + patch=$((patch - 1)) + elif [ "$minor" -gt 0 ]; then + minor=$((minor - 1)) + patch=0 + else + echo "ERROR: cannot resolve the release preceding major pre-release version ${version};" \ + "set CORTEX_LATEST_RELEASE_IMAGE to the latest published release image." >&2 + return 1 + fi + version="${major}.${minor}.${patch}" + ;; + esac + echo "quay.io/cortexproject/cortex:v${version}" + } + retry docker pull minio/minio:RELEASE.2024-05-28T17-19-04Z retry docker pull consul:1.8.4 retry docker pull quay.io/coreos/etcd:v3.5.29 @@ -329,7 +359,7 @@ jobs: retry docker pull quay.io/cortexproject/cortex:v1.21.0 retry docker pull quay.io/cortexproject/cortex:v1.21.1 elif [ "$TEST_TAGS" = "integration_query_fuzz" ]; then - retry docker pull quay.io/cortexproject/cortex:v$(cat testdata/VERSION) + retry docker pull "$(latest_release_image)" retry docker pull quay.io/prometheus/prometheus:v3.9.1 elif [ "$TEST_TAGS" = "integration_configs_db" ]; then retry docker pull postgres:9.6.16 diff --git a/integration/util.go b/integration/util.go index 0ec7721838..0c3e802b83 100644 --- a/integration/util.go +++ b/integration/util.go @@ -8,6 +8,7 @@ import ( "os" "os/exec" "path/filepath" + "strconv" "strings" "github.com/pkg/errors" @@ -36,20 +37,94 @@ func getCortexProjectDir() string { return os.Getenv("GOPATH") + "/src/github.com/cortexproject/cortex" } -// getLatestReleaseImage returns the Cortex image reference for the latest release, -// derived from the VERSION file at the project root. +// getLatestReleaseImage returns the Cortex image reference for the latest published +// release, derived from the VERSION file at the project root. +// +// Set CORTEX_LATEST_RELEASE_IMAGE to override the resolution entirely. +// +// If you change how this resolves, remember to update the preloading done by GitHub +// Actions too (see .github/workflows/test-build-deploy.yml). func getLatestReleaseImage() (string, error) { + if image := os.Getenv("CORTEX_LATEST_RELEASE_IMAGE"); image != "" { + return image, nil + } + content, err := os.ReadFile(filepath.Join(getCortexProjectDir(), "VERSION")) if err != nil { return "", errors.Wrap(err, "unable to read VERSION file") } - version := strings.TrimSpace(string(content)) + version, err := latestReleaseVersion(strings.TrimSpace(string(content))) + if err != nil { + return "", err + } + + return fmt.Sprintf("quay.io/cortexproject/cortex:v%s", version), nil +} + +// latestReleaseVersion maps the contents of the VERSION file to a version that has +// actually been published to the container registries. +// +// VERSION does not always name a published release. On a release branch it is bumped to +// the version being prepared (e.g. "1.22.0-rc.0") long before the deploy job publishes +// that tag, and the integration job runs before deploy. So a pre-release version resolves +// to the release preceding it, which is always already published by then: +// +// 1.21.1 -> 1.21.1 (VERSION on master is the last GA, whose image exists) +// 1.22.0-rc.0 -> 1.21.0 (the previous minor always shipped a .0) +// 1.22.2-rc.1 -> 1.22.1 (the preceding patch of the same minor) +func latestReleaseVersion(version string) (string, error) { if version == "" { return "", errors.New("VERSION file is empty") } - return fmt.Sprintf("quay.io/cortexproject/cortex:v%s", version), nil + // Anything after the first "-" is a pre-release identifier (e.g. "-rc.0"). + base, preRelease, isPreRelease := strings.Cut(version, "-") + if !isPreRelease { + return version, nil + } + + major, minor, patch, err := parseVersion(base) + if err != nil { + return "", errors.Wrapf(err, "unable to resolve the release preceding pre-release version %q", version) + } + + switch { + case patch > 0: + // A patch pre-release: the preceding patch of the same minor is published. + patch-- + case minor > 0: + // A minor pre-release: the previous minor's initial release is published. Using + // .0 rather than its latest patch keeps this derivable from VERSION alone. + minor-- + patch = 0 + default: + // A major pre-release (e.g. "2.0.0-rc.0"). The last release of the previous major + // is not derivable from VERSION, so the maintainer has to say which one it is. + return "", errors.Errorf("cannot resolve the release preceding major pre-release version %q (base %q, pre-release %q):"+ + " set CORTEX_LATEST_RELEASE_IMAGE to the latest published release image", version, base, preRelease) + } + + return fmt.Sprintf("%d.%d.%d", major, minor, patch), nil +} + +func parseVersion(version string) (major, minor, patch int, err error) { + parts := strings.Split(version, ".") + if len(parts) != 3 { + return 0, 0, 0, errors.Errorf("expected a major.minor.patch version, got %q", version) + } + + out := make([]int, len(parts)) + for i, part := range parts { + if out[i], err = strconv.Atoi(part); err != nil { + return 0, 0, 0, errors.Wrapf(err, "invalid version %q", version) + } + if out[i] < 0 { + return 0, 0, 0, errors.Errorf("invalid version %q", version) + } + } + + return out[0], out[1], out[2], nil } func writeFileToSharedDir(s *e2e.Scenario, dst string, content []byte) error { diff --git a/integration/util_test.go b/integration/util_test.go new file mode 100644 index 0000000000..8b84d98856 --- /dev/null +++ b/integration/util_test.go @@ -0,0 +1,94 @@ +//go:build integration + +package integration + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLatestReleaseVersion(t *testing.T) { + tests := map[string]struct { + version string + expected string + expectedErr bool + }{ + "a GA version is already published": { + version: "1.21.1", + expected: "1.21.1", + }, + "a GA version with a zero patch is already published": { + version: "1.21.0", + expected: "1.21.0", + }, + "a minor release candidate falls back to the previous minor": { + version: "1.22.0-rc.0", + expected: "1.21.0", + }, + "a later minor release candidate falls back to the same previous minor": { + version: "1.22.0-rc.3", + expected: "1.21.0", + }, + "a patch release candidate falls back to the preceding patch": { + version: "1.22.1-rc.0", + expected: "1.22.0", + }, + "a later patch release candidate falls back to the preceding patch": { + version: "1.22.3-rc.1", + expected: "1.22.2", + }, + "a major release candidate cannot be resolved": { + version: "2.0.0-rc.0", + expectedErr: true, + }, + "an empty VERSION is rejected": { + version: "", + expectedErr: true, + }, + "a malformed pre-release base is rejected": { + version: "1.22-rc.0", + expectedErr: true, + }, + "a non-numeric pre-release base is rejected": { + version: "1.x.0-rc.0", + expectedErr: true, + }, + } + + for name, testData := range tests { + t.Run(name, func(t *testing.T) { + actual, err := latestReleaseVersion(testData.version) + if testData.expectedErr { + require.Error(t, err) + return + } + + require.NoError(t, err) + assert.Equal(t, testData.expected, actual) + }) + } +} + +func TestGetLatestReleaseImage(t *testing.T) { + // Point getCortexProjectDir() at a scratch checkout so we can exercise the VERSION file + // contents a release branch would actually have. + dir := t.TempDir() + t.Setenv("CORTEX_CHECKOUT_DIR", dir) + require.NoError(t, os.WriteFile(filepath.Join(dir, "VERSION"), []byte("1.22.0-rc.0\n"), 0o600)) + + image, err := getLatestReleaseImage() + require.NoError(t, err) + assert.Equal(t, "quay.io/cortexproject/cortex:v1.21.0", image) +} + +func TestGetLatestReleaseImage_HonorsOverride(t *testing.T) { + t.Setenv("CORTEX_LATEST_RELEASE_IMAGE", "quay.io/cortexproject/cortex:v1.20.1") + + image, err := getLatestReleaseImage() + require.NoError(t, err) + assert.Equal(t, "quay.io/cortexproject/cortex:v1.20.1", image) +}