Support Forgejo as primary update source with GitHub fallback.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-10-06 23:55:12 +08:00
parent b639f01c0f
commit 07a7e17e22
4 changed files with 217 additions and 57 deletions

View file

@ -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.

View file

@ -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

View file

@ -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,51 +69,156 @@ function Compare-SemVer {
return 0
}
function Get-ManifestFromUrl {
param([string]$Url)
$resp = Invoke-WebRequest -Uri $Url -UseBasicParsing -TimeoutSec 30
return ($resp.Content | ConvertFrom-Json)
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 Get-FromGitHubReleases {
$api = "https://api.github.com/repos/hansjone/netx/releases/latest"
$headers = @{ "User-Agent" = "NetX-UpdateCheck"; "Accept" = "application/vnd.github+json" }
$rel = Invoke-RestMethod -Uri $api -Headers $headers -TimeoutSec 30
$tag = [string]$rel.tag_name
function Convert-ReleaseToManifest {
param($Rel, [string]$SourceName)
$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" }
$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
notes_url = [string]$Rel.html_url
source = $SourceName
windows = [pscustomobject]@{
url = [string]$zipAsset.browser_download_url
url = $zipUrl
sha256 = ""
size = [int64]$zipAsset.size
setup_url = $(if ($setupAsset) { [string]$setupAsset.browser_download_url } else { "" })
size = [int64]$(if ($zipAsset.size) { $zipAsset.size } else { 0 })
setup_url = $setupUrl
}
}
}
function Get-ManifestFromUrl {
param([string]$Url)
$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/$GithubRepo/releases/latest"
$headers = Get-AuthHeaders -Style "bearer"
$headers["Accept"] = "application/vnd.github+json"
$rel = Invoke-RestMethod -Uri $api -Headers $headers -TimeoutSec 30
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"
$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
@ -101,6 +227,7 @@ $result = [pscustomobject]@{
current = $current
latest = $latest
update_available = ($cmp -lt 0)
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 { "" })
@ -108,20 +235,18 @@ $result = [pscustomobject]@{
}
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()

View file

@ -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