feat(sprint5): quality pass, smoke test workflow, Get-CIJobSummary

Task 5.1: Delete runner/Install-Runner.ps1 (superseded by Setup-Host.ps1).
Task 5.2: Replace Unicode/Discord emoji in Watch-DiskSpace.ps1, Watch-RunnerHealth.ps1,
          Test-E2E-Section3.3.ps1 with plain-text prefixes ([WARNING], [ALERT], [WARN]).
Task 5.3: docs/TEST-PLAN-v1.3-to-HEAD.md: ForEach-Object -Parallel replaced with Start-Job;
          -RepositoryUrl replaced with -RepoUrl (3 occurrences); PS7 syntax removed.
Task 5.4: scripts/Get-CIJobSummary.ps1 (new): scans JSONL logs in F:\CI\Logs, prints summary
          table per job, -JobId detail view, -Failed filter, -Last N.
Task 3.5: gitea/workflows/self-test.yml (new): workflow_dispatch, smoke-windows + smoke-linux.
          scripts/Test-Smoke.ps1 (new): invokes Invoke-CIJob.ps1 with marker-file build,
          asserts artifact dir and JSONL success event.
Task 5.6: docs/BEST-PRACTICES.md Section 10 added: SHA256 pinning policy, priority table,
          PS 5.1 implementation pattern, update guidance.
