Files
Jonathan Miller 9a5720e8ad
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Branch Policy Check / Verify merge target (pull_request) Successful in 1s
PR RC Release / Build RC Release (pull_request) Successful in 3s
Universal: PR Check / Validate PR (pull_request) Failing after 6s
Branch Cleanup / Delete merged branch (pull_request) Successful in 1s
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
chore: rename Go module from git. to code.mokoconsulting.tech (#336)
Full namespace migration: update the Go module path and all import
statements from git.mokoconsulting.tech to code.mokoconsulting.tech.
Also updates all URL references in templates, workflows, configs,
tests, and documentation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-31 10:28:25 -05:00

174 lines
4.2 KiB
Go

// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later
package updatechecker
import (
"encoding/xml"
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/setting"
)
// UpdateInfo holds the result of the latest update check.
type UpdateInfo struct {
UpdateAvailable bool
LatestVersion string
ReleaseURL string
DockerImage string
Channel string
CheckedAt time.Time
}
// NotifyFunc is called when a new update is detected for the first time.
// Set this from the cron/mailer layer to send admin email notifications.
var NotifyFunc func(info *UpdateInfo)
var (
cachedInfo *UpdateInfo
lastNotifiedVer string
mu sync.RWMutex
)
// xmlUpdates mirrors the updates.xml structure (Joomla-style).
type xmlUpdates struct {
XMLName xml.Name `xml:"updates"`
Updates []xmlUpdate `xml:"update"`
}
type xmlUpdate struct {
Name string `xml:"name"`
Version string `xml:"version"`
Tags xmlTags `xml:"tags"`
InfoURL xmlInfoURL `xml:"infourl"`
Downloads xmlDownloads `xml:"downloads"`
Maintainer string `xml:"maintainer"`
Description string `xml:"description"`
}
type xmlTags struct {
Tag string `xml:"tag"`
}
type xmlInfoURL struct {
Title string `xml:"title,attr"`
URL string `xml:",chardata"`
}
type xmlDownloads struct {
DownloadURL []xmlDownloadURL `xml:"downloadurl"`
}
type xmlDownloadURL struct {
Type string `xml:"type,attr"`
Format string `xml:"format,attr"`
URL string `xml:",chardata"`
}
// CheckForUpdate fetches updates.xml from the configured endpoint,
// filters by the selected channel, and compares to the running version.
func CheckForUpdate() error {
if !setting.UpdateChecker.Enabled || setting.UpdateChecker.Endpoint == "" {
return nil
}
channel := setting.UpdateChecker.Channel
if channel == "" {
channel = "stable"
}
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Get(setting.UpdateChecker.Endpoint)
if err != nil {
return fmt.Errorf("update check failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("update check returned status %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("reading update response: %w", err)
}
var updates xmlUpdates
if err := xml.Unmarshal(body, &updates); err != nil {
return fmt.Errorf("parsing updates.xml: %w", err)
}
// Find the entry matching the selected channel
var matched *xmlUpdate
for i := range updates.Updates {
if strings.EqualFold(updates.Updates[i].Tags.Tag, channel) {
matched = &updates.Updates[i]
break
}
}
if matched == nil {
log.Debug("No update entry found for channel %q", channel)
return nil
}
latestVersion := matched.Version
currentVersion := setting.AppVer
// Extract docker image URL if available
dockerImage := ""
for _, dl := range matched.Downloads.DownloadURL {
if dl.Format == "docker" {
dockerImage = strings.TrimSpace(dl.URL)
break
}
}
info := &UpdateInfo{
LatestVersion: latestVersion,
ReleaseURL: strings.TrimSpace(matched.InfoURL.URL),
DockerImage: dockerImage,
Channel: channel,
CheckedAt: time.Now(),
}
// Update is available if the latest version string is not a prefix of the current version.
info.UpdateAvailable = latestVersion != "" && !strings.Contains(currentVersion, latestVersion)
mu.Lock()
cachedInfo = info
// Notify only once per new version (avoid spamming on every cron tick)
shouldNotify := info.UpdateAvailable && latestVersion != lastNotifiedVer
if shouldNotify {
lastNotifiedVer = latestVersion
}
mu.Unlock()
if info.UpdateAvailable {
log.Info("MokoGitea update available: %s [%s] (current: %s)", latestVersion, channel, currentVersion)
if shouldNotify && NotifyFunc != nil {
NotifyFunc(info)
}
} else {
log.Debug("MokoGitea is up to date: %s [%s]", currentVersion, channel)
}
return nil
}
// GetUpdateInfo returns the cached update check result.
func GetUpdateInfo() *UpdateInfo {
mu.RLock()
defer mu.RUnlock()
if cachedInfo == nil {
return &UpdateInfo{}
}
return cachedInfo
}