From a5c9a8f58d16209d72b0cacc8396120e70a38cf6 Mon Sep 17 00:00:00 2001 From: oliver Date: Mon, 12 Oct 2026 15:54:53 +0800 Subject: [PATCH] Prepare NetX 0.4.14 Windows release --- .github/workflows/release-windows.yml | 7 ++ packaging/README.md | 23 +++-- packaging/build_release.ps1 | 8 +- packaging/check_update.ps1 | 69 +++++++++++--- packaging/installer/netx.iss | 83 +++++++++++++++-- packaging/manifest.example.json | 8 +- packaging/publish_forgejo_release.ps1 | 5 +- packaging/publish_release.ps1 | 33 ++++++- packaging/service_lifecycle.ps1 | 124 ++++++++++++++++++++++++++ pyproject.toml | 2 +- 10 files changed, 320 insertions(+), 42 deletions(-) create mode 100644 packaging/service_lifecycle.ps1 diff --git a/.github/workflows/release-windows.yml b/.github/workflows/release-windows.yml index 3c8444b..12b777e 100644 --- a/.github/workflows/release-windows.yml +++ b/.github/workflows/release-windows.yml @@ -38,11 +38,18 @@ jobs: shell: pwsh run: | $ver = (Get-Content pyproject.toml | Select-String '^\s*version\s*=').Line -replace '.*"(.+)"','$1' + if ($env:GITHUB_REF_NAME -ne "v$ver") { throw "Release tag $env:GITHUB_REF_NAME does not match pyproject version $ver" } ISCC.exe "/DMyAppVersion=$ver" packaging/installer/netx.iss + if ($LASTEXITCODE -ne 0) { throw "Inno Setup failed with exit code $LASTEXITCODE" } + $setup = "packaging/release/NetX-Setup-$ver.exe" + if (-not (Test-Path $setup) -or (Get-Item $setup).Length -lt 1MB) { throw "Missing or invalid installer: $setup" } + $hash = (Get-FileHash $setup -Algorithm SHA256).Hash.ToLowerInvariant() + Set-Content "$setup.sha256" -Value $hash -Encoding ascii -NoNewline - name: Upload to GitHub Release uses: softprops/action-gh-release@v2 with: files: | packaging/release/NetX-Setup-*.exe + packaging/release/NetX-Setup-*.exe.sha256 generate_release_notes: true diff --git a/packaging/README.md b/packaging/README.md index 847943d..ed7f165 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -1,4 +1,4 @@ -# NetX Windows packaging +# NetX Windows packaging **Linux is unchanged:** keep using your own PostgreSQL and `NETX_DATABASE_URL` with `scripts/start_netx.sh`. Nothing under this folder is required on Linux. @@ -20,20 +20,20 @@ Zip packages are **not** published. Releases ship **Setup.exe only**. **Installer:** English + 简体中文 (language dialog). Icons use `packaging/assets/netx.ico`. -**Offline:** Setup ships portable PostgreSQL, **`python/runtime` + `.venv`** (portable; not tied to the build PC’s user profile), and WinSW. **First install** shows the Database page (built-in or external PostgreSQL; external credentials validated with `psql SELECT 1`). **Re-running Setup when `%ProgramData%\NetX\.env` already has `NETX_DB_MODE`** skips the DB wizard and updates program files in place — **no GitHub/EDB download on the target PC**. Service install uses bundled WinSW (pass `-AllowDownload` only on a build/dev machine if the binary is missing). Auto-update still needs network later, and is unchecked by default. +**Offline:** Setup ships portable PostgreSQL, **`python/runtime` + `.venv`** (portable; not tied to the build PC’s user profile), and WinSW. **First install** shows the Database page (built-in or external PostgreSQL; external credentials validated with `psql SELECT 1`). **Re-running Setup when `%ProgramData%\NetX\.env` has `NETX_DB_MODE` or a non-empty legacy `NETX_DATABASE_URL`** skips the DB wizard by default. Update stops and resumes an installed Windows service or startup task while replacing files. Service install uses bundled WinSW. Auto-update still needs network later, and is unchecked by default. -**OS:** Windows 10/11 or Windows Server **2016+** recommended. Packaging scripts are UTF-8 **with BOM** so Chinese UI works on Windows PowerShell 5.x. Bundled Python in current releases is **3.13+**, which does **not** support Windows Server 2012 R2 — use Server 2016+ or a newer desktop OS. +**OS:** Windows 10/11 or Windows Server **2016+** recommended. Packaging scripts are UTF-8 **with BOM** so Chinese UI works on Windows PowerShell 5.x. The Windows release workflow pins bundled Python to **3.12**, so local release builds should use the same version for consistent artifacts. ## Build (developer machine) -Prerequisites: Python 3.11+, Node 20+, PowerShell, network (to download PG binaries once). +Prerequisites: Python **3.12.x**, Node 20+, PowerShell, network (to download PostgreSQL binaries once). ```powershell cd netx -# Optional: download portable Postgres into packaging\postgres\pgsql +# Required once per clean build machine: download portable PostgreSQL into packaging\postgres\pgsql powershell -ExecutionPolicy Bypass -File .\packaging\download_postgres.ps1 -# Build web + stage (-CreateVenv ships a ready .venv; large). No zip by default. +# Build web + offline stage containing Python, .venv, PostgreSQL and WinSW. No zip by default. powershell -ExecutionPolicy Bypass -File .\packaging\build_release.ps1 -CreateVenv ``` @@ -118,15 +118,14 @@ Stops services, replaces program folders (`netx_api`, `web`, `packaging`, `postg .\packaging\check_update.ps1 -Apply ``` -Both sides should publish the same **Setup.exe**. `check_update.ps1` prefers `NetX-Setup-*.exe` (legacy `win64.zip` still works if present). **Code mirror alone is not enough** — Forgejo pull-mirror syncs git/tags only; Release assets must be uploaded separately (or clients can only fall back to GitHub). +Both release hosts must publish the same **Setup.exe and `.sha256` sidecar**. `check_update.ps1` prefers `NetX-Setup-*.exe` (legacy `win64.zip` is still accepted); it verifies the Setup against a manifest hash or the adjacent checksum asset. Old releases without a sidecar remain supported with a warning. **Code mirror alone is not enough** — Forgejo pull-mirror syncs git/tags only; release assets are uploaded separately. ### Maintainer release checklist (Windows) -Do this on the **dev PC on the home LAN** after bumping version: +Release from a clean, version-matched checkout in this order: -1. **Build** — `.\packaging\build_release.ps1 -CreateVenv` + Inno Setup → `NetX-Setup-*.exe` -2. **GitHub** — `.\packaging\publish_release.ps1 -Version x.y.z` (uploads Setup.exe only) -3. **Forgejo** — upload the same Setup.exe (default: public domain `https://git.avelo.top`): +1. **Build and publish GitHub** — ` .\packaging\publish_release.ps1 -Version x.y.z` builds, verifies and uploads Setup.exe plus SHA-256. +2. **Forgejo** — publish the same Setup.exe and checksum sidecar (default: public domain `https://git.avelo.top`): ```powershell # One-time: User env var (never commit the token) @@ -148,7 +147,7 @@ Do this on the **dev PC on the home LAN** after bumping version: | Update probe (default) | GitHub + `https://git.avelo.top/.../releases/latest` | Verify: - +- Confirm each release has `NetX-Setup-x.y.z.exe` and `NetX-Setup-x.y.z.exe.sha256`. - https://git.avelo.top/hansjone/netx/releases - Probe: `.\packaging\check_update.ps1` → both `github` and `forgejo` reachable with the new version diff --git a/packaging/build_release.ps1 b/packaging/build_release.ps1 index 3f35475..c836d73 100644 --- a/packaging/build_release.ps1 +++ b/packaging/build_release.ps1 @@ -107,7 +107,7 @@ foreach ($name in @( "download_postgres.ps1", "setup_first_run.ps1", "start_netx_app.ps1", "stop_netx_app.ps1", "update_netx.ps1", "check_update.ps1", "launch_shortcut.ps1", "netx_tray.ps1", "install_autostart.ps1", "install_service.ps1", - "service_run.ps1", "install_update_task.ps1", "uninstall_prepare.ps1", "uninstall_delete_data.ps1", "sign_release.ps1", + "service_run.ps1", "service_lifecycle.ps1", "install_update_task.ps1", "uninstall_prepare.ps1", "uninstall_delete_data.ps1", "sign_release.ps1", "build_release.ps1", "publish_release.ps1", "publish_forgejo_release.ps1", "README.md", "manifest.example.json" )) { $src = Join-Path $PSScriptRoot $name @@ -173,7 +173,9 @@ foreach ($cmdName in @("NetX-FirstRun.cmd", "NetX-Start.cmd", "NetX-Tray.cmd", " if ($CreateVenv) { Write-Host "==> Creating portable python/runtime + .venv (must not reference build-machine paths)" $pyPath = Get-NetxSystemPython - if (-not $pyPath) { throw "python_not_found_for_venv: need Python 3.11+ (not WindowsApps stub)" } + if (-not $pyPath) { throw "python_not_found_for_venv: need Python 3.12.x (not WindowsApps stub)" } + $pyVersion = (& $pyPath -c "import sys; print('%d.%d' % sys.version_info[:2])" 2>$null | Out-String).Trim() + if ($pyVersion -ne '3.12') { throw "python_version_mismatch: Windows releases require Python 3.12.x for reproducible runtimes; found $pyVersion at $pyPath" } Write-Host " Build Python: $pyPath" $prefix = (& $pyPath -c "import sys; print(sys.base_prefix)" 2>$null | Out-String).Trim() @@ -250,4 +252,4 @@ Write-Host "" Write-Host "Ship:" Write-Host " Exe: compile packaging\installer\netx.iss with Inno Setup (needs stage above)" Write-Host " (Zip packages are not published; use -CreateZip only for local testing.)" -Write-Host "Python: install 3.11+ on target, or rebuild with -CreateVenv and ship .venv" +Write-Host "Python: bundled python/runtime + .venv; target PCs do not need Python installed" diff --git a/packaging/check_update.ps1 b/packaging/check_update.ps1 index 7c4f39f..99be3ec 100644 --- a/packaging/check_update.ps1 +++ b/packaging/check_update.ps1 @@ -376,13 +376,43 @@ try { } Invoke-WebRequest -Uri $result.download_url -OutFile $pkgPath -Headers $dlHeaders -UseBasicParsing } - if ($result.sha256 -and $result.sha256 -notmatch 'REPLACE' -and $result.sha256.Trim()) { - $hash = (Get-FileHash -Path $pkgPath -Algorithm SHA256).Hash.ToLowerInvariant() - $expect = $result.sha256.ToLowerInvariant() - if ($hash -ne $expect) { - throw "sha256_mismatch: got $hash expected $expect" + $expectedHash = [string]$result.sha256 + if (-not $expectedHash -or $expectedHash -match 'REPLACE') { + $checksumUri = [uri]$result.download_url + $checksumBuilder = New-Object -TypeName System.UriBuilder -ArgumentList $checksumUri + $checksumBuilder.Path = $checksumBuilder.Path + '.sha256' + $checksumBuilder.Query = '' + $checksumUrl = $checksumBuilder.Uri.AbsoluteUri + $checksumHeaders = @{ 'User-Agent' = 'NetX-UpdateCheck' } + $isChecksumGithub = ($checksumUri.Host -match '(^|\.)github\.com$' -or $checksumUri.Host -match '(^|\.)githubusercontent\.com$') + if ($UpdateToken -and -not $isChecksumGithub) { $checksumHeaders['Authorization'] = "token $UpdateToken" } + $checksumFile = "$pkgPath.sha256" + try { + Invoke-WebRequest -Uri $checksumUrl -OutFile $checksumFile -Headers $checksumHeaders -UseBasicParsing + $expectedHash = (Get-Content -LiteralPath $checksumFile -Raw).Trim() -split '\s+' | Select-Object -First 1 + } catch { + $statusCode = 0 + if ($_.Exception.Response -and $_.Exception.Response.StatusCode) { + $statusCode = [int]$_.Exception.Response.StatusCode + } + if ($statusCode -eq 404) { + Write-Host "[WARN] Release has no SHA-256 sidecar; accepting legacy asset without checksum verification." -ForegroundColor Yellow + } else { + throw + } + } finally { + Remove-Item -LiteralPath $checksumFile -Force -ErrorAction SilentlyContinue } - Write-Host "==> SHA256 OK" + } + if ($expectedHash) { + $expectedHash = $expectedHash.Trim().ToLowerInvariant() + if ($expectedHash -notmatch '^[0-9a-f]{64}$') { throw 'invalid_sha256_metadata' } + $actualHash = (Get-FileHash -LiteralPath $pkgPath -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actualHash -ne $expectedHash) { + Remove-Item -LiteralPath $pkgPath -Force -ErrorAction SilentlyContinue + throw "sha256_mismatch: got $actualHash expected $expectedHash" + } + Write-Host '==> SHA256 OK' } # Program Files installs need admin; elevate once (avoid loop via NETX_UPDATE_ELEVATED). @@ -422,10 +452,29 @@ try { & powershell -NoProfile -ExecutionPolicy Bypass -File $repairPs1 ` -ProgramRoot $prog -DataRoot $data } - Write-Host "==> Restarting NetX after Setup" - & (Join-Path $PSScriptRoot "start_netx_app.ps1") ` - -ProgramRoot $prog -DataRoot $data -SkipBrowser - if ($LASTEXITCODE -and $LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $managedRunnerActive = $false + $netxService = Get-Service -Name "NetX" -ErrorAction SilentlyContinue + if ($netxService -and $netxService.Status -eq "Running") { $managedRunnerActive = $true } + if (-not $managedRunnerActive) { + try { + $netxTask = Get-ScheduledTask -TaskName "NetXService" -TaskPath "\NetX\" -ErrorAction Stop + $managedRunnerActive = ([string]$netxTask.State -eq "Running") + } catch {} + } + if ($managedRunnerActive) { + Write-Host "==> Waiting for the previously running NetX service/task" + $netxPort = 8890 + $netxEnv = Read-DotEnv -Path (Join-Path $data '.env') + if ($netxEnv['NETX_PORT']) { try { $netxPort = [int]$netxEnv['NETX_PORT'] } catch {} } + if (-not (Wait-NetxApiHealthy -HostName "127.0.0.1" -Port $netxPort -TimeoutSec 180)) { + throw "updated_service_unhealthy: NetX service/task was resumed but /health did not become ready" + } + } else { + Write-Host "==> Restarting NetX after Setup" + & (Join-Path $PSScriptRoot "start_netx_app.ps1") ` + -ProgramRoot $prog -DataRoot $data -SkipBrowser + if ($LASTEXITCODE -and $LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + } } else { Write-Host "==> Applying legacy zip update via update_netx.ps1" & powershell -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot "update_netx.ps1") ` diff --git a/packaging/installer/netx.iss b/packaging/installer/netx.iss index fc2feaa..0f43470 100644 --- a/packaging/installer/netx.iss +++ b/packaging/installer/netx.iss @@ -5,7 +5,7 @@ #define MyAppName "NetX" #ifndef MyAppVersion - #define MyAppVersion "0.4.13" + #define MyAppVersion "0.4.14" #endif #define MyAppPublisher "NetX" #define MyAppURL "https://github.com/hansjone/netx" @@ -140,6 +140,9 @@ Source: "{#StageDir}\postgres\pgsql\bin\liblz4.dll"; Flags: dontcopy Source: "{#StageDir}\postgres\pgsql\bin\libzstd.dll"; Flags: dontcopy Source: "{#StageDir}\postgres\pgsql\bin\libxml2.dll"; Flags: dontcopy Source: "{#StageDir}\postgres\pgsql\bin\libxslt.dll"; Flags: dontcopy +; These helpers are extracted for PrepareToInstall, before the new program tree is copied. +Source: "{#StageDir}\packaging\_common.ps1"; Flags: dontcopy +Source: "{#StageDir}\packaging\service_lifecycle.ps1"; Flags: dontcopy [Dirs] ; users-modify so tray / non-admin reconfigure can update .env and runtime files. @@ -277,9 +280,27 @@ begin end; function DetectExistingConfiguredInstall(): Boolean; +var + Lines: TArrayOfString; + i: Integer; + Line: String; begin - { Configured install = ProgramData\.env already has NETX_DB_MODE. } + { NETX_DATABASE_URL-only installs predate NETX_DB_MODE and use an external DB. } Result := EnvHasDbMode(); + if Result then + Exit; + if not LoadStringsFromFile(ExpandConstant('{commonappdata}\NetX\.env'), Lines) then + Exit; + for i := 0 to GetArrayLength(Lines) - 1 do + begin + Line := Trim(Lines[i]); + if (Pos('NETX_DATABASE_URL=', Line) = 1) and + (Length(Line) > Length('NETX_DATABASE_URL=')) then + begin + Result := True; + Exit; + end; + end; end; procedure SyncSkipDbFromInstallMode(); @@ -343,18 +364,41 @@ end; procedure StopNetxBeforeFileReplace(); var ResultCode: Integer; - StopScript: String; + LifecycleScript: String; Params: String; begin - StopScript := ExpandConstant('{app}\packaging\stop_netx_app.ps1'); - if not FileExists(StopScript) then + ExtractTemporaryFile('_common.ps1'); + ExtractTemporaryFile('service_lifecycle.ps1'); + LifecycleScript := ExpandConstant('{tmp}\service_lifecycle.ps1'); + Params := + '-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File "' + LifecycleScript + '"' + + ' -Action StopForUpdate' + + ' -ProgramRoot "' + ExpandConstant('{app}') + '"' + + ' -DataRoot "' + ExpandConstant('{commonappdata}\NetX') + '"'; + if not Exec('powershell.exe', Params, ExpandConstant('{app}'), SW_HIDE, ewWaitUntilTerminated, ResultCode) then + RaiseException('Could not stop NetX before replacing program files.'); + if ResultCode <> 0 then + RaiseException('Could not stop NetX services/tasks before replacing program files. See ProgramData\\NetX\\data\\runtime\\service_lifecycle.log.'); +end; + +procedure ResumeNetxServiceAfterInstall(); +var + ResultCode: Integer; + LifecycleScript: String; + Params: String; +begin + LifecycleScript := ExpandConstant('{app}\packaging\service_lifecycle.ps1'); + if not FileExists(LifecycleScript) then Exit; Params := - '-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File "' + StopScript + '"' + + '-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File "' + LifecycleScript + '"' + + ' -Action ResumeAfterUpdate' + ' -ProgramRoot "' + ExpandConstant('{app}') + '"' + - ' -DataRoot "' + ExpandConstant('{commonappdata}\NetX') + '"' + - ' -KillTray'; - Exec('powershell.exe', Params, ExpandConstant('{app}'), SW_HIDE, ewWaitUntilTerminated, ResultCode); + ' -DataRoot "' + ExpandConstant('{commonappdata}\NetX') + '"'; + if not Exec('powershell.exe', Params, ExpandConstant('{app}'), SW_HIDE, ewWaitUntilTerminated, ResultCode) then + RaiseException('NetX was updated, but its previously running service could not be restarted.'); + if ResultCode <> 0 then + RaiseException('NetX was updated, but its previously running service could not be restarted. See ProgramData\\NetX\\data\\runtime\\service_lifecycle.log.'); end; function NetxShouldStartTray(): Boolean; @@ -988,6 +1032,9 @@ end; function InitializeUninstall(): Boolean; var DeleteSrc: String; + LifecycleScript: String; + Params: String; + ResultCode: Integer; begin Result := True; GDeleteNetxData := False; @@ -1008,6 +1055,23 @@ begin GDeleteNetxData := MsgBox(ExpandConstant('{cm:UninstallDeleteData}'), mbConfirmation, MB_YESNO) = IDYES; end; + + LifecycleScript := ExpandConstant('{app}\packaging\service_lifecycle.ps1'); + if FileExists(LifecycleScript) then + begin + Params := + '-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File "' + LifecycleScript + '"' + + ' -Action RemoveForUninstall' + + ' -ProgramRoot "' + ExpandConstant('{app}') + '"' + + ' -DataRoot "' + ExpandConstant('{commonappdata}\NetX') + '"'; + if (not Exec('powershell.exe', Params, ExpandConstant('{app}'), SW_HIDE, ewWaitUntilTerminated, ResultCode)) or + (ResultCode <> 0) then + begin + MsgBox('Could not stop/remove the NetX Windows service or startup task. Uninstall has been cancelled to protect the installation. See ProgramData\\NetX\\data\\runtime\\service_lifecycle.log.', mbError, MB_OK); + Result := False; + Exit; + end; + end; end; procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep); @@ -1069,5 +1133,6 @@ begin UninstallKey := 'Software\Microsoft\Windows\CurrentVersion\Uninstall\{A7E3C2D1-9F40-4B8E-9C1A-NETXWIN64001}_is1'; RegDeleteValue(HKLM, UninstallKey, 'QuietUninstallString'); + ResumeNetxServiceAfterInstall(); end; end; diff --git a/packaging/manifest.example.json b/packaging/manifest.example.json index e8748ef..ab97808 100644 --- a/packaging/manifest.example.json +++ b/packaging/manifest.example.json @@ -1,11 +1,11 @@ { "channel": "stable", - "latest": "0.4.13", + "latest": "0.4.14", "min_compatible": "0.3.0", - "notes_url": "https://github.com/hansjone/netx/releases/tag/v0.4.13", + "notes_url": "https://github.com/hansjone/netx/releases/tag/v0.4.14", "windows": { - "url": "https://github.com/hansjone/netx/releases/download/v0.4.13/NetX-Setup-0.4.13.exe", - "setup_url": "https://github.com/hansjone/netx/releases/download/v0.4.13/NetX-Setup-0.4.13.exe", + "url": "https://github.com/hansjone/netx/releases/download/v0.4.14/NetX-Setup-0.4.14.exe", + "setup_url": "https://github.com/hansjone/netx/releases/download/v0.4.14/NetX-Setup-0.4.14.exe", "package": "setup", "sha256": "", "size": 0 diff --git a/packaging/publish_forgejo_release.ps1 b/packaging/publish_forgejo_release.ps1 index 0d633af..8e3df11 100644 --- a/packaging/publish_forgejo_release.ps1 +++ b/packaging/publish_forgejo_release.ps1 @@ -28,6 +28,7 @@ if (-not $Version) { $tag = "v$Version" $relDir = if ($ReleaseDir) { $ReleaseDir } else { Join-Path $PSScriptRoot "release" } $setup = Join-Path $relDir "NetX-Setup-$Version.exe" +$checksum = "$setup.sha256" if (-not $Token) { $Token = $env:NETX_FORGEJO_TOKEN @@ -39,7 +40,8 @@ if (-not $Token) { if (-not (Test-Path $setup)) { throw "missing_setup: $setup (run build_release.ps1 + ISCC first)" } - +$hash = (Get-FileHash -LiteralPath $setup -Algorithm SHA256).Hash.ToLowerInvariant() +Set-Content -LiteralPath $checksum -Value $hash -Encoding ascii -NoNewline $apiBase = "$ForgejoBase/api/v1/repos/$Owner/$Repo" $headers = @{ "Authorization" = "token $Token" @@ -122,6 +124,7 @@ function Upload-ForgejoAsset { } Upload-ForgejoAsset -Path $setup +Upload-ForgejoAsset -Path $checksum $releaseUrl = "$ForgejoBase/$Owner/$Repo/releases/tag/$tag" Write-Host "" diff --git a/packaging/publish_release.ps1 b/packaging/publish_release.ps1 index 122df57..28181a2 100644 --- a/packaging/publish_release.ps1 +++ b/packaging/publish_release.ps1 @@ -17,12 +17,20 @@ if (-not $Version) { $tag = "v$Version" $releaseDir = Join-Path $PSScriptRoot "release" $setup = Join-Path $releaseDir "NetX-Setup-$Version.exe" +$checksum = "$setup.sha256" +$stage = Join-Path $releaseDir "netx-win64" + +function Assert-NativeSuccess([string]$Operation) { + if ($LASTEXITCODE -ne 0) { throw "$Operation failed with exit code $LASTEXITCODE" } +} Write-Host "==> NetX publish $tag" if (-not $SkipBuild) { + if (Test-Path -LiteralPath $setup) { Remove-Item -LiteralPath $setup -Force } & powershell -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot "build_release.ps1") ` -Version $Version -CreateVenv + Assert-NativeSuccess 'Build stage' } if (-not $SkipInstaller) { @@ -38,8 +46,10 @@ if (-not $SkipInstaller) { throw "innosetup_not_found: install with winget install JRSoftware.InnoSetup" } Write-Host "==> Compiling installer: $iscc" + if (Test-Path -LiteralPath $setup) { Remove-Item -LiteralPath $setup -Force } & $iscc "/DMyAppVersion=$Version" (Join-Path $PSScriptRoot "installer\netx.iss") - if (-not (Test-Path $setup)) { + Assert-NativeSuccess 'Inno Setup compilation' + if (-not (Test-Path -LiteralPath $setup) -or (Get-Item -LiteralPath $setup).Length -lt 1MB) { throw "installer_build_failed: expected $setup" } Write-Host "==> Setup.exe: $setup" -ForegroundColor Green @@ -49,11 +59,26 @@ if (-not (Test-Path $setup)) { throw "missing_setup: $setup" } +$stageVersionPath = Join-Path $stage 'version.json' +if (-not (Test-Path -LiteralPath $stageVersionPath)) { throw "missing_release_stage: $stageVersionPath" } +$stageVersion = (Get-Content -LiteralPath $stageVersionPath -Raw | ConvertFrom-Json).version +if ([string]$stageVersion -ne $Version) { + throw "release_version_mismatch: requested=$Version stage=$stageVersion" +} +if ((Get-Item -LiteralPath $setup).LastWriteTimeUtc -lt (Get-Item -LiteralPath $stageVersionPath).LastWriteTimeUtc) { + throw "stale_installer: $setup predates the release stage; rebuild or recompile before publishing" +} + if ($Sign) { Write-Host "==> Signing release artifacts" & powershell -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot "sign_release.ps1") -Files @($setup) + Assert-NativeSuccess 'Code signing' } +$hash = (Get-FileHash -LiteralPath $setup -Algorithm SHA256).Hash.ToLowerInvariant() +Set-Content -LiteralPath $checksum -Value $hash -Encoding ascii -NoNewline +Write-Host "==> SHA-256 $hash" + if ($SkipGitHub) { Write-Host "==> Skip GitHub release (-SkipGitHub)" exit 0 @@ -72,7 +97,8 @@ $viewCode = $LASTEXITCODE $ErrorActionPreference = $prevEap if ($viewCode -eq 0) { Write-Host "==> Release $tag exists; uploading assets" - gh release upload $tag $setup --clobber + gh release upload $tag $setup $checksum --clobber + Assert-NativeSuccess 'GitHub asset upload' } else { $notes = @" ## NetX $Version @@ -99,8 +125,11 @@ See [packaging/README.md](https://github.com/hansjone/netx/blob/main/packaging/R $args = @("release", "create", $tag, "--title", "NetX $Version", "--notes", $notes) if ($Draft) { $args += "--draft" } $args += $setup + $args += $checksum & gh @args + Assert-NativeSuccess 'GitHub release creation' } Write-Host "==> Published $tag" -ForegroundColor Green gh release view $tag --web 2>$null +Assert-NativeSuccess 'GitHub release verification' diff --git a/packaging/service_lifecycle.ps1 b/packaging/service_lifecycle.ps1 new file mode 100644 index 0000000..1014ca2 --- /dev/null +++ b/packaging/service_lifecycle.ps1 @@ -0,0 +1,124 @@ +param( + [Parameter(Mandatory = $true)] + [ValidateSet('StopForUpdate', 'ResumeAfterUpdate', 'RemoveForUninstall')] + [string]$Action, + [string]$ProgramRoot = '', + [string]$DataRoot = '' +) + +$ErrorActionPreference = 'Stop' +. "$PSScriptRoot\_common.ps1" +$prog = Get-NetxProgramRoot -Override $ProgramRoot +$data = Get-NetxDataRoot -ProgramRoot $prog -Override $DataRoot +$runtime = Join-Path $data 'data\runtime' +$null = New-Item -ItemType Directory -Path $runtime -Force +$stateFile = Join-Path $runtime 'service-update-state.json' +$logFile = Join-Path $runtime 'service_lifecycle.log' + +function Write-LifecycleLog([string]$Message) { + Add-Content -LiteralPath $logFile -Value "$(Get-Date -Format o) $Message" -Encoding utf8 +} + +function Get-NetxTask { + try { return Get-ScheduledTask -TaskName 'NetXService' -TaskPath '\NetX\' -ErrorAction Stop } + catch { return $null } +} + +function Stop-NetxRunners([switch]$Remove) { + $state = @{ serviceWasRunning = $false; taskWasRunning = $false } + $svc = Get-Service -Name 'NetX' -ErrorAction SilentlyContinue + if ($svc) { + $state.serviceWasRunning = ($svc.Status -ne 'Stopped') + $state | ConvertTo-Json | Set-Content -LiteralPath $stateFile -Encoding utf8 + if ($svc.Status -ne 'Stopped') { + Write-LifecycleLog 'Stopping Windows service NetX' + Stop-Service -Name 'NetX' -Force -ErrorAction Stop + (Get-Service -Name 'NetX').WaitForStatus('Stopped', [TimeSpan]::FromSeconds(60)) + } + } + $task = Get-NetxTask + if ($task) { + $state.taskWasRunning = ([string]$task.State -eq 'Running') + $state | ConvertTo-Json | Set-Content -LiteralPath $stateFile -Encoding utf8 + if ($state.taskWasRunning) { + Write-LifecycleLog 'Stopping scheduled task NetXService' + Stop-ScheduledTask -TaskName 'NetXService' -TaskPath '\NetX\' -ErrorAction Stop + $deadline = (Get-Date).AddSeconds(60) + do { + Start-Sleep -Milliseconds 500 + $task = Get-NetxTask + } while ($task -and [string]$task.State -eq 'Running' -and (Get-Date) -lt $deadline) + if ($task -and [string]$task.State -eq 'Running') { throw 'NetXService scheduled task did not stop within 60 seconds.' } + } + } + if ($Remove) { + if ($svc) { + $winsw = Join-Path $prog 'packaging\winsw\NetX.exe' + if (Test-Path -LiteralPath $winsw) { + Write-LifecycleLog 'Uninstalling WinSW service' + & $winsw uninstall + if ($LASTEXITCODE -ne 0) { throw "WinSW uninstall failed: $LASTEXITCODE" } + } else { + throw "NetX service exists but WinSW executable is missing: $winsw" + } + } + if ($task) { + Unregister-ScheduledTask -TaskName 'NetXService' -TaskPath '\NetX\' -Confirm:$false -ErrorAction Stop + } + } + return $state +} + +try { + switch ($Action) { + 'StopForUpdate' { + $state = Stop-NetxRunners + $state | ConvertTo-Json | Set-Content -LiteralPath $stateFile -Encoding utf8 + $stopScript = Join-Path $prog 'packaging\stop_netx_app.ps1' + if (Test-Path -LiteralPath $stopScript) { + & powershell.exe -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File $stopScript ` + -ProgramRoot $prog -DataRoot $data -KillTray + if ($LASTEXITCODE -ne 0) { throw "NetX application stop failed: $LASTEXITCODE" } + } + } + 'ResumeAfterUpdate' { + if (-not (Test-Path -LiteralPath $stateFile)) { break } + $state = Get-Content -LiteralPath $stateFile -Raw | ConvertFrom-Json + if ($state.serviceWasRunning) { + Write-LifecycleLog 'Restarting Windows service NetX after update' + Start-Service -Name 'NetX' -ErrorAction Stop + } + if ($state.taskWasRunning) { + Write-LifecycleLog 'Restarting scheduled task NetXService after update' + Start-ScheduledTask -TaskName 'NetXService' -TaskPath '\NetX\' -ErrorAction Stop + } + Remove-Item -LiteralPath $stateFile -Force + } + 'RemoveForUninstall' { + $null = Stop-NetxRunners -Remove + $stopScript = Join-Path $prog 'packaging\stop_netx_app.ps1' + if (Test-Path -LiteralPath $stopScript) { + & powershell.exe -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File $stopScript ` + -ProgramRoot $prog -DataRoot $data -KillTray + if ($LASTEXITCODE -ne 0) { throw "NetX application stop failed: $LASTEXITCODE" } + } + } + } + Write-LifecycleLog "Action completed: $Action" + exit 0 +} catch { + Write-LifecycleLog "Action failed: $Action; $($_.Exception.Message)" + if ($Action -eq 'StopForUpdate' -and (Test-Path -LiteralPath $stateFile)) { + try { + $prior = Get-Content -LiteralPath $stateFile -Raw | ConvertFrom-Json + if ($prior.serviceWasRunning) { Start-Service -Name 'NetX' -ErrorAction Stop } + if ($prior.taskWasRunning) { Start-ScheduledTask -TaskName 'NetXService' -TaskPath '\NetX\' -ErrorAction Stop } + Remove-Item -LiteralPath $stateFile -Force + Write-LifecycleLog 'Restored service/task after update stop failure' + } catch { + Write-LifecycleLog "Could not restore service/task after update stop failure: $($_.Exception.Message)" + } + } + Write-Error $_ + exit 1 +} diff --git a/pyproject.toml b/pyproject.toml index 1a39136..7f924ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "netx-ops" -version = "0.4.13" +version = "0.4.14" description = "netx operations tool: alarm-centric workflows with REST API and stdio MCP" readme = "README.md" requires-python = ">=3.11"