From 65041b025e5d459dc8bd50a424f4e3ba7a6ebcc9 Mon Sep 17 00:00:00 2001 From: Zaine Date: Wed, 6 May 2026 16:41:40 +0100 Subject: [PATCH] First look at pwsh --- .gitea/workflows/build.yml | 8 +- gitea-build-monitor.ps1 | 693 +++++++++++++++++++++++++++++++++++++ 2 files changed, 700 insertions(+), 1 deletion(-) create mode 100644 gitea-build-monitor.ps1 diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index 054e01f..58d8cca 100755 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -16,4 +16,10 @@ jobs: steps: - uses: actions/checkout@v4 - - run: make + - name: Build site + run: make + + - name: Monitor build and notify Discord + if: always() + shell: pwsh + run: ./gitea-build-monitor.ps1 diff --git a/gitea-build-monitor.ps1 b/gitea-build-monitor.ps1 new file mode 100644 index 0000000..d7cb3ba --- /dev/null +++ b/gitea-build-monitor.ps1 @@ -0,0 +1,693 @@ +<# +.SYNOPSIS + Monitor recent Gitea Actions build jobs and notify Discord about failures, warnings, errors, or slow builds. + +.DESCRIPTION + This script is intended to run after the main build pipeline. It queries a self-hosted Gitea instance, + analyzes recent Actions jobs for one or more repositories, parses logs where available, caches processed + job IDs locally, and sends grouped Discord embed notifications. + + Required token permissions depend on your Gitea version and repository visibility. For private repos, + use a personal access token that can read repository Actions/jobs and logs. + + Sample Discord payload structure: + { + "username": "Gitea Build Monitor", + "embeds": [ + { + "title": "CRITICAL: owner/repo build #123", + "url": "http://127.0.0.1:3000/owner/repo/actions/runs/123", + "color": 15158332, + "fields": [ + { "name": "Status", "value": "❌ failure", "inline": true }, + { "name": "Branch", "value": "main", "inline": true }, + { "name": "Commit", "value": "`abcdef12` Fix build", "inline": false } + ] + } + ] + } +#> + +param( + [switch]$DryRun, + [switch]$ValidateOnly +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +# ========================= +# Example configuration +# ========================= +$Config = @{ + GiteaBaseUrl = 'http://127.0.0.1:3000' + ApiToken = '328ae2196703fc988e9c21962341ba88709511ad' + DiscordWebhookUrl = 'https://discord.com/api/webhooks/1501605890581856430/j3zeMhCW-V583awzqhyrZMfVwzZl-RRCLdIa32jUiymbodzpu9WZaX_p3HiiQMtUUO0m' + + # Supports multiple repositories. + Repositories = @( + @{ Owner = 'zaine'; Name = 'org_roam' } + ) + + LookbackMinutes = 30 + SlowBuildThresholdMins = 20 + CacheFile = Join-Path $PSScriptRoot 'gitea-build-monitor-cache.json' + LogFile = Join-Path $PSScriptRoot 'gitea-build-monitor.log' + MaxJobsPerRepository = 50 + MaxLogBytesToAnalyze = 250000 + MaxDiscordEmbeds = 10 + RetryCount = 3 + RetryDelaySeconds = 2 + + # Tune these patterns to your build tooling. + ErrorPatterns = @( + '(?im)\b(error|exception|fatal|failed|failure)\b', + '(?im)\bbuild failed\b', + '(?im)\btest(s)? failed\b' + ) + WarningPatterns = @( + '(?im)\bwarning\b', + '(?im)\bdeprecated\b' + ) + + # When true, successful but slow/warned jobs are reported too. + NotifyWarningsAndSlow = $true +} + +function Write-Log { + param( + [Parameter(Mandatory)] + [string]$Message, + + [ValidateSet('INFO', 'WARN', 'ERROR')] + [string]$Level = 'INFO' + ) + + $line = '{0} [{1}] {2}' -f (Get-Date).ToString('o'), $Level, $Message + Write-Host $line + Add-Content -Path $Config.LogFile -Value $line +} + +function Assert-Config { + if ([string]::IsNullOrWhiteSpace($Config.ApiToken)) { + throw 'Missing Gitea API token. Set $Config.ApiToken or the GITEA_API_TOKEN environment variable.' + } + if ([string]::IsNullOrWhiteSpace($Config.DiscordWebhookUrl)) { + throw 'Missing Discord webhook URL. Set $Config.DiscordWebhookUrl or the DISCORD_WEBHOOK_URL environment variable.' + } + if (-not $Config.Repositories -or $Config.Repositories.Count -eq 0) { + throw 'Configure at least one repository in $Config.Repositories.' + } +} + +function Join-Url { + param( + [Parameter(Mandatory)][string]$Base, + [Parameter(Mandatory)][string]$Path + ) + + '{0}/{1}' -f $Base.TrimEnd('/'), $Path.TrimStart('/') +} + +function Invoke-GiteaApi { + param( + [Parameter(Mandatory)][string]$Path, + [ValidateSet('GET', 'POST')][string]$Method = 'GET', + [object]$Body = $null, + [switch]$RawText + ) + + $uri = Join-Url -Base $Config.GiteaBaseUrl -Path $Path + $headers = @{ + Authorization = "token $($Config.ApiToken)" + Accept = if ($RawText) { 'text/plain' } else { 'application/json' } + } + + for ($attempt = 1; $attempt -le [int]$Config.RetryCount; $attempt++) { + try { + $parameters = @{ + Uri = $uri + Method = $Method + Headers = $headers + ErrorAction = 'Stop' + } + + if ($null -ne $Body) { + $parameters.Body = ($Body | ConvertTo-Json -Depth 10) + $parameters.ContentType = 'application/json' + } + + return Invoke-RestMethod @parameters + } + catch { + $status = $null + if ($_.Exception.Response -and $_.Exception.Response.StatusCode) { + $status = [int]$_.Exception.Response.StatusCode + } + + Write-Log -Level 'WARN' -Message ("Gitea API call failed, attempt {0}/{1}, status {2}: {3}" -f $attempt, $Config.RetryCount, $status, $_.Exception.Message) + if ($attempt -ge [int]$Config.RetryCount) { + throw + } + Start-Sleep -Seconds ([int]$Config.RetryDelaySeconds * $attempt) + } + } +} + +function Invoke-DiscordWebhook { + param([Parameter(Mandatory)][hashtable]$Payload) + + if ($DryRun) { + Write-Log -Message "Dry run enabled; Discord payload was not sent: $($Payload | ConvertTo-Json -Depth 12 -Compress)" + return + } + + for ($attempt = 1; $attempt -le [int]$Config.RetryCount; $attempt++) { + try { + Invoke-RestMethod -Uri $Config.DiscordWebhookUrl -Method POST -ContentType 'application/json' -Body ($Payload | ConvertTo-Json -Depth 12) -ErrorAction Stop | Out-Null + return + } + catch { + Write-Log -Level 'WARN' -Message ("Discord webhook failed, attempt {0}/{1}: {2}" -f $attempt, $Config.RetryCount, $_.Exception.Message) + if ($attempt -ge [int]$Config.RetryCount) { + throw + } + Start-Sleep -Seconds ([int]$Config.RetryDelaySeconds * $attempt) + } + } +} + +function Read-ProcessedCache { + if (-not (Test-Path -Path $Config.CacheFile)) { + return @{} + } + + try { + $json = Get-Content -Path $Config.CacheFile -Raw + if ([string]::IsNullOrWhiteSpace($json)) { + return @{} + } + + $data = $json | ConvertFrom-Json + $cache = @{} + foreach ($property in $data.PSObject.Properties) { + $cache[$property.Name] = [datetime]$property.Value + } + return $cache + } + catch { + Write-Log -Level 'WARN' -Message "Could not read cache file, starting with an empty cache: $($_.Exception.Message)" + return @{} + } +} + +function Save-ProcessedCache { + param([Parameter(Mandatory)][hashtable]$Cache) + + $cutoff = (Get-Date).AddDays(-14) + $trimmed = @{} + foreach ($key in $Cache.Keys) { + if ([datetime]$Cache[$key] -ge $cutoff) { + $trimmed[$key] = ([datetime]$Cache[$key]).ToString('o') + } + } + + $directory = Split-Path -Parent $Config.CacheFile + if ($directory -and -not (Test-Path -Path $directory)) { + New-Item -ItemType Directory -Path $directory -Force | Out-Null + } + + $trimmed | ConvertTo-Json -Depth 5 | Set-Content -Path $Config.CacheFile -Encoding UTF8 +} + +function Get-RepositoryOwner { + param([Parameter(Mandatory)][hashtable]$Repository) + return [string]$Repository['Owner'] +} + +function Get-RepositoryName { + param([Parameter(Mandatory)][hashtable]$Repository) + return [string]$Repository['Name'] +} + +function Get-RepositorySlug { + param([Parameter(Mandatory)][hashtable]$Repository) + return '{0}/{1}' -f (Get-RepositoryOwner -Repository $Repository), (Get-RepositoryName -Repository $Repository) +} + +function Get-BuildRuns { + param( + [Parameter(Mandatory)][hashtable]$Repository + ) + + $owner = [uri]::EscapeDataString((Get-RepositoryOwner -Repository $Repository)) + $repo = [uri]::EscapeDataString((Get-RepositoryName -Repository $Repository)) + $path = "/api/v1/repos/$owner/$repo/actions/jobs?page=1&limit=$($Config.MaxJobsPerRepository)" + $response = Invoke-GiteaApi -Path $path + + $jobs = Get-ObjectValue -Object $response -Name 'jobs' + if ($jobs) { + return @($jobs) + } + + return @() +} + +function Get-CommitMessage { + param( + [Parameter(Mandatory)][hashtable]$Repository, + [Parameter(Mandatory)][string]$Sha + ) + + if ([string]::IsNullOrWhiteSpace($Sha)) { + return '' + } + + try { + $owner = [uri]::EscapeDataString((Get-RepositoryOwner -Repository $Repository)) + $repo = [uri]::EscapeDataString((Get-RepositoryName -Repository $Repository)) + $encodedSha = [uri]::EscapeDataString($Sha) + $commit = Invoke-GiteaApi -Path "/api/v1/repos/$owner/$repo/git/commits/$encodedSha" + + $message = Get-ObjectValue -Object $commit -Name 'message' -Default '' + if ($message) { + return (($message -split "`r?`n")[0]).Trim() + } + } + catch { + Write-Log -Level 'WARN' -Message "Could not fetch commit message for $(Get-RepositorySlug -Repository $Repository) $($Sha): $($_.Exception.Message)" + } + + return '' +} + +function Get-JobLogText { + param( + [Parameter(Mandatory)][hashtable]$Repository, + [Parameter(Mandatory)][object]$Job + ) + + try { + $owner = [uri]::EscapeDataString((Get-RepositoryOwner -Repository $Repository)) + $repo = [uri]::EscapeDataString((Get-RepositoryName -Repository $Repository)) + $jobId = [uri]::EscapeDataString([string](Get-ObjectValue -Object $Job -Name 'id')) + $log = Invoke-GiteaApi -Path "/api/v1/repos/$owner/$repo/actions/jobs/$jobId/logs" -RawText + + if ($null -eq $log) { + return '' + } + + $text = [string]$log + if ($text.Length -gt [int]$Config.MaxLogBytesToAnalyze) { + return $text.Substring([Math]::Max(0, $text.Length - [int]$Config.MaxLogBytesToAnalyze)) + } + return $text + } + catch { + Write-Log -Level 'WARN' -Message "Could not fetch logs for job $(Get-ObjectValue -Object $Job -Name 'id'): $($_.Exception.Message)" + return '' + } +} + +function Get-Duration { + param([Parameter(Mandatory)][object]$Job) + + $start = $null + $end = $null + + $startedAt = Get-ObjectValue -Object $Job -Name 'started_at' + $createdAt = Get-ObjectValue -Object $Job -Name 'created_at' + $completedAt = Get-ObjectValue -Object $Job -Name 'completed_at' + + if ($startedAt) { + $start = [datetime]$startedAt + } + elseif ($createdAt) { + $start = [datetime]$createdAt + } + + if ($completedAt) { + $end = [datetime]$completedAt + } + else { + $end = Get-Date + } + + if ($null -eq $start) { + return [timespan]::Zero + } + + return ($end.ToUniversalTime() - $start.ToUniversalTime()) +} + +function Get-ObjectValue { + param( + [Parameter(Mandatory)][object]$Object, + [Parameter(Mandatory)][string]$Name, + [object]$Default = $null + ) + + if ($null -eq $Object) { + return $Default + } + + if ($Object -is [hashtable] -and $Object.ContainsKey($Name)) { + return $Object[$Name] + } + + $property = $Object.PSObject.Properties[$Name] + if ($property) { + return $property.Value + } + + return $Default +} + +function Format-Duration { + param([Parameter(Mandatory)][timespan]$Duration) + + if ($Duration.TotalHours -ge 1) { + return '{0}h {1}m {2}s' -f [int]$Duration.TotalHours, $Duration.Minutes, $Duration.Seconds + } + if ($Duration.TotalMinutes -ge 1) { + return '{0}m {1}s' -f [int]$Duration.TotalMinutes, $Duration.Seconds + } + return '{0}s' -f [int]$Duration.TotalSeconds +} + +function Get-LogSummary { + param([Parameter(Mandatory)][string]$LogText) + + if ([string]::IsNullOrWhiteSpace($LogText)) { + return @{ + HasErrors = $false + HasWarnings = $false + Summary = 'No log summary available.' + } + } + + $matches = New-Object System.Collections.Generic.List[string] + $hasErrors = $false + $hasWarnings = $false + + foreach ($line in ($LogText -split "`r?`n")) { + $trimmed = $line.Trim() + if ([string]::IsNullOrWhiteSpace($trimmed)) { + continue + } + + $isError = $false + foreach ($pattern in $Config.ErrorPatterns) { + if ($trimmed -match $pattern) { + $isError = $true + $hasErrors = $true + break + } + } + + $isWarning = $false + foreach ($pattern in $Config.WarningPatterns) { + if ($trimmed -match $pattern) { + $isWarning = $true + $hasWarnings = $true + break + } + } + + if (($isError -or $isWarning) -and $matches.Count -lt 8) { + $matches.Add($trimmed) + } + } + + $summary = if ($matches.Count -gt 0) { + ($matches | Select-Object -First 8) -join "`n" + } + else { + 'No matching warning or error lines found.' + } + + return @{ + HasErrors = $hasErrors + HasWarnings = $hasWarnings + Summary = $summary + } +} + +function Get-Severity { + param( + [Parameter(Mandatory)][string]$Status, + [Parameter(Mandatory)][bool]$HasErrors, + [Parameter(Mandatory)][bool]$HasWarnings, + [Parameter(Mandatory)][bool]$IsSlow + ) + + $normalized = $Status.ToLowerInvariant() + if ($normalized -in @('failure', 'failed', 'cancelled', 'timed_out') -or $HasErrors) { + return 'Critical' + } + if ($IsSlow -or $HasWarnings) { + return 'Warning' + } + return 'Info' +} + +function Analyze-Build { + param( + [Parameter(Mandatory)][hashtable]$Repository, + [Parameter(Mandatory)][object]$Job + ) + + $conclusion = Get-ObjectValue -Object $Job -Name 'conclusion' + $jobStatus = Get-ObjectValue -Object $Job -Name 'status' + $status = if ($conclusion) { [string]$conclusion } elseif ($jobStatus) { [string]$jobStatus } else { 'unknown' } + $duration = Get-Duration -Job $Job + $isSlow = $duration.TotalMinutes -ge [double]$Config.SlowBuildThresholdMins + $logText = '' + + $definitelyProblematicStatus = $status.ToLowerInvariant() -in @('failure', 'failed', 'cancelled', 'timed_out') + if ($definitelyProblematicStatus -or $Config.NotifyWarningsAndSlow) { + $logText = Get-JobLogText -Repository $Repository -Job $Job + } + + $logSummary = Get-LogSummary -LogText $logText + $severity = Get-Severity -Status $status -HasErrors $logSummary.HasErrors -HasWarnings $logSummary.HasWarnings -IsSlow $isSlow + + $shouldAlert = $severity -ne 'Info' + if (-not $Config.NotifyWarningsAndSlow -and $severity -eq 'Warning') { + $shouldAlert = $false + } + + $sha = [string](Get-ObjectValue -Object $Job -Name 'head_sha' -Default '') + $commitMessage = Get-CommitMessage -Repository $Repository -Sha $sha + $runId = [string](Get-ObjectValue -Object $Job -Name 'run_id' -Default (Get-ObjectValue -Object $Job -Name 'id')) + $htmlUrl = [string](Get-ObjectValue -Object $Job -Name 'html_url' -Default '') + $runUrl = [string](Get-ObjectValue -Object $Job -Name 'run_url' -Default '') + $repositorySlug = Get-RepositorySlug -Repository $Repository + $buildUrl = if ($htmlUrl) { $htmlUrl } elseif ($runUrl) { $runUrl } else { Join-Url -Base $Config.GiteaBaseUrl -Path "$repositorySlug/actions/runs/$runId" } + $startedAt = Get-ObjectValue -Object $Job -Name 'started_at' + $createdAt = Get-ObjectValue -Object $Job -Name 'created_at' + + return [pscustomobject]@{ + Repository = $repositorySlug + Owner = Get-RepositoryOwner -Repository $Repository + Repo = Get-RepositoryName -Repository $Repository + JobId = [string](Get-ObjectValue -Object $Job -Name 'id') + RunId = $runId + Branch = [string](Get-ObjectValue -Object $Job -Name 'head_branch' -Default 'unknown') + CommitSha = $sha + CommitMessage = $commitMessage + Status = $status + Duration = $duration + DurationText = Format-Duration -Duration $duration + BuildUrl = $buildUrl + ErrorSummary = $logSummary.Summary + HasErrors = $logSummary.HasErrors + HasWarnings = $logSummary.HasWarnings + IsSlow = $isSlow + Severity = $severity + ShouldAlert = $shouldAlert + StartedAt = if ($startedAt) { [datetime]$startedAt } elseif ($createdAt) { [datetime]$createdAt } else { Get-Date } + } +} + +function New-DiscordEmbed { + param([Parameter(Mandatory)][object]$Analysis) + + $emoji = switch ($Analysis.Severity) { + 'Critical' { '❌' } + 'Warning' { '⚠️' } + default { 'ℹ️' } + } + + $color = switch ($Analysis.Severity) { + 'Critical' { 15158332 } + 'Warning' { 16776960 } + default { 3447003 } + } + + $shortSha = if ($Analysis.CommitSha.Length -ge 8) { $Analysis.CommitSha.Substring(0, 8) } else { $Analysis.CommitSha } + $commitValue = if ($shortSha) { + if ($Analysis.CommitMessage) { "``$shortSha`` $($Analysis.CommitMessage)" } else { "``$shortSha``" } + } + else { + 'unknown' + } + + $flags = @() + if ($Analysis.HasErrors) { $flags += 'errors found in logs' } + if ($Analysis.HasWarnings) { $flags += 'warnings found in logs' } + if ($Analysis.IsSlow) { $flags += "duration exceeded $($Config.SlowBuildThresholdMins)m" } + if ($flags.Count -eq 0) { $flags += 'problematic status' } + + $summary = $Analysis.ErrorSummary + if ($summary.Length -gt 950) { + $summary = $summary.Substring(0, 947) + '...' + } + + return @{ + title = "$emoji $($Analysis.Severity): $($Analysis.Repository) build #$($Analysis.RunId)" + url = $Analysis.BuildUrl + color = $color + description = ($flags -join ', ') + fields = @( + @{ name = 'Status'; value = "$emoji $($Analysis.Status)"; inline = $true }, + @{ name = 'Branch'; value = $Analysis.Branch; inline = $true }, + @{ name = 'Duration'; value = $Analysis.DurationText; inline = $true }, + @{ name = 'Commit'; value = $commitValue; inline = $false }, + @{ name = 'Summary'; value = $summary; inline = $false } + ) + footer = @{ + text = "Job ID $($Analysis.JobId) | Run ID $($Analysis.RunId)" + } + timestamp = (Get-Date).ToUniversalTime().ToString('o') + } +} + +function Send-DiscordNotification { + param([Parameter(Mandatory)][object[]]$Analyses) + + $alerting = @($Analyses | Where-Object { $_.ShouldAlert } | Sort-Object Severity, Repository, RunId) + if ($alerting.Count -eq 0) { + Write-Log -Message 'No alertable builds found.' + return + } + + $chunks = for ($i = 0; $i -lt $alerting.Count; $i += [int]$Config.MaxDiscordEmbeds) { + ,($alerting[$i..([Math]::Min($i + [int]$Config.MaxDiscordEmbeds - 1, $alerting.Count - 1))]) + } + + foreach ($chunk in $chunks) { + $criticalCount = @($chunk | Where-Object { $_.Severity -eq 'Critical' }).Count + $warningCount = @($chunk | Where-Object { $_.Severity -eq 'Warning' }).Count + $payload = @{ + username = 'Gitea Build Monitor' + content = if ($criticalCount -gt 0) { "❌ Gitea build monitor found $criticalCount critical issue(s) and $warningCount warning(s)." } else { "⚠️ Gitea build monitor found $warningCount warning(s)." } + embeds = @($chunk | ForEach-Object { New-DiscordEmbed -Analysis $_ }) + } + + Invoke-DiscordWebhook -Payload $payload + Write-Log -Message "Sent Discord notification with $($chunk.Count) embed(s)." + } +} + +function Test-IsRecentBuild { + param([Parameter(Mandatory)][object]$Job) + + $cutoff = (Get-Date).ToUniversalTime().AddMinutes(-[int]$Config.LookbackMinutes) + $candidateDates = @( + Get-ObjectValue -Object $Job -Name 'started_at' + Get-ObjectValue -Object $Job -Name 'completed_at' + Get-ObjectValue -Object $Job -Name 'created_at' + ) | Where-Object { $_ } + foreach ($date in $candidateDates) { + if (([datetime]$date).ToUniversalTime() -ge $cutoff) { + return $true + } + } + return $false +} + +function Get-CacheKey { + param( + [Parameter(Mandatory)][hashtable]$Repository, + [Parameter(Mandatory)][object]$Job + ) + + $jobId = Get-ObjectValue -Object $Job -Name 'id' + $conclusion = Get-ObjectValue -Object $Job -Name 'conclusion' + $status = Get-ObjectValue -Object $Job -Name 'status' -Default 'unknown' + $state = if ($conclusion) { $conclusion } else { $status } + + return "$(Get-RepositorySlug -Repository $Repository):job:$jobId`:state:$state" +} + +function Start-BuildMonitor { + Assert-Config + + $logDirectory = Split-Path -Parent $Config.LogFile + if ($logDirectory -and -not (Test-Path -Path $logDirectory)) { + New-Item -ItemType Directory -Path $logDirectory -Force | Out-Null + } + + if ($ValidateOnly) { + Write-Log -Message 'Validation mode completed: configuration and PowerShell runtime are valid.' + return + } + + $cache = if ($DryRun) { @{} } else { Read-ProcessedCache } + $analyses = New-Object System.Collections.Generic.List[object] + + foreach ($repository in $Config.Repositories) { + Write-Log -Message "Fetching recent Gitea Actions jobs for $(Get-RepositorySlug -Repository $repository)." + + try { + $jobs = Get-BuildRuns -Repository $repository + } + catch { + Write-Log -Level 'ERROR' -Message "Failed to retrieve jobs for $(Get-RepositorySlug -Repository $repository): $($_.Exception.Message)" + continue + } + + foreach ($job in $jobs) { + if (-not (Test-IsRecentBuild -Job $job)) { + continue + } + + $cacheKey = Get-CacheKey -Repository $repository -Job $job + if ($cache.ContainsKey($cacheKey)) { + continue + } + + try { + $analysis = Analyze-Build -Repository $repository -Job $job + $analyses.Add($analysis) + if (-not $DryRun) { + $cache[$cacheKey] = Get-Date + } + Write-Log -Message ("Analyzed job {0} for {1}: severity={2}, status={3}, duration={4}" -f (Get-ObjectValue -Object $job -Name 'id'), $analysis.Repository, $analysis.Severity, $analysis.Status, $analysis.DurationText) + } + catch { + Write-Log -Level 'ERROR' -Message "Failed to analyze job $(Get-ObjectValue -Object $job -Name 'id'): $($_.Exception.Message)" + } + } + } + + if ($analyses.Count -gt 0) { + Send-DiscordNotification -Analyses @($analyses) + } + else { + Write-Log -Message "No new jobs found in the last $($Config.LookbackMinutes) minute(s)." + } + + if (-not $DryRun) { + Save-ProcessedCache -Cache $cache + } +} + +try { + Start-BuildMonitor +} +catch { + Write-Log -Level 'ERROR' -Message "Build monitor failed: $($_.Exception.Message)" + exit 1 +}