From c04a2f25aa31b4f25daa11872307695e7c73a8da Mon Sep 17 00:00:00 2001 From: Simone Date: Tue, 12 May 2026 21:13:15 +0200 Subject: [PATCH] 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. --- docs/BEST-PRACTICES.md | 35 ++++++ docs/TEST-PLAN-v1.3-to-HEAD.md | 22 ++-- gitea/workflows/self-test.yml | 46 +++++++ runner/Install-Runner.ps1 | 207 -------------------------------- scripts/Get-CIJobSummary.ps1 | 181 ++++++++++++++++++++++++++++ scripts/Test-E2E-Section3.3.ps1 | 2 +- scripts/Test-Smoke.ps1 | 166 +++++++++++++++++++++++++ scripts/Watch-DiskSpace.ps1 | 2 +- scripts/Watch-RunnerHealth.ps1 | 8 +- 9 files changed, 447 insertions(+), 222 deletions(-) create mode 100644 gitea/workflows/self-test.yml delete mode 100644 runner/Install-Runner.ps1 create mode 100644 scripts/Get-CIJobSummary.ps1 create mode 100644 scripts/Test-Smoke.ps1 diff --git a/docs/BEST-PRACTICES.md b/docs/BEST-PRACTICES.md index 304d68f..e545c93 100644 --- a/docs/BEST-PRACTICES.md +++ b/docs/BEST-PRACTICES.md @@ -357,3 +357,38 @@ When a new .NET SDK or VS Build Tools version is released: 6. Take new snapshot: `BaseClean_$(Get-Date -Format yyyyMMdd)` 7. Update `SnapshotName` in `runner/config.yaml` 8. Delete the old snapshot after confirming new one works for 1 week + +--- + +## 10. SHA256 Pinning for Tier-1 Toolchain Installers + +**Homelab policy**: partial-coverage pinning is acceptable. Pin only the installers +that are re-downloaded as part of template provisioning (not installers already +cached in the ISO or in `F:\CI\ISO\`). + +Priority targets (descending risk): + +| Installer | Script | Where to pin | +|-----------|--------|--------------| +| Git for Windows `.exe` | `template/Install-CIToolchain-WinBuild2025.ps1` | `-sha256` param or `Get-FileHash` check after download | +| 7-Zip `.msi` / `.exe` | `template/Install-CIToolchain-WinBuild2025.ps1` | same | +| Python `.exe` | `template/Install-CIToolchain-WinBuild2025.ps1` | same | +| .NET SDK install script | `template/Install-CIToolchain-WinBuild2025.ps1` | HTTPS only; hash less critical | +| Ubuntu cloud VMDK | `template/Deploy-LinuxBuild2404.ps1` | already implemented via `-VmdkSha256` parameter | + +**Implementation pattern** (PS 5.1): + +```powershell +# After downloading $installerPath: +if ($ExpectedSha256 -ne '') { + $actual = (Get-FileHash -Path $installerPath -Algorithm SHA256).Hash + if ($actual -ne $ExpectedSha256.ToUpper()) { + throw "SHA256 mismatch for $installerPath.`n Expected: $ExpectedSha256`n Actual: $actual" + } + Write-Host "[Install] SHA256 OK: $installerPath" +} +``` + +Pin values must be updated each time a new installer version is adopted. +Store the expected hash in the script's parameter default or in a companion +`.sha256` sidecar file next to the cached installer in `F:\CI\ISO\`. diff --git a/docs/TEST-PLAN-v1.3-to-HEAD.md b/docs/TEST-PLAN-v1.3-to-HEAD.md index 05dfa24..7a4a986 100644 --- a/docs/TEST-PLAN-v1.3-to-HEAD.md +++ b/docs/TEST-PLAN-v1.3-to-HEAD.md @@ -52,13 +52,17 @@ Test-Path F:\CI\State\ip-leases\ # Expected: True # 2. Start 4 parallel jobs on nsis-plugin-nsinnounp (or similar) -# Simulate via: -1..4 | ForEach-Object -Parallel { - & 'N:\Code\Workspace\Local-CI-CD-System\scripts\Invoke-CIJob.ps1' ` - -RepositoryUrl 'http://10.10.20.11:3100/Simone/nsis-plugin-nsinnounp' ` - -JobId "test-parallel-$_" ` - -Branch main +# Simulate via (PS 5.1 — Start-Job, not ForEach-Object -Parallel): +$jobs = 1..4 | ForEach-Object { + $i = $_ + Start-Job -ScriptBlock { + & 'N:\Code\Workspace\Local-CI-CD-System\scripts\Invoke-CIJob.ps1' ` + -RepoUrl 'http://10.10.20.11:3100/Simone/nsis-plugin-nsinnounp' ` + -JobId "test-parallel-$using:i" ` + -Branch main + } } +$jobs | Wait-Job | Receive-Job # 3. Monitor IP leases during build Get-ChildItem F:\CI\State\ip-leases\ | ForEach-Object { @@ -228,7 +232,7 @@ to text transcript, enabling machine parsing and future Loki/Grafana integration # 1. Run a complete build and capture job ID $jobId = 'test-jsonl-logging' & 'N:\Code\Workspace\Local-CI-CD-System\scripts\Invoke-CIJob.ps1' ` - -RepositoryUrl 'http://10.10.20.11:3100/Simone/nsis-plugin-nsinnounp' ` + -RepoUrl 'http://10.10.20.11:3100/Simone/nsis-plugin-nsinnounp' ` -JobId $jobId ` -Branch main @@ -832,7 +836,7 @@ After validating new features, run one full end-to-end build to ensure nothing b ```powershell # Full e2e validation & 'N:\Code\Workspace\Local-CI-CD-System\scripts\Invoke-CIJob.ps1' ` - -RepositoryUrl 'http://10.10.20.11:3100/Simone/nsis-plugin-nsinnounp' ` + -RepoUrl 'http://10.10.20.11:3100/Simone/nsis-plugin-nsinnounp' ` -JobId 'e2e-test-post-v1.3' ` -Branch main ` -Verbose @@ -840,4 +844,4 @@ After validating new features, run one full end-to-end build to ensure nothing b # Expected: Build completes successfully, artifact created, JSONL log present, no errors ``` - + diff --git a/gitea/workflows/self-test.yml b/gitea/workflows/self-test.yml new file mode 100644 index 0000000..8bab687 --- /dev/null +++ b/gitea/workflows/self-test.yml @@ -0,0 +1,46 @@ +# CI Self-Test (smoke) — Local CI/CD System +# +# On-demand smoke test that validates the full pipeline end-to-end: +# VM clone -> start -> IP detect -> trivial build -> artifact collect -> destroy +# +# Trigger: workflow_dispatch only (never runs on push/tag). +# Both jobs are independent — a failure in one does not cancel the other. +# +# Prerequisites (must be in place before running): +# - Runner labels: windows-build:host and linux-build:host active +# - Templates at GITEA_CI_TEMPLATE_PATH / GITEA_CI_LINUX_TEMPLATE_PATH +# - Snapshots BaseClean (Windows) and BaseClean-Linux (Linux) exist +# - ci-report-ip.service enabled in LinuxBuild2404 template + +name: CI Self-Test (smoke) + +on: + workflow_dispatch: + +jobs: + + smoke-windows: + runs-on: windows-build + steps: + - uses: Simone/local-ci-system/.gitea/actions/local-ci-build@main + with: + # Trivial no-op build: write a marker file to the CI output directory. + # The Windows build VM maps C:\CI\output as the artifact source. + build-command: 'New-Item -ItemType Directory -Path C:\CI\output -Force | Out-Null; Set-Content C:\CI\output\smoke.txt "smoke-ok"' + artifact-source: 'C:\CI\output' + guest-os: 'Windows' + job-id-suffix: 'smoke-win' + artifact-name: 'smoke-windows-${{ github.run_id }}' + + smoke-linux: + runs-on: linux-build + steps: + - uses: Simone/local-ci-system/.gitea/actions/local-ci-build@main + with: + # Trivial no-op build: write a marker file to the CI output directory. + # The Linux build VM uses /opt/ci/output as the artifact source. + build-command: 'mkdir -p /opt/ci/output && echo smoke-ok > /opt/ci/output/smoke.txt' + artifact-source: '/opt/ci/output' + guest-os: 'Linux' + job-id-suffix: 'smoke-linux' + artifact-name: 'smoke-linux-${{ github.run_id }}' diff --git a/runner/Install-Runner.ps1 b/runner/Install-Runner.ps1 deleted file mode 100644 index 9efc4f3..0000000 --- a/runner/Install-Runner.ps1 +++ /dev/null @@ -1,207 +0,0 @@ -#Requires -RunAsAdministrator -#Requires -Version 5.1 -<# -.SYNOPSIS - Downloads, registers, and installs act_runner as a Windows service. - -.NOTES - DEPRECATED — use Setup-Host.ps1 instead. - Setup-Host.ps1 handles the full host bootstrap including act_runner service - installation in a single idempotent script. Install-Runner.ps1 is kept for - reference but may drift from Setup-Host.ps1 over time. - -.DESCRIPTION - 1. Downloads the act_runner binary from the specified URL (or Gitea releases). - 2. Registers the runner with the Gitea instance using a registration token. - 3. Installs it as a Windows service that auto-starts with the system. - - Prerequisites: - - Run as Administrator - - NSSM (Non-Sucking Service Manager) must be installed and in PATH, - OR use the built-in sc.exe method (creates a wrapper via PowerShell). - - The Gitea instance must be reachable from this host. - -.PARAMETER GiteaURL - Base URL of your Gitea instance. Example: http://192.168.1.50:3000 - -.PARAMETER RegistrationToken - Runner registration token from Gitea: - Admin Panel → Settings → Actions → Runners → "Create Runner Token" - -.PARAMETER InstallDir - Directory where act_runner will be installed. - Default: F:\CI\act_runner - -.PARAMETER RunnerName - Display name for this runner in the Gitea UI. - Default: local-windows-runner - -.PARAMETER ActRunnerVersion - Version tag to download. Default: latest (resolves via GitHub API). - Example: v0.2.11 - -.PARAMETER ServiceName - Windows service name. Default: act_runner - -.EXAMPLE - .\Install-Runner.ps1 ` - -GiteaURL "http://192.168.1.50:3000" ` - -RegistrationToken "abc123xyz" -#> -[CmdletBinding()] -param( - [Parameter(Mandatory)] - [string] $GiteaURL, - - [Parameter(Mandatory)] - [string] $RegistrationToken, - - [string] $InstallDir = 'F:\CI\act_runner', - - [string] $RunnerName = 'local-windows-runner', - - [string] $ActRunnerVersion = 'latest', - - [string] $ServiceName = 'act_runner' -) - -Set-StrictMode -Version Latest -$ErrorActionPreference = 'Stop' - -# ── Resolve download URL ────────────────────────────────────────────────────── -if ($ActRunnerVersion -eq 'latest') { - Write-Host "[Install-Runner] Resolving latest act_runner version from Gitea releases..." - try { - $releaseInfo = Invoke-RestMethod -Uri 'https://dl.gitea.com/act_runner/version.json' -UseBasicParsing - $ActRunnerVersion = $releaseInfo.latest - } - catch { - Write-Warning "Could not resolve latest version automatically. Set -ActRunnerVersion explicitly." - Write-Warning "Find releases at: https://gitea.com/gitea/act_runner/releases" - throw - } -} - -$downloadUrl = "https://dl.gitea.com/act_runner/${ActRunnerVersion}/act_runner-${ActRunnerVersion}-windows-amd64.exe" -$binaryPath = Join-Path $InstallDir 'act_runner.exe' -$configPath = Join-Path $InstallDir 'config.yaml' - -Write-Host "[Install-Runner] Version : $ActRunnerVersion" -Write-Host "[Install-Runner] Install dir: $InstallDir" -Write-Host "[Install-Runner] Service : $ServiceName" - -# ── Create install directory ────────────────────────────────────────────────── -if (-not (Test-Path $InstallDir)) { - New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null - Write-Host "[Install-Runner] Created directory: $InstallDir" -} - -# ── Download act_runner binary ──────────────────────────────────────────────── -if (Test-Path $binaryPath) { - Write-Host "[Install-Runner] act_runner.exe already exists — skipping download." - Write-Host " Delete $binaryPath to force re-download." -} -else { - Write-Host "[Install-Runner] Downloading from: $downloadUrl" - Invoke-WebRequest -Uri $downloadUrl -OutFile $binaryPath -UseBasicParsing - Write-Host "[Install-Runner] Downloaded: $binaryPath" -} - -# ── Copy config.yaml if not already present ─────────────────────────────────── -$repoConfigPath = Join-Path $PSScriptRoot 'config.yaml' -if (-not (Test-Path $configPath)) { - if (Test-Path $repoConfigPath) { - Copy-Item $repoConfigPath $configPath - Write-Host "[Install-Runner] Copied config.yaml from repo to $configPath" - Write-Host " Review and update paths in $configPath before proceeding." - } - else { - Write-Warning "[Install-Runner] config.yaml not found at $repoConfigPath" - Write-Warning " Generating default config and registering without custom config." - # Generate default config - Push-Location $InstallDir - & $binaryPath generate-config | Out-File $configPath -Encoding UTF8 - Pop-Location - } -} -else { - Write-Host "[Install-Runner] Using existing config.yaml: $configPath" -} - -# ── Register runner with Gitea ──────────────────────────────────────────────── -$stateFile = Join-Path $InstallDir '.runner' -if (Test-Path $stateFile) { - Write-Host "[Install-Runner] Runner already registered (.runner state file exists)." - Write-Host " Delete $stateFile to re-register." -} -else { - Write-Host "[Install-Runner] Registering runner with Gitea at $GiteaURL..." - Push-Location $InstallDir - try { - & $binaryPath register ` - --no-interactive ` - --instance $GiteaURL ` - --token $RegistrationToken ` - --name $RunnerName ` - --labels 'windows-build:host,dotnet:host,msbuild:host' ` - --config $configPath - if ($LASTEXITCODE -ne 0) { - throw "act_runner register exited with code $LASTEXITCODE" - } - } - finally { - Pop-Location - } - Write-Host "[Install-Runner] Runner registered successfully." -} - -# ── Install as Windows service ──────────────────────────────────────────────── -$existingService = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue - -if ($existingService) { - Write-Host "[Install-Runner] Service '$ServiceName' already exists." - Write-Host " Stopping and updating..." - Stop-Service -Name $ServiceName -Force -ErrorAction SilentlyContinue -} - -# Build service command (act_runner daemon with explicit config) -$serviceCmd = "`"$binaryPath`" daemon --config `"$configPath`"" - -if ($existingService) { - # Update existing service binary path - sc.exe config $ServiceName binpath= $serviceCmd | Out-Null -} -else { - # Create new service - New-Service ` - -Name $ServiceName ` - -BinaryPathName $serviceCmd ` - -DisplayName 'Gitea act_runner (CI/CD)' ` - -Description 'Gitea Actions runner for ephemeral VMware build VMs' ` - -StartupType Automatic | Out-Null - Write-Host "[Install-Runner] Service created: $ServiceName" -} - -# Configure service recovery: restart on failure -sc.exe failure $ServiceName reset= 86400 actions= restart/5000/restart/10000/restart/30000 | Out-Null - -# Start the service -Start-Service -Name $ServiceName -Write-Host "[Install-Runner] Service '$ServiceName' started." - -# ── Verify ──────────────────────────────────────────────────────────────────── -Start-Sleep -Seconds 3 -$svc = Get-Service -Name $ServiceName -if ($svc.Status -eq 'Running') { - Write-Host "[Install-Runner] act_runner is running as a Windows service." - Write-Host "[Install-Runner] Check Gitea Admin → Settings → Actions → Runners to confirm it appears Online." -} -else { - Write-Warning "[Install-Runner] Service status: $($svc.Status). Check Windows Event Log for errors." -} - -Write-Host "" -Write-Host "Installation complete." -Write-Host " Binary : $binaryPath" -Write-Host " Config : $configPath" -Write-Host " Service : $ServiceName (startup: Automatic)" diff --git a/scripts/Get-CIJobSummary.ps1 b/scripts/Get-CIJobSummary.ps1 new file mode 100644 index 0000000..0182f3e --- /dev/null +++ b/scripts/Get-CIJobSummary.ps1 @@ -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 '' diff --git a/scripts/Test-E2E-Section3.3.ps1 b/scripts/Test-E2E-Section3.3.ps1 index 539aaff..85849ac 100644 --- a/scripts/Test-E2E-Section3.3.ps1 +++ b/scripts/Test-E2E-Section3.3.ps1 @@ -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 { diff --git a/scripts/Test-Smoke.ps1 b/scripts/Test-Smoke.ps1 new file mode 100644 index 0000000..4783cbe --- /dev/null +++ b/scripts/Test-Smoke.ps1 @@ -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\\ + - 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-. + +.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 +} diff --git a/scripts/Watch-DiskSpace.ps1 b/scripts/Watch-DiskSpace.ps1 index 952532a..ce1bd00 100644 --- a/scripts/Watch-DiskSpace.ps1 +++ b/scripts/Watch-DiskSpace.ps1 @@ -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 diff --git a/scripts/Watch-RunnerHealth.ps1 b/scripts/Watch-RunnerHealth.ps1 index 4d7363a..6b0c20e 100644 --- a/scripts/Watch-RunnerHealth.ps1 +++ b/scripts/Watch-RunnerHealth.ps1 @@ -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 }