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:
@@ -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)`
|
6. Take new snapshot: `BaseClean_$(Get-Date -Format yyyyMMdd)`
|
||||||
7. Update `SnapshotName` in `runner/config.yaml`
|
7. Update `SnapshotName` in `runner/config.yaml`
|
||||||
8. Delete the old snapshot after confirming new one works for 1 week
|
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\`.
|
||||||
|
|||||||
@@ -52,13 +52,17 @@ Test-Path F:\CI\State\ip-leases\
|
|||||||
# Expected: True
|
# Expected: True
|
||||||
|
|
||||||
# 2. Start 4 parallel jobs on nsis-plugin-nsinnounp (or similar)
|
# 2. Start 4 parallel jobs on nsis-plugin-nsinnounp (or similar)
|
||||||
# Simulate via:
|
# Simulate via (PS 5.1 — Start-Job, not ForEach-Object -Parallel):
|
||||||
1..4 | ForEach-Object -Parallel {
|
$jobs = 1..4 | ForEach-Object {
|
||||||
|
$i = $_
|
||||||
|
Start-Job -ScriptBlock {
|
||||||
& 'N:\Code\Workspace\Local-CI-CD-System\scripts\Invoke-CIJob.ps1' `
|
& '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 "test-parallel-$_" `
|
-JobId "test-parallel-$using:i" `
|
||||||
-Branch main
|
-Branch main
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
$jobs | Wait-Job | Receive-Job
|
||||||
|
|
||||||
# 3. Monitor IP leases during build
|
# 3. Monitor IP leases during build
|
||||||
Get-ChildItem F:\CI\State\ip-leases\ | ForEach-Object {
|
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
|
# 1. Run a complete build and capture job ID
|
||||||
$jobId = 'test-jsonl-logging'
|
$jobId = 'test-jsonl-logging'
|
||||||
& 'N:\Code\Workspace\Local-CI-CD-System\scripts\Invoke-CIJob.ps1' `
|
& '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 `
|
-JobId $jobId `
|
||||||
-Branch main
|
-Branch main
|
||||||
|
|
||||||
@@ -832,7 +836,7 @@ After validating new features, run one full end-to-end build to ensure nothing b
|
|||||||
```powershell
|
```powershell
|
||||||
# Full e2e validation
|
# Full e2e validation
|
||||||
& 'N:\Code\Workspace\Local-CI-CD-System\scripts\Invoke-CIJob.ps1' `
|
& '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' `
|
-JobId 'e2e-test-post-v1.3' `
|
||||||
-Branch main `
|
-Branch main `
|
||||||
-Verbose
|
-Verbose
|
||||||
|
|||||||
@@ -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 }}'
|
||||||
@@ -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)"
|
|
||||||
@@ -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 ''
|
||||||
@@ -198,7 +198,7 @@ if ($baselineResult -and $guestCloneResult) {
|
|||||||
Write-Host "✓ §3.3 guest-clone is FASTER by $(Format-Elapsed ([TimeSpan]::FromSeconds($diff)))" -ForegroundColor Green
|
Write-Host "✓ §3.3 guest-clone is FASTER by $(Format-Elapsed ([TimeSpan]::FromSeconds($diff)))" -ForegroundColor Green
|
||||||
}
|
}
|
||||||
else {
|
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 {
|
else {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -83,7 +83,7 @@ try {
|
|||||||
|
|
||||||
# Optional webhook (Discord / Gitea)
|
# Optional webhook (Discord / Gitea)
|
||||||
if ($WebhookUrl) {
|
if ($WebhookUrl) {
|
||||||
$body = @{ content = ":warning: **CI Disk Alert** — $msg" } | ConvertTo-Json -Compress
|
$body = @{ content = "[WARNING] CI Disk Alert -- $msg" } | ConvertTo-Json -Compress
|
||||||
try {
|
try {
|
||||||
$null = Invoke-RestMethod -Uri $WebhookUrl -Method Post `
|
$null = Invoke-RestMethod -Uri $WebhookUrl -Method Post `
|
||||||
-Body $body -ContentType 'application/json' -TimeoutSec 10
|
-Body $body -ContentType 'application/json' -TimeoutSec 10
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ if ($recentCount -ge $MaxRestarts) {
|
|||||||
"Check Event Viewer → Application (source: $logSource) and F:\CI\act_runner\logs\."
|
"Check Event Viewer → Application (source: $logSource) and F:\CI\act_runner\logs\."
|
||||||
Write-Warning "[RunnerHealth] $limitMsg"
|
Write-Warning "[RunnerHealth] $limitMsg"
|
||||||
Write-CIEvent -EventId 1004 -EntryType Error -Message $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
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,8 +147,8 @@ try {
|
|||||||
$evtType = if ($newStatus -eq 'Running') { 'Information' } else { 'Warning' }
|
$evtType = if ($newStatus -eq 'Running') { 'Information' } else { 'Warning' }
|
||||||
Write-CIEvent -EventId 1003 -EntryType $evtType -Message $restartMsg
|
Write-CIEvent -EventId 1003 -EntryType $evtType -Message $restartMsg
|
||||||
|
|
||||||
$icon = if ($newStatus -eq 'Running') { ':warning:' } else { ':sos:' }
|
$prefix = if ($newStatus -eq 'Running') { '[WARNING]' } else { '[ALERT]' }
|
||||||
Send-Webhook -Content "$icon **CI Runner Alert** — $restartMsg"
|
Send-Webhook -Content "$prefix CI Runner Alert -- $restartMsg"
|
||||||
|
|
||||||
exit $(if ($newStatus -eq 'Running') { 0 } else { 1 })
|
exit $(if ($newStatus -eq 'Running') { 0 } else { 1 })
|
||||||
}
|
}
|
||||||
@@ -156,6 +156,6 @@ catch {
|
|||||||
$errMsg = "$ServiceName restart failed: $_"
|
$errMsg = "$ServiceName restart failed: $_"
|
||||||
Write-Warning "[RunnerHealth] $errMsg"
|
Write-Warning "[RunnerHealth] $errMsg"
|
||||||
Write-CIEvent -EventId 1004 -EntryType Error -Message $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
|
exit 1
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user