Compare commits

...

1 Commits

Author SHA1 Message Date
Jonathan Miller 05f1ac1a12 feat(admin): add MokoGitea update checker (#74)
Branch Policy Check / Verify merge target (pull_request) Successful in 1s
Replace removed upstream Gitea update checker with MokoGitea-native
version that checks our own releases API.

- New module: modules/updatechecker/ — fetches latest release from
  git.mokoconsulting.tech, compares semver, caches result
- Cron task: runs every 24h (and at startup)
- Admin dashboard: shows green banner when update available
- Configurable via [update_checker] in app.ini

Closes #74

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-19 21:41:10 -05:00
5 changed files with 151 additions and 2 deletions
+16
View File
@@ -28,6 +28,15 @@ var (
CfgProvider ConfigProvider
IsWindows bool
// UpdateChecker configuration for MokoGitea version checking
UpdateChecker = struct {
Enabled bool
Endpoint string
}{
Enabled: true,
Endpoint: "https://git.mokoconsulting.tech/api/v1/repos/MokoConsulting/MokoGitea/releases/latest",
}
// IsInTesting indicates whether the testing is running (unit test or integration test). It can be used for:
// * Skip nonsense error logs during testing caused by unreliable code (TODO: this is only a temporary solution, we should make the test code more reliable)
// * Panic in dev or testing mode to make the problem more obvious and easier to debug
@@ -158,9 +167,16 @@ func loadCommonSettingsFrom(cfg ConfigProvider) error {
loadMarkupFrom(cfg)
loadGlobalLockFrom(cfg)
loadOtherFrom(cfg)
loadUpdateCheckerFrom(cfg)
return nil
}
func loadUpdateCheckerFrom(cfg ConfigProvider) {
sec := cfg.Section("update_checker")
UpdateChecker.Enabled = sec.Key("ENABLED").MustBool(true)
UpdateChecker.Endpoint = sec.Key("ENDPOINT").MustString(UpdateChecker.Endpoint)
}
func loadRunModeFrom(rootCfg ConfigProvider) {
rootSec := rootCfg.Section("")
RunUser = rootSec.Key("RUN_USER").MustString(user.CurrentUsername())
+106
View File
@@ -0,0 +1,106 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later
package updatechecker
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
)
// UpdateInfo holds the result of the latest update check.
type UpdateInfo struct {
UpdateAvailable bool
LatestVersion string
ReleaseURL string
CheckedAt time.Time
}
var (
cachedInfo *UpdateInfo
mu sync.RWMutex
)
// giteaRelease is the subset of Gitea's release API response we need.
type giteaRelease struct {
TagName string `json:"tag_name"`
HTMLURL string `json:"html_url"`
Draft bool `json:"draft"`
}
// CheckForUpdate fetches the latest release from the configured endpoint
// and compares it to the running version.
func CheckForUpdate() error {
if !setting.UpdateChecker.Enabled || setting.UpdateChecker.Endpoint == "" {
return nil
}
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 release giteaRelease
if err := json.Unmarshal(body, &release); err != nil {
return fmt.Errorf("parsing update response: %w", err)
}
if release.Draft || release.TagName == "" {
return nil
}
latestVersion := strings.TrimPrefix(release.TagName, "v")
currentVersion := setting.AppVer
info := &UpdateInfo{
LatestVersion: latestVersion,
ReleaseURL: release.HTMLURL,
CheckedAt: time.Now(),
}
// Simple comparison: if latest != current, update is available.
// This handles both upgrades and the case where versions differ
// in any way (patch, upstream bump, etc.)
info.UpdateAvailable = latestVersion != "" && !strings.HasPrefix(currentVersion, latestVersion)
mu.Lock()
cachedInfo = info
mu.Unlock()
if info.UpdateAvailable {
log.Info("MokoGitea update available: %s (current: %s)", latestVersion, currentVersion)
} else {
log.Debug("MokoGitea is up to date: %s", currentVersion)
}
return nil
}
// GetUpdateInfo returns the cached update check result.
func GetUpdateInfo() *UpdateInfo {
mu.RLock()
defer mu.RUnlock()
if cachedInfo == nil {
return &UpdateInfo{}
}
return cachedInfo
}
+8 -2
View File
@@ -21,6 +21,7 @@ import (
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/updatechecker"
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/services/context"
@@ -135,8 +136,13 @@ func prepareStartupProblemsAlert(ctx *context.Context) {
func Dashboard(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("admin.dashboard")
ctx.Data["PageIsAdminDashboard"] = true
// MokoGitea: upstream update checker removed — this is an independent fork
ctx.Data["NeedUpdate"] = false
// MokoGitea update checker
info := updatechecker.GetUpdateInfo()
ctx.Data["NeedUpdate"] = info.UpdateAvailable
ctx.Data["LatestVersion"] = info.LatestVersion
ctx.Data["ReleaseURL"] = info.ReleaseURL
updateSystemStatus()
ctx.Data["SysStatus"] = sysStatus
ctx.Data["SSH"] = setting.SSH
+14
View File
@@ -13,6 +13,7 @@ import (
"code.gitea.io/gitea/models/webhook"
"code.gitea.io/gitea/modules/git/gitcmd"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/updatechecker"
"code.gitea.io/gitea/services/auth"
"code.gitea.io/gitea/services/migrations"
mirror_service "code.gitea.io/gitea/services/mirror"
@@ -183,4 +184,17 @@ func initBasicTasks() {
registerCleanupPackages()
}
registerSyncRepoLicenses()
if setting.UpdateChecker.Enabled {
registerUpdateChecker()
}
}
func registerUpdateChecker() {
RegisterTaskFatal("update_checker", &BaseConfig{
Enabled: true,
RunAtStart: true,
Schedule: "@every 24h",
}, func(ctx context.Context, _ *user_model.User, _ Config) error {
return updatechecker.CheckForUpdate()
})
}
+7
View File
@@ -1,5 +1,12 @@
{{template "admin/layout_head" (dict "pageClass" "admin dashboard")}}
<div class="admin-setting-content">
{{if .NeedUpdate}}
<div class="ui positive message">
<div class="header">{{svg "octicon-info"}} MokoGitea Update Available</div>
<p>A new version <strong>{{.LatestVersion}}</strong> is available.
{{if .ReleaseURL}}<a href="{{.ReleaseURL}}" target="_blank" rel="noopener noreferrer">View release notes</a>{{end}}</p>
</div>
{{end}}
<h4 class="ui top attached header">
{{ctx.Locale.Tr "admin.dashboard.maintenance_operations"}}
</h4>