- Updated the final master plan to improve clarity and consistency in the status of various capabilities, including fixes and enhancements made as of 2026-05-12. - Revised the opus47 analysis to provide a more structured overview of the system's production readiness, including detailed coverage of OWASP Top 10 risks and operational gaps. - Enhanced the opus47 review of GPT-5.5 by clarifying severity ratings and rationales for various items, while adding new high-severity items that were previously overlooked.
67 KiB
Local-CI-CD-System — Comprehensive Technical Review
Reviewer role: lead architect / senior DevOps engineer. Scope: complete repository audit at HEAD. Style: direct, opinionated, no hedging. Severities: CRITICAL / HIGH / MEDIUM / LOW / NICE-TO-HAVE.
1. Executive Summary
This is a well-engineered homelab CI/CD system that solves a real, narrow problem: give a single workstation the ability to run isolated, reproducible builds against ephemeral Windows and Linux VMs, triggered from a self-hosted Gitea, with artifact collection and lifecycle management. The scope is appropriate, the implementation is largely competent, and the audit trail in TODO.md shows the author has been doing serious, deliberate engineering rather than vibe-coding.
That said, the system has the typical pathologies of a one-person homelab CI: the "tests" do not test what matters, the documented "production-ready" status rests on three manual e2e runs (e2e-008, e2e-009, §3.3 e2e), and several large operational gaps remain hidden behind the Home Lab — Deferred label in TODO.md. Those gaps are not catastrophic, but they would be unacceptable in a paid environment.
The single biggest architectural concern is not security — the network is air-gapped enough for a homelab — but operational resilience under concurrency. capacity: 4 is configured in runner/config.yaml, but the IP-allocation mutex in scripts/Invoke-CIJob.ps1 has only ever been exercised with a single job at a time. Whether the lock semantics, the DHCP behaviour on VMnet8, and the vmrun start race actually behave correctly when four real builds collide is an open question.
The second largest concern is the complete absence of automated regression testing for the orchestration itself. The four Pester files use a fake vmrun.cmd and exercise argument parsing only. There is no CI for the CI.
Top three priorities, in order:
- CRITICAL — Validate or downgrade
capacity: 4. Either run a real 4-way concurrency burn-in or setcapacity: 1until validated. Today the production claim is a guess. - HIGH — Add a single end-to-end smoke test runnable on demand. Not a unit test. A scripted
Measure-CIBenchmark.ps1-style invocation that exercises Phase 1–6 against a real VM and exits non-zero on failure. Wire it into a manual Gitea workflow. - HIGH — Eliminate the
runner/Install-Runner.ps1deprecated script and theSetup-Host.ps1default-password ghost. Dead code and lying defaults erode confidence in everything else.
The rest of this document expands on these and 30+ smaller findings.
2. Project Completeness
The system declares itself "production-ready" in README.md line 1. Measured against what a reasonable definition of that phrase requires, here is the state:
| Capability | Status | Evidence |
|---|---|---|
| Ephemeral VM lifecycle (clone, start, destroy) | Complete | scripts/New-BuildVM.ps1, Remove-BuildVM.ps1, Invoke-CIJob.ps1 |
| Windows guest (WinRM HTTPS) | Complete | Invoke-RemoteBuild.ps1, Get-BuildArtifacts.ps1 Windows branch |
| Linux guest (SSH) | Complete | _Transport.psm1, Linux branches in build/artifact scripts |
| Auto-detect guest OS from VMX | Complete | Invoke-CIJob.ps1 guestOS regex |
| Host-clone mode + zip transfer (Mode 1) | Complete | Default path in Invoke-RemoteBuild.ps1 |
In-VM git clone (Mode 2) |
Complete | -UseGitClone, PAT via http.extraHeader |
| Credential storage (CredentialManager) | Complete | BuildVMGuest, GiteaPAT |
| Reusable composite action | Complete | gitea/actions/local-ci-build/action.yml |
| Build matrix Windows + Linux | Complete | gitea/workflows/build-nsInnoUnp.yml |
| Shared NuGet/pip cache via HGFS | Complete | Set-TemplateSharedFolders.ps1, -UseSharedCache |
| Structured JSONL logs | Complete | Write-JobEvent in Invoke-CIJob.ps1 |
| Scheduled maintenance | Complete | Register-CIScheduledTasks.ps1 (4 tasks) |
| Disk-space + runner-health watchdogs | Complete | Watch-DiskSpace.ps1, Watch-RunnerHealth.ps1 |
| Retention policy with aggressive fallback | Complete | Invoke-RetentionPolicy.ps1 |
| Orphan cleanup | Complete | Cleanup-OrphanedBuildVMs.ps1 |
| Template backup before refresh | Complete | Backup-CITemplate.ps1 |
| Benchmark harness | Complete | Measure-CIBenchmark.ps1 |
Secret injection into guest (ExtraGuestEnv) |
Complete | Forwarded host → action → Invoke-CIJob → Invoke-RemoteBuild |
| Pester unit tests | Stub-level | tests/*.Tests.ps1, all use fake vmrun.cmd |
| Automated integration test | Missing | No automated harness exercises real VMs |
| Concurrency burn-in | Missing | TODO.md §2.1 admits "race non riprodotta in e2e-008/009" |
| Multi-host federation | Not in scope | TODO.md §6.3 deferred |
| Template snapshot refresh procedure | Partial | Backup-CITemplate.ps1 exists; refresh procedure not codified |
| Setup-Host post-conditions documented | Partial | Setup-Host.ps1 does the work but Validate-DeployState.ps1 covers templates, not host |
Overall completion against the original scope: ~85%. The remaining 15% is concentrated in test coverage and concurrency validation — exactly the parts that justify the "production-ready" label.
3. Architecture Deep Dive
3.1 Component Map
+---------------------+ +------------------------+
| Gitea (10.10.20.11)|<-----| act_runner (NSSM svc) |
+---------------------+ | F:\CI\act_runner |
| capacity: 4 |
+-----------+-------------+
|
v
+-----------+-------------+
| gitea/actions/ |
| local-ci-build |
| action.yml |
+-----------+-------------+
|
v
+-----------+-------------+
| Invoke-CIJob.ps1 |
| (orchestrator) |
+-----+---+---+-----+-----+
| | | |
+-----------------+ | | +-----------------+
| | | |
v v v v
+---------+--------+ +--------+--+--------+ +-----------+-----+
| New-BuildVM.ps1 | | Wait-VMReady.ps1 | | Invoke-Remote |
| (vmrun clone) | | (list, ping/22, | | Build.ps1 |
| | | 5986/ssh echo) | | (WinRM or SSH) |
+---------+--------+ +--------------------+ +-----------+-----+
| |
| v
| +------------+--------+
| | Get-BuildArtifacts |
| | (Copy-Item -From |
| | Session or scp) |
| +------------+--------+
v |
+---------+--------+ |
| Remove-BuildVM | <--------- always runs in finally -----+
| (stop, deleteVM) |
+------------------+
This is a clean pipeline. The orchestrator is a single script that owns the try/finally and the state file (leaseFile). Sub-scripts are pure: they receive parameters and return either a value or throw. That is the right shape.
3.2 Layering — What Is Right
_Common.psm1and_Transport.psm1correctly separate two distinct concerns: WinRM/vmrun helpers (host-side, used by Windows path) and SSH helpers (host→guest, used by Linux path).Invoke-Vmrunreturning[pscustomobject]@{ExitCode, Output}is the right abstraction — most callers want both, and$LASTEXITCODEis fragile across pipelines.- The composite action
local-ci-buildis the right boundary. The calling repository (nsis-plugin-nsinnounp, perbuild-nsInnoUnp.yml) declares intent (build-command,artifact-source) and the action handles transport. Adding a second consumer would require zero changes here. - The file-based IP mutex + lease is conceptually correct. Serialize the racey phase (clone + start + IP acquisition), then release. Build phases run parallel. That is the right granularity.
- The Mode 1 / Mode 2 source-transfer split. Mode 1 (host clone → tar/zip → ship) is the safe default. Mode 2 (
-UseGitClone, PAT viahttp.extraHeader) is the right optimization for large repos with submodules — and thehttp.extraHeaderBasic-auth approach keeps the PAT out of the URL, argv, andgit config. - Phase 1 readiness check uses
vmrun list, notgetGuestIPAddress. This is correct and the rationale in the comments ofscripts/Wait-VMReady.ps1is exactly right. The author has clearly been burned by this in the past.
3.3 Layering — What Is Wrong or Suspect
Measure-CIBenchmark.ps1usesgetGuestIPAddressdirectly (line ~135) for the IP-acquire phase, contradicting the lesson encoded inWait-VMReady.ps1. The benchmark therefore measures something subtly different than what production does. It also has no fallback. MEDIUM.Invoke-RemoteBuild.ps1has two PAT-injection paths: a Linux branch that builds an authenticated URL with[uri]::EscapeDataStringand a Windows branch that useshttp.extraHeader. The Linux version writes the PAT into the URL of anexport GIT_CLONE_URL=...; git clone "$GIT_CLONE_URL" ...; unsetsequence. That URL is visible in the SSH wire — encrypted, but the PAT is also visible in/proc/<pid>/environfor whoever can read it (the sameci_builduser). The Windows path is strictly safer. MEDIUM. Make Linux usehttp.extraHeadertoo.- The IP-allocation lock is held during
New-BuildVM.ps1(clone) ANDvmrun startAND IP detection (Invoke-CIJob.ps1lines ~340–430). A linked clone takes a few seconds;vmrun startheadless + IP-acquire takes 30–90 s on a typical Windows guest. Withcapacity: 4, the worst-case wait for the fourth job to enter the lock is ~4 × 90 s = 6 minutes before its build even starts. The build phase is parallel, so amortized this is fine, but document this explicitly. MEDIUM. - No per-IP allocation strategy. The system relies entirely on VMware's NAT DHCP to hand out unique IPs. The lock prevents simultaneous starts but does nothing if DHCP hands the same IP to two VMs on the same
VMnet8lease window. The lease-collision check atInvoke-CIJob.ps1:~430(if (Test-Path $leaseFile) { throw }) is a fail-fast trap but never a recovery path. MEDIUM — see §15 for the suggested static-IP scheme.
3.4 The "Auto" GuestOS Detection
This is clever and correct: read the VMX, regex guestOS = "...", classify as Linux if it contains ubuntu or linux, else Windows. It works for the two templates that exist. It will silently mis-classify a future guestOS = "windows2025srv-64" if Microsoft renames the family — but that is acceptable in this scope. LOW: assert against a known list and warn if the family is unknown.
3.5 The guestVar ci-ip Channel
Invoke-CIJob.ps1 reads guestVar 'ci-ip' as the primary IP-discovery channel and falls back to getGuestIPAddress. The guest side is ci-report-ip.service writing via vmware-rpctool — referenced but not in any file I read. The fallback works for Windows; the primary is the only viable path for Linux because getGuestIPAddress on Linux is notoriously unreliable.
The risk: there is no validation in Validate-DeployState.ps1 that the guest service is enabled in a fresh template. If someone refreshes the Linux template and forgets the systemd unit, every Linux job will silently use the (broken) getGuestIPAddress fallback until it times out at 120 s. HIGH: add a Validate-DeployState.ps1 check that fails the deploy if ci-report-ip.service is not present on Linux templates.
4. Code Quality
4.1 PowerShell 5.1 Discipline
The codebase respects the PS 5.1 constraint rigorously. Every script I read has the standard preamble:
#Requires -Version 5.1
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
No null-coalescing operators, no ternaries, no &&/||, no ForEach-Object -Parallel. This is consistent across 14 scripts. The author understands the runtime.
A subtler PS 5.1 issue does appear in scripts/Backup-CITemplate.ps1:
$totalBytes = [long]0
if ($backupFiles) { foreach ($f in $backupFiles) { $totalBytes += $f.Length } }
This manual sum is there because Measure-Object -Sum plus Set-StrictMode -Version Latest interacts badly when the input is empty. That is correct defensive coding for PS 5.1 + StrictMode. Good.
4.2 ErrorActionPreference Discipline
Three scripts intentionally relax $ErrorActionPreference to Continue:
Cleanup-OrphanedBuildVMs.ps1— cleanup must proceed through all orphans.Invoke-RetentionPolicy.ps1— same rationale.Watch-DiskSpace.ps1andWatch-RunnerHealth.ps1— exit-code-driven contract with Task Scheduler.
This is correct. But three places set Continue for narrower reasons:
Invoke-CIJob.ps1:~265wrapsgit cloneto allow capturing exit code.Wait-VMReady.ps1:~190wrapssshto demote the "Permanently added host key" stderr.Invoke-CIJob.ps1:~395wrapsvmrun readVariable guestVar ci-ipto allow exit-code inspection.
All three save/restore $savedEap = $ErrorActionPreference; ...; $ErrorActionPreference = $savedEap correctly. This is the only safe pattern in PS 5.1 — try { ... } finally { $ErrorActionPreference = ... } would also work but is more verbose. No issue.
4.3 Parameter Validation
Excellent in places, missing in others.
Strong:
- IP regex
ValidatePattern('^((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)$')is used consistently inInvoke-CIJob.ps1,Invoke-RemoteBuild.ps1,Get-BuildArtifacts.ps1,Wait-VMReady.ps1. ValidateRangeon numeric parameters (MaxAgeHours,RetentionDays,MinFreeGB,Iterations,TimeoutSeconds,MaxRestarts).ValidateSetonGuestOS('Windows', 'Linux', 'Auto') andTransport('WinRM', 'SSH').ValidatePattern('^[A-Za-z]$')onDriveLetter.
Missing:
JobIdinInvoke-CIJob.ps1is[string]$JobIdmandatory but unvalidated. It is used as a directory name and a Credential-Manager target lookup key. A user-suppliedJobIdcontaining..\..\..or..\Logs\${someone-else}\would let a malicious workflow write outsideF:\CI\Logs. In this homelab that surface is local-only, but it is one line of defense to add:[ValidatePattern('^[A-Za-z0-9._-]+$')]. LOW.TemplatePathinInvoke-CIJob.ps1is read from$env:GITEA_CI_TEMPLATE_PATHand validated only withTest-Path -PathType Leaf. Not a security issue (the runner controls the env var), but a sanity-check that the path lives underF:\CI\Templateswould prevent operator typos pointing at a production VMX. LOW.BuildCommandinInvoke-CIJob.ps1is forwarded verbatim into the guest. By design — the caller can run anything — but documenting that the build-command runs as the privileged build user with no sandboxing is worth saying explicitly in the README. LOW.
4.4 Style
PSScriptAnalyzer is configured in PSScriptAnalyzerSettings.psd1 with the right exclusions for this project (PSAvoidUsingWriteHost excluded because act_runner captures Write-Host and Write-Output interferes with return values). Indentation is 4-space consistent. Function naming follows verb-noun (Write-JobEvent, Compress-BuildArtifact, Remove-OldJobDirs). Comment-based help is present on every script. This is genuinely good housekeeping.
One stylistic complaint: the inline Invoke-Command -Session $session -ScriptBlock { ... } blocks in Invoke-RemoteBuild.ps1 are getting long enough (~50 lines each, twice in the file) that splitting them into named local functions on the host side would help testability. NICE-TO-HAVE.
4.5 Dead Code
runner/Install-Runner.ps1is marked DEPRECATED at the top of the file and superseded bySetup-Host.ps1. It should be deleted. LOW but immediate.Setup-Host.ps1.DESCRIPTIONreferences a default password'CIBuild!ChangeMe2026'but the actualparam([string]$GuestPassword = '')is empty. The documentation lies. LOW — pick one and align.
4.6 Logging Density
Write-Host is used liberally and consistently with [Component] prefixes ([Invoke-CIJob], [Wait-VMReady], [Cleanup], etc.). Combined with the JSONL log via Write-JobEvent, the runtime observability is genuinely good. The transcript is preserved per job under F:\CI\Logs\$JobId\invoke-ci.log and invoke-ci.jsonl. This is better than most paid CI systems give you.
5. Security
The threat model documented in docs/BEST-PRACTICES.md §2.1 (per TODO.md) explicitly accepts: Defender / Firewall / UAC disabled inside the build VM, self-signed WinRM cert with SkipCACheck/SkipCNCheck/SkipRevocationCheck, SSH StrictHostKeyChecking=no with UserKnownHostsFile=NUL. For an isolated VMnet8 NAT network in a single-tenant home lab, this is defensible — the attack surface is host-local.
Findings within that scope:
5.1 PAT Handling (Mode 2 Windows path) — GOOD
scripts/Invoke-RemoteBuild.ps1 Mode 2 Windows branch reads the PAT from Get-StoredCredential -Target 'GiteaPAT' on the host, base64-encodes user:pat, and injects it via git -c http.extraHeader=Authorization: Basic <b64>. The PAT never appears in:
- the clone URL
- argv (because
-c key=valueis one arg) git configfiles- the transcript log (the
Write-Hostcalls echo$cloneUrl, not the auth-injected version)
The PAT does live in memory inside the WinRM session for the duration of the clone. The WinRM session is Authentication Basic over TLS to the local guest. This is acceptable.
5.2 PAT Handling (Mode 2 Linux path) — WEAKER, MEDIUM
The Linux branch rewrites the URL to https://user:pat@host/repo.git and passes it via env var:
$envCloneCmd = "export GIT_CLONE_URL='$cloneTargetUrl'; git clone --depth 1 --branch '$CloneBranch'"
...
$envCloneCmd += " `"`$GIT_CLONE_URL`" '$GuestLinuxWorkDir'; unset GIT_CLONE_URL"
The PAT is exposed in:
/proc/<pid>/environwhilegit cloneruns (readable by the sameci_builduser, but also root, but also any other process under the same UID).- The SSH command line on the host side as a single command string (encrypted in transit, but visible in
ps auxto anyone on the host watchingssh.exe's commandline).
Fix: mirror the Windows approach. Pass user and pat as separate variables, use git -c http.extraHeader=Authorization: Basic <base64>. MEDIUM, fix.
# Suggested Linux Mode 2 replacement
$b64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes("$($pat.UserName):$($pat.GetNetworkCredential().Password)"))
$cloneCmd = @(
"GIT_TERMINAL_PROMPT=0 git",
"-c 'credential.helper='",
"-c 'http.extraHeader=Authorization: Basic $b64'",
"clone --depth 1 --branch '$CloneBranch'",
$(if ($CloneSubmodules) { '--recurse-submodules' }),
"'$CloneUrl' '$GuestLinuxWorkDir'"
) -join ' '
Invoke-SshCommand -IP $IPAddress -User $SshUser -KeyPath $SshKeyPath -Command $cloneCmd
The base64 still appears in the SSH command line argument briefly, but it is no longer a credential URL that could be cached by git or leaked via a redirected error.
5.3 Guest Credential Storage
Get-StoredCredential -Target 'BuildVMGuest' is correct usage of Windows Credential Manager. Setup-Host.ps1 stores it once. The PSCredential object is forwarded to all scripts. Good. No issue.
5.4 The Lab-Wide BuildVMGuest Account
Every clone has the same credentials. A compromised build (malicious build-command, e.g. on a public PR) could:
- Read
$Credential.GetNetworkCredential().Passwordfrom its own WinRM session: it does not have this — credentials live on the host, only the WinRM service receives the authenticated handshake. Not exploitable in-guest. - Persist data into the template by mounting
\\vmware-host\Shared Folders\(HGFS). Yes — the shared folders for NuGet/pip caches are read-write. A malicious build could poison a NuGet package and have it served to the next build. MEDIUM.
Mitigation: mark the shared folders read-only in the VMX for builds that do not need to write to them. Or, more practically, use per-template-version cache subdirs and accept the risk for a homelab. Document it. MEDIUM.
5.5 ExtraGuestEnv Secret Injection
The path secrets.SIGN_PASS → workflow → action input extra-guest-env-json → ConvertFrom-Json → hashtable → Invoke-CIJob.ps1 → Invoke-RemoteBuild.ps1 → [System.Environment]::SetEnvironmentVariable($pair.Key, $pair.Value, 'Process') is clean.
But: the action.yml line INPUT_EXTRA_GUEST_ENV_JSON: ${{ inputs.extra-guest-env-json }} puts the secret JSON into a process environment variable on the runner host. Gitea Actions masks secrets.* in logs by string match. If a secret value happens to be a substring of a path or filename, the masking can miss it. This is a Gitea limitation, not yours, but worth a one-liner in docs/WORKFLOW-AUTHORING.md. LOW.
5.6 Setup-Host.ps1 Password Default
Currently [string] $GuestPassword = '', which triggers a prompt — fine. But the help text claims 'CIBuild!ChangeMe2026' is the default. If a future user reads the help and assumes the default is acceptable, they may not change it. LOW — strip the default password from documentation or make it Read-Host -AsSecureString-only.
5.7 OWASP Top 10 Coverage (in scope)
| Risk | Status |
|---|---|
| A01 Broken Access Control | Local host only. Acceptable. |
| A02 Cryptographic Failures | Self-signed TLS for lab use. Acceptable. PAT base64 in command line is the only real concern (§5.2). |
| A03 Injection | BuildCommand is by-design caller-controlled. JSON env injection parses safely via ConvertFrom-Json. SSH commands built from string concatenation in Invoke-RemoteBuild.ps1 Linux branch — $GuestLinuxWorkDir, $CloneBranch, etc. are not user-supplied in the trigger path but a workflow author could pass '$(rm -rf /)' in submodules etc. The single-quote escape on ExtraGuestEnv values is correct; other interpolated strings are not escaped. MEDIUM — see §6.7. |
| A04 Insecure Design | Acceptable for homelab. |
| A05 Security Misconfiguration | Defender/Firewall/UAC off, documented in BEST-PRACTICES. Acceptable for isolated lab. |
| A06 Vulnerable Components | TODO.md §1.3 SHA256 pinning was deferred. Toolchain installer downloads (Tier-1/Tier-2) use only placeholders. MEDIUM — see §6.8. |
| A07 Auth Failures | Shared BuildVMGuest account — see §5.4. |
| A08 Software/Data Integrity | No artifact signing. NICE-TO-HAVE for a homelab. |
| A09 Logging Failures | Strong (invoke-ci.jsonl, Event Log, webhook). Good. |
| A10 SSRF | Not applicable. |
6. Reliability
6.1 IP Allocation Under Concurrency — CRITICAL UNVALIDATED
Invoke-CIJob.ps1 lines ~310–460 implement: open F:\CI\State\vm-start.lock with FileShare.None (acts as cross-process mutex on Windows), 10-minute deadline, retry every 5 s. Inside the lock: clone, start, acquire IP, write lease file. Release the lock.
This is sound design. The bugs hiding here are:
-
The lock is implemented as a file handle with
FileMode.OpenOrCreate. If the host crashes while the lock is held, the file remains on disk. On reboot, the next process can re-open it because the kernel has released the lock — that part is fine. But a process inside its own lock that throws beforetry { ... } finallyreaches the lock release would leak the handle. Thetry { acquire } ... finally { release }pattern in lines ~340–460 is correct, but the lock acquisition itself is outside a try/finally in the surroundingtry. Trace it carefully — there is one path where[System.IO.File]::Open(...)succeeds, then thewhile ($true) { ... break }exits normally, and execution continues intoWrite-Host "[Invoke-CIJob] VM-start lock acquired."and then into the innertry { ... } finally { release }. The handle is closed correctly on every path. OK. -
Remove-Item $lockPath -Forceinside the innerfinally. This deletes the lock file on the filesystem after closing the handle. If two processes are racing — process A has just released and deleted the file, process B is in itswhile ($true)retry loop — process B will successfullyOpenOrCreatea new file. That is fine. But theRemove-Itemis a useful indicator-of-cleanliness on disk, nothing more. Some Windows AV products briefly hold a handle on file deletes which would causeRemove-Itemto fail — the-ErrorAction SilentlyContinuehandles that. No issue. -
IP detection inside the lock: 120 s deadline, polling every 2 s. The slowest plausible case is a Windows guest booting cold and waiting for VMware Tools to publish an IP. 120 s is generous and validated by
Measure-CIBenchmark.ps1data (assumed; not actually inspected). OK. -
The unvalidated case: four concurrent jobs all hit the lock within milliseconds of each other. Job 1 enters, takes ~60 s. Jobs 2/3/4 retry every 5 s. After job 1 releases, only one of 2/3/4 will win the race (whichever's
OpenOrCreatehappens first in the kernel). The other two retry. This serializes the four jobs into ~60 s × 4 = 4 minutes of pre-build phase. The build phase then runs parallel. Acceptable but unmeasured. TheTODO.md §2.1 — Home Lab: deferred. Race non riprodotta in e2e-008/009.is honest but incomplete. HIGH — actually runInvoke-CIJob.ps1four times in parallel from a PowerShell harness and confirm zero IP collisions, zero lock leaks, zero hangs.
6.2 VM Destroy Resilience
Remove-BuildVM.ps1's soft-stop-then-hard-stop-then-poll-then-deleteVM-with-3-retries sequence is well thought out. The 20 s "wait until VMX disappears from vmrun list" step exists because vmware-vmx.exe holds a file lock briefly after stop. This is real and the workaround is correct.
The fallback Remove-Item -Recurse after deleteVM failure is the right "give up gracefully" path. Combined with Cleanup-OrphanedBuildVMs.ps1 scheduled every 6 h with MaxAgeHours 4, anything that survives the immediate destroy path is reaped within hours. GOOD.
6.3 act_runner Auto-Restart
Watch-RunnerHealth.ps1 is rate-limited (3 restarts/h via runner-restart-log.json rolling window). Beyond that limit it writes EventId 1004 Error and exits non-zero so Task Scheduler shows red. Webhook posted on every restart. This is genuinely good.
One subtle bug: the cooldown log is JSON-serialized as an array of ISO timestamp strings. Line ~118 $restartLog = @($raw | ConvertFrom-Json). If the log contains exactly one entry, ConvertFrom-Json returns a single string, not an array. The @(...) coercion handles that. If the log file is empty (zero bytes), ConvertFrom-Json throws — but $raw is checked for truthiness first. OK.
A more relevant flaw: the script does not check whether the service was stopped intentionally (e.g. operator running Backup-CITemplate.ps1 which calls Stop-Service act_runner). It will try to restart it. Backup-CITemplate.ps1 itself uses Stop-Service -Force and Start-Service in a try/finally, so the conflict window is short, but a Watch-RunnerHealth.ps1 invocation that falls inside that window will count as a "restart" in the cooldown log. This is benign but noisy. LOW — Backup-CITemplate.ps1 could disable the scheduled task while it works, or Watch-RunnerHealth.ps1 could check for a F:\CI\State\runner-maintenance.flag and exit 0.
6.4 Disk Space Alerting
Watch-DiskSpace.ps1 checks every 15 min, writes Event Log on alert, optionally posts to webhook. Exits non-zero so Task Scheduler shows red. Pairs with Invoke-RetentionPolicy.ps1's aggressive-mode fallback (below 50 GB free → 7-day retention instead of 30). GOOD.
6.5 Retention Policy
Invoke-RetentionPolicy.ps1 correctly handles the case where the drive is missing (falls back to [double]::MaxValue to avoid aggressive mode firing on no data). The stale-lease cleanup (>12 h) is the right defensive belt-and-suspenders for crashed-host recovery. GOOD.
One thing missing: there is no retention on F:\CI\BuildVMs\ itself. Orphan cleanup handles VMs older than 4 h; the retention policy handles artifacts and logs. But a partial clone (a directory created without a .vmx because vmrun clone failed mid-way) older than 4 h with no .vmx is removed by Cleanup-OrphanedBuildVMs.ps1 only via the dir-removal-only fallback — good, no issue.
6.6 Wait-VMReady Phase Logic
The 3-phase wait is the right shape and the comment in lines ~108–120 correctly documents why getGuestIPAddress is not used for Phase 1 (Tools-dependent). The fact that the Windows fallback in Invoke-CIJob.ps1 IP detection still uses getGuestIPAddress is consistent — by Phase 3b the VM has been up long enough that Tools should be responsive.
6.7 String Interpolation into SSH Commands
Invoke-RemoteBuild.ps1 Linux branch builds shell commands by string concatenation:
$buildCmd = "${envPrefix}cd '$GuestLinuxWorkDir' && $BuildCommand"
$GuestLinuxWorkDir is single-quoted, which is correct. $BuildCommand is not — by design, because the caller provides shell. $envPrefix correctly single-quote-escapes values via $envVal = $ExtraGuestEnv[$envKey] -replace "'", "'\''". OK for trusted inputs.
But:
Invoke-SshCommand -IP $IPAddress -User $SshUser -KeyPath $SshKeyPath `
-Command "rm -rf '$GuestLinuxWorkDir' && mkdir -p '$GuestLinuxWorkDir'"
If a workflow author somehow gets $GuestLinuxWorkDir to contain ' && rm -rf / # (it cannot today — it is hardcoded in Invoke-CIJob.ps1), they would have remote code execution as ci_build. Today this is not exploitable because all interpolated values into SSH commands originate from Invoke-CIJob.ps1 parameters that are not workflow-controllable. LOW. Audit boundary should be documented.
6.8 Toolchain Installer Pinning
Install-CIToolchain-WinBuild2025.ps1 and Install-CIToolchain-Linux2404.sh (per TODO.md §1.3) use SHA256 placeholders — they were deferred for the homelab. This means a man-in-the-middle on the public internet at template-rebuild time could substitute compromised toolchains. The probability is low (TLS protects everything), but the defense in depth is missing. MEDIUM — implement real hash pinning before any non-homelab use.
7. Operational Gaps
| Gap | Severity | Notes |
|---|---|---|
| No documented template-refresh runbook | HIGH | Backup-CITemplate.ps1 exists, but the procedure (Win KMS reactivation, snapshot rebuild, validation) is not codified. |
| No automated end-to-end smoke test | HIGH | The "production-ready" claim rests on e2e-008/e2e-009 manual runs. |
capacity: 4 never validated |
HIGH | See §6.1. |
| No CI for the CI | HIGH | PSScriptAnalyzer lint workflow exists; no Pester run on commit; no integration test on commit. |
| No alerting on stuck jobs | MEDIUM | A job that hangs at 1 h 50 min (just under the 2 h runner timeout) is invisible. Watchdog only checks the runner service, not job duration. |
| No central log aggregation | MEDIUM | JSONL files are per-job. Cross-job analysis requires manual grep. A Get-CIJobStats.ps1 summarizer would help. |
| No quota/limit on PR builds | MEDIUM | A misbehaving workflow could fork the orchestrator. Concurrency lock prevents IP collisions but not disk fill. MaxAgeHours 4 provides eventual cleanup. |
| No multi-host fan-out | NICE-TO-HAVE | §6.3 deferred. Single point of failure is the workstation. Acceptable for homelab. |
| No remote-access procedure for debugging a stuck VM | LOW | Cleanup-OrphanedBuildVMs.ps1 will eventually destroy a stuck VM. Manual inspection between job and cleanup requires knowing the clone path. |
| No host hardware monitoring | LOW | CPU/RAM/disk-IO not measured. Measure-CIBenchmark.ps1 is phase-time only. |
BaseClean snapshot age not tracked |
LOW | Windows KMS lease is 180 days. After that, builds will silently degrade (activation issues affecting dotnet). |
runner/Install-Runner.ps1 deprecated but present |
LOW | Delete it. |
Setup-Host.ps1 password documentation mismatch |
LOW | See §4.5. |
8. CI/CD Workflow Quality
8.1 The Composite Action — Mostly Good
gitea/actions/local-ci-build/action.yml does the right things:
- Forwards every workflow input through
env:rather than direct interpolation into the shell. This prevents shell injection frominputs.build-command. GOOD. - Builds the
Invoke-CIJob.ps1parameter hashtable conditionally based on which inputs are populated. GOOD. - Emits
artifact-pathandartifact-nameas step outputs for downstreamactions/upload-artifact. GOOD. - Has both a success-path
upload-artifactand a failure-path log upload. GOOD.
Concerns:
- Hardcoded
$ciScriptsDir = 'N:\Code\Workspace\Local-CI-CD-System\scripts'. If the user clones the repo elsewhere, the action breaks. The composite action should resolve scripts via${{ github.action_path }}\..\..\scripts(i.e. relative to the action file). MEDIUM — this couples the action to one specific host filesystem layout. - The action's
outputs:block declaresartifact-pathandartifact-namebut they appear inside theinputs:map by mistake (lines just beforeruns:). YAML-wise this still parses as anoutputs:-like key at the wrong level, but a stricter linter would flag it. LOW — restructure. - No input validation.
inputs.guest-osaccepts any string; if a typo'Linus'is passed, it propagates toInvoke-CIJob.ps1which rejects it viaValidateSet. The error is clear at runtime but late. LOW. - No timeouts at the action level. Relies entirely on the runner-level
timeout: 2hinrunner/config.yaml. LOW.
8.2 The Calling Workflow build-nsInnoUnp.yml
- Matrix on
[windows, linux],fail-fast: false. GOOD. job-id-suffix: '${{ matrix.target }}'to disambiguate the artifact directories. GOOD — this is exactly the right use of the suffix.submodules: 'true'as string (the action only checkseq 'true'). OK.repo-url: 'ssh://gitea-ci/Simone/nsis-plugin-nsinnounp.git'uses the host-side SSH alias configured inSetup-Host.ps1. The alias lives in the user's~/.ssh/config— which user?act_runnerruns asSYSTEM(perRegister-CIScheduledTasks.ps1).SYSTEM's home isC:\Windows\System32\config\systemprofile\. SoSetup-Host.ps1writes the SSH config to the user's~/.ssh/config, notSYSTEM's. HIGH — this only works if the runner was installed under the user account, not SYSTEM. Verify. Ifact_runneris installed as a service under SYSTEM, this URL alias is broken.
I cannot fully verify §8.2 last point without reading the NSSM install args in Setup-Host.ps1 more carefully (the snippet I read showed sc.exe failure configurations but the actual nssm install line for the service account was not in my reads). Treat as HIGH unverified — exact service account configuration must be checked.
8.3 Lint Workflow
gitea/workflows/lint.yml (not read in this session) is referenced in TODO.md as completed for PSScriptAnalyzer. The fact that there is no equivalent for Pester is the gap. HIGH — add a tests.yml workflow that runs the Pester suite on every push.
8.4 Workflow Example
gitea/workflow-example.yml exists at the repo root as a reference for downstream users. GOOD — useful for onboarding.
9. Test Coverage
Four Pester v5 files exist in tests/:
_Common.Tests.ps1New-BuildVM.Tests.ps1Remove-BuildVM.Tests.ps1Wait-VMReady.Tests.ps1
Per TODO.md §5.1, they all use a fake vmrun.cmd driven by $env:FAKE_VMRUN_EXIT. What this means in practice:
| Tested | Not Tested |
|---|---|
Argument construction for vmrun clone |
Whether vmrun clone actually clones |
Invoke-Vmrun return shape |
Whether Invoke-Vmrun works with real vmrun.exe |
| Parameter validation regexes | Whether Test-NetConnection -Port 5986 works |
| Path resolution helpers | Whether Start-Sleep polling deadlines fire correctly |
| Throw-on-error paths via fake exit codes | The actual failure modes (vmware-vmx.exe file lock, DHCP timeout, etc.) |
| WinRM session options object shape | Whether WinRM actually accepts self-signed cert |
Phase 1 readiness loop (with fake vmrun list output) |
Whether real VM boot timing fits the deadline |
This is meaningful coverage — the Pester suite catches regressions in argument shape, parameter validation, return types, and exit-code interpretation. It is NOT integration coverage. Calling this "production-ready" testing is overstating it.
What is missing in priority order:
- HIGH — a
tests/Integration/folder with at least one script that doesMeasure-CIBenchmark.ps1 -Iterations 1and asserts all phases under thresholds. Runnable on demand (manual workflow_dispatch in Gitea). Wire to a[ci-burnin]commit tag for full 4× concurrency. - HIGH — Pester tests for
Invoke-CIJob.ps1phase orchestration (mock the sub-scripts via function override in the test scope). Today the orchestrator is the most complex script and has the least coverage. - MEDIUM — Pester tests for
Invoke-RemoteBuild.ps1Linux PAT-injection (after fixing §5.2). - MEDIUM — Pester tests for
Invoke-RetentionPolicy.ps1aggressive-mode threshold logic and stale-lease cleanup with mocked file dates. - LOW — a single "make sure all 14 scripts parse cleanly" check via
[System.Management.Automation.Language.Parser]::ParseFilein CI. The lint workflow probably already does this implicitly.
Estimated test coverage (lines exercised by Pester / lines of production code): ~15%. That is below typical homelab expectations but consistent with a system written by one operator who tests by running the real thing.
10. Observability
This is the area I am most positive about. The system genuinely has better observability than most paid CI systems give you:
| Channel | Purpose | Quality |
|---|---|---|
F:\CI\Logs\$JobId\invoke-ci.log |
Per-job transcript | High |
F:\CI\Logs\$JobId\invoke-ci.jsonl |
Per-job structured phase events | High |
F:\CI\Logs\benchmark.jsonl |
Cross-job benchmark trend | High |
Windows Event Log CI-DiskAlert (EventId 1001) |
Disk alert | Medium |
Windows Event Log CI-RunnerHealth (EventIds 1002/1003/1004) |
Runner state changes | High |
| Webhook (Discord/Gitea-compatible) | Real-time notification | High |
F:\CI\State\runner-restart-log.json |
Rolling 1-hour restart cooldown | High |
F:\CI\State\ip-leases\<ip>.lease |
Active IP allocations | Medium |
| Task Scheduler history | Scheduled-task health | Medium |
| PSScriptAnalyzer lint workflow output | Code health | Medium |
What is missing:
- Job duration alerting. A job pinned at 1 h 55 min before timing out at 2 h is invisible until it fails. A simple per-job watchdog (Phase 5 start time + 90 min → webhook warning) would help. MEDIUM.
- A
Get-CIJobSummary.ps1script that scans the last N days ofinvoke-ci.jsonlfiles and produces a table of jobs, phases, durations, success rate. Today this is a manual grep. LOW. - Host hardware metrics. CPU/RAM/disk-IO over time would let you correlate slow builds with host contention. Not in scope today. NICE-TO-HAVE.
- No metric on linked-clone delta size growth. If something starts modifying the template files outside of refresh cycles, deltas balloon.
Measure-CIBenchmark.ps1doesn't track this. LOW.
11. Technical Debt
In priority order:
runner/Install-Runner.ps1deprecated. Delete.Setup-Host.ps1password documentation mismatch. Align help with actual default.Measure-CIBenchmark.ps1usesgetGuestIPAddressinstead ofguestVar ci-ip. Inconsistent with production path. Refactor to share IP-acquire logic withInvoke-CIJob.ps1via a helper in_Common.psm1.- Hardcoded
N:\Code\Workspace\Local-CI-CD-System\paths ingitea/actions/local-ci-build/action.ymlandRegister-CIScheduledTasks.ps1. The action should use${{ github.action_path }}; the scheduled tasks could read from a config file inF:\CI\State\. - Linux PAT injection rewrites the URL. Convert to
http.extraHeader(§5.2). - Two divergent SHA256 placeholder strategies in toolchain installers (
TODO.md §1.3). Real pinning would fix this, but at minimum keep the placeholder shape identical across both installers. Invoke-RemoteBuild.ps1is 500+ lines with two ~50-line ScriptBlocks inline. ExtractNew-GuestBuildScriptBlockandNew-GuestCloneScriptBlockinto named functions for readability and testability.- Duplicate parameter blocks for
IPAddress,Credential,GuestOS,SshKeyPath,SshUserrepeated acrossInvoke-RemoteBuild.ps1,Get-BuildArtifacts.ps1,Wait-VMReady.ps1. A shared parameter splat helper would reduce drift risk. docs/archived/2026-05-10/holds old plan/README/test docs. Fine as history, but theTEST-PLAN-v1.3-to-HEAD.mdat top level should reference these explicitly so readers know where to find prior state.- No CHANGELOG. The TODO.md is acting as one. Move historical "done" sections into
docs/CHANGELOG.mdonce a release is tagged.
12. Issues by Priority
CRITICAL
capacity: 4is unvalidated. Either burn-in or downgrade. Right now the system claims a concurrency capability it has not measured. (runner/config.yaml,scripts/Invoke-CIJob.ps1)
HIGH
- No automated end-to-end test.
Measure-CIBenchmark.ps1exists but is not wired to CI and runs without assertions. (scripts/Measure-CIBenchmark.ps1) act_runnerservice account vs SSH alias mismatch potential. If runner runs as SYSTEM, thegitea-ciSSH alias written bySetup-Host.ps1lives in the wrong~/.ssh/config. (Setup-Host.ps1,gitea/workflows/build-nsInnoUnp.yml)- No CI for the CI: Pester tests never run on commit, no integration test. (
gitea/workflows/) - Template-refresh procedure not codified. KMS re-activation, snapshot rebuild, validation steps live in operator memory. (
docs/, missing) Validate-DeployState.ps1does not assertci-report-ip.servicepresence on Linux templates. Silent fallback to 120 s timeout if the service is missing. (template/Validate-DeployState.ps1)
MEDIUM
- Linux Mode 2 PAT injection rewrites the URL. Move to
http.extraHeader(§5.2). (scripts/Invoke-RemoteBuild.ps1) Measure-CIBenchmark.ps1usesgetGuestIPAddress(anti-pattern documented elsewhere). (scripts/Measure-CIBenchmark.ps1)- Shared HGFS caches are read-write from the guest. A compromised build can poison NuGet/pip caches. (
scripts/Set-TemplateSharedFolders.ps1) - Hardcoded
N:\Code\Workspace\...paths in composite action and scheduled tasks. (gitea/actions/local-ci-build/action.yml,scripts/Register-CIScheduledTasks.ps1) - No retention on
F:\CI\BuildVMs\partial-clone directories below the 4 h orphan threshold. (scripts/Cleanup-OrphanedBuildVMs.ps1) - No SHA256 pinning of toolchain installers (homelab-deferred but worth a flag). (
template/Install-CIToolchain-WinBuild2025.ps1) - No job-duration watchdog. Jobs near the 2 h timeout invisible until they fail. (
scripts/, missing) Invoke-RemoteBuild.ps1has duplicate PAT-injection logic between Linux and Windows branches. (scripts/Invoke-RemoteBuild.ps1)
LOW
runner/Install-Runner.ps1deprecated — delete.Setup-Host.ps1password default documentation mismatch.JobIdparameter unvalidated — path-traversal-shaped values accepted.TemplatePathparameter not asserted to live underF:\CI\Templates\.- Composite action's
outputs:block sits insideinputs:map — YAML layout error. Watch-RunnerHealth.ps1cannot distinguish operator-initiated stops from crashes.Backup-CITemplate.ps1does not validate that the template is fully powered-off before copy — relies onStop-Service act_runnerbut does not check for residualvmware-vmx.exe.Get-CIJobStats.ps1summarizer missing.docs/CHANGELOG.mdmissing (TODO.md is acting as one).- String interpolation in SSH commands with non-user-controlled paths — not exploitable today, audit-document.
NICE-TO-HAVE
- Artifact signing.
- Host hardware metrics.
- Multi-host federation (
TODO.md §6.3). - Quota system for runaway builds.
- Per-build Defender exclusion lift/restore on the host (faster I/O during clone).
- Linked-clone delta size monitoring.
13. Top 10 Quick Wins
These are the changes that pay back the most for the least effort.
-
Delete
runner/Install-Runner.ps1. Five-second change. Removes a misleading file. -
Align
Setup-Host.ps1password documentation with code.# In param block [string] $GuestPassword = '' # keep # In help text .PARAMETER GuestPassword Plain-text password for the guest VM build account. When empty (default), the script will prompt with Read-Host -AsSecureString. Do NOT hardcode this in production scripts. -
Add a
[ValidatePattern('^[A-Za-z0-9._-]+$')]toJobIdinInvoke-CIJob.ps1and the composite action. One line each. Closes path-traversal even though not exploitable today. -
Replace hardcoded
$ciScriptsDirinaction.ymlwith${{ github.action_path }}\..\..\scripts:run: | $ciScriptsDir = Resolve-Path (Join-Path '${{ github.action_path }}' '..\..\scripts')Decouples the action from one specific clone location.
-
Fix Linux Mode 2 PAT injection to use
http.extraHeader(snippet in §5.2). Removes PAT from URL/argv/process environment. -
Add a
Validate-DeployState.ps1check forci-report-ip.serviceon Linux templates. A singlesystemctl is-enabled ci-report-ip.serviceover SSH after deploy. -
Wire
Measure-CIBenchmark.ps1 -Iterations 1to aworkflow_dispatchjob named "ci-self-test" in a newgitea/workflows/self-test.yml. Exits non-zero on any phase failure. The runner runs it against itself. -
Add a single test for the orchestrator's IP-detection fallback path in
tests/Invoke-CIJob.Tests.ps1. MockInvoke-Vmrunto return emptyguestVar, assert fallback togetGuestIPAddress. ~30 lines. -
Add
Get-CIJobSummary.ps1that reads the last 7 days ofF:\CI\Logs\*\invoke-ci.jsonland prints a table:JobId Started Elapsed Phase1 Phase5 Status 42-1 2026-05-10 14:22 04:32 0:08 03:42 success 43-1-windows 2026-05-10 14:30 03:18 0:07 02:48 success~40 lines.
-
Add a 90-minute "phase 5 still running" webhook warning to
Invoke-CIJob.ps1. A backgroundStart-Jobwith a sleep and a POST. Fires once per job. ~15 lines.
14. Completion Roadmap
What separates "working homelab" from "actually production-ready, even by homelab standards":
Phase A — Validation (1–2 sessions of work)
- Run a real 4-way concurrent burn-in. Document the result. Decision: keep
capacity: 4or downgrade. - Wire the self-test workflow (Quick Win #7).
- Wire a Pester run on commit.
- Verify the
act_runnerservice-account vs SSH-alias question (§8.2).
Phase B — Hardening (1–2 sessions)
- Fix Linux Mode 2 PAT injection.
- Add Validate-DeployState check for
ci-report-ip.service. - Document the template-refresh runbook.
- Resolve hardcoded paths in the composite action.
- Add job-duration watchdog (Quick Win #10).
Phase C — Polish (background work)
- Add
Get-CIJobSummary.ps1. - Add
JobIdandTemplatePathvalidators. - Add Pester tests for
Invoke-CIJob.ps1(mock sub-scripts). - Move
TODO.mddone-sections todocs/CHANGELOG.md. - Delete
runner/Install-Runner.ps1and fix theSetup-Host.ps1doc mismatch.
Phase D — Optional (if scope grows)
- Implement SHA256 pinning for toolchain installers.
- Implement multi-host federation (
TODO.md §6.3). - Implement read-only HGFS shared folders option.
- Implement artifact signing.
The system is genuinely useful today. Phase A is the difference between "works for me" and "works repeatably under load". Phase B is the difference between "works repeatably" and "I would trust it with somebody else's code".
15. What Would I Change
If I were starting this project from scratch today, given the same constraints (Windows 11 host, VMware Workstation, Gitea, single-operator homelab), here is what I would do differently:
15.1 IP Allocation: Static, Not DHCP
The file mutex + lease pattern is correct in shape but solves a problem you do not need to have. Configure VMnet8 with a small static-IP pool (192.168.79.100–192.168.79.107, eight slots for capacity: 8 headroom) and assign each clone a specific IP from the pool at clone time. Done via:
- A
F:\CI\State\ip-pool.jsonlisting slots{ ip: "192.168.79.100", inUse: false, jobId: null }. New-BuildVM.ps1claims a slot atomically (lock the JSON, markinUse: true, writejobId).- The clone gets a
bootCmdor cloud-init that assigns the static IP at boot. For Windows:netsh interface ip set addressin the autounattend. For Linux: acloud-initnetwork-configfile. Wait-VMReady.ps1polls the known IP rather than discovering it.
Benefits: no DHCP race, no getGuestIPAddress dependency, no guestVar ci-ip plumbing, no 120 s polling deadline, simpler Invoke-CIJob.ps1. The lock becomes per-slot instead of global, so all four jobs can clone+start in parallel.
Cost: one more file to maintain, template autounattend has a single static-IP placeholder. Worth it.
15.2 Drop the WinRM-vs-SSH Branch Duplication
Invoke-RemoteBuild.ps1, Get-BuildArtifacts.ps1, Wait-VMReady.ps1 all have if ($GuestOS -eq 'Linux') { ... } else { ... } branches. This is fine for two transports but does not scale and is a known maintenance pain.
A cleaner shape: a _Transport.psm1 that exports a single interface:
Invoke-GuestCommand -Transport <SSH|WinRM> -IP -Auth -Command
Copy-GuestItem -Transport ... -Direction -Source -Destination
Wait-GuestTransportReady -Transport ... -IP -TimeoutSeconds
with two implementations behind the same surface. Each of the three high-level scripts then becomes ~half the size and ~half the maintenance.
15.3 Centralize Configuration
F:\CI\ paths and BuildVMGuest credential target are hardcoded in many places. A single F:\CI\State\config.json read at the top of each script via a Get-CIConfig helper in _Common.psm1 would centralize this. Today changing the artifact root requires editing 6+ scripts.
15.4 Move the JSONL Log to a SQLite Sink
JSONL is fine for emitting. For querying ("show me all jobs that failed in Phase 5 last week"), SQLite is much better. Append to JSONL for forward compatibility, batch-import into F:\CI\State\history.db from Get-CIJobSummary.ps1. Single dependency.
15.5 Treat the Composite Action's Inputs as a Schema
Today inputs.guest-os accepts any string. Define a JSON schema for the inputs in action.yml and validate at action entry. Bonus: this becomes self-documenting.
15.6 Write the Integration Test First, Not Last
The four Pester unit tests are useful but they were not the test you needed. The test you needed was: "given a fresh template, run a real job, assert artifact exists, assert phase timings within bounds." Build that first before writing PowerShell. It would have caught real bugs faster than fake-vmrun.cmd tests.
15.7 Stop Using Write-Host for Logging
Write-Host is necessary for act_runner output capture per PSScriptAnalyzerSettings.psd1 exclusion. But it conflates user-facing operator output with structured log emission. Define a Write-CILog -Level Info|Warn|Error -Component "..." -Message "..." helper that also writes JSONL and console. One channel, one helper. Today every script writes Write-Host "[Component] msg" and also calls Write-JobEvent. They drift.
15.8 Use pwsh (PowerShell 7) Not PS 5.1
This is the biggest "what would I change" and the one most outside the documented constraints. The PS 5.1 mandate per AGENTS.md is a real constraint that was clearly hit (the $LASTEXITCODE && other_cmd workaround pattern is everywhere). PS 7 is a side-by-side install, does not break PS 5.1, and gives:
&&/||for shell-style chaining??/??=ForEach-Object -Parallel(could parallelize Phase 1 host clones)Test-Jsonfor theextra-guest-env-jsonvalidation- Better error messages
Reasonable counter-argument: PS 5.1 is built-in on Windows 11, PS 7 is one more thing to manage. For a homelab single operator, that argument is weak.
15.9 Bake the Validation Step Into the Orchestrator
template/Validate-DeployState.ps1 and template/Validate-SetupState.ps1 exist as one-shot validation scripts but are not invoked automatically. The orchestrator should run a quick template-health check at the start of each job (cached if recent) and fail-fast if the template is wrong:
# Inside Invoke-CIJob.ps1, very early
$validationStamp = Join-Path $stateDir "template-validated-$($TemplateHash).flag"
if (-not (Test-Path $validationStamp) -or $validationStamp.LastWriteTime -lt (Get-Date).AddDays(-7)) {
& "$scriptDir\..\template\Validate-DeployState.ps1" -TemplatePath $TemplatePath
if ($LASTEXITCODE -ne 0) { throw "Template validation failed." }
Set-Content $validationStamp -Value (Get-Date -Format 'o')
}
This catches the "someone manually edited the template and broke ci-report-ip.service" failure mode.
15.10 Drop the Mode 1 / Mode 2 Choice
Mode 2 (-UseGitClone) is faster per TODO.md (-25.7% vs baseline). Mode 1 exists as the default for the case where the runner has the source already (actions/checkout@v4 in the workflow). But actions/checkout in this setup just checks out on the host, then the host re-zips and ships it to the guest. That double-work is the slow path. The fast path (Mode 2) is faster because the guest has Git installed and can hit Gitea directly over the LAN.
Make Mode 2 the default. Keep Mode 1 only for the offline-template-test edge case. Reduces parameter surface, reduces code paths to maintain.
16. Final Verdict
This is genuinely good homelab engineering. The author understands the constraints (PS 5.1, single workstation, VMware Workstation Pro), has documented those constraints in AGENTS.md for their future self, has built a proper audit trail in TODO.md, has named scripts thoughtfully, has separated concerns into the right modules, and has handled the obvious failure modes (orphans, disk fill, runner crash). The composite action is the right abstraction. The retention policy with aggressive fallback is the right shape. The JSONL+Event Log+webhook observability layer is better than most paid CIs.
What stops me from agreeing with the README.md claim of "production-ready":
capacity: 4is a promise the system has not measured itself keeping. The IP-allocation lock could be correct; or it could leak handles on a specific failure path; or DHCP could hand the same IP twice. The author admits this inTODO.md §2.1 — race non riprodotta in e2e-008/009. Either run the burn-in or setcapacity: 1until it is done.- The Pester suite tests argument-shape, not behavior. A real integration test takes one afternoon to write — that the system does not have one is the single biggest reason this is not actually "production-ready".
- The composite action depends on a hardcoded N:\ path and the
act_runnerservice account / SSH alias question. One of these is likely a runtime bug waiting to be discovered. - The Linux PAT-injection path is weaker than the Windows one with no documented reason. This is sloppy.
- The deprecated
runner/Install-Runner.ps1file and theSetup-Host.ps1ghost-password documentation are small, but they are exactly the kind of small thing that says "this codebase has not had a recent close read by a second pair of eyes." They suggest other small things have also drifted.
Honest grade:
| Dimension | Grade |
|---|---|
| Architecture | A- |
| Code Quality | B+ |
| Security (in scope) | B |
| Reliability (in scope) | B- (would be A- with §6.1 validated) |
| Test Coverage | C |
| Observability | A- |
| Documentation | B+ |
| Operational Readiness | B- |
| Overall | B+ |
Production-ready for the homelab use case it was built for, with the caveats above. Not production-ready by any commercial standard, but it was never built to be, and that is fine.
If I were the author, the next 4 hours I spent on this project would be: (a) run the burn-in, (b) write the integration self-test, (c) fix the Linux PAT path, (d) delete the dead Install-Runner.ps1. Those four changes move it from B+ to A-.
End of review.