From decdbcc037fd6f03a9bcb6093912deffcf42a80b Mon Sep 17 00:00:00 2001 From: Pulse Test Date: Fri, 28 Aug 2026 14:43:40 +0100 Subject: [PATCH] fix(updates): preserve install URL on feed fallback --- .../subsystems/deployment-installability.md | 8 ++ frontend-modern/browser-verification.json | 63 +++------ .../src/stores/__tests__/updates.test.ts | 3 +- frontend-modern/src/stores/updates.ts | 9 +- internal/updates/manager.go | 122 +++++++++++------- .../updates/manager_check_updates_test.go | 10 +- internal/updates/manager_retry_test.go | 63 +++++++++ .../updates/manager_stranded_upgrade_test.go | 5 +- 8 files changed, 180 insertions(+), 103 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index a7d57f117..01f4c538a 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -2880,6 +2880,14 @@ 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`). +When GitHub's release API is rate-limited, the Atom fallback must also return +an installable current-platform archive URL and the entry's release timestamp, +not merely a version string. The archive URL may be derived only from the +validated Pulse tag and the governed deterministic release-asset naming +contract; the normal apply pipeline must still verify its pinned SSHSIG and +checksum before installation. A fallback response that advertises an update +with an empty download URL is a release-blocking defect because the UI cannot +start the apply request. Proof: `internal/updates/manager_retry_test.go`. 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/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index d4ff32f4b..949d2c861 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,61 +1,36 @@ { "version": 1, - "base_sha": "3916854c18daea76b47e82cd8498016559c7ebaf", - "verified_at": "2026-08-28T10:37:02Z", + "base_sha": "9e820355b60b68954a2c03cfaf77ba81ca9e4a59", + "verified_at": "2026-08-28T13:49:59Z", "result": "passed", "changed_paths": [ - "frontend-modern/src/components/Settings/APIAccessPanel.tsx", - "frontend-modern/src/components/Settings/DockerRuntimeSettingsCard.tsx", - "frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx", - "frontend-modern/src/components/Settings/GuestDockerDiscoverySettingsCard.tsx", - "frontend-modern/src/components/Settings/SettingsPageShell.tsx", - "frontend-modern/src/components/shared/FeatureGateSection.tsx", - "frontend-modern/src/components/shared/SettingsPanel.tsx", - "frontend-modern/src/i18n/messages.de.ts", - "frontend-modern/src/i18n/messages.es.ts", - "frontend-modern/src/i18n/messages.ts", - "frontend-modern/src/index.css", - "frontend-modern/src/utils/settingsShellPresentation.ts" + "frontend-modern/src/stores/updates.ts" ], "content_sha256": { - "frontend-modern/src/components/Settings/APIAccessPanel.tsx": "dfd5915a945b4d423e0c050c66593130dc82129fe08dbc071fe8386b85f6dc27", - "frontend-modern/src/components/Settings/DockerRuntimeSettingsCard.tsx": "8f5f2696eeb3145470d5b7ac3aa286f91a678b6c431dfe960c2f482dff0540ba", - "frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx": "7d28d2273109578c1718d7e6c1ed1fd3703c5caed9243f542be902ec5654a082", - "frontend-modern/src/components/Settings/GuestDockerDiscoverySettingsCard.tsx": "16abc50db9e865da1baf25151bfe7d81fcac79f62afd90564b73dbd3e58483a4", - "frontend-modern/src/components/Settings/SettingsPageShell.tsx": "5b8b67a82b2b50beb9a8d2f89b2787782e783aec55c1e6103e022a68b5b51273", - "frontend-modern/src/components/shared/FeatureGateSection.tsx": "c825da12b250926cf6ab2919fc211cab11ce6826553cb263d59341c09a2b94c7", - "frontend-modern/src/components/shared/SettingsPanel.tsx": "4e28b98fc8534c9ed2be7a43a11b0850707563e1c29af550302bba03e39141bd", - "frontend-modern/src/i18n/messages.de.ts": "4e73e3ad3f51f90f681983b00226ff808e27385f75e5f4e11a4d1b410d91d8d4", - "frontend-modern/src/i18n/messages.es.ts": "33449bd9f58debb62855bda140eff6b233a9c0c9e468f3efaa954532c03fbbee", - "frontend-modern/src/i18n/messages.ts": "8b1cf9a69df51c32e9c23c831edfa8149653d505f630c709b531271c53908085", - "frontend-modern/src/index.css": "f73a414a7e4bc0ab3806653859216e2404ac33d9f38f4bbbad426a778a062ca0", - "frontend-modern/src/utils/settingsShellPresentation.ts": "1e35ac7e362ee8745a4dbe83e3bddc8c65c2944bfa58d039a4bd0fa7685ad2c3" + "frontend-modern/src/stores/updates.ts": "3f193c0427338f8f12fe7275b7fc691a7c90a65fd4e47ee972673e370bd5e9b6" }, "routes": [ - "/proxmox/overview", - "/settings/system-general", - "/settings/api", "/settings/system-updates" ], "viewports": [ - { "width": 1440, "height": 900 }, - { "width": 390, "height": 844 }, - { "width": 320, "height": 844 } + { + "width": 1440, + "height": 900 + }, + { + "width": 390, + "height": 844 + } ], "states": [ - "Proxmox overview reference density on a 390 pixel phone", - "General settings at the top of the page and after a long scroll", - "searchable grouped settings index with a filtered API Access result", - "API Access content at desktop, 390 pixel, and 320 pixel widths", - "Updates content with the shared compact section framing", - "capability-filtered index with an explicit close affordance" + "update available with the confirmation dialog open and acknowledgement unchecked", + "update confirmation acknowledged with an intentionally missing download URL", + "failed update start with the error notification visible while the dialog remains open" ], "interactions": [ - "opened the settings index from the sticky mobile section bar", - "searched the settings index for API and selected API Access", - "closed the settings index without selecting a different section", - "scrolled General settings and confirmed the section bar remained sticky above content", - "confirmed Pulse mobile navigation remained visible and interactive above settings content", - "resized Settings between desktop, 390 pixel, and 320 pixel widths and confirmed no horizontal overflow" + "opened the confirmation dialog from the update banner", + "acknowledged the temporary-unavailability warning and selected Start Update", + "confirmed the missing download URL produced a visible error without starting an update", + "repeated the failed-start state at desktop and 390 pixel widths and confirmed no horizontal overflow" ] } diff --git a/frontend-modern/src/stores/__tests__/updates.test.ts b/frontend-modern/src/stores/__tests__/updates.test.ts index f0ca29b18..59c1da5ae 100644 --- a/frontend-modern/src/stores/__tests__/updates.test.ts +++ b/frontend-modern/src/stores/__tests__/updates.test.ts @@ -163,13 +163,14 @@ describe('updateStore', () => { expect(persisted.pendingApply).toBeUndefined(); }); - it('does nothing without a download URL', async () => { + it('reports an error without a download URL', async () => { const updateStore = await loadUpdateStore(); const started = await updateStore.applyUpdate(); expect(started).toBe(false); expect(mockApplyUpdate).not.toHaveBeenCalled(); + expect(mockNotifyError).toHaveBeenCalledWith('Unable to start the update. Please try again.'); }); }); diff --git a/frontend-modern/src/stores/updates.ts b/frontend-modern/src/stores/updates.ts index 1827c62b4..d28bbc26b 100644 --- a/frontend-modern/src/stores/updates.ts +++ b/frontend-modern/src/stores/updates.ts @@ -251,7 +251,14 @@ const confirmPendingApply = (currentVersion: string, state: UpdateState) => { // backend accepted the apply request. const applyUpdate = async (): Promise => { const info = updateInfo(); - if (!info?.downloadUrl) return false; + if (!info?.downloadUrl) { + logger.error( + 'Failed to start update', + new Error('The update check did not return an installable download URL'), + ); + notificationStore.error(getStartUpdateErrorMessage()); + return false; + } markApplyStarted(versionInfo()?.version || info.currentVersion, info.latestVersion); try { diff --git a/internal/updates/manager.go b/internal/updates/manager.go index 6e4b7eefe..6bb60d23b 100644 --- a/internal/updates/manager.go +++ b/internal/updates/manager.go @@ -7,6 +7,7 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "encoding/xml" "errors" "fmt" "io" @@ -40,18 +41,21 @@ type UpdateStatus struct { UpdatedAt string `json:"updatedAt"` } +// ReleaseAsset represents one downloadable artifact attached to a release. +type ReleaseAsset struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` +} + // ReleaseInfo represents a GitHub release type ReleaseInfo struct { - TagName string `json:"tag_name"` - Name string `json:"name"` - Body string `json:"body"` - Prerelease bool `json:"prerelease"` - Draft bool `json:"draft"` - PublishedAt time.Time `json:"published_at"` - Assets []struct { - Name string `json:"name"` - BrowserDownloadURL string `json:"browser_download_url"` - } `json:"assets"` + TagName string `json:"tag_name"` + Name string `json:"name"` + Body string `json:"body"` + Prerelease bool `json:"prerelease"` + Draft bool `json:"draft"` + PublishedAt time.Time `json:"published_at"` + Assets []ReleaseAsset `json:"assets"` } // UpdateInfo represents available update information @@ -106,6 +110,27 @@ func updateReleaseDownloadPrefix() string { return fmt.Sprintf("https://github.com/%s/releases/download/", updateReleaseRepo()) } +func updateReleaseAssetForRuntime(tagName string) (ReleaseAsset, bool) { + targetArch, ok := map[string]string{ + "amd64": "amd64", + "arm64": "arm64", + "arm": "armv7", + "386": "386", + }[runtime.GOARCH] + if !ok { + return ReleaseAsset{}, false + } + + assetName := fmt.Sprintf("pulse-%s-linux-%s.tar.gz", tagName, targetArch) + downloadURL := fmt.Sprintf( + "%s%s/%s", + updateReleaseDownloadPrefix(), + url.PathEscape(tagName), + url.PathEscape(assetName), + ) + return ReleaseAsset{Name: assetName, BrowserDownloadURL: downloadURL}, true +} + func updateReleaseAPIPath() string { return fmt.Sprintf("/repos/%s/releases", updateReleaseRepo()) } @@ -459,26 +484,12 @@ func (m *Manager) CheckForUpdatesWithChannel(ctx context.Context, channel string // Find download URL for current architecture downloadURL := "" - arch := runtime.GOARCH - // Map Go architecture names to release asset names - archMap := map[string]string{ - "amd64": "amd64", - "arm64": "arm64", - "arm": "armv7", - "386": "386", - } - - targetArch, ok := archMap[arch] - if !ok { - targetArch = arch // Use as-is if not in map - } - - // Look for architecture-specific binary - targetName := fmt.Sprintf("pulse-%s-linux-%s.tar.gz", release.TagName, targetArch) - for _, asset := range release.Assets { - if asset.Name == targetName { - downloadURL = asset.BrowserDownloadURL - break + if targetAsset, ok := updateReleaseAssetForRuntime(release.TagName); ok { + for _, asset := range release.Assets { + if asset.Name == targetAsset.Name { + downloadURL = asset.BrowserDownloadURL + break + } } } @@ -1035,25 +1046,31 @@ func (m *Manager) getLatestReleaseFromFeed(ctx context.Context, channel string) return nil, fmt.Errorf("feed response exceeds %d bytes", maxReleaseFeedBytes) } - // Parse the Atom feed to extract version tags - // The feed format includes entries like: Pulse v5.0.0 - // We use simple string parsing rather than a full XML parser for minimal deps - content := string(body) + type atomEntry struct { + Title string `xml:"title"` + Published string `xml:"published"` + Updated string `xml:"updated"` + } + type atomFeed struct { + Entries []atomEntry `xml:"entry"` + } - // Find all version tags in the feed (format: "Pulse vX.Y.Z" or "Pulse vX.Y.Z-rc.N") - versionRegex := regexp.MustCompile(`Pulse (v\d+\.\d+\.\d+(?:-[a-zA-Z0-9.]+)?)`) - matches := versionRegex.FindAllStringSubmatch(content, -1) - - if len(matches) == 0 { + var feed atomFeed + if err := xml.Unmarshal(body, &feed); err != nil { + return nil, fmt.Errorf("failed to decode release feed: %w", err) + } + if len(feed.Entries) == 0 { return nil, fmt.Errorf("no version tags found in feed") } + versionTitleRegex := regexp.MustCompile(`^Pulse (v\d+\.\d+\.\d+(?:-[a-zA-Z0-9.]+)?)$`) // 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 { + for _, entry := range feed.Entries { + match := versionTitleRegex.FindStringSubmatch(strings.TrimSpace(entry.Title)) if len(match) < 2 { continue } @@ -1075,12 +1092,27 @@ func (m *Manager) getLatestReleaseFromFeed(ctx context.Context, channel string) if best != nil && !ver.IsNewerThan(bestVer) { continue } + publishedAt := time.Time{} + for _, rawTimestamp := range []string{entry.Published, entry.Updated} { + if parsed, err := time.Parse(time.RFC3339, strings.TrimSpace(rawTimestamp)); err == nil { + publishedAt = parsed + break + } + } + assets := []ReleaseAsset{} + if asset, ok := updateReleaseAssetForRuntime(tagName); ok { + assets = append(assets, asset) + } 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 + TagName: tagName, + Name: "Pulse " + tagName, + Prerelease: isPrerelease, + PublishedAt: publishedAt, + // Atom does not list release assets. Published Pulse versions have a + // deterministic runtime archive name, so synthesize only the exact + // current-platform URL; ApplyUpdate still verifies its SSHSIG and + // checksum before installing anything. + Assets: assets, } bestVer = ver } diff --git a/internal/updates/manager_check_updates_test.go b/internal/updates/manager_check_updates_test.go index e5969c727..84dcd0a18 100644 --- a/internal/updates/manager_check_updates_test.go +++ b/internal/updates/manager_check_updates_test.go @@ -87,10 +87,7 @@ func TestCheckForUpdatesWithChannel_AvailableUsesCache(t *testing.T) { Body: "Release notes", Prerelease: false, PublishedAt: releaseTime, - Assets: []struct { - Name string `json:"name"` - BrowserDownloadURL string `json:"browser_download_url"` - }{ + Assets: []ReleaseAsset{ { Name: "pulse-v99.0.0-linux-amd64.tar.gz", BrowserDownloadURL: "https://example.com/pulse-v99.0.0-linux-amd64.tar.gz", @@ -171,10 +168,7 @@ func TestCheckForUpdates_Wrapper(t *testing.T) { Body: "Release notes", Prerelease: false, PublishedAt: time.Date(2024, 2, 3, 4, 5, 6, 0, time.UTC), - Assets: []struct { - Name string `json:"name"` - BrowserDownloadURL string `json:"browser_download_url"` - }{ + Assets: []ReleaseAsset{ { Name: "pulse-v99.1.0-linux-amd64.tar.gz", BrowserDownloadURL: "https://example.com/pulse-v99.1.0-linux-amd64.tar.gz", diff --git a/internal/updates/manager_retry_test.go b/internal/updates/manager_retry_test.go index 6c9d9de1d..1fb1111ff 100644 --- a/internal/updates/manager_retry_test.go +++ b/internal/updates/manager_retry_test.go @@ -5,10 +5,12 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "io" "net/http" "net/http/httptest" "os" "path/filepath" + "runtime" "strings" "sync/atomic" "testing" @@ -169,6 +171,67 @@ func TestGetLatestReleaseForChannelRetriesTransientStatus(t *testing.T) { } } +func TestGetLatestReleaseForChannelRateLimitFallbackIncludesRuntimeAsset(t *testing.T) { + setRetrySettingsForTest(t, 1, time.Millisecond, time.Millisecond) + withBuildVersion(t, "6.4.0-rc.10") + + releaseTime := time.Date(2026, 8, 28, 12, 52, 29, 0, time.UTC) + feed := ` + + + Pulse v6.4.0-rc.11 + 2026-08-28T12:52:29Z + +` + + origTransport := http.DefaultTransport + http.DefaultTransport = roundTripperFunc(func(req *http.Request) (*http.Response, error) { + status := http.StatusNotFound + body := "not found" + header := http.Header{"Content-Type": []string{"text/plain"}} + switch req.URL.String() { + case "https://api.github.com/repos/rcourtman/Pulse/releases": + status = http.StatusForbidden + body = `{"message":"API rate limit exceeded"}` + header.Set("X-RateLimit-Remaining", "0") + case "https://github.com/rcourtman/Pulse/releases.atom": + status = http.StatusOK + body = feed + header.Set("Content-Type", "application/atom+xml") + } + return &http.Response{ + StatusCode: status, + Status: http.StatusText(status), + Body: io.NopCloser(strings.NewReader(body)), + Header: header, + Request: req, + }, nil + }) + t.Cleanup(func() { http.DefaultTransport = origTransport }) + + manager := NewManager(&config.Config{UpdateChannel: "rc"}) + info, err := manager.CheckForUpdatesWithChannel(context.Background(), "rc") + if err != nil { + t.Fatalf("CheckForUpdatesWithChannel error: %v", err) + } + if !info.Available || info.LatestVersion != "6.4.0-rc.11" { + t.Fatalf("fallback update = %+v, want available v6.4.0-rc.11", info) + } + expectedAsset, supported := updateReleaseAssetForRuntime("v6.4.0-rc.11") + if !supported { + t.Fatalf("test runner architecture %q must map to a release asset", runtime.GOARCH) + } + if info.DownloadURL != expectedAsset.BrowserDownloadURL { + t.Fatalf("fallback DownloadURL = %q, want %q", info.DownloadURL, expectedAsset.BrowserDownloadURL) + } + if !info.ReleaseDate.Equal(releaseTime) { + t.Fatalf("fallback ReleaseDate = %s, want %s", info.ReleaseDate, releaseTime) + } + if target, err := ValidateApplyTargetVersion("rc", info.DownloadURL); err != nil || target != "v6.4.0-rc.11" { + t.Fatalf("fallback apply target = %q, err=%v", target, err) + } +} + func TestGetLatestReleaseForChannelRejectsUpdateServerUserinfo(t *testing.T) { manager := NewManager(&config.Config{UpdateChannel: "stable"}) currentVer, err := ParseVersion("1.0.0") diff --git a/internal/updates/manager_stranded_upgrade_test.go b/internal/updates/manager_stranded_upgrade_test.go index b0fbd8513..c51dad47e 100644 --- a/internal/updates/manager_stranded_upgrade_test.go +++ b/internal/updates/manager_stranded_upgrade_test.go @@ -33,10 +33,7 @@ func makeRelease(tag string, prerelease bool) ReleaseInfo { 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"` - }{ + release.Assets = append(release.Assets, ReleaseAsset{ 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), })