fix(updates): preserve install URL on feed fallback

This commit is contained in:
Pulse Test
2026-08-28 14:43:40 +01:00
parent 9e820355b6
commit decdbcc037
8 changed files with 180 additions and 103 deletions
@@ -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
+19 -44
View File
@@ -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"
]
}
@@ -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.');
});
});
+8 -1
View File
@@ -251,7 +251,14 @@ const confirmPendingApply = (currentVersion: string, state: UpdateState) => {
// backend accepted the apply request.
const applyUpdate = async (): Promise<boolean> => {
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 {
+77 -45
View File
@@ -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: <title>Pulse v5.0.0</title>
// 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(`<title>Pulse (v\d+\.\d+\.\d+(?:-[a-zA-Z0-9.]+)?)</title>`)
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
}
@@ -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",
+63
View File
@@ -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 := `<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<entry>
<title>Pulse v6.4.0-rc.11</title>
<updated>2026-08-28T12:52:29Z</updated>
</entry>
</feed>`
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")
@@ -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),
})