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