chore: initial commit local CI/CD system (e2e verified, production-ready)

Sistema CI locale basato su VMware Workstation + Gitea Actions + act_runner.
Testato e2e con nsis-plugin-nsinnounp (MSBuild + Python, 4 configurazioni parallele).

- scripts/: Invoke-CIJob, Invoke-RemoteBuild, New/Remove-BuildVM, Wait-VMReady, Get-BuildArtifacts
- runner/: act_runner config (windows-build label, capacity 4)
- gitea/workflows/: build-nsis.yml (template per progetti MSBuild/Python)
- template/: script di provisioning template VM (VS BuildTools 2026, .NET SDK 10, Python 3.13)
- docs/: ARCHITECTURE, CI-FLOW, OPTIMIZATION, BEST-PRACTICES, Setup-GiteaSSH

Verificato: e2e-009 SUCCESS in 02:09, cleanup automatico VM confermato.
This commit is contained in:
Simone
2026-05-08 23:25:50 +02:00
commit dd238121b3
21 changed files with 3574 additions and 0 deletions
+122
View File
@@ -0,0 +1,122 @@
#Requires -Version 5.1
<#
.SYNOPSIS
Copies build artifacts from an ephemeral VM to the host artifact directory.
.DESCRIPTION
Opens a WinRM session to the build VM and uses Copy-Item -FromSession
to transfer the artifact archive and optional log files.
Validates that at least one file was successfully transferred.
Throws on failure so the caller's try/finally can clean up the VM.
.PARAMETER IPAddress
IP of the build VM.
.PARAMETER Credential
PSCredential for the guest build user.
.PARAMETER GuestArtifactPath
Full path to the artifact file or directory inside the VM.
Example: C:\CI\output\artifacts.zip
.PARAMETER HostArtifactDir
Directory on the host where artifacts will be stored.
The directory is created if it does not exist.
Example: F:\CI\Artifacts\run-42
.PARAMETER IncludeLogs
If set, also copies *.log files from the guest work directory
(C:\CI\build\*.log) alongside the artifact archive.
.EXAMPLE
$cred = Get-Credential
.\Get-BuildArtifacts.ps1 `
-IPAddress "192.168.11.101" `
-Credential $cred `
-GuestArtifactPath "C:\CI\output\artifacts.zip" `
-HostArtifactDir "F:\CI\Artifacts\run-42"
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[ValidatePattern('^(\d{1,3}\.){3}\d{1,3}$')]
[string] $IPAddress,
[Parameter(Mandatory)]
[System.Management.Automation.PSCredential] $Credential,
[Parameter(Mandatory)]
[string] $GuestArtifactPath,
[Parameter(Mandatory)]
[string] $HostArtifactDir,
[switch] $IncludeLogs
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
# Ensure destination exists on host
if (-not (Test-Path $HostArtifactDir)) {
New-Item -ItemType Directory -Path $HostArtifactDir -Force | Out-Null
Write-Host "[Get-BuildArtifacts] Created host artifact directory: $HostArtifactDir"
}
$sessionOptions = New-PSSessionOption -SkipCACheck -SkipCNCheck -SkipRevocationCheck
Write-Host "[Get-BuildArtifacts] Connecting to VM at $IPAddress..."
$session = New-PSSession `
-ComputerName $IPAddress `
-Credential $Credential `
-SessionOption $sessionOptions `
-ErrorAction Stop
try {
# ── Verify artifact exists in guest ──────────────────────────────────
$guestExists = Invoke-Command -Session $session -ScriptBlock {
param($path)
Test-Path $path
} -ArgumentList $GuestArtifactPath
if (-not $guestExists) {
throw "Artifact not found in guest at: $GuestArtifactPath"
}
# ── Copy main artifact ────────────────────────────────────────────────
$artifactFileName = Split-Path $GuestArtifactPath -Leaf
$hostDestPath = Join-Path $HostArtifactDir $artifactFileName
Write-Host "[Get-BuildArtifacts] Copying $artifactFileName from guest..."
Copy-Item -Path $GuestArtifactPath -Destination $hostDestPath -FromSession $session -Force
# ── Copy logs if requested ────────────────────────────────────────────
if ($IncludeLogs) {
$logFiles = Invoke-Command -Session $session -ScriptBlock {
Get-ChildItem -Path 'C:\CI\build' -Filter '*.log' -ErrorAction SilentlyContinue |
Select-Object -ExpandProperty FullName
}
foreach ($log in $logFiles) {
$logDest = Join-Path $HostArtifactDir (Split-Path $log -Leaf)
Write-Host "[Get-BuildArtifacts] Copying log: $(Split-Path $log -Leaf)"
Copy-Item -Path $log -Destination $logDest -FromSession $session -Force
}
}
# ── Validate at least the main artifact landed ────────────────────────
if (-not (Test-Path $hostDestPath)) {
throw "Artifact was not found at expected host path after copy: $hostDestPath"
}
$sizeKB = [math]::Round((Get-Item $hostDestPath).Length / 1KB, 1)
if ($sizeKB -eq 0) {
throw "Artifact file exists but is empty: $hostDestPath"
}
Write-Host "[Get-BuildArtifacts] Artifact collected: $hostDestPath ($sizeKB KB)"
return $hostDestPath
}
finally {
Remove-PSSession $session -ErrorAction SilentlyContinue
}
+281
View File
@@ -0,0 +1,281 @@
#Requires -Version 5.1
<#
.SYNOPSIS
Main CI orchestrator: creates an ephemeral VM, runs a build, collects
artifacts, and destroys the VM — in a guaranteed try/finally pattern.
.DESCRIPTION
This script is called by act_runner as the primary build step.
It coordinates all sub-scripts:
1. New-BuildVM.ps1 — linked clone from template
2. vmrun start — start the clone
3. Wait-VMReady.ps1 — poll until WinRM is ready
4. Invoke-RemoteBuild.ps1 — execute build inside VM
5. Get-BuildArtifacts.ps1 — copy artifacts to host
6. Remove-BuildVM.ps1 — destroy VM (always runs via finally)
Exit codes:
0 = success
1 = build or orchestration failure (VM destroyed, artifacts may be partial)
.PARAMETER JobId
Unique job identifier (use ${{ github.run_id }} from Gitea Actions).
.PARAMETER RepoUrl
Git URL of the repository to build (reachable from the build VM).
.PARAMETER Branch
Branch to build.
.PARAMETER Commit
Optional specific commit SHA to check out.
.PARAMETER Configuration
Build configuration. Default: Release
.PARAMETER TemplatePath
Full path to the template VM .vmx file.
Can also be set via env var GITEA_CI_TEMPLATE_PATH.
.PARAMETER SnapshotName
Template snapshot to clone from. Default: BaseClean
.PARAMETER CloneBaseDir
Base directory for clone VMs.
Can also be set via env var GITEA_CI_CLONE_BASE_DIR.
Default: F:\CI\BuildVMs
.PARAMETER ArtifactBaseDir
Host directory where artifacts are stored per job.
Default: F:\CI\Artifacts
.PARAMETER VMIPAddress
IP address to assign/expect for the build VM.
Must be unique per concurrent build.
Can be passed explicitly or derived from a registry/file-based IP allocator.
.PARAMETER GuestCredentialTarget
Target name in Windows Credential Manager for guest credentials.
Default: BuildVMGuest
.PARAMETER VmrunPath
Path to vmrun.exe.
Default: C:\Program Files (x86)\VMware\VMware Workstation\vmrun.exe
.EXAMPLE
# Called from Gitea Actions workflow:
.\scripts\Invoke-CIJob.ps1 `
-JobId "${{ github.run_id }}" `
-RepoUrl "http://gitea.local/org/myrepo.git" `
-Branch "${{ github.ref_name }}" `
-Commit "${{ github.sha }}" `
-VMIPAddress "192.168.11.101"
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string] $JobId,
[Parameter(Mandatory)]
[string] $RepoUrl,
[Parameter(Mandatory)]
[string] $Branch,
[string] $Commit = '',
[string] $Configuration = 'Release',
# Pass -Submodules to clone with --recurse-submodules
[switch] $Submodules,
# Custom build command to run inside the VM (empty = dotnet build)
[string] $BuildCommand = '',
# Artifact source pattern relative to GuestWorkDir (used with BuildCommand)
[string] $GuestArtifactSource = 'dist',
[string] $TemplatePath = $env:GITEA_CI_TEMPLATE_PATH,
[string] $SnapshotName = 'BaseClean',
[string] $CloneBaseDir = $(if ($env:GITEA_CI_CLONE_BASE_DIR) { $env:GITEA_CI_CLONE_BASE_DIR } else { 'F:\CI\BuildVMs' }),
[string] $ArtifactBaseDir = 'F:\CI\Artifacts',
[ValidatePattern('^(\d{1,3}\.){3}\d{1,3}$')]
[string] $VMIPAddress = '', # if empty, auto-detected via vmrun getGuestIPAddress
[string] $GuestCredentialTarget = 'BuildVMGuest',
[string] $VmrunPath = 'C:\Program Files (x86)\VMware\VMware Workstation\vmrun.exe'
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
# ── Resolve script directory for dot-sourcing sibling scripts ─────────────────
$scriptDir = $PSScriptRoot
# ── Validate required config ──────────────────────────────────────────────────
if ([string]::IsNullOrWhiteSpace($TemplatePath)) {
Write-Error "TemplatePath is required. Set -TemplatePath or env:GITEA_CI_TEMPLATE_PATH."
exit 1
}
if (-not (Test-Path $TemplatePath -PathType Leaf)) {
Write-Error "Template VMX not found: $TemplatePath"
exit 1
}
if (-not (Test-Path $VmrunPath -PathType Leaf)) {
Write-Error "vmrun.exe not found: $VmrunPath"
exit 1
}
# ── Load guest credentials from Windows Credential Manager ───────────────────
# Requires the CredentialManager module: Install-Module CredentialManager
# In a lab setup you can fall back to prompting, but never hardcode credentials.
$credential = $null
try {
$credential = Get-StoredCredential -Target $GuestCredentialTarget -ErrorAction Stop
Write-Host "[Invoke-CIJob] Loaded guest credentials from Credential Manager ($GuestCredentialTarget)."
}
catch {
Write-Warning "[Invoke-CIJob] Could not load credentials from Credential Manager ($GuestCredentialTarget)."
Write-Warning " Using interactive prompt as fallback. Store credentials with:"
Write-Warning " cmdkey /generic:$GuestCredentialTarget /user:DOMAIN\user /pass:password"
$credential = Get-Credential -Message "Enter guest VM credentials for job $JobId"
}
# ── Setup paths ───────────────────────────────────────────────────────────────
$jobArtifactDir = Join-Path $ArtifactBaseDir $JobId
$hostCloneDir = Join-Path $env:TEMP "ci-src-$JobId"
$cloneVmxPath = $null
$startTime = Get-Date
Write-Host "============================================================"
Write-Host "[Invoke-CIJob] Job : $JobId"
Write-Host "[Invoke-CIJob] Repository : $RepoUrl"
Write-Host "[Invoke-CIJob] Branch : $Branch"
if ($Commit) { Write-Host "[Invoke-CIJob] Commit : $Commit" }
Write-Host "[Invoke-CIJob] Build VM IP : $VMIPAddress"
Write-Host "[Invoke-CIJob] Template : $TemplatePath"
Write-Host "[Invoke-CIJob] Artifacts : $jobArtifactDir"
Write-Host "[Invoke-CIJob] Started : $startTime"
Write-Host "============================================================"
$exitCode = 0
try {
# ── Phase 1: Clone repo on HOST ───────────────────────────────────────
# The build VM has no internet/LAN access (Host-Only network).
# Source is cloned here on the host, then copied into the VM via WinRM.
Write-Host "`n[Phase 1/6] Cloning repository on host..."
if (Test-Path $hostCloneDir) { Remove-Item $hostCloneDir -Recurse -Force }
$gitArgs = @('clone', '--depth', '1', '--branch', $Branch)
if ($Submodules) { $gitArgs += '--recurse-submodules' }
$gitArgs += @($RepoUrl, $hostCloneDir)
$ErrorActionPreference = 'Continue'
$gitOutput = & git @gitArgs 2>&1 | ForEach-Object { "$_" }
$gitExit = $LASTEXITCODE
$ErrorActionPreference = 'Stop'
if ($gitExit -ne 0) {
throw "git clone failed (exit $gitExit):`n$($gitOutput -join "`n")"
}
if ($Commit -ne '') {
$ErrorActionPreference = 'Continue'
$gitOutput = & git -C $hostCloneDir checkout $Commit 2>&1 | ForEach-Object { "$_" }
$gitExit = $LASTEXITCODE
$ErrorActionPreference = 'Stop'
if ($gitExit -ne 0) {
throw "git checkout $Commit failed (exit $gitExit):`n$($gitOutput -join "`n")"
}
}
Write-Host "[Invoke-CIJob] Source cloned to: $hostCloneDir"
# ── Phase 2: Clone VM ─────────────────────────────────────────────────
Write-Host "`n[Phase 2/6] Cloning VM..."
$cloneVmxPath = & "$scriptDir\New-BuildVM.ps1" `
-TemplatePath $TemplatePath `
-SnapshotName $SnapshotName `
-CloneBaseDir $CloneBaseDir `
-JobId $JobId `
-VmrunPath $VmrunPath
# ── Phase 3: Start VM ─────────────────────────────────────────────────
Write-Host "`n[Phase 3/6] Starting VM..."
$startOutput = & $VmrunPath -T ws start $cloneVmxPath nogui 2>&1
if ($LASTEXITCODE -ne 0) {
throw "vmrun start failed (exit $LASTEXITCODE): $startOutput"
}
Write-Host "[Invoke-CIJob] VM started."
# ── Phase 3b: Resolve VM IP if not provided ─────────────────────────
if ([string]::IsNullOrWhiteSpace($VMIPAddress)) {
Write-Host "[Invoke-CIJob] Auto-detecting VM IP via vmrun getGuestIPAddress..."
$detectedIP = & $VmrunPath getGuestIPAddress $cloneVmxPath -wait 2>&1
if ($LASTEXITCODE -ne 0 -or $detectedIP -notmatch '^(\d{1,3}\.){3}\d{1,3}$') {
throw "Could not detect VM IP address: $detectedIP"
}
$VMIPAddress = $detectedIP.Trim()
Write-Host "[Invoke-CIJob] Detected VM IP: $VMIPAddress"
}
# ── Phase 4: Wait for readiness ───────────────────────────────────────
Write-Host "`n[Phase 4/6] Waiting for VM readiness..."
& "$scriptDir\Wait-VMReady.ps1" `
-VMPath $cloneVmxPath `
-IPAddress $VMIPAddress `
-VmrunPath $VmrunPath
# ── Phase 5: Remote build (copy source + build inside VM) ─────────────
Write-Host "`n[Phase 5/6] Invoking remote build..."
$remoteBuildParams = @{
IPAddress = $VMIPAddress
Credential = $credential
HostSourceDir = $hostCloneDir
Configuration = $Configuration
}
if ($BuildCommand -ne '') { $remoteBuildParams['BuildCommand'] = $BuildCommand }
if ($GuestArtifactSource -ne '') { $remoteBuildParams['GuestArtifactSource'] = $GuestArtifactSource }
& "$scriptDir\Invoke-RemoteBuild.ps1" @remoteBuildParams
# ── Phase 6: Collect artifacts ────────────────────────────────────────
Write-Host "`n[Phase 6/6] Collecting artifacts..."
& "$scriptDir\Get-BuildArtifacts.ps1" `
-IPAddress $VMIPAddress `
-Credential $credential `
-GuestArtifactPath 'C:\CI\output\artifacts.zip' `
-HostArtifactDir $jobArtifactDir `
-IncludeLogs
$elapsed = (Get-Date) - $startTime
Write-Host "`n============================================================"
Write-Host "[Invoke-CIJob] SUCCESS — Job $JobId completed in $($elapsed.ToString('mm\:ss'))"
Write-Host "[Invoke-CIJob] Artifacts: $jobArtifactDir"
Write-Host "============================================================"
}
catch {
$exitCode = 1
$elapsed = (Get-Date) - $startTime
Write-Host "`n============================================================"
Write-Host "[Invoke-CIJob] FAILURE — Job $JobId failed after $($elapsed.ToString('mm\:ss'))"
Write-Host "Error: $_"
Write-Host "============================================================"
}
finally {
# ── Always destroy the VM ─────────────────────────────────────────────
if ($null -ne $cloneVmxPath -and (Test-Path $cloneVmxPath -PathType Leaf)) {
Write-Host "`n[Cleanup] Destroying ephemeral VM..."
& "$scriptDir\Remove-BuildVM.ps1" -VMPath $cloneVmxPath -VmrunPath $VmrunPath
}
else {
Write-Host "[Cleanup] No VM to destroy (clone was not created or already gone)."
}
# ── Always clean up host-side git clone ──────────────────────────────
if (Test-Path $hostCloneDir) {
Write-Host "[Cleanup] Removing host source clone ($hostCloneDir)..."
Remove-Item $hostCloneDir -Recurse -Force -ErrorAction SilentlyContinue
}
}
exit $exitCode
+202
View File
@@ -0,0 +1,202 @@
#Requires -Version 5.1
<#
.SYNOPSIS
Copies pre-cloned source from host into a build VM and runs dotnet build.
.DESCRIPTION
The caller (Invoke-CIJob.ps1) is responsible for cloning the repository
on the HOST before calling this script. This script then:
1. Compresses the local source tree on the host, copies the archive into
the VM via WinRM (single file = much faster than recursive Copy-Item),
then expands it inside the VM.
2. Runs dotnet restore and dotnet build inside the VM
3. Packages the output as C:\CI\output\artifacts.zip inside the VM
The build VM does NOT need internet access or git. All network I/O happens
on the host before this script is called.
.PARAMETER IPAddress
IP of the build VM (e.g. 192.168.11.101).
.PARAMETER Credential
PSCredential for the build user inside the VM.
Retrieve from Windows Credential Manager in production:
$cred = Get-StoredCredential -Target 'BuildVMGuest'
.PARAMETER HostSourceDir
Full path on the HOST to the already-cloned repository directory.
This directory is copied into the VM as the build working directory.
Example: C:\Users\user\AppData\Local\Temp\ci-src-run-42
.PARAMETER Configuration
MSBuild/dotnet build configuration. Default: Release
.PARAMETER GuestWorkDir
Working directory inside the VM where source will be placed.
Default: C:\CI\build
.PARAMETER GuestArtifactZip
Full path inside the VM where the artifact archive will be written.
Default: C:\CI\output\artifacts.zip
.EXAMPLE
$cred = Get-Credential
.\Invoke-RemoteBuild.ps1 `
-IPAddress "192.168.11.101" `
-Credential $cred `
-HostSourceDir "C:\Temp\ci-src-run-42"
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[ValidatePattern('^(\d{1,3}\.){3}\d{1,3}$')]
[string] $IPAddress,
[Parameter(Mandatory)]
[System.Management.Automation.PSCredential] $Credential,
[Parameter(Mandatory)]
[ValidateScript({ Test-Path $_ -PathType Container })]
[string] $HostSourceDir,
[string] $Configuration = 'Release',
# Custom build command to run inside the VM (working dir = GuestWorkDir).
# When empty, defaults to: dotnet restore + dotnet build --configuration $Configuration
# Example: 'python build_plugin.py --final'
[string] $BuildCommand = '',
# Glob pattern (relative to GuestWorkDir) of files to include in the artifact zip.
# Used only when BuildCommand is set. Default: collect everything under 'dist'.
# Example: 'dist\**' or 'Contrib\nsInnoUnp\Build\**'
[string] $GuestArtifactSource = 'dist',
[string] $GuestWorkDir = 'C:\CI\build',
[string] $GuestArtifactZip = 'C:\CI\output\artifacts.zip'
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
# ── Session options: skip SSL certificate checks for self-signed (lab use) ──
$sessionOptions = New-PSSessionOption -SkipCACheck -SkipCNCheck -SkipRevocationCheck
Write-Host "[Invoke-RemoteBuild] Connecting to VM at $IPAddress..."
$session = New-PSSession `
-ComputerName $IPAddress `
-Credential $Credential `
-SessionOption $sessionOptions `
-ErrorAction Stop
try {
# ── Ensure working directories exist on guest ────────────────────────
Invoke-Command -Session $session -ScriptBlock {
param($workDir, $outputDir)
foreach ($dir in @($workDir, (Split-Path $outputDir -Parent))) {
if (-not (Test-Path $dir)) {
New-Item -ItemType Directory -Path $dir -Force | Out-Null
}
}
# Clean previous build if present
if (Test-Path $workDir) {
Remove-Item $workDir -Recurse -Force
}
New-Item -ItemType Directory -Path $workDir -Force | Out-Null
} -ArgumentList $GuestWorkDir, $GuestArtifactZip
# ── Copy source tree: compress on host, transfer zip, expand in guest ──
# Single-file transfer over WinRM is far faster than recursive Copy-Item.
$hostZip = [System.IO.Path]::GetTempFileName() -replace '\.tmp$', '.zip'
try {
Write-Host "[Invoke-RemoteBuild] Compressing source on host..."
Compress-Archive -Path "$HostSourceDir\*" -DestinationPath $hostZip -CompressionLevel Fastest -Force
$zipSize = [math]::Round((Get-Item $hostZip).Length / 1MB, 1)
Write-Host "[Invoke-RemoteBuild] Transferring archive to guest ($zipSize MB)..."
$guestZip = 'C:\CI\src-transfer.zip'
Copy-Item -Path $hostZip -Destination $guestZip -ToSession $session -Force
Write-Host "[Invoke-RemoteBuild] Expanding archive in guest..."
Invoke-Command -Session $session -ScriptBlock {
param($zip, $dest)
Expand-Archive -Path $zip -DestinationPath $dest -Force
Remove-Item $zip -Force
} -ArgumentList $guestZip, $GuestWorkDir
Write-Host "[Invoke-RemoteBuild] Source transfer complete."
}
finally {
Remove-Item $hostZip -Force -ErrorAction SilentlyContinue
}
# ── Build ──────────────────────────────────────────────────────────
if ($BuildCommand -ne '') {
# ── Custom build command (e.g. python, msbuild, cmake…) ──────────
Write-Host "[Invoke-RemoteBuild] Running build command: $BuildCommand"
$buildResult = Invoke-Command -Session $session -ScriptBlock {
param($workDir, $cmd, $artifactZip, $artifactSrc)
Set-Location $workDir
$output = Invoke-Expression "$cmd 2>&1" | ForEach-Object { "$_" }
$buildExit = $LASTEXITCODE
if ($buildExit -eq 0) {
$archiveDir = Split-Path $artifactZip -Parent
if (-not (Test-Path $archiveDir)) {
New-Item -ItemType Directory -Path $archiveDir -Force | Out-Null
}
$srcPath = Join-Path $workDir $artifactSrc
Compress-Archive -Path $srcPath -DestinationPath $artifactZip -Force
}
return @{ ExitCode = $buildExit; Output = ($output -join "`n") }
} -ArgumentList $GuestWorkDir, $BuildCommand, $GuestArtifactZip, $GuestArtifactSource
Write-Host $buildResult.Output
if ($buildResult.ExitCode -ne 0) {
throw "Build command failed (exit $($buildResult.ExitCode))"
}
}
else {
# ── Default: dotnet restore + dotnet build ────────────────────────
Write-Host "[Invoke-RemoteBuild] Running dotnet restore..."
$restoreResult = Invoke-Command -Session $session -ScriptBlock {
param($workDir)
Set-Location $workDir
$output = dotnet restore 2>&1
return @{ ExitCode = $LASTEXITCODE; Output = ($output -join "`n") }
} -ArgumentList $GuestWorkDir
Write-Host $restoreResult.Output
if ($restoreResult.ExitCode -ne 0) {
throw "dotnet restore failed (exit $($restoreResult.ExitCode))"
}
Write-Host "[Invoke-RemoteBuild] Running dotnet build (configuration: $Configuration)..."
$buildResult = Invoke-Command -Session $session -ScriptBlock {
param($workDir, $config, $artifactZip)
Set-Location $workDir
$outputDir = Join-Path $workDir 'bin\CIOutput'
$output = dotnet build --configuration $config --output $outputDir 2>&1
$buildExit = $LASTEXITCODE
if ($buildExit -eq 0) {
$archiveDir = Split-Path $artifactZip -Parent
if (-not (Test-Path $archiveDir)) {
New-Item -ItemType Directory -Path $archiveDir -Force | Out-Null
}
Compress-Archive -Path "$outputDir\*" -DestinationPath $artifactZip -Force
}
return @{ ExitCode = $buildExit; Output = ($output -join "`n") }
} -ArgumentList $GuestWorkDir, $Configuration, $GuestArtifactZip
Write-Host $buildResult.Output
if ($buildResult.ExitCode -ne 0) {
throw "dotnet build failed (exit $($buildResult.ExitCode))"
}
}
Write-Host "[Invoke-RemoteBuild] Build succeeded. Artifact at $GuestArtifactZip"
}
finally {
Remove-PSSession $session -ErrorAction SilentlyContinue
}
+114
View File
@@ -0,0 +1,114 @@
#Requires -Version 5.1
<#
.SYNOPSIS
Creates a linked clone of the template VM for an ephemeral CI build.
.DESCRIPTION
Uses vmrun.exe to create a linked clone from a frozen template snapshot.
Returns the full path to the new clone's .vmx file on success.
The template VM and its snapshot are never modified.
.PARAMETER TemplatePath
Full path to the template VM's .vmx file.
Example: F:\CI\Templates\WinBuild\WinBuild.vmx
.PARAMETER SnapshotName
Name of the snapshot on the template VM to clone from.
Default: BaseClean
.PARAMETER CloneBaseDir
Directory under which the new clone folder will be created.
The clone folder is named after the job ID and timestamp.
Example: F:\CI\BuildVMs
.PARAMETER JobId
Unique identifier for the CI job (e.g. run ID from Gitea Actions).
Used to name the clone: Clone_{JobId}_{yyyyMMdd_HHmmss}
.PARAMETER VmrunPath
Full path to vmrun.exe.
Default: C:\Program Files (x86)\VMware\VMware Workstation\vmrun.exe
.OUTPUTS
[string] Full path to the new clone's .vmx file.
.EXAMPLE
$vmxPath = .\New-BuildVM.ps1 `
-TemplatePath "F:\CI\Templates\WinBuild\WinBuild.vmx" `
-CloneBaseDir "F:\CI\BuildVMs" `
-JobId "run-42"
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[ValidateScript({ Test-Path $_ -PathType Leaf })]
[string] $TemplatePath,
[string] $SnapshotName = 'BaseClean',
[Parameter(Mandatory)]
[string] $CloneBaseDir,
[Parameter(Mandatory)]
[string] $JobId,
[string] $VmrunPath = 'C:\Program Files (x86)\VMware\VMware Workstation\vmrun.exe'
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
# --- Validate vmrun.exe ---
if (-not (Test-Path $VmrunPath -PathType Leaf)) {
throw "vmrun.exe not found at: $VmrunPath`nInstall VMware Workstation Pro and update -VmrunPath if needed."
}
# --- Build clone path ---
$timestamp = Get-Date -Format 'yyyyMMdd_HHmmss'
$cloneName = "Clone_${JobId}_${timestamp}"
$cloneDir = Join-Path $CloneBaseDir $cloneName
$cloneVmx = Join-Path $cloneDir "$cloneName.vmx"
# Ensure clone base dir exists
if (-not (Test-Path $CloneBaseDir)) {
New-Item -ItemType Directory -Path $CloneBaseDir -Force | Out-Null
}
Write-Host "[New-BuildVM] Creating linked clone..."
Write-Host " Template : $TemplatePath"
Write-Host " Snapshot : $SnapshotName"
Write-Host " Clone VMX : $cloneVmx"
$sw = [System.Diagnostics.Stopwatch]::StartNew()
# --- Run vmrun clone ---
$vmrunArgs = @(
'-T', 'ws',
'clone',
$TemplatePath,
$cloneVmx,
'linked',
'-snapshot', $SnapshotName
)
$result = & $VmrunPath @vmrunArgs 2>&1
$exitCode = $LASTEXITCODE
$sw.Stop()
if ($exitCode -ne 0) {
# Clean up partial clone directory if it was created
if (Test-Path $cloneDir) {
Remove-Item $cloneDir -Recurse -Force -ErrorAction SilentlyContinue
}
throw "vmrun clone failed (exit $exitCode).`nOutput: $result"
}
if (-not (Test-Path $cloneVmx -PathType Leaf)) {
throw "vmrun reported success but clone VMX not found at: $cloneVmx"
}
Write-Host "[New-BuildVM] Clone created in $($sw.Elapsed.TotalSeconds.ToString('F1'))s : $cloneVmx"
# Return the VMX path as output
return $cloneVmx
+100
View File
@@ -0,0 +1,100 @@
#Requires -Version 5.1
<#
.SYNOPSIS
Stops and permanently deletes an ephemeral build VM.
.DESCRIPTION
Performs a graceful shutdown (soft stop) with a timeout fallback to
a hard stop, then uses vmrun deleteVM to remove all VM files.
Finally, removes the clone directory from disk.
This script is designed to be called from a try/finally block so
it always runs, even on build failure.
.PARAMETER VMPath
Full path to the clone VM's .vmx file.
.PARAMETER VmrunPath
Full path to vmrun.exe.
Default: C:\Program Files (x86)\VMware\VMware Workstation\vmrun.exe
.PARAMETER GracefulTimeoutSeconds
Seconds to wait for graceful shutdown before forcing power-off.
Default: 15
.EXAMPLE
.\Remove-BuildVM.ps1 -VMPath "F:\CI\BuildVMs\Clone_run-42_20260508_143022\Clone_run-42_20260508_143022.vmx"
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string] $VMPath,
[string] $VmrunPath = 'C:\Program Files (x86)\VMware\VMware Workstation\vmrun.exe',
[ValidateRange(5, 60)]
[int] $GracefulTimeoutSeconds = 15
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Continue' # Don't stop on errors — always try next step
$cloneDir = Split-Path $VMPath -Parent
Write-Host "[Remove-BuildVM] Destroying VM: $VMPath"
# ── Step 1: Check if VM exists at all ────────────────────────────────────────
if (-not (Test-Path $VMPath -PathType Leaf)) {
Write-Warning "[Remove-BuildVM] VMX file not found — nothing to remove: $VMPath"
# Still attempt to remove the directory in case of partial clone
if (Test-Path $cloneDir) {
Remove-Item $cloneDir -Recurse -Force -ErrorAction SilentlyContinue
}
return
}
# ── Step 2: Graceful stop ─────────────────────────────────────────────────────
Write-Host "[Remove-BuildVM] Sending soft stop..."
& $VmrunPath -T ws stop $VMPath soft 2>&1 | Out-Null
$deadline = (Get-Date).AddSeconds($GracefulTimeoutSeconds)
while ((Get-Date) -lt $deadline) {
Start-Sleep -Seconds 2
$stateOutput = & $VmrunPath -T ws getState $VMPath 2>&1
if ($stateOutput -notmatch 'running') {
Write-Host "[Remove-BuildVM] VM stopped gracefully."
break
}
}
# ── Step 3: Force stop if still running ──────────────────────────────────────
$stateOutput = & $VmrunPath -T ws getState $VMPath 2>&1
if ($stateOutput -match 'running') {
Write-Host "[Remove-BuildVM] Graceful stop timed out — forcing hard stop..."
& $VmrunPath -T ws stop $VMPath hard 2>&1 | Out-Null
Start-Sleep -Seconds 3
}
# ── Step 4: Delete VM via vmrun (removes VMX + delta VMDK) ───────────────────
Write-Host "[Remove-BuildVM] Deleting VM..."
$deleteOutput = & $VmrunPath -T ws deleteVM $VMPath 2>&1
$deleteExit = $LASTEXITCODE
if ($deleteExit -ne 0) {
Write-Warning "[Remove-BuildVM] vmrun deleteVM returned exit code $deleteExit : $deleteOutput"
Write-Warning "[Remove-BuildVM] Will attempt manual directory removal."
}
# ── Step 5: Remove clone directory (catches any leftover files) ───────────────
if (Test-Path $cloneDir) {
Write-Host "[Remove-BuildVM] Removing clone directory: $cloneDir"
Remove-Item $cloneDir -Recurse -Force -ErrorAction SilentlyContinue
if (Test-Path $cloneDir) {
Write-Warning "[Remove-BuildVM] Clone directory could not be fully removed: $cloneDir"
Write-Warning " Manual cleanup required."
} else {
Write-Host "[Remove-BuildVM] Clone directory removed."
}
}
Write-Host "[Remove-BuildVM] VM destruction complete."
+134
View File
@@ -0,0 +1,134 @@
#Requires -Version 5.1
<#
.SYNOPSIS
Waits until a build VM is fully ready to accept WinRM connections.
.DESCRIPTION
Performs a three-phase readiness check in a polling loop:
1. vmrun getState returns "running"
2. ICMP ping succeeds
3. Test-WSMan (WinRM) responds without error
Throws if the VM does not become ready within the timeout period.
.PARAMETER VMPath
Full path to the clone VM's .vmx file.
.PARAMETER IPAddress
IP address of the VM on the build network (e.g. 192.168.11.101).
.PARAMETER TimeoutSeconds
Maximum seconds to wait for the VM to become ready.
Default: 300 (5 minutes)
.PARAMETER PollIntervalSeconds
Seconds between readiness checks.
Default: 5
.PARAMETER VmrunPath
Full path to vmrun.exe.
Default: C:\Program Files (x86)\VMware\VMware Workstation\vmrun.exe
.EXAMPLE
.\Wait-VMReady.ps1 `
-VMPath "F:\CI\BuildVMs\Clone_run-42_20260508_143022\Clone_run-42_20260508_143022.vmx" `
-IPAddress "192.168.11.101"
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string] $VMPath,
[Parameter(Mandatory)]
[ValidatePattern('^(\d{1,3}\.){3}\d{1,3}$')]
[string] $IPAddress,
[ValidateRange(30, 600)]
[int] $TimeoutSeconds = 300,
[ValidateRange(3, 30)]
[int] $PollIntervalSeconds = 5,
[string] $VmrunPath = 'C:\Program Files (x86)\VMware\VMware Workstation\vmrun.exe',
# Skip ICMP ping phase (phase 2). Useful when the guest firewall blocks ICMP
# but WinRM (TCP 5985) is open. Phase 1 (vmrun state) and phase 3 (WinRM)
# are still checked.
[switch] $SkipPing
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
if (-not (Test-Path $VmrunPath -PathType Leaf)) {
throw "vmrun.exe not found at: $VmrunPath"
}
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
$attempt = 0
$phase = 'vmrun-state' # tracks current phase for informative output
$lastPhase = ''
Write-Host "[Wait-VMReady] Waiting for VM ready (timeout: ${TimeoutSeconds}s, IP: $IPAddress)..."
while ((Get-Date) -lt $deadline) {
$attempt++
# ── Phase 1: VM must be running ─────────────────────────────────────
# vmrun has no getState; getGuestIPAddress fails with "not powered on"
# when the VM is off, and succeeds (exit 0) when running.
try {
& $VmrunPath getGuestIPAddress $VMPath 2>&1 | Out-Null
$isRunning = ($LASTEXITCODE -eq 0)
}
catch {
$isRunning = $false
}
if (-not $isRunning) {
if ($phase -ne 'vmrun-state') {
$phase = 'vmrun-state'
}
if ($lastPhase -ne $phase) {
Write-Host "[Wait-VMReady] Phase 1/3: waiting for VM to enter running state..."
$lastPhase = $phase
}
Start-Sleep -Seconds $PollIntervalSeconds
continue
}
# ── Phase 2: Network ping (optional) ───────────────────────────────
if (-not $SkipPing) {
$pingOk = Test-Connection -ComputerName $IPAddress -Count 1 -Quiet -ErrorAction SilentlyContinue
if (-not $pingOk) {
$phase = 'ping'
if ($lastPhase -ne $phase) {
Write-Host "[Wait-VMReady] Phase 2/3: VM running, waiting for network (ping $IPAddress)..."
$lastPhase = $phase
}
Start-Sleep -Seconds $PollIntervalSeconds
continue
}
}
# ── Phase 3: WinRM responsive ────────────────────────────────────────
try {
Test-WSMan -ComputerName $IPAddress -ErrorAction Stop | Out-Null
# Success — VM is ready
$elapsed = ($deadline - (Get-Date)).Negate().TotalSeconds + $TimeoutSeconds
Write-Host "[Wait-VMReady] VM ready after ~$([int]($attempt * $PollIntervalSeconds))s (attempt $attempt)."
return
}
catch {
$phase = 'winrm'
if ($lastPhase -ne $phase) {
Write-Host "[Wait-VMReady] Phase 3/3: ping OK, waiting for WinRM on port 5985..."
$lastPhase = $phase
}
Start-Sleep -Seconds $PollIntervalSeconds
continue
}
}
throw "[Wait-VMReady] Timed out after ${TimeoutSeconds}s waiting for VM at $IPAddress to become ready. Last phase: $phase"