This commit is contained in:
Simone
2026-05-12 21:13:15 +02:00
parent 9de86ac289
commit c04a2f25aa
9 changed files with 447 additions and 222 deletions
+181
View File
@@ -0,0 +1,181 @@
#Requires -Version 5.1
<#
.SYNOPSIS
Displays a summary table of CI job results parsed from JSONL log files.
.DESCRIPTION
Scans F:\CI\Logs\ (or a custom path) for invoke-ci.jsonl files and
prints a compact table with job ID, status, total elapsed time, and the
last error message (if any).
Each row corresponds to one completed (or failed) CI job.
Use -JobId to inspect a single job in detail.
.PARAMETER LogDir
Base directory containing per-job log subdirectories.
Default: F:\CI\Logs
.PARAMETER Last
Show only the N most recent jobs (sorted by job-start timestamp).
Default: 20
.PARAMETER JobId
When specified, shows a detailed phase-by-phase breakdown for that job.
.PARAMETER Failed
When specified, filters to failed jobs only.
.EXAMPLE
# Show last 20 jobs
.\Get-CIJobSummary.ps1
.EXAMPLE
# Show last 5 failed jobs
.\Get-CIJobSummary.ps1 -Last 5 -Failed
.EXAMPLE
# Detailed breakdown for a specific job
.\Get-CIJobSummary.ps1 -JobId 'run-12345'
#>
[CmdletBinding()]
param(
[string] $LogDir = 'F:\CI\Logs',
[ValidateRange(1, 1000)]
[int] $Last = 20,
[string] $JobId = '',
[switch] $Failed
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
# ── Helpers ────────────────────────────────────────────────────────────────────
function Format-SecToHMS {
param([int] $Seconds)
if ($Seconds -lt 0) { return '?' }
$h = [math]::Floor($Seconds / 3600)
$m = [math]::Floor(($Seconds % 3600) / 60)
$s = $Seconds % 60
if ($h -gt 0) { return '{0}h{1:00}m{2:00}s' -f $h, $m, $s }
return '{0}m{1:00}s' -f $m, $s
}
function Read-JobEvents {
param([string] $JsonlPath)
$events = [System.Collections.Generic.List[PSCustomObject]]::new()
foreach ($line in (Get-Content $JsonlPath -Encoding UTF8 -ErrorAction SilentlyContinue)) {
$line = $line.Trim()
if (-not $line) { continue }
try {
$events.Add(($line | ConvertFrom-Json))
} catch {
Write-Debug "[Get-CIJobSummary] Skipping malformed JSONL line in $JsonlPath"
}
}
return $events
}
# ── Validate log dir ────────────────────────────────────────────────────────
if (-not (Test-Path $LogDir -PathType Container)) {
Write-Warning "Log directory not found: $LogDir"
exit 1
}
# ── Single-job detail mode ──────────────────────────────────────────────────
if ($JobId -ne '') {
$jsonlPath = Join-Path $LogDir (Join-Path $JobId 'invoke-ci.jsonl')
if (-not (Test-Path $jsonlPath)) {
Write-Warning "No JSONL log found for job '$JobId' at: $jsonlPath"
exit 1
}
$events = Read-JobEvents -JsonlPath $jsonlPath
if ($events.Count -eq 0) {
Write-Warning "JSONL file is empty or unreadable: $jsonlPath"
exit 1
}
Write-Host "`nJob: $JobId"
Write-Host ('=' * 60)
Write-Host ('{0,-35} {1,-10} {2}' -f 'Phase', 'Status', 'Timestamp')
Write-Host ('{0,-35} {1,-10} {2}' -f ('-'*35), ('-'*10), ('-'*24))
foreach ($ev in $events) {
$ts = if ($ev.ts) { $ev.ts } else { '' }
Write-Host ('{0,-35} {1,-10} {2}' -f $ev.phase, $ev.status, $ts)
}
# Print error if present
$failEvent = $events | Where-Object { $_.status -eq 'failure' } | Select-Object -Last 1
if ($failEvent -and $failEvent.data -and $failEvent.data.error) {
Write-Host "`nError: $($failEvent.data.error)"
}
exit 0
}
# ── Scan all job JSONL files ─────────────────────────────────────────────────
$jsonlFiles = Get-ChildItem -Path $LogDir -Recurse -Filter 'invoke-ci.jsonl' -ErrorAction SilentlyContinue |
Sort-Object { $_.LastWriteTime } -Descending
if ($jsonlFiles.Count -eq 0) {
Write-Host "No invoke-ci.jsonl files found under $LogDir"
exit 0
}
$rows = [System.Collections.Generic.List[PSCustomObject]]::new()
foreach ($file in $jsonlFiles) {
$events = Read-JobEvents -JsonlPath $file.FullName
if ($events.Count -eq 0) { continue }
$jobEvent = $events | Where-Object { $_.phase -eq 'job' -and $_.status -eq 'start' } | Select-Object -First 1
$successEvent= $events | Where-Object { $_.phase -eq 'job' -and $_.status -eq 'success' } | Select-Object -First 1
$failEvent = $events | Where-Object { $_.phase -eq 'job' -and $_.status -eq 'failure' } | Select-Object -First 1
$jId = if ($events[0].jobId) { $events[0].jobId } else { (Split-Path (Split-Path $file.FullName -Parent) -Leaf) }
$started = if ($jobEvent -and $jobEvent.ts) { $jobEvent.ts } else { '' }
if ($successEvent) {
$status = 'success'
$elSec = if ($successEvent.data -and $null -ne $successEvent.data.elapsedSec) { [int]$successEvent.data.elapsedSec } else { -1 }
$errMsg = ''
} elseif ($failEvent) {
$status = 'FAILED'
$elSec = if ($failEvent.data -and $null -ne $failEvent.data.elapsedSec) { [int]$failEvent.data.elapsedSec } else { -1 }
$errMsg = if ($failEvent.data -and $failEvent.data.error) { "$($failEvent.data.error)" } else { '' }
# Truncate error for table display
if ($errMsg.Length -gt 60) { $errMsg = $errMsg.Substring(0, 57) + '...' }
} else {
$status = 'in-progress'
$elSec = -1
$errMsg = ''
}
$rows.Add([PSCustomObject]@{
JobId = $jId
Status = $status
Elapsed = Format-SecToHMS -Seconds $elSec
Started = $started
Error = $errMsg
})
}
# ── Apply filters + limit ────────────────────────────────────────────────────
if ($Failed) {
$rows = @($rows | Where-Object { $_.Status -eq 'FAILED' })
}
$rows = @($rows | Select-Object -First $Last)
if ($rows.Count -eq 0) {
Write-Host "No matching jobs found."
exit 0
}
# ── Print table ──────────────────────────────────────────────────────────────
Write-Host "`nCI Job Summary (last $($rows.Count) jobs):"
Write-Host ('{0,-40} {1,-12} {2,-10} {3,-26} {4}' -f 'JobId', 'Status', 'Elapsed', 'Started', 'Error')
Write-Host ('{0,-40} {1,-12} {2,-10} {3,-26} {4}' -f ('-'*40), ('-'*12), ('-'*10), ('-'*26), ('-'*30))
foreach ($r in $rows) {
Write-Host ('{0,-40} {1,-12} {2,-10} {3,-26} {4}' -f $r.JobId, $r.Status, $r.Elapsed, $r.Started, $r.Error)
}
Write-Host ''
+1 -1
View File
@@ -198,7 +198,7 @@ if ($baselineResult -and $guestCloneResult) {
Write-Host "✓ §3.3 guest-clone is FASTER by $(Format-Elapsed ([TimeSpan]::FromSeconds($diff)))" -ForegroundColor Green
}
else {
Write-Host " Host-side is faster by $(Format-Elapsed ([TimeSpan]::FromSeconds(-$diff)))" -ForegroundColor Yellow
Write-Host "[WARN] Host-side is faster by $(Format-Elapsed ([TimeSpan]::FromSeconds(-$diff)))" -ForegroundColor Yellow
}
}
else {
+166
View File
@@ -0,0 +1,166 @@
#Requires -Version 5.1
<#
.SYNOPSIS
Smoke test for the CI pipeline. Validates the full lifecycle end-to-end
using a trivial no-op build.
.DESCRIPTION
Invokes Invoke-CIJob.ps1 with a minimal build command (write a marker file)
and checks that the pipeline completes successfully:
- Invoke-CIJob.ps1 exits 0
- Artifact directory is created: F:\CI\Artifacts\<JobId>\
- invoke-ci.jsonl contains a job-success event
This script can be run directly on the CI host without act_runner.
It is also invoked by gitea/workflows/self-test.yml via the composite action.
.PARAMETER GuestOS
Guest OS template to use. Windows (default) or Linux.
.PARAMETER TemplatePath
Override the VMX template path. Defaults to the standard template for
the chosen GuestOS.
.PARAMETER SnapshotName
Snapshot to clone from. Default: BaseClean (Windows) or BaseClean-Linux (Linux).
.PARAMETER JobId
Job ID for this test run. Default: smoke-<timestamp>.
.PARAMETER VmrunPath
Path to vmrun.exe. Default: C:\Program Files (x86)\VMware\VMware Workstation\vmrun.exe
.EXAMPLE
# Test Windows template
.\Test-Smoke.ps1
.EXAMPLE
# Test Linux template
.\Test-Smoke.ps1 -GuestOS Linux
#>
[CmdletBinding()]
param(
[ValidateSet('Windows', 'Linux')]
[string] $GuestOS = 'Windows',
[string] $TemplatePath,
[string] $SnapshotName,
[string] $JobId = "smoke-$(Get-Date -Format 'yyyyMMdd-HHmmss')",
[string] $VmrunPath = 'C:\Program Files (x86)\VMware\VMware Workstation\vmrun.exe',
[string] $ArtifactBaseDir = 'F:\CI\Artifacts',
[string] $LogDir = 'F:\CI\Logs'
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$scriptDir = $PSScriptRoot
# ── Resolve defaults ──────────────────────────────────────────────────────────
if (-not $TemplatePath) {
if ($GuestOS -eq 'Linux') {
$TemplatePath = if ($env:GITEA_CI_LINUX_TEMPLATE_PATH) {
$env:GITEA_CI_LINUX_TEMPLATE_PATH
} else {
'F:\CI\Templates\LinuxBuild2404\LinuxBuild2404.vmx'
}
} else {
$TemplatePath = if ($env:GITEA_CI_TEMPLATE_PATH) {
$env:GITEA_CI_TEMPLATE_PATH
} else {
'F:\CI\Templates\WinBuild2025\WinBuild2025.vmx'
}
}
}
if (-not $SnapshotName) {
$SnapshotName = if ($GuestOS -eq 'Linux') { 'BaseClean-Linux' } else { 'BaseClean' }
}
# ── Build command ─────────────────────────────────────────────────────────────
# Writes a marker file to the standard output directory for the chosen OS.
if ($GuestOS -eq 'Linux') {
$buildCommand = 'mkdir -p /opt/ci/output && echo smoke-ok > /opt/ci/output/smoke.txt'
$artifactSource = '/opt/ci/output'
} else {
$buildCommand = 'New-Item -ItemType Directory -Path C:\CI\output -Force | Out-Null; Set-Content C:\CI\output\smoke.txt "smoke-ok"'
$artifactSource = 'C:\CI\output'
}
Write-Host "============================================================"
Write-Host "[Test-Smoke] GuestOS : $GuestOS"
Write-Host "[Test-Smoke] Template : $TemplatePath"
Write-Host "[Test-Smoke] Snapshot : $SnapshotName"
Write-Host "[Test-Smoke] JobId : $JobId"
Write-Host "============================================================"
# ── Run the pipeline ──────────────────────────────────────────────────────────
$invokeParams = @{
JobId = $JobId
RepoUrl = 'https://example.invalid/smoke-test.git' # never cloned; UseGitClone omitted
Branch = 'main'
TemplatePath = $TemplatePath
SnapshotName = $SnapshotName
BuildCommand = $buildCommand
GuestArtifactSource = $artifactSource
ArtifactBaseDir = $ArtifactBaseDir
LogDir = $LogDir
GuestOS = $GuestOS
VmrunPath = $VmrunPath
}
$invokeScript = Join-Path $scriptDir 'Invoke-CIJob.ps1'
& $invokeScript @invokeParams
$pipelineExit = $LASTEXITCODE
# ── Assert artifacts ──────────────────────────────────────────────────────────
$artifactDir = Join-Path $ArtifactBaseDir $JobId
$jsonlPath = Join-Path $LogDir $JobId 'invoke-ci.jsonl'
Write-Host "`n[Test-Smoke] Verifying results..."
$pass = $true
if ($pipelineExit -ne 0) {
Write-Host "[Test-Smoke] FAIL: Invoke-CIJob.ps1 exited $pipelineExit" -ForegroundColor Red
$pass = $false
} else {
Write-Host "[Test-Smoke] OK: pipeline exit 0" -ForegroundColor Green
}
if (Test-Path $artifactDir) {
Write-Host "[Test-Smoke] OK: artifact dir exists: $artifactDir" -ForegroundColor Green
} else {
Write-Host "[Test-Smoke] FAIL: artifact dir not found: $artifactDir" -ForegroundColor Red
$pass = $false
}
if (Test-Path $jsonlPath) {
$jobSuccess = Get-Content $jsonlPath -Raw -ErrorAction SilentlyContinue |
Where-Object { $_ } |
ForEach-Object { $_ } |
Where-Object { $_ -match '"phase"\s*:\s*"job"' -and $_ -match '"status"\s*:\s*"success"' }
if ($jobSuccess) {
Write-Host "[Test-Smoke] OK: JSONL contains job/success event" -ForegroundColor Green
} else {
Write-Host "[Test-Smoke] FAIL: JSONL missing job/success event in: $jsonlPath" -ForegroundColor Red
$pass = $false
}
} else {
Write-Host "[Test-Smoke] FAIL: JSONL log not found: $jsonlPath" -ForegroundColor Red
$pass = $false
}
Write-Host ''
if ($pass) {
Write-Host "[Test-Smoke] PASSED — $GuestOS smoke test completed successfully." -ForegroundColor Green
exit 0
} else {
Write-Host "[Test-Smoke] FAILED — one or more checks did not pass." -ForegroundColor Red
exit 1
}
+1 -1
View File
@@ -83,7 +83,7 @@ try {
# Optional webhook (Discord / Gitea)
if ($WebhookUrl) {
$body = @{ content = ":warning: **CI Disk Alert** — $msg" } | ConvertTo-Json -Compress
$body = @{ content = "[WARNING] CI Disk Alert -- $msg" } | ConvertTo-Json -Compress
try {
$null = Invoke-RestMethod -Uri $WebhookUrl -Method Post `
-Body $body -ContentType 'application/json' -TimeoutSec 10
+4 -4
View File
@@ -127,7 +127,7 @@ if ($recentCount -ge $MaxRestarts) {
"Check Event Viewer → Application (source: $logSource) and F:\CI\act_runner\logs\."
Write-Warning "[RunnerHealth] $limitMsg"
Write-CIEvent -EventId 1004 -EntryType Error -Message $limitMsg
Send-Webhook -Content ":sos: **CI Runner Alert** — $limitMsg"
Send-Webhook -Content "[ALERT] CI Runner Alert -- $limitMsg"
exit 1
}
@@ -147,8 +147,8 @@ try {
$evtType = if ($newStatus -eq 'Running') { 'Information' } else { 'Warning' }
Write-CIEvent -EventId 1003 -EntryType $evtType -Message $restartMsg
$icon = if ($newStatus -eq 'Running') { ':warning:' } else { ':sos:' }
Send-Webhook -Content "$icon **CI Runner Alert** — $restartMsg"
$prefix = if ($newStatus -eq 'Running') { '[WARNING]' } else { '[ALERT]' }
Send-Webhook -Content "$prefix CI Runner Alert -- $restartMsg"
exit $(if ($newStatus -eq 'Running') { 0 } else { 1 })
}
@@ -156,6 +156,6 @@ catch {
$errMsg = "$ServiceName restart failed: $_"
Write-Warning "[RunnerHealth] $errMsg"
Write-CIEvent -EventId 1004 -EntryType Error -Message $errMsg
Send-Webhook -Content ":sos: **CI Runner Alert** — $errMsg"
Send-Webhook -Content "[ALERT] CI Runner Alert -- $errMsg"
exit 1
}