From 6351fd6526a151390e7e679eec2a2fa32445a709 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Fri, 17 Jul 2026 23:17:23 +0100 Subject: [PATCH] fix(updates): select releases by highest version, not GitHub list order Production telemetry (7d, 2026-07-17) shows a persistent tail of live installs on 6.0.0-rc.x and 6.0.3/6.0.4 while 6.0.5 has been stable since 2026-07-09. Root cause for the reachable stranding class: release selection trusted GitHub's created_at ordering, and this repo interleaves v5-line maintenance releases with v6 releases (v5.1.36 was created the day before v6.0.5). Whenever a v5.1.x release is the most recently created stable: - stable channel (getLatestReleaseForChannel) returned the first non-prerelease in list order -> "no update" for every v6 install until the next v6 release ships - the RSS rate-limit fallback returned the first feed entry matching the channel, same failure, both channels - the unattended updater trusted /releases/latest, which GitHub defines as most-recently-created stable -> timer no-ops in the same window All three now select the highest eligible version: the API path tracks newest stable + newest prerelease in one pass (prerelease = GitHub flag OR tag shape), the feed fallback picks the max matching the channel, and pulse-auto-update.sh scans /releases pairing each tag with its own draft/prerelease flags before the fail-closed shape filter, keeping /releases/latest as fallback. helm-chart-* tags fail semver parsing and are dropped everywhere. Verified not broken (pinned by new tests): rc-channel installs are offered the newer stable (6.0.0-rc.6 -> 6.0.5) and move onto the next rc line when one opens (-> 6.1.0-rc.2); the auto-update prerelease filter (fail-closed is_prerelease_tag, metadata-flag refusal) still refuses prerelease targets on stable. Go tests fail on the pre-fix code (returned 5.1.37 / "no update"). Contract: deployment-installability now pins version-max release selection with proofs in manager_stranded_upgrade_test.go and pulse_auto_update_test.go. --- .../subsystems/deployment-installability.md | 16 ++ internal/updates/manager.go | 178 ++++++-------- .../updates/manager_stranded_upgrade_test.go | 224 ++++++++++++++++++ .../installtests/pulse_auto_update_test.go | 76 ++++++ scripts/pulse-auto-update.sh | 56 ++++- scripts/tests/test-pulse-auto-update.sh | 53 +++++ 6 files changed, 491 insertions(+), 112 deletions(-) create mode 100644 internal/updates/manager_stranded_upgrade_test.go diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 48f76fc639..dd9c2480b2 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -1421,6 +1421,22 @@ runtime copy: `install.sh`, `scripts/pulse-auto-update.sh`, and `internal/updates/manager.go` must present `rc`-tagged builds as prerelease or preview paths in menus, CLI help text, operator diagnostics, and runtime logs rather than as release-candidate promises. +That same update-check boundary also owns release selection order: because +v5-line maintenance releases interleave with v6 releases in the same GitHub +repo (v5.1.36 was created the day before v6.0.5), `internal/updates/manager.go` +— both the release-list path and the Atom feed fallback — and +`scripts/pulse-auto-update.sh` must select the highest parseable version +eligible for the channel rather than trusting GitHub's `created_at` ordering +or the `/releases/latest` pointer, so a more recently created lower-version +release can never mask the newest stable and strand installs until the next +release ships. Stable-channel selection must keep excluding draft releases, +metadata-flagged prereleases, and prerelease- or non-semver-shaped tags +(`helm-chart-*`), and the `rc` channel must keep offering the newest stable to +prerelease installs so an rc line that lands on stable (6.0.0-rc.x → 6.0.5) +moves its installs forward instead of stranding them. Proof: +`internal/updates/manager_stranded_upgrade_test.go` and +`scripts/installtests/pulse_auto_update_test.go` +(`TestGetLatestStableVersionPrefersHighestVersion`). Those same workflows must also fetch and dispatch the governed release branch derived from release-control metadata instead of hardcoding `pulse/v6`, `pulse/v6-release`, `main`, or any later branch literal inline; when a stable diff --git a/internal/updates/manager.go b/internal/updates/manager.go index 6e33ec286c..6e4b7eefef 100644 --- a/internal/updates/manager.go +++ b/internal/updates/manager.go @@ -912,125 +912,71 @@ func (m *Manager) getLatestReleaseForChannel(ctx context.Context, channel string return nil, fmt.Errorf("failed to decode releases: %w", err) } - // Find latest release based on channel - // Prerelease channel: return newest release (prerelease or stable), even if not newer than current - // Stable channel: return newest stable release, even if not newer than current + // Find latest release based on channel, selecting by version rather than + // list position: GitHub returns releases sorted by created_at, and v5-line + // maintenance releases interleave with v6 releases in the same repo, so + // the most recently published stable is not necessarily the highest one. + // Prerelease channel: newest release (prerelease or stable), even if not newer than current + // Stable channel: newest stable release, even if not newer than current // The caller will determine if it's actually an update by comparing versions - if channel == "rc" { - // For the prerelease channel: find newest release (prerelease or stable) - // Prerelease users should see both prereleases and stable releases - var newestRC *ReleaseInfo - var newestStable *ReleaseInfo + var newestRC, newestStable *ReleaseInfo + var newestRCVer, newestStableVer *Version - for i := range releases { - // Skip draft releases - if releases[i].Draft { - log.Debug().Str("tag", releases[i].TagName).Msg("Skipping draft release") - continue - } - - releaseVer, err := ParseVersion(releases[i].TagName) - if err != nil { - log.Debug().Str("tag", releases[i].TagName).Err(err).Msg("Failed to parse release version") - continue - } - - if releases[i].Prerelease { - // Track newest prerelease - if newestRC == nil { - newestRC = &releases[i] - } else { - newestRCVer, _ := ParseVersion(newestRC.TagName) - if releaseVer.IsNewerThan(newestRCVer) { - newestRC = &releases[i] - } - } - } else { - // Track newest stable - if newestStable == nil { - newestStable = &releases[i] - } else { - newestStableVer, _ := ParseVersion(newestStable.TagName) - if releaseVer.IsNewerThan(newestStableVer) { - newestStable = &releases[i] - } - } - } + for i := range releases { + // Skip draft releases + if releases[i].Draft { + log.Debug().Str("tag", releases[i].TagName).Msg("Skipping draft release") + continue } - // Return the highest version among candidates - // Stable versions are considered higher than RCs (4.22.0 > 4.22.0-rc.3) - if newestStable != nil && newestRC != nil { - stableVer, _ := ParseVersion(newestStable.TagName) - rcVer, _ := ParseVersion(newestRC.TagName) - if stableVer.IsNewerThan(rcVer) { - isUpdate := stableVer.IsNewerThan(currentVer) - if isUpdate { - log.Info().Str("version", newestStable.TagName).Msg("Found stable update for prerelease user") - } else { - log.Info().Str("version", newestStable.TagName).Msg("On latest stable version") - } - return newestStable, nil + releaseVer, err := ParseVersion(releases[i].TagName) + if err != nil { + log.Debug().Str("tag", releases[i].TagName).Err(err).Msg("Failed to parse release version") + continue + } + + // A release counts as prerelease when either GitHub metadata or the + // version tag itself says so (guards against metadata set incorrectly) + if releases[i].Prerelease || releaseVer.IsPrerelease() { + if newestRC == nil || releaseVer.IsNewerThan(newestRCVer) { + newestRC = &releases[i] + newestRCVer = releaseVer } - isUpdate := rcVer.IsNewerThan(currentVer) - if isUpdate { - log.Info().Str("version", newestRC.TagName).Msg("Found prerelease update") - } else { - log.Info().Str("version", newestRC.TagName).Msg("On latest prerelease version") + } else { + if newestStable == nil || releaseVer.IsNewerThan(newestStableVer) { + newestStable = &releases[i] + newestStableVer = releaseVer } - return newestRC, nil - } else if newestStable != nil { - isUpdate := newestStable.TagName != currentVer.String() - if isUpdate { + } + } + + if channel == "rc" { + // For the prerelease channel: return the highest version among both + // candidates, so prerelease users are moved forward onto stable once + // the rc line lands (6.0.5 > 6.0.0-rc.7). + if newestStable != nil && (newestRC == nil || newestStableVer.IsNewerThan(newestRCVer)) { + if newestStableVer.IsNewerThan(currentVer) { log.Info().Str("version", newestStable.TagName).Msg("Found stable update for prerelease user") } else { log.Info().Str("version", newestStable.TagName).Msg("On latest stable version") } return newestStable, nil - } else if newestRC != nil { - isUpdate := newestRC.TagName != currentVer.String() - if isUpdate { + } + if newestRC != nil { + if newestRCVer.IsNewerThan(currentVer) { log.Info().Str("version", newestRC.TagName).Msg("Found prerelease update") } else { log.Info().Str("version", newestRC.TagName).Msg("On latest prerelease version") } return newestRC, nil } - } else { - // For stable channel: find latest non-prerelease - for i := range releases { - // Skip draft releases - if releases[i].Draft { - log.Debug().Str("tag", releases[i].TagName).Msg("Skipping draft release") - continue - } - - if releases[i].Prerelease { - continue - } - - releaseVer, err := ParseVersion(releases[i].TagName) - if err != nil { - log.Debug().Str("tag", releases[i].TagName).Err(err).Msg("Failed to parse release version") - continue - } - - // Also skip if the version tag itself indicates a prerelease - // (guards against GitHub metadata being set incorrectly) - if releaseVer.IsPrerelease() { - log.Debug().Str("tag", releases[i].TagName).Msg("Skipping release with prerelease version tag on stable channel") - continue - } - - // Found the latest stable release - isUpdate := releaseVer.IsNewerThan(currentVer) - if isUpdate { - log.Info().Str("version", releases[i].TagName).Msg("Found stable update") - } else { - log.Info().Str("version", releases[i].TagName).Msg("On latest stable version") - } - return &releases[i], nil + } else if newestStable != nil { + if newestStableVer.IsNewerThan(currentVer) { + log.Info().Str("version", newestStable.TagName).Msg("Found stable update") + } else { + log.Info().Str("version", newestStable.TagName).Msg("On latest stable version") } + return newestStable, nil } // No releases found at all for this channel @@ -1102,7 +1048,11 @@ func (m *Manager) getLatestReleaseFromFeed(ctx context.Context, channel string) return nil, fmt.Errorf("no version tags found in feed") } - // Filter based on channel + // Pick the highest version matching the channel rather than the first + // entry: the feed is publication-ordered and v5-line maintenance releases + // interleave with v6 releases in the same repo. + var best *ReleaseInfo + var bestVer *Version for _, match := range matches { if len(match) < 2 { continue @@ -1122,20 +1072,26 @@ func (m *Manager) getLatestReleaseFromFeed(ctx context.Context, channel string) continue } - // Found a valid release for this channel - log.Debug(). - Str("tag", tagName). - Bool("prerelease", isPrerelease). - Str("channel", channel). - Msg("Found release from feed") - - return &ReleaseInfo{ + if best != nil && !ver.IsNewerThan(bestVer) { + continue + } + best = &ReleaseInfo{ TagName: tagName, Name: "Pulse " + tagName, Prerelease: isPrerelease, // Note: Feed doesn't include full release notes or asset info // This is just for version checking - actual download still uses known URL patterns - }, nil + } + bestVer = ver + } + + if best != nil { + log.Debug(). + Str("tag", best.TagName). + Bool("prerelease", best.Prerelease). + Str("channel", channel). + Msg("Found release from feed") + return best, nil } return nil, fmt.Errorf("no suitable release found for channel %s", channel) diff --git a/internal/updates/manager_stranded_upgrade_test.go b/internal/updates/manager_stranded_upgrade_test.go new file mode 100644 index 0000000000..b0fbd85130 --- /dev/null +++ b/internal/updates/manager_stranded_upgrade_test.go @@ -0,0 +1,224 @@ +package updates + +// Pins the stranded-upgrade cases from the 2026-07 production telemetry tail: +// installs on 6.0.0-rc.x and 6.0.x must be offered the newest stable (6.0.5) +// even though the same GitHub repo interleaves v5-line maintenance releases +// (v5.1.36 shipped between v6.0.5-rc.4 and v6.0.5) and helm-chart-* tags. +// Release selection must go by version, never by GitHub's created_at order. + +import ( + "context" + "fmt" + "io" + "net/http" + "strings" + "testing" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/config" +) + +func withBuildVersion(t *testing.T, version string) { + t.Helper() + orig := BuildVersion + BuildVersion = version + t.Cleanup(func() { BuildVersion = orig }) +} + +func makeRelease(tag string, prerelease bool) ReleaseInfo { + release := ReleaseInfo{ + TagName: tag, + Name: "Pulse " + tag, + Prerelease: prerelease, + PublishedAt: time.Date(2026, 7, 9, 14, 36, 38, 0, time.UTC), + } + for _, arch := range []string{"amd64", "arm64", "armv7", "386"} { + release.Assets = append(release.Assets, struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` + }{ + Name: fmt.Sprintf("pulse-%s-linux-%s.tar.gz", tag, arch), + BrowserDownloadURL: fmt.Sprintf("https://example.com/%s/pulse-%s-linux-%s.tar.gz", tag, tag, arch), + }) + } + return release +} + +// The production release list around the 6.0.5 launch, in GitHub's +// created_at ordering. helm-chart-* releases are flagged prerelease and have +// unparseable tags; v5.1.36 is a stable release from the v5 maintenance line. +func julyReleaseList() []ReleaseInfo { + return []ReleaseInfo{ + makeRelease("v6.0.5", false), + makeRelease("helm-chart-6.0.5", true), + makeRelease("v6.0.5-rc.4", true), + makeRelease("v5.1.36", false), + makeRelease("v6.0.4", false), + makeRelease("v6.0.3", false), + makeRelease("v6.0.0-rc.7", true), + makeRelease("v6.0.0-rc.6", true), + } +} + +// A 6.0.0-rc.6 install (auto-detected rc channel, no persisted setting) must +// be offered 6.0.5 once the rc line lands on stable. +func TestStrandedUpgrade_RC6IsOfferedStable605(t *testing.T) { + withBuildVersion(t, "6.0.0-rc.6") + + server := newReleaseServer(t, julyReleaseList(), nil) + defer server.Close() + t.Setenv("PULSE_UPDATE_SERVER", server.URL) + + manager := NewManager(&config.Config{}) + info, err := manager.CheckForUpdatesWithChannel(context.Background(), "") + if err != nil { + t.Fatalf("CheckForUpdatesWithChannel returned error: %v", err) + } + if !info.Available { + t.Fatalf("expected update available for 6.0.0-rc.6, got none (latest=%q)", info.LatestVersion) + } + if info.LatestVersion != "6.0.5" { + t.Fatalf("LatestVersion = %q, want 6.0.5", info.LatestVersion) + } + if info.IsPrerelease { + t.Fatalf("6.0.5 must not be flagged prerelease") + } + if info.DownloadURL == "" || !strings.Contains(info.DownloadURL, "v6.0.5") { + t.Fatalf("DownloadURL = %q, want a v6.0.5 asset", info.DownloadURL) + } +} + +// A 6.0.4 install on the stable channel must be offered 6.0.5 even when a +// v5-line maintenance release was created more recently and therefore sits +// ahead of it in GitHub's list ordering. +func TestStrandedUpgrade_604IsOffered605DespiteNewerV5Release(t *testing.T) { + withBuildVersion(t, "6.0.4") + + releases := append([]ReleaseInfo{ + makeRelease("v5.1.37", false), + makeRelease("helm-chart-5.1.37", true), + }, julyReleaseList()...) + + server := newReleaseServer(t, releases, nil) + defer server.Close() + t.Setenv("PULSE_UPDATE_SERVER", server.URL) + + manager := NewManager(&config.Config{UpdateChannel: "stable"}) + info, err := manager.CheckForUpdatesWithChannel(context.Background(), "") + if err != nil { + t.Fatalf("CheckForUpdatesWithChannel returned error: %v", err) + } + if !info.Available { + t.Fatalf("expected update available for 6.0.4, got none (latest=%q)", info.LatestVersion) + } + if info.LatestVersion != "6.0.5" { + t.Fatalf("LatestVersion = %q, want 6.0.5 (v5-line release must not mask it)", info.LatestVersion) + } + if info.DownloadURL == "" || !strings.Contains(info.DownloadURL, "v6.0.5") { + t.Fatalf("DownloadURL = %q, want a v6.0.5 asset", info.DownloadURL) + } +} + +// Once the next rc line opens (6.1.0-rc.x), rc installs move onto it instead +// of the older stable. +func TestStrandedUpgrade_RCChannelPrefersNewerRCLine(t *testing.T) { + withBuildVersion(t, "6.0.0-rc.6") + + releases := append([]ReleaseInfo{ + makeRelease("v6.1.0-rc.2", true), + makeRelease("v6.1.0-rc.1", true), + }, julyReleaseList()...) + + server := newReleaseServer(t, releases, nil) + defer server.Close() + t.Setenv("PULSE_UPDATE_SERVER", server.URL) + + manager := NewManager(&config.Config{UpdateChannel: "rc"}) + info, err := manager.CheckForUpdatesWithChannel(context.Background(), "") + if err != nil { + t.Fatalf("CheckForUpdatesWithChannel returned error: %v", err) + } + if !info.Available { + t.Fatalf("expected update available for 6.0.0-rc.6, got none (latest=%q)", info.LatestVersion) + } + if info.LatestVersion != "6.1.0-rc.2" { + t.Fatalf("LatestVersion = %q, want 6.1.0-rc.2", info.LatestVersion) + } + if !info.IsPrerelease { + t.Fatalf("6.1.0-rc.2 must be flagged prerelease") + } +} + +// The exact version-string comparisons behind the stranded-upgrade cases. +func TestVersionCompare_StrandedUpgradeStrings(t *testing.T) { + cases := []struct { + newer, older string + }{ + {"6.0.5", "6.0.0-rc.6"}, + {"6.0.5", "6.0.0-rc.1"}, + {"6.0.5", "6.0.0-rc.7"}, + {"6.0.5", "6.0.3"}, + {"6.0.5", "6.0.4"}, + {"6.0.5", "6.0.5-rc.4"}, + {"6.0.0-rc.7", "6.0.0-rc.6"}, + {"6.1.0-rc.2", "6.0.5"}, + } + for _, tc := range cases { + newer, err := ParseVersion(tc.newer) + if err != nil { + t.Fatalf("ParseVersion(%q): %v", tc.newer, err) + } + older, err := ParseVersion(tc.older) + if err != nil { + t.Fatalf("ParseVersion(%q): %v", tc.older, err) + } + if !newer.IsNewerThan(older) { + t.Errorf("%s must be newer than %s", tc.newer, tc.older) + } + if older.IsNewerThan(newer) { + t.Errorf("%s must not be newer than %s", tc.older, tc.newer) + } + } +} + +// The RSS fallback must also select by version, not feed order: a v5-line +// entry published after 6.0.5 sits first in the feed. +func TestFeedFallback_SelectsHighestVersionNotFirstEntry(t *testing.T) { + feed := ` + + Pulse v5.1.37 + Pulse v6.0.5 + Pulse v6.0.5-rc.4 + Pulse v6.0.0-rc.7 +` + + origTransport := http.DefaultTransport + http.DefaultTransport = roundTripperFunc(func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Status: "200 OK", + Body: io.NopCloser(strings.NewReader(feed)), + Header: http.Header{"Content-Type": []string{"application/atom+xml"}}, + Request: req, + }, nil + }) + t.Cleanup(func() { http.DefaultTransport = origTransport }) + + manager := NewManager(&config.Config{}) + + release, err := manager.getLatestReleaseFromFeed(context.Background(), "stable") + if err != nil { + t.Fatalf("stable feed error: %v", err) + } + if release.TagName != "v6.0.5" { + t.Fatalf("stable feed tag = %q, want v6.0.5", release.TagName) + } + + release, err = manager.getLatestReleaseFromFeed(context.Background(), "rc") + if err != nil { + t.Fatalf("rc feed error: %v", err) + } + if release.TagName != "v6.0.5" { + t.Fatalf("rc feed tag = %q, want v6.0.5 (stable outranks its own rc)", release.TagName) + } +} diff --git a/scripts/installtests/pulse_auto_update_test.go b/scripts/installtests/pulse_auto_update_test.go index 5c95c22290..0fd6a44b7b 100644 --- a/scripts/installtests/pulse_auto_update_test.go +++ b/scripts/installtests/pulse_auto_update_test.go @@ -173,6 +173,8 @@ curl() { EOF } ` + extractAutoUpdateFunction(t, "is_prerelease_tag") + ` +` + extractAutoUpdateFunction(t, "version_greater_than") + ` +` + extractAutoUpdateFunction(t, "pick_highest_stable_tag") + ` ` + extractAutoUpdateFunction(t, "get_latest_stable_version") + ` result=$(get_latest_stable_version) echo "RESULT=[$result]" @@ -207,6 +209,8 @@ curl() { EOF } ` + extractAutoUpdateFunction(t, "is_prerelease_tag") + ` +` + extractAutoUpdateFunction(t, "version_greater_than") + ` +` + extractAutoUpdateFunction(t, "pick_highest_stable_tag") + ` ` + extractAutoUpdateFunction(t, "get_latest_stable_version") + ` result=$(get_latest_stable_version) echo "RESULT=[$result]" @@ -239,6 +243,8 @@ curl() { EOF } ` + extractAutoUpdateFunction(t, "is_prerelease_tag") + ` +` + extractAutoUpdateFunction(t, "version_greater_than") + ` +` + extractAutoUpdateFunction(t, "pick_highest_stable_tag") + ` ` + extractAutoUpdateFunction(t, "get_latest_stable_version") + ` result=$(get_latest_stable_version) echo "RESULT=[$result]" @@ -254,6 +260,76 @@ echo "RESULT=[$result]" } } +// TestGetLatestStableVersionPrefersHighestVersion asserts that the updater +// selects the highest stable version from the release list rather than the +// most recently created one. This repo interleaves v5-line maintenance +// releases with v6 releases (v5.1.36 was created the day before v6.0.5), so +// GitHub's created_at ordering — and its /releases/latest pointer — can name +// an older version line, which would strand v6 installs until the next v6 +// release ships. Draft releases, metadata-flagged prereleases, and +// prerelease- or non-semver-shaped tags (helm-chart-*) must all be ignored. +func TestGetLatestStableVersionPrefersHighestVersion(t *testing.T) { + script := ` +set -u +GITHUB_REPO="rcourtman/Pulse" +log() { echo "[$1] $2" >&2; } +# Stub curl: the /releases list in GitHub's created_at ordering, with the +# v5-line release first. Any fallback call (e.g. /releases/latest) would +# return the same list and must not be needed for selection. +curl() { + cat <<'EOF' +[ + { + "tag_name": "v5.1.37", + "draft": false, + "prerelease": false + }, + { + "tag_name": "v9.9.9", + "draft": true, + "prerelease": false + }, + { + "tag_name": "helm-chart-6.0.5", + "draft": false, + "prerelease": true + }, + { + "tag_name": "v6.0.5", + "draft": false, + "prerelease": false + }, + { + "tag_name": "v6.0.5-rc.4", + "draft": false, + "prerelease": true + }, + { + "tag_name": "v6.0.4", + "draft": false, + "prerelease": false + } +] +EOF +} +` + extractAutoUpdateFunction(t, "is_prerelease_tag") + ` +` + extractAutoUpdateFunction(t, "version_greater_than") + ` +` + extractAutoUpdateFunction(t, "pick_highest_stable_tag") + ` +` + extractAutoUpdateFunction(t, "get_latest_stable_version") + ` +result=$(get_latest_stable_version) +echo "RESULT=[$result]" +` + + out, err := exec.Command("bash", "-c", script).CombinedOutput() + if err != nil { + t.Fatalf("bash: %v\n%s", err, out) + } + got := string(out) + if !strings.Contains(got, "RESULT=[v6.0.5]") { + t.Fatalf("get_latest_stable_version = %s, want v6.0.5 (highest stable, not most recently created)", got) + } +} + // TestInstalledBinaryIsPulseProGuard proves the unattended updater's Pro // guard: a binary whose --version reports "Pulse Pro" is detected (so main() // exits before any community download can replace it), while the community diff --git a/scripts/pulse-auto-update.sh b/scripts/pulse-auto-update.sh index c6ba8645db..83a5c76361 100755 --- a/scripts/pulse-auto-update.sh +++ b/scripts/pulse-auto-update.sh @@ -155,13 +155,67 @@ is_prerelease_tag() { return 1 } +# Select the highest stable version among a list of tags fed on stdin, +# one per line. Tags are filtered through is_prerelease_tag (fail-closed), +# which also drops non-semver tags like helm-chart-*. +pick_highest_stable_tag() { + local best="" + local tag="" + while IFS= read -r tag; do + [[ -z "$tag" ]] && continue + if is_prerelease_tag "$tag"; then + continue + fi + if [[ -z "$best" ]] || version_greater_than "$tag" "$best"; then + best="$tag" + fi + done + echo "$best" +} + # Get latest stable release from GitHub get_latest_stable_version() { local latest_version="" local release_json="" local is_prerelease_flag="" - # Get latest stable release (not pre-releases). `/releases/latest` + # Primary: highest stable version across the release list. GitHub's + # /releases/latest points at the most recently *created* stable release, + # and this repo interleaves v5-line maintenance releases with v6 releases + # (v5.1.36 shipped the day before v6.0.5) — so "latest" can be an older + # version line, which would strand v6 installs until the next v6 release. + # + # Each tag is only a candidate when its own release object says + # draft=false and prerelease=false (field order per object is + # tag_name → draft → prerelease, with body last, so a pending tag is + # confirmed or discarded before the next object's tag_name resets it). + # pick_highest_stable_tag then re-applies the fail-closed shape check. + local releases_json="" + releases_json=$(curl -s "https://api.github.com/repos/$GITHUB_REPO/releases?per_page=30" || true) + if [[ -n "$releases_json" ]] && [[ "$releases_json" != *"rate limit"* ]]; then + local line="" pending_tag="" stable_tags="" + while IFS= read -r line; do + if [[ "$line" == *'"tag_name":'* ]]; then + pending_tag=$(printf '%s' "$line" | sed -E 's/.*"tag_name":[[:space:]]*"([^"]+)".*/\1/') + fi + if [[ "$line" == *'"draft": true'* ]] || [[ "$line" == *'"draft":true'* ]]; then + pending_tag="" + fi + if [[ "$line" == *'"prerelease":'* ]]; then + if [[ -n "$pending_tag" ]] && { [[ "$line" == *'"prerelease": false'* ]] || [[ "$line" == *'"prerelease":false'* ]]; }; then + stable_tags+="$pending_tag"$'\n' + fi + pending_tag="" + fi + done <<< "$releases_json" + latest_version=$(printf '%s' "$stable_tags" | pick_highest_stable_tag) + if [[ -n "$latest_version" ]]; then + echo "$latest_version" + return 0 + fi + fi + + # Fallback: latest stable release (not pre-releases). `/releases/latest` # already skips prereleases on GitHub's side, but we still parse and # enforce the `prerelease` flag ourselves as a second line of defense. release_json=$(curl -s "https://api.github.com/repos/$GITHUB_REPO/releases/latest" || true) diff --git a/scripts/tests/test-pulse-auto-update.sh b/scripts/tests/test-pulse-auto-update.sh index 9fb6eaacdc..fdbd1f31b2 100755 --- a/scripts/tests/test-pulse-auto-update.sh +++ b/scripts/tests/test-pulse-auto-update.sh @@ -61,6 +61,57 @@ test_wait_for_service_active_times_out_when_never_active() { return 0 } +test_pick_highest_stable_tag_ignores_list_order_and_prereleases() { + local picked + picked=$(pick_highest_stable_tag <<'EOF' +v5.1.37 +helm-chart-6.0.5 +v6.0.5-rc.4 +v6.0.5 +v6.0.4 +v6.0.0-rc.7 +EOF +) + if [[ "$picked" != "v6.0.5" ]]; then + echo "pick_highest_stable_tag picked ${picked:-}, want v6.0.5" >&2 + return 1 + fi + return 0 +} + +test_get_latest_stable_version_prefers_highest_over_created_order() { + # A v5-line maintenance release created after v6.0.5 sits first in the + # /releases list and is what /releases/latest would point at; the updater + # must still pick v6.0.5. + curl() { + local url="${*: -1}" + if [[ "$url" == *"/releases?per_page="* ]]; then + cat <<'EOF' +[ + { "tag_name": "v5.1.37", "prerelease": false }, + { "tag_name": "helm-chart-6.0.5", "prerelease": true }, + { "tag_name": "v6.0.5", "prerelease": false }, + { "tag_name": "v6.0.5-rc.4", "prerelease": true }, + { "tag_name": "v6.0.4", "prerelease": false } +] +EOF + return 0 + fi + echo "unexpected curl call in test: $*" >&2 + return 1 + } + + local got + got=$(get_latest_stable_version) + unset -f curl + + if [[ "$got" != "v6.0.5" ]]; then + echo "get_latest_stable_version returned ${got:-}, want v6.0.5" >&2 + return 1 + fi + return 0 +} + test_perform_update_restores_backup_when_service_stays_down() { local tmpdir tmpdir="$(mktemp -d)" @@ -151,6 +202,8 @@ INSTALLER main() { assert_success "wait_for_service_active retries until active" test_wait_for_service_active_succeeds_after_retry assert_success "wait_for_service_active times out when never active" test_wait_for_service_active_times_out_when_never_active + assert_success "pick_highest_stable_tag ignores list order and prereleases" test_pick_highest_stable_tag_ignores_list_order_and_prereleases + assert_success "get_latest_stable_version prefers highest version over created order" test_get_latest_stable_version_prefers_highest_over_created_order assert_success "perform_update restores backup when service stays down" test_perform_update_restores_backup_when_service_stays_down if (( failures > 0 )); then