Production-ready updater lifecycle hardening with final-head checks, Windows PowerShell 5.1 compatibility, reproducible package generation, provenance validation, payload rollback protection and documented acceptance.
156 lines
35 KiB
PowerShell
156 lines
35 KiB
PowerShell
[CmdletBinding()]
|
||
param(
|
||
[Parameter(Mandatory=$true)][string]$StatusFile,
|
||
[Parameter(Mandatory=$true)][string]$EventFile,
|
||
[Parameter(Mandatory=$true)][string]$InputFile,
|
||
[Parameter(Mandatory=$true)][string]$CancelFile,
|
||
[Parameter(Mandatory=$true)][string]$SessionFile,
|
||
[Parameter(Mandatory=$true)][string]$LogFile,
|
||
[switch]$SkipProductionDeploy,
|
||
[switch]$SelfTest,
|
||
[string]$SelfTestFailAt = ''
|
||
)
|
||
|
||
Set-StrictMode -Version 2.0
|
||
$ErrorActionPreference='Stop';$ProgressPreference='SilentlyContinue'
|
||
Add-Type -AssemblyName System.Net.Http
|
||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||
$PackageRoot=(Resolve-Path -LiteralPath (Join-Path $PSScriptRoot '..\..') -ErrorAction Stop).ProviderPath.TrimEnd([System.IO.Path]::DirectorySeparatorChar,[System.IO.Path]::AltDirectorySeparatorChar)
|
||
$Repository='Masterluke77/vendoo';$BaseBranch='main';$TargetVersion='1.43.0';$UpdaterVersion='3.4.0';$PublicBaseUrl='https://vendoo.flatlined.de'
|
||
$Branch="release/$TargetVersion-production-update-v4"
|
||
$LocalRoot=Join-Path $env:LOCALAPPDATA 'Vendoo\Updater';$ToolsRoot=Join-Path $LocalRoot 'tools';$LogsRoot=Join-Path $LocalRoot 'logs';$WorkRoot=Join-Path $LocalRoot "work\$TargetVersion-v4";$CloneDir=Join-Path $WorkRoot 'repository'
|
||
New-Item -ItemType Directory -Path $ToolsRoot,$LogsRoot,(Split-Path -Parent $SessionFile) -Force|Out-Null
|
||
$Stamp=Get-Date -Format 'yyyyMMdd-HHmmss';$script:Tools=@{};$script:CurrentStep='package';$script:LastHeartbeat=Get-Date;$script:LastCheckLog=[DateTime]::MinValue
|
||
$LifecycleScript=Join-Path $PackageRoot 'tools\updater-lifecycle-3.4.mjs'
|
||
$PayloadGuardScript=Join-Path $PackageRoot 'tools\check-release-payload-3.4.mjs'
|
||
$script:Steps=@(
|
||
[ordered]@{id='package';title='Paket prüfen';state='pending'},[ordered]@{id='tools';title='System prüfen';state='pending'},[ordered]@{id='github';title='GitHub verbinden';state='pending'},
|
||
[ordered]@{id='production';title='Zielzustand erkennen';state='pending'},[ordered]@{id='workspace';title='Payload sicher vorbereiten';state='pending'},[ordered]@{id='tests';title='Vollständig testen';state='pending'},
|
||
[ordered]@{id='pullrequest';title='Pull Request abgleichen';state='pending'},[ordered]@{id='merge';title='Finalen Head prüfen und mergen';state='pending'},[ordered]@{id='deploy';title='Coolify deployen';state='pending'},[ordered]@{id='verify';title='Produktion bestätigen';state='pending'}
|
||
)
|
||
|
||
function Write-Event([string]$Level,[string]$Message){
|
||
$safe=$Message -replace '(?i)(https://[^\s]*(?:token|webhook)[^\s]*)','[VERDECKT]'
|
||
$entry=[ordered]@{at=(Get-Date).ToString('o');level=$Level.ToUpperInvariant();message=$safe}|ConvertTo-Json -Compress
|
||
[IO.File]::AppendAllText($EventFile,$entry+[Environment]::NewLine,(New-Object Text.UTF8Encoding($false)))
|
||
[IO.File]::AppendAllText($LogFile,"$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') [$($Level.ToUpperInvariant())] $safe$([Environment]::NewLine)",(New-Object Text.UTF8Encoding($false)))
|
||
}
|
||
function Write-State([int]$Percent,[string]$Phase,[string]$Detail,[string]$State='running',[string]$Error='',[string]$Remediation='', $Request=$null){
|
||
$payload=[ordered]@{version=$TargetVersion;updaterVersion=$UpdaterVersion;percent=[Math]::Max(0,[Math]::Min(100,$Percent));phase=$Phase;detail=$Detail;state=$State;error=$Error;remediation=$Remediation;updatedAt=(Get-Date).ToString('o');logFile=$LogFile;sessionFile=$SessionFile;steps=$script:Steps;request=$Request}|ConvertTo-Json -Depth 10
|
||
$tmp="$StatusFile.$PID.tmp";[IO.File]::WriteAllText($tmp,$payload,(New-Object Text.UTF8Encoding($false)));Move-Item -LiteralPath $tmp -Destination $StatusFile -Force
|
||
}
|
||
function Set-Step([string]$Id,[string]$State){foreach($s in $script:Steps){if($s.id -eq $Id){$s.state=$State}elseif($State -eq 'active' -and $s.state -eq 'active'){$s.state='pending'}};$script:CurrentStep=$Id}
|
||
function Complete-Step([string]$Id){foreach($s in $script:Steps){if($s.id -eq $Id){$s.state='done'}}}
|
||
function Skip-Step([string]$Id){foreach($s in $script:Steps){if($s.id -eq $Id){$s.state='skipped'}}}
|
||
function Fail-Step{foreach($s in $script:Steps){if($s.id -eq $script:CurrentStep){$s.state='failed'}}}
|
||
function Assert-NotCancelled{if(Test-Path -LiteralPath $CancelFile){throw [System.OperationCanceledException]::new('Das Update wurde durch den Benutzer abgebrochen.')}}
|
||
function Add-OrSetProperty($Object,[string]$Name,$Value){if($Object.PSObject.Properties.Name -contains $Name){$Object.$Name=$Value}else{$Object|Add-Member -NotePropertyName $Name -NotePropertyValue $Value}}
|
||
function Save-Session($Data){
|
||
Add-OrSetProperty $Data 'schema' 'vendoo-updater-session-v2';Add-OrSetProperty $Data 'repository' $Repository;Add-OrSetProperty $Data 'targetVersion' $TargetVersion;Add-OrSetProperty $Data 'updaterVersion' $UpdaterVersion;Add-OrSetProperty $Data 'payloadHash' $script:Manifest.payloadHash;Add-OrSetProperty $Data 'branch' $Branch;Add-OrSetProperty $Data 'updatedAt' (Get-Date).ToString('o')
|
||
$tmp="$SessionFile.tmp";$json=$Data|ConvertTo-Json -Depth 10;[IO.File]::WriteAllText($tmp,$json,(New-Object Text.UTF8Encoding($false)));Move-Item $tmp $SessionFile -Force
|
||
}
|
||
function New-Session{return [pscustomobject]@{schema='vendoo-updater-session-v2';repository=$Repository;targetVersion=$TargetVersion;updaterVersion=$UpdaterVersion;payloadHash=[string]$script:Manifest.payloadHash;branch=$Branch;releaseState='new';prNumber=$null;prUrl='';finalPrHeadSha='';mergeCommit='';productionVersion='';completedAt='';updatedAt=''}}
|
||
function Load-Session{
|
||
if(Test-Path -LiteralPath $SessionFile){try{$saved=Get-Content $SessionFile -Raw -Encoding UTF8|ConvertFrom-Json;if([string]$saved.schema-eq'vendoo-updater-session-v2' -and [string]$saved.repository-eq$Repository -and [string]$saved.targetVersion-eq$TargetVersion -and [string]$saved.payloadHash-eq[string]$script:Manifest.payloadHash){return $saved};Write-Event 'INFO' 'Veralteter oder anderer Updater-Sitzungsstand wird nicht übernommen.'}catch{Write-Event 'WARN' 'Gespeicherter Sitzungsstand ist ungültig und wird verworfen.'}}
|
||
return New-Session
|
||
}
|
||
function Quote-Arg([string]$Value){if($null -eq $Value){return '""'};if($Value -notmatch '[\s"]'){return $Value};return '"'+($Value -replace '(\\*)"','$1$1\"' -replace '(\\+)$','$1$1')+'"'}
|
||
function Stop-Tree([int]$ProcessId){try{& taskkill.exe /PID $ProcessId /T /F 2>$null|Out-Null}catch{try{Stop-Process -Id $ProcessId -Force -ErrorAction SilentlyContinue}catch{}}}
|
||
function Get-TailText([string]$Text,[int]$MaxLength){if([string]::IsNullOrWhiteSpace($Text)){return ''};$value=$Text.Trim();if($value.Length -gt $MaxLength){return $value.Substring($value.Length-$MaxLength)};return $value}
|
||
function Get-ProcessFailureSummary([string]$StdErr,[string]$StdOut){$parts=New-Object Collections.Generic.List[string];$e=Get-TailText $StdErr 2200;$o=Get-TailText $StdOut 1200;if($e){$parts.Add("Fehlerausgabe:`n$e")};if($o){$parts.Add("Letzte Standardausgabe:`n$o")};if($parts.Count-eq0){return 'Der Prozess lieferte keine Diagnoseausgabe.'};return($parts-join"`n`n")}
|
||
function Invoke-External{
|
||
param([string]$File,[string[]]$Arguments,[string]$WorkingDirectory=$PackageRoot,[int]$TimeoutSeconds=120,[switch]$AllowFailure,[string]$InputText='',[string]$Label='Prozess',[switch]$Quiet)
|
||
Assert-NotCancelled
|
||
$psi=New-Object Diagnostics.ProcessStartInfo;$psi.FileName=$File;$psi.Arguments=(@($Arguments|ForEach-Object{Quote-Arg([string]$_)})-join' ');$psi.WorkingDirectory=$WorkingDirectory;$psi.UseShellExecute=$false;$psi.CreateNoWindow=$true;$psi.RedirectStandardOutput=$true;$psi.RedirectStandardError=$true;$psi.RedirectStandardInput=$true
|
||
$utf8=New-Object Text.UTF8Encoding($false);if($psi.PSObject.Properties.Name-contains'StandardOutputEncoding'){$psi.StandardOutputEncoding=$utf8};if($psi.PSObject.Properties.Name-contains'StandardErrorEncoding'){$psi.StandardErrorEncoding=$utf8}
|
||
$psi.EnvironmentVariables['NO_COLOR']='1';$psi.EnvironmentVariables['FORCE_COLOR']='0';$psi.EnvironmentVariables['NPM_CONFIG_COLOR']='false';$psi.EnvironmentVariables['NPM_CONFIG_UNICODE']='true'
|
||
$p=New-Object Diagnostics.Process;$p.StartInfo=$psi;if(-not$Quiet){Write-Event 'INFO' "$Label gestartet."};if(-not$p.Start()){throw "$Label konnte nicht gestartet werden."};$outTask=$p.StandardOutput.ReadToEndAsync();$errTask=$p.StandardError.ReadToEndAsync();if($InputText){$p.StandardInput.Write($InputText)};$p.StandardInput.Close();$sw=[Diagnostics.Stopwatch]::StartNew()
|
||
while(-not$p.WaitForExit(500)){if(Test-Path -LiteralPath $CancelFile){Stop-Tree $p.Id;throw [System.OperationCanceledException]::new('Das Update wurde durch den Benutzer abgebrochen.')};if($sw.Elapsed.TotalSeconds-gt$TimeoutSeconds){Stop-Tree $p.Id;throw "$Label hat das Zeitlimit von $TimeoutSeconds Sekunden überschritten."};if(-not$Quiet-and((Get-Date)-$script:LastHeartbeat).TotalSeconds-ge8){Write-Event 'INFO' ("$Label läuft seit {0:mm\:ss}."-f$sw.Elapsed);$script:LastHeartbeat=Get-Date}}
|
||
$p.WaitForExit();$stdout=[string]$outTask.Result;$stderr=[string]$errTask.Result;$code=$p.ExitCode;$p.Dispose()
|
||
if(-not$Quiet){foreach($line in @($stdout-split"`r?`n")){if($line.Trim()){Write-Event 'OUTPUT' $line.Trim()}};foreach($line in @($stderr-split"`r?`n")){$clean=$line.Trim();if(-not$clean){continue};if($code-ne0){Write-Event 'ERROR' $clean}elseif($clean-match'(?i)\b(warn|warning|deprecated)\b'){Write-Event 'WARN' $clean}else{Write-Event 'OUTPUT' $clean}}}
|
||
if(-not$AllowFailure-and$code-ne0){throw "$Label ist mit Exit-Code $code fehlgeschlagen.`n$(Get-ProcessFailureSummary $stderr $stdout)"};return[pscustomobject]@{Code=$code;Output=$stdout;Error=$stderr}
|
||
}
|
||
function Resolve-Tool([string]$Name,[string[]]$Candidates=@()){if($script:Tools.ContainsKey($Name)-and(Test-Path -LiteralPath $script:Tools[$Name])){return$script:Tools[$Name]};foreach($c in @($Candidates)){if($c-and(Test-Path -LiteralPath $c -PathType Leaf)){$script:Tools[$Name]=(Resolve-Path $c).Path;return$script:Tools[$Name]}};$cmd=Get-Command $Name -CommandType Application -ErrorAction SilentlyContinue|Select-Object -First 1;if($cmd-and$cmd.Source-and(Test-Path $cmd.Source)){$script:Tools[$Name]=$cmd.Source;return$cmd.Source};return$null}
|
||
function Get-GitPath{return Resolve-Tool 'git.exe' @("$env:ProgramFiles\Git\cmd\git.exe","$env:LOCALAPPDATA\Programs\Git\cmd\git.exe")}
|
||
function Get-NodePath{return Resolve-Tool 'node.exe' @("$env:ProgramFiles\nodejs\node.exe","$env:LOCALAPPDATA\Programs\nodejs\node.exe")}
|
||
function Get-NpmPath{return Resolve-Tool 'npm.cmd' @("$env:ProgramFiles\nodejs\npm.cmd","$env:LOCALAPPDATA\Programs\nodejs\npm.cmd")}
|
||
function Invoke-WebJson([string]$Uri){for($i=1;$i-le3;$i++){try{return Invoke-RestMethod -Uri $Uri -Headers @{'User-Agent'="Vendoo-Updater/$UpdaterVersion";'Accept'='application/vnd.github+json'} -TimeoutSec 30}catch{if($i-eq3){throw};Start-Sleep -Seconds(2*$i)}}}
|
||
function Download-File([string]$Uri,[string]$Destination,[string]$Label){for($attempt=1;$attempt-le3;$attempt++){try{$handler=New-Object Net.Http.HttpClientHandler;$client=[Net.Http.HttpClient]::new($handler);$client.Timeout=[TimeSpan]::FromMinutes(5);$client.DefaultRequestHeaders.UserAgent.ParseAdd("Vendoo-Updater/$UpdaterVersion");$resp=$client.GetAsync($Uri,[Net.Http.HttpCompletionOption]::ResponseHeadersRead).Result;$null=$resp.EnsureSuccessStatusCode();$input=$resp.Content.ReadAsStreamAsync().Result;$output=[IO.File]::Open($Destination,[IO.FileMode]::Create,[IO.FileAccess]::Write,[IO.FileShare]::None);try{$buf=New-Object byte[] 65536;while(($read=$input.Read($buf,0,$buf.Length))-gt0){Assert-NotCancelled;$output.Write($buf,0,$read)}}finally{$output.Dispose();$input.Dispose();$resp.Dispose();$client.Dispose();$handler.Dispose()};return}catch{if($attempt-eq3){throw "$Label konnte nach drei Versuchen nicht geladen werden: $($_.Exception.Message)"};Start-Sleep -Seconds(3*$attempt)}}}
|
||
function Ensure-GitHubCli{
|
||
$managed=Join-Path $ToolsRoot 'github-cli\current\bin\gh.exe';if(Test-Path $managed){$test=Invoke-External $managed @('--version') -TimeoutSeconds 20 -AllowFailure -Label 'GitHub CLI prüfen' -Quiet;if($test.Code-eq0){$script:Tools['gh.exe']=$managed;return$managed}}
|
||
Write-State 18 'GitHub CLI' 'Die offizielle portable GitHub CLI wird sicher geladen und per SHA-256 geprüft.';$release=Invoke-WebJson 'https://api.github.com/repos/cli/cli/releases/latest';$arch=if([Environment]::Is64BitOperatingSystem){'amd64'}else{'386'};$asset=@($release.assets|Where-Object{$_.name-like"gh_*_windows_$arch.zip"}|Select-Object -First 1);$checks=@($release.assets|Where-Object{$_.name-like'*_checksums.txt'}|Select-Object -First 1);if(-not$asset-or-not$checks){throw'Die offiziellen GitHub-CLI-Release-Dateien konnten nicht ermittelt werden.'}
|
||
$tmp=Join-Path $env:TEMP "vendoo-gh-$PID";New-Item -ItemType Directory -Path $tmp -Force|Out-Null;$zip=Join-Path $tmp $asset.name;$checkFile=Join-Path $tmp $checks.name;$extract=Join-Path $tmp 'extract'
|
||
try{Download-File $asset.browser_download_url $zip 'GitHub CLI';Download-File $checks.browser_download_url $checkFile 'Prüfsummen';$line=Get-Content $checkFile|Where-Object{$_-match[regex]::Escape($asset.name)}|Select-Object -First 1;if(-not$line){throw'Für das GitHub-CLI-Archiv wurde keine offizielle Prüfsumme gefunden.'};$expected=($line-split'\s+')[0].ToLowerInvariant();$actual=(Get-FileHash $zip -Algorithm SHA256).Hash.ToLowerInvariant();if($actual-ne$expected){throw'SHA-256-Prüfung der GitHub CLI fehlgeschlagen.'};New-Item -ItemType Directory -Path $extract -Force|Out-Null;Expand-Archive $zip $extract -Force;$source=Get-ChildItem $extract -Filter gh.exe -File -Recurse|Select-Object -First 1;if(-not$source){throw'Das geprüfte GitHub-CLI-Archiv enthält keine gh.exe.'};$targetRoot=Split-Path -Parent(Split-Path -Parent $managed);if(Test-Path $targetRoot){Remove-Item $targetRoot -Recurse -Force};New-Item -ItemType Directory -Path(Split-Path -Parent $managed)-Force|Out-Null;Copy-Item $source.FullName $managed -Force;$null=Invoke-External $managed @('--version') -TimeoutSeconds 20 -Label 'GitHub CLI validieren';$script:Tools['gh.exe']=$managed;Write-Event 'OK' "GitHub CLI $($release.tag_name) wurde geprüft und lokal bereitgestellt.";return$managed}finally{Remove-Item $tmp -Recurse -Force -ErrorAction SilentlyContinue}
|
||
}
|
||
function Test-GitHubAuthentication([string]$GhPath){$status=Invoke-External $GhPath @('auth','status','--hostname','github.com') -TimeoutSeconds 20 -AllowFailure -Label 'GitHub-Anmeldung prüfen' -Quiet;return($status.Code-eq0)}
|
||
function Invoke-VisibleGitHubLogin([string]$GhPath){
|
||
$runtimeDirectory=Split-Path -Parent $StatusFile;$loginScript=Join-Path $runtimeDirectory 'github-login.cmd';$escapedGh=$GhPath.Replace('"','""');$lines=@('@echo off','chcp 65001 >nul','title Vendoo - GitHub Anmeldung','echo Vendoo - Sichere GitHub Anmeldung','echo.','echo Folge dem Browserdialog zur GitHub-Anmeldung.','echo.',('"{0}" auth login --hostname github.com --git-protocol https --web --clipboard'-f$escapedGh),'exit /b %ERRORLEVEL%');[IO.File]::WriteAllLines($loginScript,$lines,(New-Object Text.UTF8Encoding($false)));Write-State 26 'GitHub-Anmeldung' 'Im geöffneten Fenster den Gerätecode bestätigen.' 'waiting';$loginProcess=Start-Process -FilePath 'cmd.exe' -ArgumentList @('/d','/c',('"{0}"'-f$loginScript))-WorkingDirectory $runtimeDirectory -WindowStyle Normal -PassThru;$deadline=(Get-Date).AddMinutes(10);while((Get-Date)-lt$deadline){Assert-NotCancelled;if(Test-GitHubAuthentication $GhPath){return};if($loginProcess.HasExited){throw'Die GitHub-Anmeldung wurde nicht abgeschlossen.'};Start-Sleep -Seconds 2};throw'Die GitHub-Anmeldung wurde nicht innerhalb von zehn Minuten abgeschlossen.'
|
||
}
|
||
function Check-ProductionVersion{try{$r=Invoke-RestMethod -Uri "$PublicBaseUrl/readyz?updater=$Stamp" -TimeoutSec 20 -Headers @{'Cache-Control'='no-cache';'User-Agent'="Vendoo-Updater/$UpdaterVersion"};return[pscustomobject]@{Ready=[bool]$r.ok;Version=[string]$r.version}}catch{return[pscustomobject]@{Ready=$false;Version='unbekannt'}}}
|
||
function Invoke-Lifecycle([string]$Command,$InputObject){$json=$InputObject|ConvertTo-Json -Depth 20 -Compress;$r=Invoke-External $script:Node @($LifecycleScript,$Command)-InputText $json -TimeoutSeconds 60 -Label "Lifecycle $Command" -Quiet;return($r.Output|ConvertFrom-Json)}
|
||
function Assert-Package{
|
||
foreach($f in @('package.json','package-lock.json','update-manifest.json','release-manifest.json','db\schema.sql','tools\apply-release-manifest.mjs','tools\updater-lifecycle-3.4.mjs','tools\check-release-payload-3.4.mjs')){if(-not(Test-Path(Join-Path $PackageRoot $f))){throw"Paketdatei fehlt: $f"}}
|
||
$p=Get-Content(Join-Path $PackageRoot 'package.json')-Raw-Encoding UTF8|ConvertFrom-Json;$m=Get-Content(Join-Path $PackageRoot 'update-manifest.json')-Raw-Encoding UTF8|ConvertFrom-Json;$r=Get-Content(Join-Path $PackageRoot 'release-manifest.json')-Raw-Encoding UTF8|ConvertFrom-Json
|
||
if([string]$p.version-ne$TargetVersion-or[string]$m.version-ne$TargetVersion-or[string]$r.targetVersion-ne$TargetVersion){throw"Paket-, Update- und Release-Manifest-Version müssen exakt $TargetVersion sein."};if([string]$r.schema-ne'vendoo-release-package-v2'){if(-not$SelfTest){throw"Auslieferbares Paket benötigt Manifest-Schema vendoo-release-package-v2, gefunden: $($r.schema)"}};if([string]$r.updaterVersion-ne$UpdaterVersion){throw"Release-Manifest gehört zu Updater $($r.updaterVersion), erwartet wird $UpdaterVersion."};foreach($name in @('sourceCommit','baseCommit','createdAt','payloadHash')){if([string]::IsNullOrWhiteSpace([string]$r.$name)){if(-not$SelfTest){throw"Release-Manifest enthält keine gültige Herkunft: $name fehlt."}}};$script:Manifest=$r
|
||
}
|
||
function Get-MainVersion([string]$GhPath){$encoded=(Invoke-External $GhPath @('api',"repos/$Repository/contents/package.json?ref=$BaseBranch",'--jq','.content')-TimeoutSeconds 60 -Label 'main-Version lesen' -Quiet).Output -replace'\s','';$text=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($encoded));return[string](($text|ConvertFrom-Json).version)}
|
||
function Get-ReleaseObservation([string]$GhPath){
|
||
$sha=(Invoke-External $GhPath @('api',"repos/$Repository/commits/$BaseBranch",'--jq','.sha')-TimeoutSeconds 60 -Label 'main-SHA lesen' -Quiet).Output.Trim();$version=Get-MainVersion $GhPath;$prod=Check-ProductionVersion
|
||
$prResult=Invoke-External $GhPath @('pr','list','--repo',$Repository,'--head',$Branch,'--state','all','--limit','100','--json','number,url,state,mergedAt,closedAt,updatedAt,headRefOid,title')-TimeoutSeconds 60 -AllowFailure -Label 'Release-PRs ermitteln' -Quiet;$prs=@();if($prResult.Code-eq0-and$prResult.Output.Trim()){$raw=@($prResult.Output|ConvertFrom-Json);foreach($pr in $raw){$prs+=[pscustomobject]@{targetVersion=$TargetVersion;number=$pr.number;url=$pr.url;state=$pr.state;mergedAt=$pr.mergedAt;closedAt=$pr.closedAt;updatedAt=$pr.updatedAt;headRefOid=$pr.headRefOid;title=$pr.title}}}
|
||
return[pscustomobject]@{MainSha=$sha;MainVersion=$version;Production=$prod;PullRequests=$prs}
|
||
}
|
||
function Get-Decision($Observation,$Session){return Invoke-Lifecycle 'decide' ([ordered]@{targetVersion=$TargetVersion;mainVersion=$Observation.MainVersion;productionReady=$Observation.Production.Ready;productionVersion=$Observation.Production.Version;pullRequests=$Observation.PullRequests;session=$Session})}
|
||
function Complete-WithoutSource([string]$Reason,$Observation,$Session){foreach($id in @('workspace','tests','pullrequest','merge','deploy','verify')){Skip-Step $id};$Session.releaseState='completed';$Session.productionVersion=$Observation.Production.Version;$Session.completedAt=(Get-Date).ToString('o');Save-Session $Session;Write-State 100 'Bereits vollständig aktuell' "Vendoo $TargetVersion ist in main und Produktion bereits bestätigt. Grund: $Reason" 'success';Write-Event 'OK' 'Zielzustand bereits erreicht; kein Branch, kein PR und kein Redeploy erforderlich.'}
|
||
function Get-OpenPrFromDecision($Decision){if($Decision.PSObject.Properties.Name-contains'pullRequest'){return$Decision.pullRequest};return$null}
|
||
function Wait-RequiredGitHubChecks([string]$GhPath,[string]$PrUrl,$Session){
|
||
$deadline=(Get-Date).AddMinutes(45);$head='';$headSince=Get-Date;$lastMatrix=''
|
||
while($true){Assert-NotCancelled;$pr=(Invoke-External $GhPath @('pr','view',$PrUrl,'--repo',$Repository,'--json','number,url,state,mergedAt,headRefOid,statusCheckRollup')-TimeoutSeconds 90 -Label 'PR-Head und Checks lesen' -Quiet).Output|ConvertFrom-Json
|
||
if($pr.mergedAt){return[pscustomobject]@{Merged=$true;HeadSha=[string]$pr.headRefOid;Pr=$pr}}
|
||
$current=[string]$pr.headRefOid;if(-not$current){throw'Der Pull Request meldet keinen Head-SHA.'};if($current-ne$head){$head=$current;$headSince=Get-Date;$Session.finalPrHeadSha=$head;Save-Session $Session;Write-Event 'INFO' "Aktueller PR-Head-SHA: $head. Check-Grace-Period wurde neu gestartet."}
|
||
$runsResult=Invoke-External $GhPath @('api',"repos/$Repository/actions/runs?head_sha=$head&per_page=100")-TimeoutSeconds 90 -AllowFailure -Label 'Workflow-Runs des finalen Heads lesen' -Quiet;$runs=@();if($runsResult.Code-eq0-and$runsResult.Output.Trim()){try{$runs=@(($runsResult.Output|ConvertFrom-Json).workflow_runs)}catch{$runs=@()}}
|
||
$elapsed=[int]((Get-Date)-$headSince).TotalSeconds;$timedOut=(Get-Date)-ge$deadline;$classification=Invoke-Lifecycle 'classify-checks' ([ordered]@{checks=@($pr.statusCheckRollup);workflowRuns=$runs;elapsedSeconds=$elapsed;gracePeriodSeconds=90;timedOut=$timedOut})
|
||
$matrix=@($classification.rows|ForEach-Object{"$($_.name): $($_.state) ($($_.conclusion)$($_.status))"})-join'; '
|
||
if($matrix-ne$lastMatrix-or((Get-Date)-$script:LastCheckLog).TotalSeconds-ge30){Write-Event 'INFO' "GitHub-Status für ${head}: $matrix";$lastMatrix=$matrix;$script:LastCheckLog=Get-Date}
|
||
if([string]$classification.state-eq'failed'){throw"Ein erforderlicher GitHub-Check ist fehlgeschlagen.`n$matrix"}
|
||
if([string]$classification.state-eq'timeout'){throw"Zeitlimit beim Warten auf GitHub-Checks für finalen Head $head.`n$matrix"}
|
||
if([string]$classification.state-eq'success'){$verify=(Invoke-External $GhPath @('pr','view',$PrUrl,'--repo',$Repository,'--json','headRefOid')-TimeoutSeconds 60 -Label 'Finalen PR-Head bestätigen' -Quiet).Output|ConvertFrom-Json;if([string]$verify.headRefOid-eq$head){return[pscustomobject]@{Merged=$false;HeadSha=$head;Pr=$pr}};Write-Event 'INFO' 'PR-Head änderte sich nach erfolgreicher Check-Auswertung; Auswertung wird für den neuen Head wiederholt.'}
|
||
Write-State 80 'GitHub-Checks' "Finaler Head ${head}: $($classification.reason).";Start-Sleep -Seconds 10
|
||
}
|
||
}
|
||
function Ensure-Deployment($Session){
|
||
$prod=Check-ProductionVersion;if($prod.Ready-and$prod.Version-eq$TargetVersion){Write-Event 'OK' 'Produktion meldet die Zielversion bereits; Redeploy-Wartephase entfällt.';Complete-Step 'deploy';return$prod}
|
||
if($SkipProductionDeploy){Skip-Step 'deploy';Skip-Step 'verify';Write-State 100 'Source aktualisiert' 'GitHub wurde aktualisiert; das Produktionsdeployment wurde auf Wunsch übersprungen.' 'success';return$null}
|
||
Set-Step 'deploy' 'active';Write-State 88 'Coolify Auto Deploy' 'main ist auf Zielstand. Coolify übernimmt das Deployment über die bestehende GitHub-Verknüpfung.';$Session.releaseState='deploying';Save-Session $Session;$deadline=(Get-Date).AddMinutes(15);$nextLog=Get-Date;$last=''
|
||
do{Assert-NotCancelled;$prod=Check-ProductionVersion;$last="ready=$($prod.Ready), version=$($prod.Version)";if((Get-Date)-ge$nextLog){Write-Event 'INFO' "Coolify Auto Deploy: $last";$nextLog=(Get-Date).AddSeconds(30)};if($prod.Ready-and$prod.Version-eq$TargetVersion){break};Start-Sleep -Seconds 10}while((Get-Date)-lt$deadline)
|
||
if(-not($prod.Ready-and$prod.Version-eq$TargetVersion)){throw"Coolify hat die Zielversion innerhalb von 15 Minuten nicht bereitgestellt. Letzter Stand: $last."};Complete-Step 'deploy';return$prod
|
||
}
|
||
|
||
try{
|
||
Write-Event 'INFO' "Vendoo Production Updater $UpdaterVersion gestartet. Zielversion $TargetVersion."
|
||
Set-Step 'package' 'active';Write-State 3 'Paketprüfung' 'Version, Herkunft, Payload-Hash und Manifest werden geprüft.';Assert-Package;$session=Load-Session;if($SelfTestFailAt-eq'package'){throw'Simulierter Paketfehler.'};Complete-Step 'package';Write-Event 'OK' "Paketprüfung erfolgreich. payloadHash=$($script:Manifest.payloadHash), sourceCommit=$($script:Manifest.sourceCommit), baseCommit=$($script:Manifest.baseCommit)."
|
||
Set-Step 'tools' 'active';Write-State 9 'Systemprüfung' 'Git, Node.js, npm und GitHub CLI werden geprüft.';$script:Git=Get-GitPath;if(-not$script:Git){throw'Git für Windows wurde nicht gefunden.'};$script:Node=Get-NodePath;$script:Npm=Get-NpmPath;if(-not$script:Node-or-not$script:Npm){throw'Node.js 22 LTS oder npm wurde nicht gefunden.'};$nv=Invoke-External $script:Node @('--version')-TimeoutSeconds 20 -Label 'Node.js prüfen';if($nv.Output-notmatch'v?(\d+)\.'-or[int]$Matches[1]-lt22){throw'Node.js 22 oder neuer ist erforderlich.'};$gh=Ensure-GitHubCli;Complete-Step 'tools'
|
||
Set-Step 'github' 'active';Write-State 22 'GitHub-Verbindung' 'Anmeldung und Repository-Berechtigungen werden geprüft.';if(-not(Test-GitHubAuthentication $gh)){Invoke-VisibleGitHubLogin $gh};Invoke-External $gh @('auth','setup-git','--hostname','github.com')-TimeoutSeconds 60 -Label 'Git-Anmeldung konfigurieren'|Out-Null;$repo=(Invoke-External $gh @('repo','view',$Repository,'--json','viewerPermission,defaultBranchRef,isPrivate')-TimeoutSeconds 60 -Label 'Repository-Zugriff prüfen').Output|ConvertFrom-Json;if([string]$repo.viewerPermission-notin@('ADMIN','MAINTAIN','WRITE')){throw"Für $Repository fehlt Schreibzugriff."};Complete-Step 'github'
|
||
Set-Step 'production' 'active';Write-State 31 'Zielzustand' 'main, Produktion, bestehende PRs und Sitzung werden vor jeder Änderung abgeglichen.';$observation=Get-ReleaseObservation $gh;$decision=Get-Decision $observation $session;Write-Event 'INFO' "Lifecycle-Entscheidung: $($decision.action) / $($decision.reason); main=$($observation.MainVersion)@$($observation.MainSha); production=$($observation.Production.Version).";Complete-Step 'production'
|
||
if($SelfTest){foreach($id in @('workspace','tests','pullrequest','merge','deploy','verify')){Set-Step $id 'active';Start-Sleep -Milliseconds 120;Complete-Step $id};Write-State 100 'Selbsttest erfolgreich' 'Updater-3.4-Oberfläche und Host-Vertrag funktionieren.' 'success';return}
|
||
if([string]$decision.action-eq'complete'){Complete-WithoutSource $decision.reason $observation $session;return}
|
||
$prUrl='';$prNumber=$null;$sourceRequired=([string]$decision.action-eq'source')
|
||
if([string]$decision.action-eq'resume-pr'){$pr=Get-OpenPrFromDecision $decision;$prUrl=[string]$pr.url;$prNumber=$pr.number;$session.prUrl=$prUrl;$session.prNumber=$prNumber;$session.releaseState='checks';Save-Session $session;foreach($id in @('workspace','tests')){Skip-Step $id};Complete-Step 'pullrequest';Write-Event 'INFO' "Vorhandener offener Pull Request wird übernommen: $prUrl"}
|
||
elseif([string]$decision.action-eq'deploy'){foreach($id in @('workspace','tests','pullrequest','merge')){Skip-Step $id};$sourceRequired=$false}
|
||
if($sourceRequired){
|
||
Set-Step 'workspace' 'active';Write-State 40 'Payload-Sicherheitsprüfung' 'main wird isoliert geklont; Herkunft und mögliche Rückschritte werden vor dem Overlay geprüft.';if(Test-Path -LiteralPath $WorkRoot){Remove-Item $WorkRoot -Recurse -Force};New-Item -ItemType Directory -Path $WorkRoot -Force|Out-Null;Invoke-External $gh @('repo','clone',$Repository,$CloneDir,'--','--branch',$BaseBranch,'--single-branch')-WorkingDirectory $WorkRoot -TimeoutSeconds 300 -Label 'Repository klonen'|Out-Null
|
||
$guard=Invoke-External $script:Node @($PayloadGuardScript,$PackageRoot,$CloneDir,[string]$script:Manifest.baseCommit,[string]$observation.MainSha)-TimeoutSeconds 300 -AllowFailure -Label 'Veralteten Payload ausschließen';if($guard.Code-ne0){$detail='';try{$g=$guard.Output|ConvertFrom-Json;$detail=@($g.conflicts)-join', '}catch{$detail=$guard.Output};throw"Payload-Rückschritt blockiert. baseCommit=$($script:Manifest.baseCommit), main=$($observation.MainSha). Konflikte: $detail"}
|
||
Invoke-External $script:Git @('switch','-c',$Branch,$observation.MainSha)-WorkingDirectory $CloneDir -TimeoutSeconds 60 -Label 'Deterministischen Release-Branch erstellen'|Out-Null;Invoke-External $script:Node @((Join-Path $PackageRoot 'tools\apply-release-manifest.mjs'),$PackageRoot,$CloneDir)-TimeoutSeconds 300 -Label 'SHA-256-geprüftes Release-Manifest anwenden'|Out-Null;Complete-Step 'workspace'
|
||
Set-Step 'tests' 'active';Write-State 55 'Abnahmetests' 'npm ci und der vollständige Releasevertrag werden auf dem sicheren Overlay ausgeführt.';Invoke-External $script:Npm @('ci','--no-audit','--no-fund')-WorkingDirectory $CloneDir -TimeoutSeconds 1200 -Label 'npm ci'|Out-Null;Invoke-External $script:Npm @('run','verify:all')-WorkingDirectory $CloneDir -TimeoutSeconds 3600 -Label 'npm run verify:all'|Out-Null;Complete-Step 'tests'
|
||
$latest=Get-ReleaseObservation $gh;$lateDecision=Get-Decision $latest $session;if([string]$lateDecision.action-eq'complete'){Complete-WithoutSource 'target-reached-during-local-tests' $latest $session;return}elseif([string]$lateDecision.action-eq'resume-pr'){$pr=Get-OpenPrFromDecision $lateDecision;$prUrl=[string]$pr.url;$prNumber=$pr.number;Write-Event 'INFO' "Paralleler Lauf hat bereits einen PR erzeugt; dieser wird übernommen: $prUrl"}elseif([string]$lateDecision.action-eq'deploy'){foreach($id in @('pullrequest','merge')){Skip-Step $id};$sourceRequired=$false}else{
|
||
Set-Step 'pullrequest' 'active';Write-State 70 'Pull Request' 'Branch und Pull Request werden idempotent abgeglichen.';Invoke-External $script:Git @('config','user.name','Vendoo Production Updater')-WorkingDirectory $CloneDir -Label 'Git-Name setzen'|Out-Null;$uid=(Invoke-External $gh @('api','user','--jq','.id')-Label 'GitHub-Benutzer-ID').Output.Trim();$ulogin=(Invoke-External $gh @('api','user','--jq','.login')-Label 'GitHub-Benutzername').Output.Trim();Invoke-External $script:Git @('config','user.email',"$uid+$ulogin@users.noreply.github.com")-WorkingDirectory $CloneDir -Label 'Git-E-Mail setzen'|Out-Null;Invoke-External $script:Git @('add','--all')-WorkingDirectory $CloneDir -Label 'Änderungen vormerken'|Out-Null;$diff=Invoke-External $script:Git @('diff','--cached','--quiet')-WorkingDirectory $CloneDir -AllowFailure -Label 'Änderungen prüfen';if($diff.Code-ne1){throw'Payload erzeugt gegenüber main keinen gültigen neuen Source-Stand.'};Invoke-External $script:Git @('commit','-m',"release: Vendoo $TargetVersion production update via updater 3.4")-WorkingDirectory $CloneDir -TimeoutSeconds 120 -Label 'Release committen'|Out-Null
|
||
$remoteLine=(Invoke-External $script:Git @('ls-remote','--heads','origin',"refs/heads/$Branch")-WorkingDirectory $CloneDir -TimeoutSeconds 60 -AllowFailure -Label 'Remote-Branch prüfen' -Quiet).Output.Trim();if($remoteLine){$remoteSha=($remoteLine-split'\s+')[0];Invoke-External $script:Git @('push',"--force-with-lease=refs/heads/$Branch`:$remoteSha",'--set-upstream','origin',$Branch)-WorkingDirectory $CloneDir -TimeoutSeconds 300 -Label 'Bestehenden Release-Branch sicher aktualisieren'|Out-Null}else{Invoke-External $script:Git @('push','--set-upstream','origin',$Branch)-WorkingDirectory $CloneDir -TimeoutSeconds 300 -Label 'Release-Branch pushen'|Out-Null}
|
||
$existing=Invoke-External $gh @('pr','list','--repo',$Repository,'--head',$Branch,'--state','open','--json','number,url','--jq','.[0]')-AllowFailure -Label 'Vorhandenen Pull Request erneut suchen' -Quiet;if($existing.Output.Trim()){$existingObj=$existing.Output|ConvertFrom-Json;$prUrl=[string]$existingObj.url;$prNumber=$existingObj.number}else{$body=Join-Path $WorkRoot 'pr-body.md';$bodyText="## Vendoo $TargetVersion – Production Update`r`n`r`nErstellt durch Vendoo Production Updater $UpdaterVersion.`r`n`r`n- vollständiges ``npm run verify:all`` erfolgreich`r`n- Payload-Herkunft und Rückschrittschutz geprüft`r`n- keine Datenbanken, Uploads, Secrets oder ``/app/data``-Inhalte`r`n- GitHub-Checks werden am finalen Head-SHA ausgewertet`r`n";[IO.File]::WriteAllText($body,$bodyText,(New-Object Text.UTF8Encoding($false)));$created=(Invoke-External $gh @('pr','create','--repo',$Repository,'--base',$BaseBranch,'--head',$Branch,'--title',"Vendoo $TargetVersion – Production Update",'--body-file',$body)-WorkingDirectory $CloneDir -TimeoutSeconds 180 -Label 'Pull Request erstellen').Output.Trim();$prUrl=$created;$prInfo=(Invoke-External $gh @('pr','view',$prUrl,'--repo',$Repository,'--json','number')-TimeoutSeconds 60 -Label 'PR-Nummer lesen' -Quiet).Output|ConvertFrom-Json;$prNumber=$prInfo.number}
|
||
$session.prUrl=$prUrl;$session.prNumber=$prNumber;$session.releaseState='checks';Save-Session $session;Complete-Step 'pullrequest';Write-Event 'OK' "Pull Request: $prUrl"
|
||
}
|
||
}
|
||
if($prUrl){Set-Step 'merge' 'active';Write-State 78 'GitHub-Checks' 'Workflow-Runs und Pflichtchecks werden für den jeweils aktuellen PR-Head verfolgt.';$checkResult=Wait-RequiredGitHubChecks $gh $prUrl $session;if(-not$checkResult.Merged){Write-State 84 'Merge' 'Der geprüfte finale Head wird atomar per Squash nach main übernommen.';Invoke-External $gh @('pr','merge',$prUrl,'--repo',$Repository,'--squash','--delete-branch','--subject',"Vendoo $TargetVersion – Production Update",'--match-head-commit',[string]$checkResult.HeadSha)-WorkingDirectory $CloneDir -TimeoutSeconds 300 -Label 'Pull Request mergen'|Out-Null};$mergedInfo=(Invoke-External $gh @('pr','view',$prUrl,'--repo',$Repository,'--json','mergedAt,mergeCommit,headRefOid,state')-TimeoutSeconds 60 -Label 'Merge-Ergebnis bestätigen' -Quiet).Output|ConvertFrom-Json;if(-not$mergedInfo.mergedAt){throw'Der Pull Request wurde nicht als gemergt bestätigt.'};$session.finalPrHeadSha=[string]$mergedInfo.headRefOid;$session.mergeCommit=[string]$mergedInfo.mergeCommit.oid;$session.releaseState='merged';Save-Session $session;Complete-Step 'merge';Write-Event 'OK' "Merge bestätigt: $($session.mergeCommit)"}
|
||
$prod=Ensure-Deployment $session;if($SkipProductionDeploy){return};Set-Step 'verify' 'active';Write-State 97 'Produktionsprüfung' 'Die öffentliche Readiness und die exakte Zielversion werden abschließend bestätigt.';if($null-eq$prod){$prod=Check-ProductionVersion};if(-not($prod.Ready-and$prod.Version-eq$TargetVersion)){throw"Die abschließende Produktionsprüfung ist fehlgeschlagen: ready=$($prod.Ready), version=$($prod.Version)"};Complete-Step 'verify';$session.productionVersion=$prod.Version;$session.releaseState='completed';$session.completedAt=(Get-Date).ToString('o');Save-Session $session;Write-State 100 'Update erfolgreich' "Vendoo $TargetVersion läuft geprüft in Produktion." 'success';Write-Event 'OK' "Vendoo $TargetVersion wurde vollständig und idempotent aktualisiert."
|
||
} catch [System.OperationCanceledException] {Fail-Step;Write-State 100 'Update abgebrochen' $_.Exception.Message 'cancelled' $_.Exception.Message 'Der gespeicherte Wiederaufnahmepunkt bleibt erhalten.';Write-Event 'WARN' $_.Exception.Message
|
||
}catch{Fail-Step;$msg=$_.Exception.Message;$remediation=if($script:CurrentStep -in @('deploy','verify')){'main kann bereits aktualisiert sein. Ein erneuter Start setzt bei Deployment und Produktionsprüfung fort.'}elseif($msg-match'Payload-Rückschritt'){'Das Paket ist gegenüber main veraltet. Kein Branch und kein PR wurden aus diesem Payload erstellt. Ein neues Paket aus aktuellem main erzeugen.'}else{'Ein erneuter Start gleicht main, PR, finalen Head-SHA und Sitzung erneut ab; bereits abgeschlossene Phasen werden nicht dupliziert.'};Write-State 100 'Update sicher gestoppt' $msg 'failed' $msg $remediation;Write-Event 'ERROR' $msg
|
||
}finally{if(Test-Path $InputFile){Remove-Item $InputFile -Force -ErrorAction SilentlyContinue}}
|