From 07a7e17e221cd2cf12abe37ad5a68ea4bbb582d7 Mon Sep 17 00:00:00 2001 From: oliver Date: Tue, 6 Oct 2026 23:55:12 +0800 Subject: [PATCH] Support Forgejo as primary update source with GitHub fallback. Co-authored-by: Cursor --- .env.example | 8 +- packaging/README.md | 24 ++- packaging/check_update.ps1 | 236 ++++++++++++++++++++------ packaging/config/database.example.env | 6 + 4 files changed, 217 insertions(+), 57 deletions(-) diff --git a/.env.example b/.env.example index a0949db..c307e8a 100644 --- a/.env.example +++ b/.env.example @@ -120,7 +120,13 @@ NETX_UME_NOTIFICATION_TOPIC=ALARM # bundled | external; unset = external (compatible with existing deploys) # NETX_BUNDLED_PG_PORT=15432 # NETX_BUNDLED_PG_DATA_DIR= -# NETX_UPDATE_URL= +# Update sources: forgejo primary + github fallback (recommended for internal Forgejo) +# NETX_UPDATE_FORGEJO_URL=https://git.example.com/api/v1/repos/ops/netx/releases/latest +# NETX_UPDATE_SOURCES=forgejo,github +# NETX_UPDATE_GITHUB_REPO=hansjone/netx +# NETX_UPDATE_URL=https://cdn.example.com/netx/manifest.json +# NETX_UPDATE_FALLBACK_URL= +# NETX_UPDATE_TOKEN= # NETX_UPDATE_CHANNEL=stable # NETX_UPDATE_AUTO=false # When true: tray/scheduled task may download+apply zip updates silently. diff --git a/packaging/README.md b/packaging/README.md index 5a912da..3abd44b 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -87,7 +87,28 @@ Or reinstall a newer `NetX-Setup-*.exe` over the same program directory. ## Check / apply updates -Default source: latest GitHub Release for `hansjone/netx`. Optional custom manifest via `NETX_UPDATE_URL` (see `manifest.example.json`). +Default when nothing is configured: **GitHub Releases** (`hansjone/netx`). + +You can put **Forgejo/Gitea first** and keep GitHub as fallback: + +```env +# Data-root .env (ProgramData\NetX\.env) +NETX_UPDATE_FORGEJO_URL=https://git.example.com/api/v1/repos/ops/netx/releases/latest +NETX_UPDATE_SOURCES=forgejo,github +# Optional private repo token (Forgejo "token …" / GitHub Bearer) +# NETX_UPDATE_TOKEN=****** +``` + +Other options: + +| Variable | Role | +|----------|------| +| `NETX_UPDATE_URL` | Custom JSON manifest (see `manifest.example.json`) | +| `NETX_UPDATE_FALLBACK_URL` | Second manifest URL | +| `NETX_UPDATE_GITHUB_REPO` | Override GitHub `owner/repo` (default `hansjone/netx`) | +| `NETX_UPDATE_SOURCES` | Explicit order, e.g. `forgejo,github` or `manifest,forgejo,github` | + +If `NETX_UPDATE_FORGEJO_URL` is set and `SOURCES` is empty, order is: manifest (if URL set) → **forgejo** → fallback manifest → **github**. ```powershell # Report only (exit 0 = up to date, 10 = update available) @@ -97,6 +118,7 @@ Default source: latest GitHub Release for `hansjone/netx`. Optional custom manif .\packaging\check_update.ps1 -Apply ``` +Forgejo must publish the same assets as GitHub (`NetX-*-win64.zip`, optional `NetX-Setup-*.exe`) on a release whose tag is like `v0.4.0`. ## Tray & autostart ```powershell diff --git a/packaging/check_update.ps1 b/packaging/check_update.ps1 index fa65ae4..a7d7215 100644 --- a/packaging/check_update.ps1 +++ b/packaging/check_update.ps1 @@ -2,7 +2,11 @@ param( [string]$ProgramRoot = "", [string]$DataRoot = "", [string]$UpdateUrl = "", + [string]$FallbackUrl = "", + [string]$ForgejoUrl = "", + [string]$GithubRepo = "", [string]$Channel = "", + [string]$Sources = "", [switch]$Apply = $false, [switch]$Quiet = $false, # Only apply when NETX_UPDATE_AUTO=true (scheduled silent updates). @@ -10,9 +14,18 @@ param( ) # Check for a newer NetX Windows package. -# Sources (first that works): -# 1) -UpdateUrl / NETX_UPDATE_URL → JSON manifest (see manifest.example.json) -# 2) GitHub Releases API for hansjone/netx (default) +# +# Source order (first success wins), controlled by NETX_UPDATE_SOURCES or defaults: +# manifest → NETX_UPDATE_URL JSON (manifest.example.json) +# forgejo → NETX_UPDATE_FORGEJO_URL (Forgejo/Gitea releases/latest API) +# github → GitHub releases/latest (NETX_UPDATE_GITHUB_REPO, default hansjone/netx) +# +# Examples: +# NETX_UPDATE_FORGEJO_URL=https://git.example.com/api/v1/repos/ops/netx/releases/latest +# NETX_UPDATE_SOURCES=forgejo,github # Forgejo primary, GitHub fallback +# NETX_UPDATE_URL=https://cdn/.../manifest.json +# NETX_UPDATE_FALLBACK_URL=https://cdn/.../manifest-backup.json +# NETX_UPDATE_TOKEN=... # optional Bearer/token for private APIs $ErrorActionPreference = "Stop" . "$PSScriptRoot\_common.ps1" @@ -23,22 +36,30 @@ $envPath = Join-Path $data ".env" $map = @{} if (Test-Path $envPath) { $map = Read-DotEnv -Path $envPath } -if (-not $UpdateUrl) { - if ($map["NETX_UPDATE_URL"]) { $UpdateUrl = $map["NETX_UPDATE_URL"] } - elseif ($env:NETX_UPDATE_URL) { $UpdateUrl = $env:NETX_UPDATE_URL } -} -if (-not $Channel) { - if ($map["NETX_UPDATE_CHANNEL"]) { $Channel = $map["NETX_UPDATE_CHANNEL"] } - elseif ($env:NETX_UPDATE_CHANNEL) { $Channel = $env:NETX_UPDATE_CHANNEL } - else { $Channel = "stable" } +function Get-Cfg([string]$Key, [string]$ParamVal = "") { + if ($ParamVal) { return $ParamVal } + if ($map.ContainsKey($Key) -and $map[$Key]) { return [string]$map[$Key] } + $envVal = [Environment]::GetEnvironmentVariable($Key) + if ($envVal) { return $envVal } + return "" } +$UpdateUrl = Get-Cfg "NETX_UPDATE_URL" $UpdateUrl +$FallbackUrl = Get-Cfg "NETX_UPDATE_FALLBACK_URL" $FallbackUrl +$ForgejoUrl = Get-Cfg "NETX_UPDATE_FORGEJO_URL" $ForgejoUrl +$GithubRepo = Get-Cfg "NETX_UPDATE_GITHUB_REPO" $GithubRepo +if (-not $GithubRepo) { $GithubRepo = "hansjone/netx" } +$Channel = Get-Cfg "NETX_UPDATE_CHANNEL" $Channel +if (-not $Channel) { $Channel = "stable" } +$Sources = Get-Cfg "NETX_UPDATE_SOURCES" $Sources +$UpdateToken = Get-Cfg "NETX_UPDATE_TOKEN" "" + $current = Get-NetxVersion -ProgramRoot $prog function Compare-SemVer { param([string]$A, [string]$B) - $pa = @($A.TrimStart('v','V').Split('.') | ForEach-Object { try { [int]$_ } catch { 0 } }) - $pb = @($B.TrimStart('v','V').Split('.') | ForEach-Object { try { [int]$_ } catch { 0 } }) + $pa = @($A.TrimStart('v', 'V').Split('.') | ForEach-Object { try { [int]$_ } catch { 0 } }) + $pb = @($B.TrimStart('v', 'V').Split('.') | ForEach-Object { try { [int]$_ } catch { 0 } }) while ($pa.Count -lt 3) { $pa += 0 } while ($pb.Count -lt 3) { $pb += 0 } for ($i = 0; $i -lt 3; $i++) { @@ -48,80 +69,184 @@ function Compare-SemVer { return 0 } +function Get-AuthHeaders { + param([string]$Style = "bearer") + $h = @{ "User-Agent" = "NetX-UpdateCheck" } + if (-not $UpdateToken) { return $h } + if ($Style -eq "token") { + $h["Authorization"] = "token $UpdateToken" + } else { + $h["Authorization"] = "Bearer $UpdateToken" + } + return $h +} + +function Convert-ReleaseToManifest { + param($Rel, [string]$SourceName) + $tag = [string]$Rel.tag_name + $ver = $tag.TrimStart('v', 'V') + $assets = @($Rel.assets) + $zipAsset = $assets | Where-Object { $_.name -match 'win64\.zip$' } | Select-Object -First 1 + $setupAsset = $assets | Where-Object { $_.name -match 'Setup-.*\.exe$' } | Select-Object -First 1 + if (-not $zipAsset) { throw "${SourceName}_release_missing_zip" } + + $zipUrl = [string]$zipAsset.browser_download_url + if (-not $zipUrl -and $zipAsset.id -and $Rel.html_url) { + # Some Gitea/Forgejo builds omit browser_download_url; leave empty to fail clearly. + $zipUrl = [string]$zipAsset.browser_download_url + } + if (-not $zipUrl) { throw "${SourceName}_zip_url_missing" } + + $setupUrl = "" + if ($setupAsset -and $setupAsset.browser_download_url) { + $setupUrl = [string]$setupAsset.browser_download_url + } + + return [pscustomobject]@{ + channel = "stable" + latest = $ver + min_compatible = $ver + notes_url = [string]$Rel.html_url + source = $SourceName + windows = [pscustomobject]@{ + url = $zipUrl + sha256 = "" + size = [int64]$(if ($zipAsset.size) { $zipAsset.size } else { 0 }) + setup_url = $setupUrl + } + } +} + function Get-ManifestFromUrl { param([string]$Url) - $resp = Invoke-WebRequest -Uri $Url -UseBasicParsing -TimeoutSec 30 - return ($resp.Content | ConvertFrom-Json) + $headers = Get-AuthHeaders -Style "bearer" + $resp = Invoke-WebRequest -Uri $Url -Headers $headers -UseBasicParsing -TimeoutSec 30 + $m = $resp.Content | ConvertFrom-Json + if (-not $m.source) { + $m | Add-Member -NotePropertyName source -NotePropertyValue "manifest" -Force + } + return $m } function Get-FromGitHubReleases { - $api = "https://api.github.com/repos/hansjone/netx/releases/latest" - $headers = @{ "User-Agent" = "NetX-UpdateCheck"; "Accept" = "application/vnd.github+json" } + $api = "https://api.github.com/repos/$GithubRepo/releases/latest" + $headers = Get-AuthHeaders -Style "bearer" + $headers["Accept"] = "application/vnd.github+json" $rel = Invoke-RestMethod -Uri $api -Headers $headers -TimeoutSec 30 - $tag = [string]$rel.tag_name - $ver = $tag.TrimStart('v', 'V') - $zipAsset = $rel.assets | Where-Object { $_.name -match 'win64\.zip$' } | Select-Object -First 1 - $setupAsset = $rel.assets | Where-Object { $_.name -match 'Setup-.*\.exe$' } | Select-Object -First 1 - if (-not $zipAsset) { throw "github_release_missing_zip" } - return [pscustomobject]@{ - channel = "stable" - latest = $ver - min_compatible = $ver - notes_url = [string]$rel.html_url - windows = [pscustomobject]@{ - url = [string]$zipAsset.browser_download_url - sha256 = "" - size = [int64]$zipAsset.size - setup_url = $(if ($setupAsset) { [string]$setupAsset.browser_download_url } else { "" }) - } + return Convert-ReleaseToManifest -Rel $rel -SourceName "github" +} + +function Get-FromForgejoReleases { + param([string]$ApiUrl) + if (-not $ApiUrl) { throw "forgejo_url_empty" } + # Forgejo/Gitea: /api/v1/repos/{owner}/{repo}/releases/latest + $headers = Get-AuthHeaders -Style "token" + $headers["Accept"] = "application/json" + $rel = Invoke-RestMethod -Uri $ApiUrl -Headers $headers -TimeoutSec 30 + return Convert-ReleaseToManifest -Rel $rel -SourceName "forgejo" +} + +function Get-UpdateSourceList { + if ($Sources) { + return @($Sources.Split(',') | ForEach-Object { $_.Trim().ToLowerInvariant() } | Where-Object { $_ }) } + $list = [System.Collections.Generic.List[string]]::new() + if ($UpdateUrl) { [void]$list.Add("manifest") } + if ($ForgejoUrl) { [void]$list.Add("forgejo") } + if ($FallbackUrl) { [void]$list.Add("manifest_fallback") } + # Always keep GitHub as last resort unless explicitly disabled via SOURCES. + if (-not $list.Contains("github")) { [void]$list.Add("github") } + # Prefer Forgejo before GitHub when both configured and no explicit SOURCES. + if ($ForgejoUrl -and $list.Contains("forgejo") -and $list.Contains("github")) { + $ordered = [System.Collections.Generic.List[string]]::new() + foreach ($s in @("manifest", "forgejo", "manifest_fallback", "github")) { + if ($list.Contains($s) -and -not $ordered.Contains($s)) { [void]$ordered.Add($s) } + } + foreach ($s in $list) { + if (-not $ordered.Contains($s)) { [void]$ordered.Add($s) } + } + return @($ordered) + } + return @($list) } Write-Host "==> Current version: $current" +$sourceList = Get-UpdateSourceList +Write-Host "==> Update sources: $($sourceList -join ' -> ')" + $manifest = $null -try { - if ($UpdateUrl) { - Write-Host "==> Fetching manifest: $UpdateUrl" - $manifest = Get-ManifestFromUrl -Url $UpdateUrl +$errors = @() +foreach ($src in $sourceList) { + try { + switch ($src) { + "manifest" { + if (-not $UpdateUrl) { throw "NETX_UPDATE_URL empty" } + Write-Host "==> Trying manifest: $UpdateUrl" + $manifest = Get-ManifestFromUrl -Url $UpdateUrl + } + "manifest_fallback" { + if (-not $FallbackUrl) { throw "NETX_UPDATE_FALLBACK_URL empty" } + Write-Host "==> Trying fallback manifest: $FallbackUrl" + $manifest = Get-ManifestFromUrl -Url $FallbackUrl + if ($manifest -and -not $manifest.source) { + $manifest | Add-Member -NotePropertyName source -NotePropertyValue "manifest_fallback" -Force + } + } + "forgejo" { + Write-Host "==> Trying Forgejo/Gitea: $ForgejoUrl" + $manifest = Get-FromForgejoReleases -ApiUrl $ForgejoUrl + } + "github" { + Write-Host "==> Trying GitHub Releases: $GithubRepo" + $manifest = Get-FromGitHubReleases + } + default { throw "unknown_source: $src" } + } if ($manifest.channel -and $Channel -and ($manifest.channel -ne $Channel)) { Write-Host "[WARN] manifest channel=$($manifest.channel) requested=$Channel" } - } else { - Write-Host "==> Fetching latest from GitHub Releases" - $manifest = Get-FromGitHubReleases + Write-Host "==> Using source: $(if ($manifest.source) { $manifest.source } else { $src })" -ForegroundColor Green + break + } catch { + $msg = $_.Exception.Message + $errors += "${src}: $msg" + Write-Host "[WARN] source '$src' failed: $msg" -ForegroundColor Yellow + $manifest = $null } -} catch { +} + +if (-not $manifest) { + $joined = ($errors -join "; ") if ($Quiet) { exit 2 } - throw "update_check_failed: $($_.Exception.Message)" + throw "update_check_failed: all sources failed ($joined)" } $latest = [string]$manifest.latest $cmp = Compare-SemVer -A $current -B $latest $result = [pscustomobject]@{ - current = $current - latest = $latest + current = $current + latest = $latest update_available = ($cmp -lt 0) - download_url = [string]$manifest.windows.url - setup_url = $(if ($manifest.windows.setup_url) { [string]$manifest.windows.setup_url } else { "" }) - notes_url = $(if ($manifest.notes_url) { [string]$manifest.notes_url } else { "" }) - sha256 = $(if ($manifest.windows.sha256) { [string]$manifest.windows.sha256 } else { "" }) + source = $(if ($manifest.source) { [string]$manifest.source } else { "unknown" }) + download_url = [string]$manifest.windows.url + setup_url = $(if ($manifest.windows.setup_url) { [string]$manifest.windows.setup_url } else { "" }) + notes_url = $(if ($manifest.notes_url) { [string]$manifest.notes_url } else { "" }) + sha256 = $(if ($manifest.windows.sha256) { [string]$manifest.windows.sha256 } else { "" }) } if (-not $result.update_available) { - Write-Host "==> Up to date (latest=$latest)" -ForegroundColor Green + Write-Host "==> Up to date (latest=$latest, source=$($result.source))" -ForegroundColor Green if (-not $Quiet) { $result | ConvertTo-Json -Compress | Write-Output } exit 0 } -Write-Host "==> Update available: $current -> $latest" -ForegroundColor Cyan +Write-Host "==> Update available: $current -> $latest (source=$($result.source))" -ForegroundColor Cyan if ($result.notes_url) { Write-Host " Notes: $($result.notes_url)" } $autoEnabled = $false -$autoVal = "" -if ($map["NETX_UPDATE_AUTO"]) { $autoVal = $map["NETX_UPDATE_AUTO"] } -elseif ($env:NETX_UPDATE_AUTO) { $autoVal = $env:NETX_UPDATE_AUTO } +$autoVal = Get-Cfg "NETX_UPDATE_AUTO" "" if ($autoVal -match '^(1|true|yes|on)$') { $autoEnabled = $true } if (-not $Apply) { @@ -164,8 +289,9 @@ try { } $zipName = "NetX-$latest-win64.zip" $zipPath = Join-Path $dlDir $zipName - Write-Host "==> Downloading $zipName ..." - Invoke-WebRequest -Uri $result.download_url -OutFile $zipPath -UseBasicParsing + Write-Host "==> Downloading $zipName from $($result.source) ..." + $dlHeaders = Get-AuthHeaders -Style "token" + Invoke-WebRequest -Uri $result.download_url -OutFile $zipPath -Headers $dlHeaders -UseBasicParsing if ($result.sha256 -and $result.sha256 -notmatch 'REPLACE' -and $result.sha256.Trim()) { $hash = (Get-FileHash -Path $zipPath -Algorithm SHA256).Hash.ToLowerInvariant() $expect = $result.sha256.ToLowerInvariant() diff --git a/packaging/config/database.example.env b/packaging/config/database.example.env index 8092b54..dc78103 100644 --- a/packaging/config/database.example.env +++ b/packaging/config/database.example.env @@ -5,6 +5,12 @@ # NETX_HOST=127.0.0.1 # NETX_PORT=8890 # NETX_UI_DIST_DIR=web/dist +# Prefer internal Forgejo, fall back to GitHub +# NETX_UPDATE_FORGEJO_URL=https://git.example.com/api/v1/repos/ops/netx/releases/latest +# NETX_UPDATE_SOURCES=forgejo,github +# NETX_UPDATE_GITHUB_REPO=hansjone/netx # NETX_UPDATE_URL= +# NETX_UPDATE_FALLBACK_URL= +# NETX_UPDATE_TOKEN= # NETX_UPDATE_CHANNEL=stable # NETX_UPDATE_AUTO=false