From 971896a1d5275750fc44946211fdd8dbab769a0d Mon Sep 17 00:00:00 2001 From: NuklearRabbit <145918611+NuklearRabbit@users.noreply.github.com> Date: Sat, 25 Jul 2026 06:18:11 +0200 Subject: [PATCH] Release ForgeFlow 0.6.1 --- CHANGELOG.md | 6 ++++ Publish-ForgeFlow-Release.ps1 | 61 ++++++++++++++++++++++++++++----- SOURCE_MANIFEST.txt | 19 +++++----- docs/RELEASE_NOTES_0.6.1.md | 10 ++++++ package-lock.json | 4 +-- package.json | 5 +-- scripts/apply-source-update.ps1 | 58 +++++++++++++++++++++++++------ scripts/verify.mjs | 8 +++-- src/main/update-service.cjs | 51 ++++++++++++++++++++------- tests/update-service.test.mjs | 56 ++++++++++++++++++++++++++++-- 10 files changed, 229 insertions(+), 49 deletions(-) create mode 100644 docs/RELEASE_NOTES_0.6.1.md diff --git a/CHANGELOG.md b/CHANGELOG.md index a839b8d..bf9114f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.6.1 + +- Fixed Windows PowerShell 5.1 updater status replacement and STARTED handshake. +- Added helper-log diagnostics and update-request identity validation. + + ## 0.6.0 - Added complete repository troubleshooting for stale `HEAD.lock`, `index.lock`, ref locks and diverged branches. diff --git a/Publish-ForgeFlow-Release.ps1 b/Publish-ForgeFlow-Release.ps1 index 07f92e0..258cf96 100644 --- a/Publish-ForgeFlow-Release.ps1 +++ b/Publish-ForgeFlow-Release.ps1 @@ -63,15 +63,58 @@ try { try { $installedManifest = Get-Content -LiteralPath $installedManifestPath -Raw | ConvertFrom-Json if ($installedManifest.name -eq "forgeflow" -and [string]$installedManifest.version -ne $version) { - $bootstrapSource = Join-Path $source "scripts\apply-source-update.ps1" - $bootstrapTarget = Join-Path $InstalledSource "scripts\apply-source-update.ps1" - $bootstrapText = Get-Content -LiteralPath $bootstrapSource -Raw - if ($bootstrapText.TrimStart() -notmatch '^param\(') { throw "The validated updater bootstrap does not start with param(." } - New-Item -ItemType Directory -Force -Path (Split-Path -Parent $bootstrapTarget) | Out-Null - Copy-Item -LiteralPath $bootstrapSource -Destination $bootstrapTarget -Force - $copiedText = Get-Content -LiteralPath $bootstrapTarget -Raw - if ($copiedText.TrimStart() -notmatch '^param\(') { throw "The updater bootstrap copy failed validation." } - Write-Host "Prepared the installed ForgeFlow $($installedManifest.version) updater helper without changing its version." -ForegroundColor Green + $helperSource = Join-Path $source "scripts\apply-source-update.ps1" + $helperTarget = Join-Path $InstalledSource "scripts\apply-source-update.ps1" + $serviceSource = Join-Path $source "src\main\update-service.cjs" + $serviceTarget = Join-Path $InstalledSource "src\main\update-service.cjs" + + $helperText = Get-Content -LiteralPath $helperSource -Raw + $serviceText = Get-Content -LiteralPath $serviceSource -Raw + if ($helperText.TrimStart() -notmatch '^param\(') { throw "The validated updater helper does not start with param(." } + if ($helperText -notmatch 'System\.IO\.File\]::Replace' -or $helperText -notmatch 'HandshakeOnly') { throw "The validated updater helper is missing the Windows status replacement fix." } + if ($serviceText -notmatch 'expectedUpdateId' -or $serviceText -notmatch 'readLogTail') { throw "The validated update service is missing the confirmed-handshake diagnostics." } + + New-Item -ItemType Directory -Force -Path (Split-Path -Parent $helperTarget) | Out-Null + New-Item -ItemType Directory -Force -Path (Split-Path -Parent $serviceTarget) | Out-Null + Copy-Item -LiteralPath $helperSource -Destination $helperTarget -Force + Copy-Item -LiteralPath $serviceSource -Destination $serviceTarget -Force + + $copiedHelper = Get-Content -LiteralPath $helperTarget -Raw + $copiedService = Get-Content -LiteralPath $serviceTarget -Raw + if ($copiedHelper.TrimStart() -notmatch '^param\(' -or $copiedHelper -notmatch 'System\.IO\.File\]::Replace') { throw "The updater helper bootstrap copy failed validation." } + if ($copiedService -notmatch 'expectedUpdateId' -or $copiedService -notmatch 'readLogTail') { throw "The update-service bootstrap copy failed validation." } + + # Prove the exact STARTED handshake on this Windows PowerShell version before + # asking the installed ForgeFlow to close itself for a real update. + $handshakeRoot = Join-Path ([IO.Path]::GetTempPath()) ("forgeflow-handshake-" + [guid]::NewGuid().ToString("N")) + New-Item -ItemType Directory -Force -Path $handshakeRoot | Out-Null + try { + $handshakeId = "publisher-" + [guid]::NewGuid().ToString("N") + $handshakeStatus = Join-Path $handshakeRoot "status.json" + $handshakeLog = Join-Path $handshakeRoot "helper.log" + $dummyArchive = Join-Path $handshakeRoot "unused.zip" + $launching = @{ schemaVersion = 1; updateId = $handshakeId; state = "launching" } | ConvertTo-Json + $utf8NoBom = New-Object System.Text.UTF8Encoding($false) + [System.IO.File]::WriteAllText($handshakeStatus, $launching, $utf8NoBom) + [System.IO.File]::WriteAllBytes($dummyArchive, [byte[]](0x50,0x4b,0x03,0x04)) + + $windowsRoot = if ($env:SystemRoot) { $env:SystemRoot } else { $env:WINDIR } + $powershellExe = if ($windowsRoot) { Join-Path $windowsRoot "System32\WindowsPowerShell\v1.0\powershell.exe" } else { "powershell.exe" } + & $powershellExe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $helperTarget ` + -SourcePath $InstalledSource -ArchivePath $dummyArchive -ExpectedVersion $version ` + -ExpectedSha256 ("0" * 64) -ParentPid 2147483647 -LogPath $handshakeLog ` + -StatusPath $handshakeStatus -UpdateId $handshakeId -HandshakeOnly + if ($LASTEXITCODE -ne 0) { throw "The installed update helper failed its Windows handshake self-test with exit code $LASTEXITCODE." } + $handshakeResult = Get-Content -LiteralPath $handshakeStatus -Raw | ConvertFrom-Json + if ($handshakeResult.state -ne "started" -or $handshakeResult.updateId -ne $handshakeId) { + $tail = if (Test-Path -LiteralPath $handshakeLog) { Get-Content -LiteralPath $handshakeLog -Tail 20 | Out-String } else { "No helper log was created." } + throw "The installed update helper did not replace the launching state with the expected STARTED marker. $tail" + } + } finally { + Remove-Item -LiteralPath $handshakeRoot -Recurse -Force -ErrorAction SilentlyContinue + } + + Write-Host "Prepared and Windows-tested the installed ForgeFlow $($installedManifest.version) updater bootstrap without changing its version." -ForegroundColor Green } } catch { throw "Release was published, but the installed updater bootstrap could not be prepared: $($_.Exception.Message)" diff --git a/SOURCE_MANIFEST.txt b/SOURCE_MANIFEST.txt index 97c33d4..ed96e48 100644 --- a/SOURCE_MANIFEST.txt +++ b/SOURCE_MANIFEST.txt @@ -1,15 +1,16 @@ -ForgeFlow 0.6.0 source manifest +ForgeFlow 0.6.1 source manifest SHA-256 BYTES PATH (The manifest excludes itself and generated release archives.) 755f4db7d76bfec0963ef051748a82810c0d58acd4ffd823aa6928a5167fceb4 58 .gitignore -aeb3772c830e37f23eca14c61d527e0021a69165e0c2bf7b9bb9374b7a409ec9 6195 CHANGELOG.md +82438ae208c8d8eaac56fcdc68e52efcfd7a57890198d3f8b8d8c69280b600a6 6359 CHANGELOG.md 4a561ead5ba7cdfaf4efce91842a4308c5f2a77980205879d83835efb8a579db 1067 LICENSE 217817c7e10a287f852735f412c25098c0983866d0b45769c828534912895c1a 347 OVERLAY-INSTRUCTIONS.md -581214b50ae378af44d773016ec7899d73e4027c523ab1f083590ae9171569fd 4927 Publish-ForgeFlow-Release.ps1 +fced7dd332ad944fac79bc3979e5a360d95c028ad0c91e1cd6d0abddf5822fbe 8281 Publish-ForgeFlow-Release.ps1 a94b84bb0c568b7c4f7df12f86a3fcca93ec2ae5ce25597e0a2d6feb6ea31106 13355 README.md 058aeaa5d9bfe377c7e322f213c7871ecc4151b5d08ef790992f4ee28d857658 743 START-FORGEFLOW-OVERLAY.ps1 c7b1ecc475931577a914c94a326fbf25ec4a4a742286f6f5c2bae8a38528f6c0 2098 START_HERE.md 8f36b542736f2933bad8b9464ad7fa37b68196009c81cf702ce3b677cd637dea 767 UPDATE_FROM_0.3.2.md +6337a7d0791e8749e3a576d7735257b0031db7a9eff4a2d6818cc251544774d2 1704 build-windows.ps1 0970821475a4452aa19e447e9397a95db836791f16890a1a83fd748ac033dc86 8830 build/icon-128.png 09112c1425ca953d8dd8b2bcfd221e5a84b9f81752f7168f360e295030cbc8f2 521 build/icon-16.png 510aa27935a63ad16cc22978ccfde3bdd441cb970ad42d9f05af52c0e5999195 28923 build/icon-256.png @@ -19,7 +20,6 @@ ca32a76e708d565c4af659f0f4d2615fc32114c3f75aec1454862a3ed1e72c41 2263 4633990a4b055bb3d00fef915ee29e85be5ee8413f809334728ad9688973c183 3364 build/icon-64.png 25048ed854e8ce8fece115e555c98d25507b002f8019b6ae717b54604c868c50 46223 build/icon.ico 16efd2fca83004f781eae40ae0f706a004ce0bddf338dd087b8adf7eb10c1d84 85704 build/icon.png -6337a7d0791e8749e3a576d7735257b0031db7a9eff4a2d6818cc251544774d2 1704 build-windows.ps1 2d9836ae6d576bab5494b9f094bc673e5ca4772bf5771006583d1bc46fe46698 8296 docs/ARCHITECTURE.md 30a92bcf5daadb019efa2f82cb820ea302490dd1d68fb772674dc3faccd3e594 2045 docs/DEPLOYMENT_SETUP.md eb42f979666e05d51c587e4223282914926a2b9b1ade9f3fb75525019ce7f738 4616 docs/DIAGNOSTICS.md @@ -41,6 +41,7 @@ d7d007e4c2807698db07b2ebe1cb48c36bd162bf4daad77c9d299096c9654d5a 721 3e77df12a7ff4b545069933410bf14fe8891f39915182df25112c722cf4e243d 1030 docs/RELEASE_NOTES_0.5.3.md 61f6cbc1c3f263fa96b5c6a70a26baa7cd577d37ac633455eed45d9b63169a35 710 docs/RELEASE_NOTES_0.5.4.md 9f72a5d039615785ccd4771f38b060bb58386b3217ba2a56459067cebbbb812f 4875 docs/RELEASE_NOTES_0.6.0.md +179860938908bc65b8ce8ca5019fd0a24e79b1eb88d368c9856191d48ec00d3a 675 docs/RELEASE_NOTES_0.6.1.md c465f1a9c4454c9a18f38f68a243037b8897c2c9929077a586604acd4ff26d35 3655 docs/ROADMAP.md 322624242d246d07180cc719e14c91e8fb69e123676a02e5046f4e576cca1ca1 5569 docs/SECURITY.md c79123aa4c718ac3ab0d79771f2967710c28f939b58fca0094b02e3172f2c024 13067 docs/SETUP_GUIDE.md @@ -62,12 +63,12 @@ c230b931abf2293d2d44b7a69b94c35f1142c093cc46b88739a0de5cbd6d1896 1532 106538d4a14a5a7b13419f9520c582b19809e8fafe2cb8c7dce2bc3e600dd10a 397 examples/server/nginx-forgeflow-status.conf 2dff25fb39ce8fc7844026a50524b23f241bec5b614eb05371c7f908a080f69a 398 examples/server/status-example.json 1e47552cfde3ca925ba1f24fbc471f3fc29468ca7ef3351beb9bde2bf0747bc7 7887 main.cjs -70ffe80e89f979ca1e72c6c1ea75b6456e685485aae53447ecbd4a4de16d104a 2708 package.json +1dc73c9ec2393a3aeb655ade78eb81a5a6946c2760884bc4c653cbef171896cf 2745 package.json 0cd434cb21af86e7f6983416e4ed14762565df90edc2d00d4a60378df75b7419 6846 preload.cjs -118c2600734d9a25310a148f791f8adeec793b186c3c3c764790c323c73ecfb6 8663 scripts/apply-source-update.ps1 +874cabc5ff1abce4e1ba6560f35d5956f9ec69f1302164169f0da56cc62ca954 10056 scripts/apply-source-update.ps1 f427dfcd7b5ee7079de13633c8d7d22a91115e0bbc4f2a9a96246f42176d4880 3596 scripts/doctor.mjs 444b397d515d65a7ee59d3088cba869cbb812d2b8cc18fc5d255105e3edb58c2 1468 scripts/serve-demo.mjs -4300ef0cee50d1aaf1f63be7f9884e67b2411b8f6743f48e5aa26fa724d4e428 9821 scripts/verify.mjs +4bd28f46cb2dda347e534b5d57a46425b9fe5862dd22cdb58b983d01b3174fcf 10161 scripts/verify.mjs 92524adae60aced3af23f8afe82c011873ae9f1e53d854e4d12a94e8d1be1aa9 2075 setup-windows.ps1 87885d640a1148078426522c87d7c9b7fced1fabb371020a4781bb94b256ee00 19664 src/main/config-store.cjs a970ff3f47d1641bf1ab9611e1122349aa65ff8fee4789585e078431368b8c6b 23655 src/main/deployment-service.cjs @@ -82,7 +83,7 @@ e89b54e7e3174b4b0a1dcd9058d8344e29431f9d16d0e6bb8d11559b691440a0 2508 a302bdcfbf2e2b66fdb4e13d94cc7a78cd4065a779b980f4487b6075a6472017 7583 src/main/repository-service.cjs ad9e8b67bd10f2f5708d00ebacf660ab4917dd02b3d22b3b6d730bcb2a7e1c18 8124 src/main/ssh-service.cjs a716f7402d3039296f99f1a7958a9aa521032f091e4171e224fa548d131a4183 49354 src/main/unraid-deployment-service.cjs -86f6b762e85de29dc36a53427f9f62f0fb21b2e389bd7ce6ad747e527c28e7e7 11322 src/main/update-service.cjs +709a6eb6a9b277cdc33f4d97558d2aca9d7ebe62a0e38fe52adc9dc3c9f94422 12791 src/main/update-service.cjs cea387e7996de8d185cd11f7d8d4e0675a58df1ca8f3cb6c3c6d6363c72f5f12 150880 src/renderer/app.js 16efd2fca83004f781eae40ae0f706a004ce0bddf338dd087b8adf7eb10c1d84 85704 src/renderer/assets/itworx-mark.png 813b8cdeecac43794166f3db9d3c5d2c441e0292f9ab7bd465ba136d6201e95d 82476 src/renderer/assets/itworx-wordmark-dark.png @@ -117,7 +118,7 @@ ecfdad2a03c24898c822fcf05abac89c8f8fe452a05b16fdafc0236a64c27a23 614 020eccfa9c4aef7a4ac4736d9af90518fcb6d1ad75aedcfaa1c92832a9e3d6d8 4609 tests/shell-verification.test.mjs 8a6a8477eb94b85ccef18cddd2640afb0d1eafa679c96bc7de20428d5d69e1be 1794 tests/tool-invocation.test.mjs 7c0f5268028cf8904b446c5d9c5a8b6450966f493018777256104df2626d0f3e 15662 tests/unraid-deployment.test.mjs -927a3c13ce054fa1210fd9f25e7030af0125b01b8fddd1790153f5f823fc1a64 7712 tests/update-service.test.mjs +44c82a2658f4286afb657c5952d12a6038c83f0adfe7ea0765e29af412a718dc 10233 tests/update-service.test.mjs 4d1f0a4c46190ca72b51fddf79ec6d4d02e65fa6f42ef3755de5d414f7da75bb 655 tests/validation.test.mjs 7ef4d4b9f5f3e6979293b29d571ce0e39f83197f3cade2d999a9cea7bacdd84d 1781 tests/zip-writer.test.mjs 3ea68269b66f639b3aba50c9605ccbfaa32c22a1d2cc842d8296c4ee59df6212 1537 update-windows.ps1 diff --git a/docs/RELEASE_NOTES_0.6.1.md b/docs/RELEASE_NOTES_0.6.1.md new file mode 100644 index 0000000..e922952 --- /dev/null +++ b/docs/RELEASE_NOTES_0.6.1.md @@ -0,0 +1,10 @@ +# ForgeFlow 0.6.1 + +## Windows source updater reliability + +- Fixes the Windows PowerShell 5.1 STARTED-handshake failure caused by replacing an already existing status file with `Move-Item -Force`. +- Uses `System.IO.File.Replace` with an overwrite-copy fallback for deterministic status persistence. +- Adds a handshake-only verification mode used by the local bootstrap overlay. +- Keeps ForgeFlow open when startup cannot be proven and now includes the helper log tail in the visible error. +- Requires the status `updateId` to match the current request, preventing an old status file from being accepted. +- Records the actual restart PID after a successful update or rollback. diff --git a/package-lock.json b/package-lock.json index 9ce3f2b..a39cbb1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "forgeflow", - "version": "0.6.0", + "version": "0.6.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "forgeflow", - "version": "0.6.0", + "version": "0.6.1", "dependencies": { "ssh2": "1.17.0" }, diff --git a/package.json b/package.json index 3a91a5f..7509025 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "forgeflow", - "version": "0.6.0", + "version": "0.6.1", "private": true, "description": "Desktop release cockpit for local Git, Gitea Actions and controlled exact-commit deployments.", "main": "main.cjs", @@ -62,7 +62,8 @@ "docs/RELEASE_NOTES_0.5.4.md", "START-FORGEFLOW-OVERLAY.ps1", "docs/RELEASE_NOTES_0.6.0.md", - "docs/RELEASE_AUDIT_0.6.0.md" + "docs/RELEASE_AUDIT_0.6.0.md", + "docs/RELEASE_NOTES_0.6.1.md" ], "directories": { "output": "dist" diff --git a/scripts/apply-source-update.ps1 b/scripts/apply-source-update.ps1 index e649c5b..ae8c978 100644 --- a/scripts/apply-source-update.ps1 +++ b/scripts/apply-source-update.ps1 @@ -6,7 +6,8 @@ param( [Parameter(Mandatory=$true)][int]$ParentPid, [Parameter(Mandatory=$true)][string]$LogPath, [Parameter(Mandatory=$true)][string]$StatusPath, - [Parameter(Mandatory=$true)][string]$UpdateId + [Parameter(Mandatory=$true)][string]$UpdateId, + [switch]$HandshakeOnly ) $ErrorActionPreference = "Stop" @@ -17,8 +18,9 @@ $backup = $null function Write-UpdateLog { param([string]$Message) $line = "$(Get-Date -Format o) $Message" - New-Item -ItemType Directory -Force -Path (Split-Path -Parent $LogPath) | Out-Null - Add-Content -Path $LogPath -Value $line -Encoding UTF8 + $directory = Split-Path -Parent $LogPath + if ($directory) { New-Item -ItemType Directory -Force -Path $directory | Out-Null } + Add-Content -LiteralPath $LogPath -Value $line -Encoding UTF8 } function Write-UpdateState { @@ -27,6 +29,7 @@ function Write-UpdateState { [string]$Message = "", [hashtable]$Extra = @{} ) + $payload = [ordered]@{ schemaVersion = 1 updateId = $UpdateId @@ -39,13 +42,28 @@ function Write-UpdateState { updatedAt = (Get-Date).ToUniversalTime().ToString("o") } foreach ($key in $Extra.Keys) { $payload[$key] = $Extra[$key] } + $directory = Split-Path -Parent $StatusPath - New-Item -ItemType Directory -Force -Path $directory | Out-Null + if ($directory) { New-Item -ItemType Directory -Force -Path $directory | Out-Null } $temporary = "$StatusPath.$PID.tmp" $json = $payload | ConvertTo-Json -Depth 8 $utf8NoBom = New-Object System.Text.UTF8Encoding($false) [System.IO.File]::WriteAllText($temporary, $json, $utf8NoBom) - Move-Item -LiteralPath $temporary -Destination $StatusPath -Force + + try { + if ([System.IO.File]::Exists($StatusPath)) { + # Windows PowerShell 5.1 does not reliably let Move-Item -Force replace + # an existing file. File.Replace is atomic on the local NTFS volume. + [System.IO.File]::Replace($temporary, $StatusPath, $null) + } else { + [System.IO.File]::Move($temporary, $StatusPath) + } + } catch { + # Some filesystems do not implement File.Replace. Copy with overwrite is + # the deterministic fallback; the temporary file is removed afterwards. + [System.IO.File]::Copy($temporary, $StatusPath, $true) + [System.IO.File]::Delete($temporary) + } } function Invoke-Robocopy { @@ -85,6 +103,11 @@ try { Write-UpdateLog "ForgeFlow source update helper started for version $ExpectedVersion." Write-UpdateState -State "started" -Message "The external update helper started successfully." -Extra @{ helperPid = $PID; startedAt = (Get-Date).ToUniversalTime().ToString("o") } + if ($HandshakeOnly) { + Write-UpdateLog "Handshake-only verification completed successfully." + exit 0 + } + Write-UpdateState -State "waiting-for-exit" -Message "Waiting for the running ForgeFlow process to exit." $deadline = (Get-Date).AddMinutes(2) while (Get-Process -Id $ParentPid -ErrorAction SilentlyContinue) { @@ -92,7 +115,7 @@ try { Start-Sleep -Milliseconds 500 } - $actualHash = (Get-FileHash -Path $ArchivePath -Algorithm SHA256).Hash.ToLowerInvariant() + $actualHash = (Get-FileHash -LiteralPath $ArchivePath -Algorithm SHA256).Hash.ToLowerInvariant() if ($actualHash -ne $ExpectedSha256.ToLowerInvariant()) { throw "Update archive checksum mismatch." } $working = Join-Path ([IO.Path]::GetTempPath()) ("forgeflow-update-" + [guid]::NewGuid().ToString("N")) @@ -143,6 +166,13 @@ try { try { $restart = Start-ForgeFlow -WorkingDirectory $SourcePath Write-UpdateLog "Update validated successfully. ForgeFlow was restarted directly with Electron PID $($restart.Id)." + Write-UpdateState -State "success" -Message "ForgeFlow $ExpectedVersion was installed and restarted successfully." -Extra @{ + installedVersion = $ExpectedVersion + completedAt = $completedAt + restartLaunched = $true + restartPid = $restart.Id + restartError = $null + } } catch { $restartError = $_.Exception.Message Write-UpdateLog "Update validated successfully, but automatic restart failed: $restartError" @@ -159,10 +189,10 @@ try { } catch { $failureMessage = $_.Exception.Message - Write-UpdateLog ("Update failed: " + $failureMessage) - Write-UpdateState -State "failed" -Message $failureMessage -Extra @{ failedAt = (Get-Date).ToUniversalTime().ToString("o") } + try { Write-UpdateLog ("Update failed: " + $failureMessage) } catch {} + try { Write-UpdateState -State "failed" -Message $failureMessage -Extra @{ failedAt = (Get-Date).ToUniversalTime().ToString("o") } } catch {} try { - if ($backup -and (Test-Path $backup)) { + if ($backup -and (Test-Path -LiteralPath $backup)) { Write-UpdateLog "Restoring previous source version." Invoke-Robocopy -From $backup -To $SourcePath Install-ForgeFlowDependencies -WorkingDirectory $SourcePath @@ -176,6 +206,12 @@ catch { try { $rollbackRestart = Start-ForgeFlow -WorkingDirectory $SourcePath Write-UpdateLog "Rollback restored and ForgeFlow restarted directly with Electron PID $($rollbackRestart.Id)." + Write-UpdateState -State "rolled-back" -Message $failureMessage -Extra @{ + completedAt = $rollbackCompletedAt + restartLaunched = $true + restartPid = $rollbackRestart.Id + restartError = $null + } } catch { $rollbackRestartError = $_.Exception.Message Write-UpdateLog ("Rollback restart failed: " + $rollbackRestartError) @@ -188,8 +224,8 @@ catch { } } } catch { - Write-UpdateLog ("Rollback failed: " + $_.Exception.Message) - Write-UpdateState -State "failed" -Message ("$failureMessage Rollback also failed: " + $_.Exception.Message) -Extra @{ completedAt = (Get-Date).ToUniversalTime().ToString("o") } + try { Write-UpdateLog ("Rollback failed: " + $_.Exception.Message) } catch {} + try { Write-UpdateState -State "failed" -Message ("$failureMessage Rollback also failed: " + $_.Exception.Message) -Extra @{ completedAt = (Get-Date).ToUniversalTime().ToString("o") } } catch {} } exit 1 } diff --git a/scripts/verify.mjs b/scripts/verify.mjs index 77cf057..e2f8ee7 100644 --- a/scripts/verify.mjs +++ b/scripts/verify.mjs @@ -19,7 +19,7 @@ const required = [ 'docs/ARCHITECTURE.md', 'docs/SECURITY.md', 'docs/ROADMAP.md', 'docs/SETUP_GUIDE.md', 'docs/UPDATING.md', 'docs/DIAGNOSTICS.md', 'docs/DEPLOYMENT_SETUP.md', 'docs/SSH_UNRAID_DEPLOYMENT.md', 'docs/LUMAOPS_SERVER_AUDIT.md', 'docs/STATUS_ENDPOINT.md', 'docs/TEST_MATRIX.md', 'docs/RELEASE_NOTES_0.4.0.md', 'docs/RELEASE_NOTES_0.4.1.md', 'docs/RELEASE_NOTES_0.4.2.md', 'docs/RELEASE_NOTES_0.4.3.md', - 'docs/RELEASE_AUDIT_0.6.0.md', 'docs/RELEASE_NOTES_0.5.0.md', 'docs/RELEASE_NOTES_0.5.1.md', 'docs/RELEASE_NOTES_0.5.2.md', 'docs/RELEASE_NOTES_0.5.3.md', 'docs/RELEASE_NOTES_0.5.4.md', 'docs/RELEASE_NOTES_0.6.0.md', + 'docs/RELEASE_AUDIT_0.6.0.md', 'docs/RELEASE_NOTES_0.6.1.md', 'docs/RELEASE_NOTES_0.5.0.md', 'docs/RELEASE_NOTES_0.5.1.md', 'docs/RELEASE_NOTES_0.5.2.md', 'docs/RELEASE_NOTES_0.5.3.md', 'docs/RELEASE_NOTES_0.5.4.md', 'docs/RELEASE_NOTES_0.6.0.md', 'Publish-ForgeFlow-Release.ps1', 'docs/RELEASE_NOTES_0.4.4.md', 'docs/RELEASE_NOTES_0.4.5.md', 'examples/gitea-actions/deploy.yml', 'examples/gitea-actions/rollback.yml', 'examples/server/forgeflow-deploy', 'examples/server/forgeflow-targets.conf', @@ -30,7 +30,7 @@ const required = [ for (const file of required) await access(path.join(root, file)); const packageJson = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8')); -if (packageJson.version !== '0.6.0') throw new Error(`Expected package version 0.6.0, got ${packageJson.version}.`); +if (packageJson.version !== '0.6.1') throw new Error(`Expected package version 0.6.1, got ${packageJson.version}.`); const sourceManifest = await readFile(path.join(root, 'SOURCE_MANIFEST.txt'), 'utf8'); if (!sourceManifest.startsWith(`ForgeFlow ${packageJson.version} source manifest\n`)) throw new Error('SOURCE_MANIFEST.txt does not match the package version.'); for (const group of ['dependencies', 'devDependencies']) { @@ -85,6 +85,7 @@ const setupGuide = await readFile(path.join(root, 'docs/SETUP_GUIDE.md'), 'utf8' const sshGuide = await readFile(path.join(root, 'docs/SSH_UNRAID_DEPLOYMENT.md'), 'utf8'); const audit = await readFile(path.join(root, 'docs/LUMAOPS_SERVER_AUDIT.md'), 'utf8'); const releaseNotes = await readFile(path.join(root, 'docs/RELEASE_NOTES_0.6.0.md'), 'utf8'); +const updaterReleaseNotes = await readFile(path.join(root, 'docs/RELEASE_NOTES_0.6.1.md'), 'utf8'); if (!setupGuide.includes('Gitea access token') || !setupGuide.includes('diagnostic bundle')) { throw new Error('Setup guide is missing required connection or diagnostics instructions.'); } @@ -97,6 +98,9 @@ if (!audit.includes('d42d4a7f08240c478d07466e3fabec654dc71367') || !audit.includ for (const phrase of ['DockerMan', 'HEAD.lock', 'deployment reconciliation', 'Portfolio', 'safety branch', 'high-contrast ITWorx']) { if (!releaseNotes.includes(phrase)) throw new Error(`Release notes are missing: ${phrase}`); } +for (const phrase of ['Windows PowerShell 5.1', 'File.Replace', 'handshake-only', 'updateId']) { + if (!updaterReleaseNotes.includes(phrase)) throw new Error(`Updater release notes are missing: ${phrase}`); +} const updateHelperPath = path.join(root, 'scripts/apply-source-update.ps1'); const updateHelperBytes = await readFile(updateHelperPath); if (updateHelperBytes[0] === 0xef && updateHelperBytes[1] === 0xbb && updateHelperBytes[2] === 0xbf) throw new Error('PowerShell update helper must not contain a UTF-8 BOM.'); diff --git a/src/main/update-service.cjs b/src/main/update-service.cjs index 3eacc16..5406ec6 100644 --- a/src/main/update-service.cjs +++ b/src/main/update-service.cjs @@ -31,28 +31,52 @@ async function readJsonFile(filePath) { catch { return null; } } +async function readLogTail(filePath, maxLines = 12) { + if (!filePath) return ''; + try { + const text = await fs.readFile(filePath, 'utf8'); + return text.split(/\r?\n/).filter(Boolean).slice(-maxLines).join('\n'); + } catch { return ''; } +} + +async function updaterStartupError(message, code, { statusPath, logPath, expectedUpdateId } = {}) { + const status = statusPath ? await readJsonFile(statusPath) : null; + const logTail = await readLogTail(logPath); + const details = []; + if (status?.updateId && expectedUpdateId && status.updateId !== expectedUpdateId) details.push('The helper wrote a status for a different update request.'); + if (status?.message) details.push(status.message); + if (logTail) details.push(`Update helper log:\n${logTail}`); + const error = new Error([message, ...details].filter(Boolean).join('\n\n')); + error.code = code; + error.status = status; + error.logPath = logPath || null; + return error; +} + async function waitForUpdaterStarted(statusPath, { - timeoutMs = 12000, + timeoutMs = 15000, pollMs = 100, - childState = null + childState = null, + expectedUpdateId = null, + logPath = null } = {}) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { const status = await readJsonFile(statusPath); - if (status && ['started', 'waiting-for-exit', 'backing-up', 'extracting', 'applying', 'validating'].includes(status.state)) { + const belongsToRequest = !expectedUpdateId || status?.updateId === expectedUpdateId; + if (status && belongsToRequest && ['started', 'waiting-for-exit', 'backing-up', 'extracting', 'applying', 'validating'].includes(status.state)) { return status; } + if (status && belongsToRequest && ['failed', 'rolled-back'].includes(status.state)) { + throw await updaterStartupError('The update helper reported a failure before ForgeFlow could close.', 'UPDATE_HELPER_START_FAILED', { statusPath, logPath, expectedUpdateId }); + } if (childState?.error) throw childState.error; if (childState?.exited) { - const error = new Error(`The update helper exited before it confirmed startup (exit code ${childState.code ?? 'unknown'}).`); - error.code = 'UPDATE_HELPER_EXITED_EARLY'; - throw error; + throw await updaterStartupError(`The update helper exited before it confirmed startup (exit code ${childState.code ?? 'unknown'}).`, 'UPDATE_HELPER_EXITED_EARLY', { statusPath, logPath, expectedUpdateId }); } await delay(pollMs); } - const error = new Error('The update helper did not confirm startup. ForgeFlow was left open and no source files were changed.'); - error.code = 'UPDATE_HELPER_START_TIMEOUT'; - throw error; + throw await updaterStartupError('The update helper did not confirm startup. ForgeFlow was left open and no source files were changed.', 'UPDATE_HELPER_START_TIMEOUT', { statusPath, logPath, expectedUpdateId }); } class UpdateService { @@ -227,12 +251,14 @@ class UpdateService { if (!child.once) finish(resolve); }); - child.unref?.(); const started = await waitForUpdaterStarted(statusPath, { timeoutMs: this.handshakeTimeoutMs, pollMs: this.handshakePollMs, - childState + childState, + expectedUpdateId: updateId, + logPath }); + child.unref?.(); await this.diagnostics?.info('updates.apply-started', { updateId, @@ -281,5 +307,6 @@ module.exports = { safeRepositoryPart, resolveWindowsPowerShellPath, waitForUpdaterStarted, - readJsonFile + readJsonFile, + readLogTail }; diff --git a/tests/update-service.test.mjs b/tests/update-service.test.mjs index b1159f8..f881dd4 100644 --- a/tests/update-service.test.mjs +++ b/tests/update-service.test.mjs @@ -71,7 +71,9 @@ test('source updater confirms an external STARTED marker before ForgeFlow may cl queueMicrotask(() => child.emit('spawn')); const statusIndex = args.indexOf('-StatusPath'); const statusPath = args[statusIndex + 1]; - setTimeout(() => writeFile(statusPath, JSON.stringify({ state: 'started', expectedVersion: '0.5.3' })), 30); + const updateIdIndex = args.indexOf('-UpdateId'); + const updateId = args[updateIdIndex + 1]; + setTimeout(() => writeFile(statusPath, JSON.stringify({ state: 'started', expectedVersion: '0.5.3', updateId })), 30); return child; }; @@ -154,7 +156,7 @@ test('PowerShell update helper starts with param and has no BOM or stray leading }); -test('release publisher verifies Gitea and bootstraps only the installed updater helper', async () => { +test('release publisher verifies Gitea and bootstraps the installed updater service and helper', async () => { const script = await readFile(new URL('../Publish-ForgeFlow-Release.ps1', import.meta.url), 'utf8'); assert.match(script, /npm install --no-audit --no-fund/); assert.match(script, /package-lock\.json/); @@ -163,7 +165,57 @@ test('release publisher verifies Gitea and bootstraps only the installed updater assert.match(script, /git ls-remote origin/); assert.match(script, /publishedCommit -ne \$localCommit/); assert.match(script, /scripts\\apply-source-update\.ps1/); + assert.match(script, /src\\main\\update-service\.cjs/); + assert.match(script, /expectedUpdateId/); + assert.match(script, /readLogTail/); + assert.match(script, /HandshakeOnly/); + assert.match(script, /handshakeResult\.state -ne "started"/); + assert.match(script, /Windows-tested/); assert.match(script, /without changing its version/); assert.doesNotMatch(script, /Copy-Item[^\n]+package\.json/); }); + +test('PowerShell helper replaces an existing launching status with a Windows-safe file API', async () => { + const script = await readFile(new URL('../scripts/apply-source-update.ps1', import.meta.url), 'utf8'); + assert.match(script, /System\.IO\.File\]::Replace\(\$temporary, \$StatusPath, \$null\)/); + assert.match(script, /System\.IO\.File\]::Copy\(\$temporary, \$StatusPath, \$true\)/); + assert.doesNotMatch(script, /Move-Item -LiteralPath \$temporary -Destination \$StatusPath -Force/); + assert.match(script, /\[switch\]\$HandshakeOnly/); + assert.match(script, /Handshake-only verification completed successfully/); +}); + +test('early helper exit reports the helper log instead of only an exit code', async () => { + const temp = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-update-log-tail-')); + const statusPath = path.join(temp, 'status.json'); + const logPath = path.join(temp, 'apply.log'); + await writeFile(statusPath, JSON.stringify({ state: 'launching', updateId: 'request-1' })); + await writeFile(logPath, 'first line\nactual helper failure\n'); + await assert.rejects( + () => waitForUpdaterStarted(statusPath, { + timeoutMs: 100, + pollMs: 5, + childState: { exited: true, code: 0, error: null }, + expectedUpdateId: 'request-1', + logPath + }), + (error) => error.code === 'UPDATE_HELPER_EXITED_EARLY' && /actual helper failure/.test(error.message) + ); + await rm(temp, { recursive: true, force: true }); +}); + +test('updater handshake rejects a stale status from another update request', async () => { + const temp = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-update-id-')); + const statusPath = path.join(temp, 'status.json'); + await writeFile(statusPath, JSON.stringify({ state: 'started', updateId: 'old-request' })); + await assert.rejects( + () => waitForUpdaterStarted(statusPath, { + timeoutMs: 50, + pollMs: 5, + childState: { exited: false, code: null, error: null }, + expectedUpdateId: 'new-request' + }), + (error) => error.code === 'UPDATE_HELPER_START_TIMEOUT' + ); + await rm(temp, { recursive: true, force: true }); +});