Release ForgeFlow 0.6.0
This commit is contained in:
+116
-13
@@ -4,11 +4,15 @@ param(
|
||||
[Parameter(Mandatory=$true)][string]$ExpectedVersion,
|
||||
[Parameter(Mandatory=$true)][string]$ExpectedSha256,
|
||||
[Parameter(Mandatory=$true)][int]$ParentPid,
|
||||
[Parameter(Mandatory=$true)][string]$LogPath
|
||||
[Parameter(Mandatory=$true)][string]$LogPath,
|
||||
[Parameter(Mandatory=$true)][string]$StatusPath,
|
||||
[Parameter(Mandatory=$true)][string]$UpdateId
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$ProgressPreference = "SilentlyContinue"
|
||||
$working = $null
|
||||
$backup = $null
|
||||
|
||||
function Write-UpdateLog {
|
||||
param([string]$Message)
|
||||
@@ -17,6 +21,33 @@ function Write-UpdateLog {
|
||||
Add-Content -Path $LogPath -Value $line -Encoding UTF8
|
||||
}
|
||||
|
||||
function Write-UpdateState {
|
||||
param(
|
||||
[Parameter(Mandatory=$true)][string]$State,
|
||||
[string]$Message = "",
|
||||
[hashtable]$Extra = @{}
|
||||
)
|
||||
$payload = [ordered]@{
|
||||
schemaVersion = 1
|
||||
updateId = $UpdateId
|
||||
state = $State
|
||||
expectedVersion = $ExpectedVersion
|
||||
sourcePath = $SourcePath
|
||||
logPath = $LogPath
|
||||
statusPath = $StatusPath
|
||||
message = $Message
|
||||
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
|
||||
$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
|
||||
}
|
||||
|
||||
function Invoke-Robocopy {
|
||||
param([string]$From, [string]$To)
|
||||
New-Item -ItemType Directory -Force -Path $To | Out-Null
|
||||
@@ -24,8 +55,37 @@ function Invoke-Robocopy {
|
||||
if ($LASTEXITCODE -gt 7) { throw "robocopy failed with exit code $LASTEXITCODE" }
|
||||
}
|
||||
|
||||
function Install-ForgeFlowDependencies {
|
||||
param([string]$WorkingDirectory)
|
||||
Push-Location $WorkingDirectory
|
||||
try {
|
||||
if (Test-Path -LiteralPath (Join-Path $WorkingDirectory "package-lock.json")) {
|
||||
Write-UpdateLog "Installing dependencies from package-lock.json with npm ci."
|
||||
& cmd.exe /d /s /c "npm ci --no-audit --no-fund" *>> $LogPath
|
||||
if ($LASTEXITCODE -ne 0) { throw "npm ci failed with exit code $LASTEXITCODE." }
|
||||
} else {
|
||||
Write-UpdateLog "No package-lock.json was supplied; installing pinned direct dependencies with npm install."
|
||||
& cmd.exe /d /s /c "npm install --no-audit --no-fund" *>> $LogPath
|
||||
if ($LASTEXITCODE -ne 0) { throw "npm install failed with exit code $LASTEXITCODE." }
|
||||
}
|
||||
} finally { Pop-Location }
|
||||
}
|
||||
|
||||
function Start-ForgeFlow {
|
||||
param([string]$WorkingDirectory)
|
||||
$electron = Join-Path $WorkingDirectory "node_modules\electron\dist\electron.exe"
|
||||
if (-not (Test-Path -LiteralPath $electron)) { throw "electron.exe was not found after dependency installation." }
|
||||
$process = Start-Process -FilePath $electron -WorkingDirectory $WorkingDirectory -ArgumentList @(".") -PassThru
|
||||
Start-Sleep -Milliseconds 1200
|
||||
if (-not $process -or $process.HasExited) { throw "ForgeFlow restart process exited before the application window could start." }
|
||||
return $process
|
||||
}
|
||||
|
||||
try {
|
||||
Write-UpdateLog "ForgeFlow source update started for version $ExpectedVersion."
|
||||
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") }
|
||||
|
||||
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) {
|
||||
if ((Get-Date) -gt $deadline) { throw "ForgeFlow did not exit before the update timeout." }
|
||||
@@ -39,10 +99,13 @@ try {
|
||||
$extract = Join-Path $working "extract"
|
||||
$backup = Join-Path $working "backup"
|
||||
New-Item -ItemType Directory -Force -Path $extract | Out-Null
|
||||
|
||||
Write-UpdateLog "Creating source backup."
|
||||
Write-UpdateState -State "backing-up" -Message "Creating a restorable backup of the current source."
|
||||
Invoke-Robocopy -From $SourcePath -To $backup
|
||||
|
||||
Write-UpdateLog "Extracting update archive."
|
||||
Write-UpdateState -State "extracting" -Message "Extracting the verified update archive."
|
||||
Expand-Archive -LiteralPath $ArchivePath -DestinationPath $extract -Force
|
||||
$manifest = Get-ChildItem -Path $extract -Filter package.json -File -Recurse |
|
||||
Where-Object {
|
||||
@@ -56,37 +119,77 @@ try {
|
||||
if (-not $manifest) { throw "The update does not contain ForgeFlow version $ExpectedVersion." }
|
||||
$incoming = Split-Path -Parent $manifest.FullName
|
||||
Write-UpdateLog "Applying verified source files."
|
||||
Write-UpdateState -State "applying" -Message "Replacing the local source with ForgeFlow $ExpectedVersion."
|
||||
Invoke-Robocopy -From $incoming -To $SourcePath
|
||||
|
||||
Write-UpdateState -State "validating" -Message "Installing dependencies and running the complete quality gate."
|
||||
Install-ForgeFlowDependencies -WorkingDirectory $SourcePath
|
||||
Push-Location $SourcePath
|
||||
try {
|
||||
Write-UpdateLog "Installing exact dependencies."
|
||||
& cmd.exe /d /s /c "npm install --no-audit --no-fund" *>> $LogPath
|
||||
if ($LASTEXITCODE -ne 0) { throw "npm install failed with exit code $LASTEXITCODE." }
|
||||
Write-UpdateLog "Running ForgeFlow quality gate."
|
||||
& cmd.exe /d /s /c "npm run check" *>> $LogPath
|
||||
if ($LASTEXITCODE -ne 0) { throw "npm run check failed with exit code $LASTEXITCODE." }
|
||||
} finally { Pop-Location }
|
||||
|
||||
Write-UpdateLog "Update validated successfully. Restarting ForgeFlow."
|
||||
Start-Process -FilePath "cmd.exe" -WorkingDirectory $SourcePath -ArgumentList "/d", "/s", "/c", "npm start"
|
||||
Remove-Item -LiteralPath $working -Recurse -Force -ErrorAction SilentlyContinue
|
||||
$completedAt = (Get-Date).ToUniversalTime().ToString("o")
|
||||
Write-UpdateState -State "success" -Message "ForgeFlow $ExpectedVersion was installed successfully." -Extra @{
|
||||
installedVersion = $ExpectedVersion
|
||||
completedAt = $completedAt
|
||||
restartLaunched = $true
|
||||
restartPid = $null
|
||||
restartError = $null
|
||||
}
|
||||
|
||||
try {
|
||||
$restart = Start-ForgeFlow -WorkingDirectory $SourcePath
|
||||
Write-UpdateLog "Update validated successfully. ForgeFlow was restarted directly with Electron PID $($restart.Id)."
|
||||
} catch {
|
||||
$restartError = $_.Exception.Message
|
||||
Write-UpdateLog "Update validated successfully, but automatic restart failed: $restartError"
|
||||
Write-UpdateState -State "success" -Message "ForgeFlow $ExpectedVersion was installed successfully, but must be started manually." -Extra @{
|
||||
installedVersion = $ExpectedVersion
|
||||
completedAt = $completedAt
|
||||
restartLaunched = $false
|
||||
restartPid = $null
|
||||
restartError = $restartError
|
||||
}
|
||||
}
|
||||
if ($working) { Remove-Item -LiteralPath $working -Recurse -Force -ErrorAction SilentlyContinue }
|
||||
exit 0
|
||||
}
|
||||
catch {
|
||||
Write-UpdateLog ("Update failed: " + $_.Exception.Message)
|
||||
$failureMessage = $_.Exception.Message
|
||||
Write-UpdateLog ("Update failed: " + $failureMessage)
|
||||
Write-UpdateState -State "failed" -Message $failureMessage -Extra @{ failedAt = (Get-Date).ToUniversalTime().ToString("o") }
|
||||
try {
|
||||
if ($backup -and (Test-Path $backup)) {
|
||||
Write-UpdateLog "Restoring previous source version."
|
||||
Invoke-Robocopy -From $backup -To $SourcePath
|
||||
Push-Location $SourcePath
|
||||
Install-ForgeFlowDependencies -WorkingDirectory $SourcePath
|
||||
$rollbackCompletedAt = (Get-Date).ToUniversalTime().ToString("o")
|
||||
Write-UpdateState -State "rolled-back" -Message $failureMessage -Extra @{
|
||||
completedAt = $rollbackCompletedAt
|
||||
restartLaunched = $true
|
||||
restartPid = $null
|
||||
restartError = $null
|
||||
}
|
||||
try {
|
||||
& cmd.exe /d /s /c "npm install --no-audit --no-fund" *>> $LogPath
|
||||
} finally { Pop-Location }
|
||||
Start-Process -FilePath "cmd.exe" -WorkingDirectory $SourcePath -ArgumentList "/d", "/s", "/c", "npm start"
|
||||
$rollbackRestart = Start-ForgeFlow -WorkingDirectory $SourcePath
|
||||
Write-UpdateLog "Rollback restored and ForgeFlow restarted directly with Electron PID $($rollbackRestart.Id)."
|
||||
} catch {
|
||||
$rollbackRestartError = $_.Exception.Message
|
||||
Write-UpdateLog ("Rollback restart failed: " + $rollbackRestartError)
|
||||
Write-UpdateState -State "rolled-back" -Message $failureMessage -Extra @{
|
||||
completedAt = $rollbackCompletedAt
|
||||
restartLaunched = $false
|
||||
restartPid = $null
|
||||
restartError = $rollbackRestartError
|
||||
}
|
||||
}
|
||||
}
|
||||
} 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") }
|
||||
}
|
||||
exit 1
|
||||
}
|
||||
|
||||
+29
-9
@@ -8,18 +8,18 @@ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const required = [
|
||||
'package.json', 'main.cjs', 'preload.cjs',
|
||||
'src/renderer/index.html', 'src/renderer/styles.css', 'src/renderer/app.js', 'src/renderer/mock-bridge.js',
|
||||
'src/renderer/assets/itworx-mark.png', 'src/renderer/assets/itworx-wordmark.png',
|
||||
'src/renderer/assets/itworx-mark.png', 'src/renderer/assets/itworx-wordmark.png', 'src/renderer/assets/itworx-wordmark-light.png', 'src/renderer/assets/itworx-wordmark-dark.png',
|
||||
'src/main/config-store.cjs', 'src/main/git-service.cjs', 'src/main/gitea-service.cjs',
|
||||
'src/main/repository-service.cjs', 'src/main/repository-monitor.cjs', 'src/main/deployment-service.cjs',
|
||||
'src/main/unraid-deployment-service.cjs', 'src/main/ssh-service.cjs', 'src/main/update-service.cjs',
|
||||
'src/main/diagnostics-service.cjs', 'src/main/preflight-service.cjs', 'src/main/log-redaction.cjs', 'src/main/ipc.cjs',
|
||||
'src/shared/clone-target.cjs', 'src/shared/semver.cjs', 'src/shared/zip-writer.cjs',
|
||||
'src/shared/tool-invocation.cjs', 'src/shared/shell-verification.cjs', 'START_HERE.md', 'README.md',
|
||||
'setup-windows.ps1', 'update-windows.ps1', 'build-windows.ps1', 'UPDATE_FROM_0.3.2.md', 'scripts/apply-source-update.ps1',
|
||||
'src/shared/tool-invocation.cjs', 'src/shared/shell-verification.cjs', 'START_HERE.md', 'README.md', 'SOURCE_MANIFEST.txt',
|
||||
'setup-windows.ps1', 'START-FORGEFLOW-OVERLAY.ps1', 'update-windows.ps1', 'build-windows.ps1', 'UPDATE_FROM_0.3.2.md', 'scripts/apply-source-update.ps1',
|
||||
'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_NOTES_0.5.0.md', 'docs/RELEASE_NOTES_0.5.1.md', 'docs/RELEASE_NOTES_0.5.2.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',
|
||||
'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,9 @@ 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.5.2') throw new Error(`Expected package version 0.5.2, got ${packageJson.version}.`);
|
||||
if (packageJson.version !== '0.6.0') throw new Error(`Expected package version 0.6.0, 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']) {
|
||||
for (const [name, version] of Object.entries(packageJson[group] || {})) {
|
||||
if (/^[~^*]/.test(version)) throw new Error(`${group} dependency ${name} must be pinned exactly, got ${version}.`);
|
||||
@@ -82,7 +84,7 @@ JSON.parse(await readFile(path.join(root, 'examples/server/status-example.json')
|
||||
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.5.2.md'), 'utf8');
|
||||
const releaseNotes = await readFile(path.join(root, 'docs/RELEASE_NOTES_0.6.0.md'), 'utf8');
|
||||
if (!setupGuide.includes('Gitea access token') || !setupGuide.includes('diagnostic bundle')) {
|
||||
throw new Error('Setup guide is missing required connection or diagnostics instructions.');
|
||||
}
|
||||
@@ -92,21 +94,39 @@ if (!sshGuide.includes('/mnt/user/appdata') || !sshGuide.includes('host-key fing
|
||||
if (!audit.includes('d42d4a7f08240c478d07466e3fabec654dc71367') || !audit.includes('source/')) {
|
||||
throw new Error('LumaOps audit is missing the exact matching SHA or nested repository finding.');
|
||||
}
|
||||
for (const phrase of ['viewport', '--pathspec-from-file', 'serialized per repository']) {
|
||||
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}`);
|
||||
}
|
||||
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.');
|
||||
const updateHelper = updateHelperBytes.toString('utf8');
|
||||
if (!updateHelper.trimStart().startsWith('param(') || updateHelper.trimStart().startsWith('\\')) throw new Error('PowerShell update helper must start directly with param(.');
|
||||
|
||||
const renderer = await readFile(path.join(root, 'src/renderer/app.js'), 'utf8');
|
||||
const styles = await readFile(path.join(root, 'src/renderer/styles.css'), 'utf8');
|
||||
const preload = await readFile(path.join(root, 'preload.cjs'), 'utf8');
|
||||
const ipc = await readFile(path.join(root, 'src/main/ipc.cjs'), 'utf8');
|
||||
for (const phrase of ['Commit selected & push to Gitea', 'checkForUpdates', 'saveServer', 'profile-provider', 'itworx-mark.png']) {
|
||||
for (const phrase of ['Commit selected & push to Gitea', 'checkForUpdates', 'saveServer', 'profile-provider', 'profile-icon-mode', 'itworx-mark.png', 'Repair DockerMan integration', 'Repository troubleshooting', 'repair-repository-sync']) {
|
||||
if (!renderer.includes(phrase) && !preload.includes(phrase)) throw new Error(`Frontend integration is missing: ${phrase}`);
|
||||
}
|
||||
if (!styles.includes('.file-list { flex: 1 1 auto;') || !styles.includes('.main-canvas.repository-canvas')) {
|
||||
throw new Error('Changed-file scrolling constraints are missing.');
|
||||
}
|
||||
for (const channel of ['updates:check', 'updates:download', 'updates:apply', 'server:save', 'server:test', 'server:inspect-project']) {
|
||||
for (const channel of ['updates:check', 'updates:download', 'updates:apply', 'server:save', 'server:test', 'server:inspect-project', 'repository:repair-git-locks', 'repository:repair-sync', 'deployment:apply-dockerman-metadata', 'deployment:reconcile']) {
|
||||
if (!ipc.includes(channel)) throw new Error(`IPC registration is missing: ${channel}`);
|
||||
}
|
||||
const gitSource = await readFile(path.join(root, 'src/main/git-service.cjs'), 'utf8');
|
||||
const unraidSource = await readFile(path.join(root, 'src/main/unraid-deployment-service.cjs'), 'utf8');
|
||||
const publisher = await readFile(path.join(root, 'Publish-ForgeFlow-Release.ps1'), 'utf8');
|
||||
for (const phrase of ['HEAD.lock', 'backup-reset', 'repairSync', "segments.includes('objects')"]) {
|
||||
if (!gitSource.includes(phrase)) throw new Error(`Git recovery implementation is missing: ${phrase}`);
|
||||
}
|
||||
for (const phrase of ['net.unraid.docker.managed', "'dockerman'", 'iconCacheRefresh', '[PORT:', 'Superseded by live commit']) {
|
||||
if (!unraidSource.includes(phrase)) throw new Error(`Unraid recovery implementation is missing: ${phrase}`);
|
||||
}
|
||||
for (const phrase of ['git ls-remote origin', 'apply-source-update.ps1', 'without changing its version']) {
|
||||
if (!publisher.includes(phrase)) throw new Error(`Publishing workflow is missing: ${phrase}`);
|
||||
}
|
||||
|
||||
console.log(`Verified ${required.length} required project files and ${javascriptFiles.length} JavaScript files for ForgeFlow ${packageJson.version}.`);
|
||||
|
||||
Reference in New Issue
Block a user