Release ForgeFlow 0.6.1

This commit is contained in:
NuklearRabbit
2026-07-25 06:18:11 +02:00
parent 9d3933c878
commit 971896a1d5
10 changed files with 229 additions and 49 deletions
+6
View File
@@ -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.
+52 -9
View File
@@ -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)"
+10 -9
View File
@@ -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
+10
View File
@@ -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.
+2 -2
View File
@@ -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"
},
+3 -2
View File
@@ -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"
+47 -11
View File
@@ -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
}
+6 -2
View File
@@ -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.');
+39 -12
View File
@@ -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
};
+54 -2
View File
@@ -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 });
});