param( [string]$CommitMessage, [switch]$SkipCommit, [switch]$SkipDiscord ) $ErrorActionPreference = "Stop" $repoRoot = Resolve-Path "$PSScriptRoot\.." Set-Location $repoRoot $Utf8NoBom = New-Object System.Text.UTF8Encoding($false) $script:CurrentStep = "initializing" function Write-TextFile { param( [Parameter(Mandatory = $true)][string]$Path, [Parameter(Mandatory = $true)][string]$Content ) $fullPath = Join-Path $repoRoot $Path $normalized = $Content -replace "`r`n", "`n" [System.IO.File]::WriteAllText($fullPath, $normalized, $Utf8NoBom) } function Assert-LastExitCode { param([Parameter(Mandatory = $true)][string]$CommandName) if ($LASTEXITCODE -ne 0) { throw "$CommandName failed with exit code $LASTEXITCODE." } } function Read-TextFile { param([Parameter(Mandatory = $true)][string]$Path) $fullPath = Join-Path $repoRoot $Path return [System.IO.File]::ReadAllText($fullPath) } function Get-EnvFileValue { param( [Parameter(Mandatory = $true)][string]$Path, [Parameter(Mandatory = $true)][string]$Name ) if (-not (Test-Path $Path)) { return $null } $match = Select-String -Path $Path -Pattern "^$([regex]::Escape($Name))=(.*)$" | Select-Object -First 1 if (-not $match) { return $null } return $match.Matches[0].Groups[1].Value.Trim() } function Get-DiscordWebhookUrl { $candidateNames = @( "PROCESS_DISCORD_WEBHOOK_URL", "MAGENT_NOTIFY_DISCORD_WEBHOOK_URL", "DISCORD_WEBHOOK_URL" ) foreach ($name in $candidateNames) { $value = [System.Environment]::GetEnvironmentVariable($name) if (-not [string]::IsNullOrWhiteSpace($value)) { return $value.Trim() } } foreach ($name in $candidateNames) { $value = Get-EnvFileValue -Path ".env" -Name $name if (-not [string]::IsNullOrWhiteSpace($value)) { return $value.Trim() } } $configPath = Join-Path $repoRoot "backend/app/config.py" if (Test-Path $configPath) { $configContent = Read-TextFile -Path "backend/app/config.py" $match = [regex]::Match( $configContent, 'discord_webhook_url:\s*Optional\[str\]\s*=\s*Field\(\s*default="([^"]+)"', [System.Text.RegularExpressions.RegexOptions]::Singleline ) if ($match.Success) { return $match.Groups[1].Value.Trim() } } return $null } function Send-DiscordUpdate { param( [Parameter(Mandatory = $true)][string]$Title, [Parameter(Mandatory = $true)][string]$Body ) if ($SkipDiscord) { Write-Host "Skipping Discord notification." return } $webhookUrl = Get-DiscordWebhookUrl if ([string]::IsNullOrWhiteSpace($webhookUrl)) { Write-Warning "Discord webhook not configured for Process 1." return } $content = "**$Title**`n$Body" Invoke-RestMethod -Method Post -Uri $webhookUrl -ContentType "application/json" -Body (@{ content = $content } | ConvertTo-Json -Compress) | Out-Null } function Get-BuildNumber { $current = "" if (Test-Path ".build_number") { $current = (Get-Content ".build_number" -Raw).Trim() } $candidate = Get-Date $buildNumber = $candidate.ToString("ddMMyyHHmm") if ($buildNumber -eq $current) { $buildNumber = $candidate.AddMinutes(1).ToString("ddMMyyHHmm") } return $buildNumber } function Wait-ForHttp { param( [Parameter(Mandatory = $true)][string]$Url, [int]$Attempts = 30, [int]$DelaySeconds = 2 ) $lastError = $null for ($attempt = 1; $attempt -le $Attempts; $attempt++) { try { return Invoke-RestMethod -Uri $Url -TimeoutSec 10 } catch { $lastError = $_ Start-Sleep -Seconds $DelaySeconds } } throw $lastError } function Get-GitChangelogLiteral { $scriptPath = Join-Path $repoRoot "scripts/render_git_changelog.py" $literal = python $scriptPath --python-literal Assert-LastExitCode -CommandName "python scripts/render_git_changelog.py --python-literal" return ($literal | Out-String).Trim() } function Update-BuildFiles { param([Parameter(Mandatory = $true)][string]$BuildNumber) Write-TextFile -Path ".build_number" -Content "$BuildNumber`n" $changelogLiteral = Get-GitChangelogLiteral $buildInfoContent = @( "BUILD_NUMBER = `"$BuildNumber`"" "CHANGELOG = $changelogLiteral" "" ) -join "`n" Write-TextFile -Path "backend/app/build_info.py" -Content $buildInfoContent $envPath = Join-Path $repoRoot ".env" if (Test-Path $envPath) { $envContent = Read-TextFile -Path ".env" if ($envContent -match '^BUILD_NUMBER=.*$') { $updatedEnv = [regex]::Replace( $envContent, '^BUILD_NUMBER=.*$', "BUILD_NUMBER=$BuildNumber", [System.Text.RegularExpressions.RegexOptions]::Multiline ) } else { $updatedEnv = "BUILD_NUMBER=$BuildNumber`n$envContent" } Write-TextFile -Path ".env" -Content $updatedEnv } $packageJson = Read-TextFile -Path "frontend/package.json" $packageJsonRegex = [regex]::new('"version"\s*:\s*"\d+"') $updatedPackageJson = $packageJsonRegex.Replace( $packageJson, "`"version`": `"$BuildNumber`"", 1 ) Write-TextFile -Path "frontend/package.json" -Content $updatedPackageJson $packageLock = Read-TextFile -Path "frontend/package-lock.json" $packageLockVersionRegex = [regex]::new('"version"\s*:\s*"\d+"') $updatedPackageLock = $packageLockVersionRegex.Replace( $packageLock, "`"version`": `"$BuildNumber`"", 1 ) $packageLockRootRegex = [regex]::new( '(""\s*:\s*\{\s*"name"\s*:\s*"magent-frontend"\s*,\s*"version"\s*:\s*)"\d+"', [System.Text.RegularExpressions.RegexOptions]::Singleline ) $updatedPackageLock = $packageLockRootRegex.Replace( $updatedPackageLock, '$1"' + $BuildNumber + '"', 1 ) Write-TextFile -Path "frontend/package-lock.json" -Content $updatedPackageLock } function Get-ChangedFilesSummary { $files = git diff --cached --name-only if (-not $files) { return "No staged files" } $count = ($files | Measure-Object).Count $sample = $files | Select-Object -First 8 $summary = ($sample -join ", ") if ($count -gt $sample.Count) { $summary = "$summary, +$($count - $sample.Count) more" } return "$count files: $summary" } $buildNumber = $null $branch = $null $commit = $null $publicInfo = $null $changedFiles = "No staged files" try { $branch = (git rev-parse --abbrev-ref HEAD).Trim() $buildNumber = Get-BuildNumber Write-Host "Process 1 build number: $buildNumber" $script:CurrentStep = "updating build metadata" Update-BuildFiles -BuildNumber $buildNumber $script:CurrentStep = "running backend quality gate" powershell -ExecutionPolicy Bypass -File (Join-Path $repoRoot "scripts\run_backend_quality_gate.ps1") Assert-LastExitCode -CommandName "scripts/run_backend_quality_gate.ps1" $script:CurrentStep = "rebuilding local docker stack" docker compose up -d --build Assert-LastExitCode -CommandName "docker compose up -d --build" $script:CurrentStep = "verifying backend health" $health = Wait-ForHttp -Url "http://127.0.0.1:8000/health" if ($health.status -ne "ok") { throw "Health endpoint returned unexpected payload: $($health | ConvertTo-Json -Compress)" } $script:CurrentStep = "verifying public build metadata" $publicInfo = Wait-ForHttp -Url "http://127.0.0.1:8000/site/public" if ($publicInfo.buildNumber -ne $buildNumber) { throw "Public build number mismatch. Expected $buildNumber but got $($publicInfo.buildNumber)." } $script:CurrentStep = "committing changes" git add -A Assert-LastExitCode -CommandName "git add -A" $changedFiles = Get-ChangedFilesSummary if ((git status --short).Trim()) { if (-not $SkipCommit) { if ([string]::IsNullOrWhiteSpace($CommitMessage)) { $CommitMessage = "Process 1 build $buildNumber" } git commit -m $CommitMessage Assert-LastExitCode -CommandName "git commit" } } $commit = (git rev-parse --short HEAD).Trim() $body = @( "Build: $buildNumber" "Branch: $branch" "Commit: $commit" "Health: ok" "Public build: $($publicInfo.buildNumber)" "Changes: $changedFiles" ) -join "`n" Send-DiscordUpdate -Title "Process 1 complete" -Body $body Write-Host "Process 1 completed successfully." } catch { $failureCommit = "" try { $failureCommit = (git rev-parse --short HEAD).Trim() } catch { $failureCommit = "unknown" } $failureBody = @( "Build: $buildNumber" "Branch: $branch" "Commit: $failureCommit" "Step: $script:CurrentStep" "Error: $($_.Exception.Message)" ) -join "`n" try { Send-DiscordUpdate -Title "Process 1 failed" -Body $failureBody } catch { Write-Warning "Failed to send Discord failure notification: $($_.Exception.Message)" } throw }