Release ForgeFlow 0.6.1
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
Generated
+2
-2
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
};
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user