mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
fix(updates): preserve install URL on feed fallback
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user