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.
This commit is contained in:
rcourtman 2026-07-17 23:17:23 +01:00
parent 817aaeabc1
commit 6351fd6526
6 changed files with 491 additions and 112 deletions

View file

@ -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

View file

@ -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)

View file

@ -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 := `<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<entry><title>Pulse v5.1.37</title></entry>
<entry><title>Pulse v6.0.5</title></entry>
<entry><title>Pulse v6.0.5-rc.4</title></entry>
<entry><title>Pulse v6.0.0-rc.7</title></entry>
</feed>`
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)
}
}

View file

@ -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

View file

@ -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)

View file

@ -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:-<empty>}, 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:-<empty>}, 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