mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-14 05:30:55 +00:00
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:
parent
817aaeabc1
commit
6351fd6526
6 changed files with 491 additions and 112 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
224
internal/updates/manager_stranded_upgrade_test.go
Normal file
224
internal/updates/manager_stranded_upgrade_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue