feat(update): 检查更新优先静态 latest.json,避免用户撞 GitHub API 限流

- 客户端优先下载 releases/latest/download/latest.json,再回退 REST API
- 成功结果写入磁盘缓存,限流/断网时可 stale 回退
- 静默检查增加节流;限流文案改为用户无需配置 Token
- CI 与 build-release 发版时生成 latest.json 清单
- 补充静态清单/缓存/节流单测与生成脚本测试
This commit is contained in:
Syngnat
2026-07-09 12:35:12 +08:00
parent 566339f09f
commit 3beab030f4
14 changed files with 873 additions and 11 deletions

View File

@@ -158,17 +158,19 @@ func (a *App) localizedUpdateError(err error) string {
}
func (a *App) CheckForUpdates() connection.QueryResult {
return a.checkForUpdates(true)
// 用户手动检查强制走网络静态清单优先API 回退)
return a.checkForUpdates(true, true)
}
func (a *App) CheckForUpdatesSilently() connection.QueryResult {
return a.checkForUpdates(false)
// 静默检查:允许节流,优先磁盘/短时缓存,避免启动刷爆网络
return a.checkForUpdates(false, false)
}
func (a *App) checkForUpdates(logFailure bool) connection.QueryResult {
func (a *App) checkForUpdates(logFailure bool, forceNetwork bool) connection.QueryResult {
a.ensurePersistedGlobalProxyRuntime()
channel := a.currentUpdateChannel()
info, err := fetchLatestUpdateInfo(channel)
info, err := fetchLatestUpdateInfoWithOptions(channel, forceNetwork)
if err != nil {
if logFailure {
updateLogCheckError(err)
@@ -456,10 +458,15 @@ func (a *App) downloadAndStageUpdate(info UpdateInfo) connection.QueryResult {
}
func fetchLatestUpdateInfo(channel updateChannel) (UpdateInfo, error) {
return fetchLatestUpdateInfoWithOptions(channel, true)
}
func fetchLatestUpdateInfoWithOptions(channel updateChannel, forceNetwork bool) (UpdateInfo, error) {
if channel != updateChannelDev {
channel = updateChannelLatest
}
release, err := fetchReleaseForChannel(channel)
// 优先静态 latest.json不占 api.github.com 配额)→ GitHub API → 磁盘缓存
release, err := fetchReleaseForChannelPreferringStatic(channel, forceNetwork)
if err != nil {
return UpdateInfo{}, err
}

View File

@@ -28,6 +28,11 @@ func TestFetchLatestUpdateInfoSkipsChecksumWhenCurrentVersionIsAlreadyLatest(t *
}()
releaseCalled := false
restoreStatic := swapUpdateFetchStaticManifest(func(channel updateChannel) (*githubRelease, error) {
// 单测走 API 路径,模拟尚无 latest.json 的历史 Release
return nil, errors.New("static manifest unavailable in test")
})
defer restoreStatic()
restoreRelease := swapUpdateFetchLatestRelease(func() (*githubRelease, error) {
releaseCalled = true
return &githubRelease{
@@ -84,6 +89,10 @@ func TestFetchLatestUpdateInfoUsesAssetDigestWhenUpdateIsAvailable(t *testing.T)
AppVersion = originalVersion
}()
restoreStatic := swapUpdateFetchStaticManifest(func(channel updateChannel) (*githubRelease, error) {
return nil, errors.New("static manifest unavailable in test")
})
defer restoreStatic()
restoreRelease := swapUpdateFetchLatestRelease(func() (*githubRelease, error) {
return &githubRelease{
TagName: "v0.6.5",
@@ -139,6 +148,10 @@ func TestFetchLatestUpdateInfoFallsBackToChecksumFileWhenAssetDigestMissing(t *t
AppVersion = originalVersion
}()
restoreStatic := swapUpdateFetchStaticManifest(func(channel updateChannel) (*githubRelease, error) {
return nil, errors.New("static manifest unavailable in test")
})
defer restoreStatic()
restoreRelease := swapUpdateFetchLatestRelease(func() (*githubRelease, error) {
return &githubRelease{
TagName: "v0.6.5",
@@ -181,7 +194,12 @@ func TestFetchLatestUpdateInfoFallsBackToChecksumFileWhenAssetDigestMissing(t *t
func TestCheckForUpdatesLogsFailuresForManualChecks(t *testing.T) {
app := &App{configDir: t.TempDir()}
t.Setenv("GONAVI_DATA_ROOT", t.TempDir())
restoreStatic := swapUpdateFetchStaticManifest(func(channel updateChannel) (*githubRelease, error) {
return nil, errors.New("static unavailable")
})
defer restoreStatic()
restoreRelease := swapUpdateFetchLatestRelease(func() (*githubRelease, error) {
return nil, errors.New("request timed out")
})
@@ -204,7 +222,12 @@ func TestCheckForUpdatesLogsFailuresForManualChecks(t *testing.T) {
func TestCheckForUpdatesSilentlySkipsFailureLogs(t *testing.T) {
app := &App{configDir: t.TempDir()}
t.Setenv("GONAVI_DATA_ROOT", t.TempDir())
restoreStatic := swapUpdateFetchStaticManifest(func(channel updateChannel) (*githubRelease, error) {
return nil, errors.New("static unavailable")
})
defer restoreStatic()
restoreRelease := swapUpdateFetchLatestRelease(func() (*githubRelease, error) {
return nil, errors.New("request timed out")
})
@@ -273,6 +296,10 @@ func TestCheckForUpdatesRestoresPersistedGlobalProxyRuntime(t *testing.T) {
AppVersion = originalVersion
}()
restoreStatic := swapUpdateFetchStaticManifest(func(channel updateChannel) (*githubRelease, error) {
return nil, errors.New("static unavailable; exercise API proxy path")
})
defer restoreStatic()
restoreRelease := swapUpdateFetchDevRelease(func() (*githubRelease, error) {
return fetchReleaseByURL("http://api.github.invalid/repos/Syngnat/GoNavi/releases/tags/dev-latest")
})
@@ -304,6 +331,10 @@ func TestFetchLatestUpdateInfoForDevChannelUsesReleaseBuildVersion(t *testing.T)
AppVersion = originalVersion
}()
restoreStatic := swapUpdateFetchStaticManifest(func(channel updateChannel) (*githubRelease, error) {
return nil, errors.New("static unavailable in test")
})
defer restoreStatic()
restoreRelease := swapUpdateFetchDevRelease(func() (*githubRelease, error) {
return &githubRelease{
TagName: "dev-latest",
@@ -362,6 +393,10 @@ func TestFetchLatestUpdateInfoForDevChannelSkipsChecksumWhenBuildMatches(t *test
AppVersion = originalVersion
}()
restoreStatic := swapUpdateFetchStaticManifest(func(channel updateChannel) (*githubRelease, error) {
return nil, errors.New("static unavailable in test")
})
defer restoreStatic()
restoreRelease := swapUpdateFetchDevRelease(func() (*githubRelease, error) {
return &githubRelease{
TagName: "dev-latest",

View File

@@ -0,0 +1,349 @@
package app
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
"GoNavi-Wails/internal/appdata"
"GoNavi-Wails/internal/logger"
)
const (
// 静态清单:挂在 GitHub Release 资产上,走 github.com 下载链路,不消耗 api.github.com 配额。
// 发版时由 CI/build-release 生成 latest.json 并上传到当前 latest 发布。
updateLatestManifestURL = "https://github.com/" + updateRepo + "/releases/latest/download/latest.json"
updateDevManifestURL = "https://github.com/" + updateRepo + "/releases/download/" + updateDevReleaseTag + "/latest-dev.json"
updateManifestSchemaVersion = 1
updateManifestFileName = "latest.json"
updateDevManifestFileName = "latest-dev.json"
// 磁盘缓存:跨重启保留;过期后仍可作为限流/网络失败时的 stale 回退
updateDiskCacheMaxAge = 7 * 24 * time.Hour
// 静默检查最短间隔,避免启动/前台切换反复打网
updateSilentCheckMinInterval = time.Hour
)
// updateReleaseManifest 是面向终端用户的静态更新清单(不依赖 GitHub REST API
type updateReleaseManifest struct {
SchemaVersion int `json:"schemaVersion"`
Channel string `json:"channel"`
TagName string `json:"tagName"`
Version string `json:"version"`
Name string `json:"name,omitempty"`
HTMLURL string `json:"htmlUrl,omitempty"`
PublishedAt string `json:"publishedAt,omitempty"`
Assets []updateManifestAsset `json:"assets"`
FetchedAt time.Time `json:"fetchedAt,omitempty"` // 仅本地缓存写入
Source string `json:"source,omitempty"` // static | api | disk-cache
}
type updateManifestAsset struct {
Name string `json:"name"`
URL string `json:"url"`
APIURL string `json:"apiUrl,omitempty"`
Size int64 `json:"size,omitempty"`
SHA256 string `json:"sha256,omitempty"`
}
type updateNetworkCheckMemory struct {
at time.Time
channel updateChannel
}
var (
updateFetchStaticManifest = fetchStaticUpdateManifest
updateNetworkCheckMu sync.Mutex
updateLastNetworkCheck updateNetworkCheckMemory
)
func swapUpdateFetchStaticManifest(next func(updateChannel) (*githubRelease, error)) func() {
original := updateFetchStaticManifest
updateFetchStaticManifest = next
return func() {
updateFetchStaticManifest = original
}
}
func updateManifestRemoteURL(channel updateChannel) string {
if channel == updateChannelDev {
return updateDevManifestURL
}
return updateLatestManifestURL
}
func updateManifestCachePath(channel updateChannel) string {
name := updateManifestFileName
if channel == updateChannelDev {
name = updateDevManifestFileName
}
return filepath.Join(appdata.MustResolveActiveRoot(), "update-cache", name)
}
func releaseFromUpdateManifest(manifest *updateReleaseManifest) *githubRelease {
if manifest == nil {
return nil
}
assets := make([]githubAsset, 0, len(manifest.Assets))
for _, item := range manifest.Assets {
name := strings.TrimSpace(item.Name)
if name == "" {
continue
}
url := strings.TrimSpace(item.URL)
apiURL := strings.TrimSpace(item.APIURL)
digest := ""
if sha := normalizeGitHubAssetSHA256(item.SHA256); sha != "" {
digest = "sha256:" + sha
}
assets = append(assets, githubAsset{
Name: name,
BrowserDownloadURL: url,
URL: firstNonEmptyString(apiURL, url),
Digest: digest,
Size: item.Size,
})
}
tagName := strings.TrimSpace(manifest.TagName)
if tagName == "" && strings.TrimSpace(manifest.Version) != "" {
tagName = "v" + strings.TrimPrefix(strings.TrimSpace(manifest.Version), "v")
}
name := strings.TrimSpace(manifest.Name)
if name == "" {
name = tagName
}
return &githubRelease{
TagName: tagName,
Name: name,
HTMLURL: strings.TrimSpace(manifest.HTMLURL),
PublishedAt: strings.TrimSpace(manifest.PublishedAt),
Assets: assets,
}
}
func updateManifestFromGitHubRelease(channel updateChannel, release *githubRelease, hashes map[string]string) *updateReleaseManifest {
if release == nil {
return nil
}
version := resolveReleaseVersion(channel, release)
assets := make([]updateManifestAsset, 0, len(release.Assets))
for _, asset := range release.Assets {
name := strings.TrimSpace(asset.Name)
if name == "" {
continue
}
sha := normalizeGitHubAssetSHA256(asset.Digest)
if sha == "" && hashes != nil {
sha = normalizeGitHubAssetSHA256(hashes[name])
}
assets = append(assets, updateManifestAsset{
Name: name,
URL: firstNonEmptyString(asset.BrowserDownloadURL, asset.URL),
APIURL: strings.TrimSpace(asset.URL),
Size: asset.Size,
SHA256: sha,
})
}
return &updateReleaseManifest{
SchemaVersion: updateManifestSchemaVersion,
Channel: string(channel),
TagName: strings.TrimSpace(release.TagName),
Version: version,
Name: strings.TrimSpace(release.Name),
HTMLURL: strings.TrimSpace(release.HTMLURL),
PublishedAt: strings.TrimSpace(release.PublishedAt),
Assets: assets,
FetchedAt: time.Now().UTC(),
Source: "api",
}
}
func loadDiskUpdateManifest(channel updateChannel) (*updateReleaseManifest, bool /*stale*/) {
path := updateManifestCachePath(channel)
data, err := os.ReadFile(path)
if err != nil {
return nil, false
}
var manifest updateReleaseManifest
if err := json.Unmarshal(data, &manifest); err != nil {
return nil, false
}
if manifest.SchemaVersion != 0 && manifest.SchemaVersion != updateManifestSchemaVersion {
return nil, false
}
if strings.TrimSpace(manifest.TagName) == "" && strings.TrimSpace(manifest.Version) == "" {
return nil, false
}
stale := false
if !manifest.FetchedAt.IsZero() {
stale = time.Since(manifest.FetchedAt) > updateDiskCacheMaxAge
}
manifest.Source = "disk-cache"
return &manifest, stale
}
func storeDiskUpdateManifest(channel updateChannel, manifest *updateReleaseManifest) {
if manifest == nil {
return
}
path := updateManifestCachePath(channel)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
logger.Warnf("写入更新清单缓存目录失败:%v", err)
return
}
clone := *manifest
if clone.FetchedAt.IsZero() {
clone.FetchedAt = time.Now().UTC()
}
if strings.TrimSpace(clone.Source) == "" {
clone.Source = "static"
}
payload, err := json.MarshalIndent(clone, "", " ")
if err != nil {
return
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, payload, 0o644); err != nil {
logger.Warnf("写入更新清单缓存失败:%v", err)
return
}
if err := os.Rename(tmp, path); err != nil {
_ = os.Remove(tmp)
logger.Warnf("提交更新清单缓存失败:%v", err)
}
}
func fetchStaticUpdateManifest(channel updateChannel) (*githubRelease, error) {
url := updateManifestRemoteURL(channel)
client := newHTTPClientWithGlobalProxy(15 * time.Second)
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "GoNavi-Updater/"+strings.TrimSpace(getCurrentVersion()))
req.Header.Set("Accept", "application/json")
resp, err := doUpdateRequest(client, req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
if readErr != nil {
return nil, wrapUpdateNetworkError(readErr)
}
if resp.StatusCode != http.StatusOK {
// 静态资产 404尚未发布 latest.json 的旧版本 Release正常回退 API
if resp.StatusCode == http.StatusNotFound {
return nil, fmt.Errorf("static update manifest not found: %s", url)
}
return nil, classifyGitHubUpdateHTTPError(resp.StatusCode, body, resp.Header, true)
}
var manifest updateReleaseManifest
if err := json.Unmarshal(body, &manifest); err != nil {
return nil, wrapUpdateNetworkError(err)
}
if strings.TrimSpace(manifest.TagName) == "" && strings.TrimSpace(manifest.Version) == "" {
return nil, localizedUpdateError{key: "app.update.backend.error.latest_version_unparseable"}
}
if manifest.SchemaVersion != 0 && manifest.SchemaVersion != updateManifestSchemaVersion {
return nil, fmt.Errorf("unsupported update manifest schema: %d", manifest.SchemaVersion)
}
manifest.FetchedAt = time.Now().UTC()
manifest.Source = "static"
if strings.TrimSpace(manifest.Channel) == "" {
manifest.Channel = string(channel)
}
storeDiskUpdateManifest(channel, &manifest)
release := releaseFromUpdateManifest(&manifest)
// 同步进进程内 API 缓存,供限流回退
if channel == updateChannelDev {
storeCachedGitHubRelease(updateDevAPIURL, release)
} else {
storeCachedGitHubRelease(updateLatestAPIURL, release)
}
return release, nil
}
// fetchReleaseForChannel prefers static manifest → GitHub API → disk cache.
// forceNetwork=false 时:静默检查若距上次成功拉网过近,直接用磁盘缓存。
func fetchReleaseForChannelPreferringStatic(channel updateChannel, forceNetwork bool) (*githubRelease, error) {
if channel != updateChannelDev {
channel = updateChannelLatest
}
if !forceNetwork {
if release := loadRecentNetworkOrDiskRelease(channel); release != nil {
return release, nil
}
}
var staticErr error
if release, err := updateFetchStaticManifest(channel); err == nil && release != nil {
markUpdateNetworkCheck(channel)
return release, nil
} else {
staticErr = err
if err != nil {
logger.Warnf("静态更新清单不可用,回退 GitHub APIchannel=%s err=%v", channel, err)
}
}
var apiErr error
release, err := fetchReleaseForChannel(channel)
if err == nil && release != nil {
// API 成功时落盘,供下次静态失败/限流时使用
storeDiskUpdateManifest(channel, updateManifestFromGitHubRelease(channel, release, nil))
markUpdateNetworkCheck(channel)
return release, nil
}
apiErr = err
if cached, stale := loadDiskUpdateManifest(channel); cached != nil {
logger.Warnf("更新检查回退磁盘清单channel=%s stale=%v staticErr=%v apiErr=%v", channel, stale, staticErr, apiErr)
return releaseFromUpdateManifest(cached), nil
}
if apiErr != nil {
return nil, apiErr
}
if staticErr != nil {
return nil, staticErr
}
return nil, localizedUpdateError{key: "app.update.backend.error.latest_version_unparseable"}
}
func loadRecentNetworkOrDiskRelease(channel updateChannel) *githubRelease {
updateNetworkCheckMu.Lock()
last := updateLastNetworkCheck
updateNetworkCheckMu.Unlock()
if last.channel == channel && !last.at.IsZero() && time.Since(last.at) < updateSilentCheckMinInterval {
if cached, stale := loadDiskUpdateManifest(channel); cached != nil && !stale {
logger.Warnf("静默更新检查节流:复用磁盘清单 channel=%s age=%s", channel, time.Since(last.at))
return releaseFromUpdateManifest(cached)
}
// 内存 API 缓存
apiURL := updateLatestAPIURL
if channel == updateChannelDev {
apiURL = updateDevAPIURL
}
if mem := loadCachedGitHubRelease(apiURL); mem != nil {
return mem
}
}
return nil
}
func markUpdateNetworkCheck(channel updateChannel) {
updateNetworkCheckMu.Lock()
updateLastNetworkCheck = updateNetworkCheckMemory{at: time.Now(), channel: channel}
updateNetworkCheckMu.Unlock()
}

View File

@@ -0,0 +1,188 @@
package app
import (
"encoding/json"
"errors"
"os"
"strings"
"sync"
"testing"
"time"
)
func TestReleaseFromUpdateManifestMapsAssets(t *testing.T) {
release := releaseFromUpdateManifest(&updateReleaseManifest{
TagName: "v1.2.3",
Version: "1.2.3",
Name: "GoNavi 1.2.3",
HTMLURL: "https://github.com/Syngnat/GoNavi/releases/tag/v1.2.3",
PublishedAt: "2026-07-09T00:00:00Z",
Assets: []updateManifestAsset{
{
Name: "GoNavi-1.2.3-Windows-Amd64.exe",
URL: "https://example.com/app.exe",
SHA256: "Aa" + strings.Repeat("b", 62),
Size: 99,
},
},
})
if release == nil {
t.Fatal("expected release")
}
if release.TagName != "v1.2.3" || len(release.Assets) != 1 {
t.Fatalf("unexpected release: %#v", release)
}
if release.Assets[0].BrowserDownloadURL != "https://example.com/app.exe" {
t.Fatalf("url = %q", release.Assets[0].BrowserDownloadURL)
}
if !strings.HasPrefix(release.Assets[0].Digest, "sha256:") {
t.Fatalf("digest = %q", release.Assets[0].Digest)
}
}
func TestDiskUpdateManifestRoundTrip(t *testing.T) {
root := t.TempDir()
t.Setenv("GONAVI_DATA_ROOT", root)
payload := &updateReleaseManifest{
SchemaVersion: 1,
Channel: "latest",
TagName: "v9.9.9",
Version: "9.9.9",
Name: "v9.9.9",
HTMLURL: "https://github.com/Syngnat/GoNavi/releases/tag/v9.9.9",
Assets: []updateManifestAsset{
{Name: "app.bin", URL: "https://example.com/app.bin", SHA256: strings.Repeat("c", 64), Size: 1},
},
FetchedAt: time.Now().UTC(),
}
storeDiskUpdateManifest(updateChannelLatest, payload)
loaded, stale := loadDiskUpdateManifest(updateChannelLatest)
if loaded == nil || stale {
t.Fatalf("expected fresh disk cache, got %#v stale=%v", loaded, stale)
}
if loaded.Version != "9.9.9" {
t.Fatalf("version = %q", loaded.Version)
}
path := updateManifestCachePath(updateChannelLatest)
if _, err := os.Stat(path); err != nil {
t.Fatalf("cache file missing: %v", err)
}
if !strings.HasPrefix(path, root) {
t.Fatalf("cache path not under data root: %s", path)
}
}
func TestFetchReleaseForChannelPreferringStaticUsesStaticFirst(t *testing.T) {
root := t.TempDir()
t.Setenv("GONAVI_DATA_ROOT", root)
updateReleaseCache = sync.Map{}
staticCalled := false
apiCalled := false
restoreStatic := swapUpdateFetchStaticManifest(func(channel updateChannel) (*githubRelease, error) {
staticCalled = true
return &githubRelease{TagName: "v2.0.0", Name: "v2.0.0"}, nil
})
defer restoreStatic()
restoreAPI := swapUpdateFetchLatestRelease(func() (*githubRelease, error) {
apiCalled = true
return nil, errors.New("api should not be called")
})
defer restoreAPI()
release, err := fetchReleaseForChannelPreferringStatic(updateChannelLatest, true)
if err != nil {
t.Fatalf("err=%v", err)
}
if !staticCalled || apiCalled {
t.Fatalf("staticCalled=%v apiCalled=%v", staticCalled, apiCalled)
}
if release.TagName != "v2.0.0" {
t.Fatalf("tag=%q", release.TagName)
}
}
func TestFetchReleaseForChannelPreferringStaticFallsBackAPIThenDisk(t *testing.T) {
root := t.TempDir()
t.Setenv("GONAVI_DATA_ROOT", root)
updateReleaseCache = sync.Map{}
storeDiskUpdateManifest(updateChannelLatest, &updateReleaseManifest{
SchemaVersion: 1,
TagName: "v1.0.0",
Version: "1.0.0",
Assets: []updateManifestAsset{{Name: "a", URL: "https://example.com/a"}},
FetchedAt: time.Now().UTC(),
})
restoreStatic := swapUpdateFetchStaticManifest(func(channel updateChannel) (*githubRelease, error) {
return nil, errors.New("static 404")
})
defer restoreStatic()
restoreAPI := swapUpdateFetchLatestRelease(func() (*githubRelease, error) {
return nil, errors.New("api rate limit")
})
defer restoreAPI()
release, err := fetchReleaseForChannelPreferringStatic(updateChannelLatest, true)
if err != nil {
t.Fatalf("expected disk fallback, err=%v", err)
}
if release.TagName != "v1.0.0" {
t.Fatalf("tag=%q", release.TagName)
}
}
func TestSilentCheckThrottleReusesDisk(t *testing.T) {
root := t.TempDir()
t.Setenv("GONAVI_DATA_ROOT", root)
updateReleaseCache = sync.Map{}
storeDiskUpdateManifest(updateChannelLatest, &updateReleaseManifest{
SchemaVersion: 1,
TagName: "v3.0.0",
Version: "3.0.0",
Assets: []updateManifestAsset{{Name: "a", URL: "u"}},
FetchedAt: time.Now().UTC(),
})
markUpdateNetworkCheck(updateChannelLatest)
staticCalls := 0
restoreStatic := swapUpdateFetchStaticManifest(func(channel updateChannel) (*githubRelease, error) {
staticCalls++
return nil, errors.New("should not hit network")
})
defer restoreStatic()
restoreAPI := swapUpdateFetchLatestRelease(func() (*githubRelease, error) {
t.Fatal("api should not be called during throttle")
return nil, nil
})
defer restoreAPI()
release, err := fetchReleaseForChannelPreferringStatic(updateChannelLatest, false)
if err != nil {
t.Fatalf("err=%v", err)
}
if release.TagName != "v3.0.0" {
t.Fatalf("tag=%q", release.TagName)
}
if staticCalls != 0 {
t.Fatalf("staticCalls=%d", staticCalls)
}
}
func TestUpdateManifestFromGitHubRelease(t *testing.T) {
release := &githubRelease{
TagName: "v1.0.1",
Name: "v1.0.1",
HTMLURL: "https://github.com/Syngnat/GoNavi/releases/tag/v1.0.1",
Assets: []githubAsset{
{Name: "a.exe", BrowserDownloadURL: "https://x/a.exe", Digest: "sha256:" + strings.Repeat("d", 64), Size: 10},
},
}
m := updateManifestFromGitHubRelease(updateChannelLatest, release, nil)
if m == nil || m.Version == "" || len(m.Assets) != 1 {
t.Fatalf("manifest=%#v", m)
}
_ = json.Marshal
}