§1.6 — Threat Model & Hardening Trade-offs: New section in BEST-PRACTICES.md §2.1
explains why Defender/Firewall/UAC are disabled in the template VM:
- Current state: tradeoff table (AV overhead vs attack surface, firewall fragility, UAC blocking elevated WinRM)
- Acceptable conditions: isolated lab, trusted code, no host sharing, VMnet8 not exposed
- Breaking conditions: untrusted code builds, shared host, LAN-exposed network
- Mitigations: how to re-enable each feature with specific cost/benefit trade-offs
Addresses security documentation gap — future modifications to security posture
can now reference this single authoritative source.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Four test files under tests/ using fake vmrun.cmd (exit code via
%FAKE_VMRUN_EXIT% env var) — no VMware or network dependency:
_Common.Tests.ps1 — New-CISessionOption TLS flags, Resolve-VmrunPath
throw/return, Invoke-Vmrun ExitCode/Output/ThrowOnError
New-BuildVM.Tests.ps1 — throw on missing template, throw+cleanup on clone
failure, Clone_{JobId}_{timestamp} name format
Remove-BuildVM.Tests.ps1 — no-op on missing VMX, partial dir cleanup,
-WhatIf preservation, full destroy removes dir
Wait-VMReady.Tests.ps1 — throw on missing vmrun, timeout throw,
IP ValidatePattern accept/reject
Run: Invoke-Pester tests\ -Output Detailed
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
§4.1 — Structured JSONL log (Invoke-CIJob.ps1)
Each job now emits a parallel invoke-ci.jsonl alongside the transcript.
Write-JobEvent appends one JSON line per phase transition (ts, jobId,
phase, status, data). Events emitted at: job.start, phase1.clone-repo
start/success, phase2-3b.vm-start start/success, phase4.wait-ready
start/success, phase5.build start/success, phase6.artifacts start/success,
job.success/failure (with elapsedSec and error string).
Silent no-op if $jsonLog is null (log dir setup failed). Errors in
Write-JobEvent are swallowed — logging never breaks the build.
Enables post-hoc per-phase duration analysis with jq or ConvertFrom-Json.
§4.3 — Disk space alert (scripts/Watch-DiskSpace.ps1)
Checks drive free space every 15 min (scheduled by Register-CIScheduledTasks).
Below MinFreeGB (default 50 GB): writes Warning to Windows Application Event Log
(source CI-DiskAlert, EventId 1001) and exits 1 so Task Scheduler flags the run.
Optional -WebhookUrl for Discord/Gitea webhook notification.
Register-CIScheduledTasks.ps1 updated with Task 3: CI-DiskSpaceAlert (every 15 min).
§4.4 — Incident runbook (docs/RUNBOOK.md)
Four scenarios with symptom / triage commands / fix / escalation:
1. Runner offline in Gitea UI
2. All builds fail in Phase 2 (clone/start/IP)
3. Builds are slow (per-phase JSONL analysis, disk/CPU/NAT checks)
4. Template VMX corrupt after host crash (lock removal + backup restore)
Quick-reference table at the end.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
§2.1 — IP race with capacity>1 (Invoke-CIJob.ps1)
Phases 2-3b (clone+start+IP) now run inside a file-based exclusive mutex
(F:\CI\State\vm-start.lock via FileShare.None). Only one job at a time is
in the VM-start phase, preventing concurrent vmrun starts from racing for
the same DHCP lease. After IP is confirmed unique it is written to an IP
lease file (F:\CI\State\ip-leases\<IP>.lease). Lock is released immediately
so build phases run fully in parallel. Lease released in finally block.
Collision detection: if a lease file already exists for the detected IP,
the job fails fast with a clear error rather than silently sharing a WinRM target.
§2.5 — Versioned snapshot (Invoke-CIJob.ps1 + runner/config.yaml)
$SnapshotName now defaults to GITEA_CI_SNAPSHOT_NAME env var, falling back
to 'BaseClean'. config.yaml documents the commented-out variable for snapshot
refresh workflow (BaseClean_<yyyyMMdd> naming convention).
§2.3 — Artifact/log retention (scripts/Invoke-RetentionPolicy.ps1)
Purges per-job subdirs under F:\CI\Artifacts and F:\CI\Logs older than
RetentionDays (default 30). Switches to AggressiveRetentionDays (default 7)
when F: free space drops below MinFreeGB (default 50 GB). Also removes stale
IP lease files older than 12 hours (defensive cleanup for crashed jobs).
SupportsShouldProcess (-WhatIf) for dry-run.
§2.2 — Scheduled task registration (scripts/Register-CIScheduledTasks.ps1)
Registers two tasks under \CI\ task folder as SYSTEM/HighestPrivilege:
CI-CleanupOrphans — every 6h + AtStartup, -MaxAgeHours 4
CI-RetentionPolicy — daily 03:00 (+30min random) + AtStartup (+15min random)
Idempotent (-Force), SupportsShouldProcess (-WhatIf), validates script paths.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Activates Windows against kms8.msguides.com before Windows Update runs
so WU sees correct license state. Uses cscript //NoLogo to route slmgr
output to stdout (dialogs hang in WinRM sessions). Best-effort: activation
failure emits a warning but does not abort Setup — CI builds function
within the grace period.
Placed between Step 3 (WinRM/network confirmed) and Step 4 (user creation).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
$sdFiles is $null when SoftwareDistribution\Download is empty. StrictMode
throws on $null.Count. @($sdFiles).Count safely returns 0 for null.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
PS 5.1 treats GenericMeasureInfo.Sum as absent (not null) when the input pipe
is empty. StrictMode throws 'property Sum cannot be found'. Replace Measure-Object
with an explicit foreach loop for SoftwareDistribution size reporting.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two persistent Validate-SetupState failures despite §7.4 fixes:
1. wuauserv — Setup's Assert-Steps pass at exit but WaaSMedicSvc (tamper-protected,
cannot be disabled) polls SCM ~30-60s and resets StartType to Manual.
Fix: write Start=4 directly to registry key (SCM API alone is overrideable);
deny WaaSMedicSvc write access to wuauserv registry key via ACL (best-effort,
non-fatal if WinRM session lacks permission); re-enforce Start=4 after DISM
in Cleanup; add Pre-Final re-affirmation block before Final gate.
2. AutoAdminLogon — Step 5c re-affirms it mid-script, but Steps 7-10 take hours
and no Final gate check catches a late reset. Fix: add Pre-Final re-affirmation
block (same as wuauserv) + add Assert-Step 'Final' 'AutoAdminLogon=1' to Final gate.
Also update WinISO default path to April 2026 refresh ISO.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Test-WSMan in Windows PowerShell 5.1 does not support -SessionOption (WSManSessionOption),
causing "Cannot find a parameter matching the name 'SessionOption'" at runtime.
Prepare-WinBuild2025.ps1:
- Remove Test-WSMan connectivity check step entirely
- Open New-PSSession directly (handles -SkipCACheck via PSSessionOption)
- Wrap New-PSSession in try/catch with the same actionable error message
- Remove $wsmOpt (WSManSessionOption) — no longer needed
Wait-GuestWinRMReady: replace Test-WSMan probe with New-PSSession probe + Remove-PSSession
Wait-VMReady.ps1:
- Remove $wsmOpt entirely
- Replace Test-WSMan -UseSSL -SessionOption with Test-NetConnection -Port 5986
(TCP open on 5986 = HTTPS listener up = VM ready; credentials not available here)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Fix emerged during §7.4 e2e test run (2026-05-10):
- Deploy: set UsoSvc to Manual explicitly (Server 2025 default = Automatic)
- Setup cleanup: re-apply Set-Service Disabled on wuauserv/UsoSvc after DISM
StartComponentCleanup (WaaSMedicSvc resets StartType during component store cleanup)
- Deploy + Setup: add Administrator autologin (AutoAdminLogon, DefaultUserName,
DefaultPassword, DefaultDomainName) in post-install.ps1; Assert-Step 5c validates it
- Add Validate-DeployState.ps1: standalone host-side check of all Deploy-set state
- Add Validate-SetupState.ps1: standalone host-side check of full post-Setup state
- Mark §7.4, §7.1 and §7.2 e2e validation items as complete in TODO.md
- Update docs: WINDOWS-TEMPLATE-SETUP.md (arch table, validation scripts section,
autologin in confine Deploy/Setup); TEST-7.4-e2e.md checklist marked done
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Step-by-step guide to validate §7 refactor on a real VM:
Deploy stand-alone, state verification, Prepare with/without -SkipWindowsUpdate,
snapshot promotion. Includes expected values table and checklist.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Convert §7.1/7.2/7.3 from emoji ✅ + prose into standard [x] checkbox lists.
§7.4 items converted to [ ] open checkboxes. Consistent with rest of TODO.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
template/Deploy-WinBuild2025.ps1:
New host-side script that drives the unattended Windows install phase:
creates the VM, injects autounattend.xml (disabling Defender/firewall/UAC
before Setup-WinBuild2025.ps1 runs), boots from ISO, waits for first-boot.
template/autounattend.template.xml:
WiM-based answer file template for Windows Server 2025 unattended install.
Sets DisableAntiSpyware GPO + RTP off so Defender is fully off at first
logon (prerequisite for Setup-WinBuild2025.ps1 Step-2 validation-only path).
TODO.md:
- Date + wording updated (2026-05-10)
- §1.1: file refs updated to post-refactor line numbers (Step 3 WinRM,
Deploy-WinBuild2025 Enable-PSRemoting, Invoke-RemoteBuild, Get-BuildArtifacts)
- §1.2: TrustedHosts audit status corrected (Setup-Host.ps1 never sets '*';
Prepare appends IP and restores in finally)
- §1.3: Python/dotnet/VS Build Tools line refs updated
- §1.5: PAT security constraints expanded with grep-on-log safety net rule
- §1.6: Defender/Firewall/UAC state updated to reflect Deploy vs Setup split
- §3.3 deploy reference: VMX path corrected to CI-WinBuild.vmx
- §3.3 doc ref: WINDOWS-TEMPLATE-SETUP updated to list Deploy step
Setup-WinBuild2025.ps1 (Step 2):
- Defender is now disabled at deploy time by Deploy-WinBuild2025.ps1 via
DisableAntiSpyware=1 GPO + RTP off during unattended install.
- Step 2 reduced to a single Assert-Step sanity check (GPO key present = 1).
Exclusion paths removed: scanner is not running, they would be moot.
Prepare-WinBuild2025.ps1 (Setup invocation):
- Replace one-shot Invoke-Command + manual 're-run with -SkipWindowsUpdate'
message with an automated loop (max 10 iterations):
* Invoke-GuestSetup helper runs Setup inside the VM and returns exit code.
* Exit 3010 (ERROR_SUCCESS_REBOOT_REQUIRED) triggers Restart-Computer on
the guest, waits for WinRM via Wait-GuestWinRMReady (20 min timeout),
reopens PSSession and loops.
* Exit 0 breaks the loop; any other code throws immediately.
* Hard cap prevents infinite loops if WU never converges.
- Unified TODO.md + CONSIGLI.md into single working document
- Added priority levels P0 (security/reliability) through P3 (nice-to-have)
- Added references to new docs/HOST-SETUP.md, WINDOWS-TEMPLATE-SETUP.md,
LINUX-TEMPLATE-SETUP.md
- Documented Assert-Step validation flow in Fase B
- Updated open items with rationale and priority tagging
Restore full reference-style TODO (setup phases, commands, e2e log).
Add new entries from FIX-TODO that were dropped in the compact merge:
- Scripts Verification: Cleanup-OrphanedBuildVMs + Remove-BuildVM fix
- Performance & Maintenance: scheduled task for Cleanup
- New "Open Bug Fixes" section: IP octet validation, SHA256 installer
- Reference Paths: add Log dir row
- Gitea Workflow: mark lint.yml done
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Collapse completed setup history into a single summary block.
Absorb FIX-TODO open items (IP octet validation, SHA256 installer
check, Cleanup scheduled task) into the appropriate TODO sections.
Remove FIX-TODO.md — single source of truth restored.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
After hard stop, vmware-vmx.exe briefly holds file locks causing
deleteVM to fail with "Insufficient permissions". Replace the fixed
3s sleep with a vmrun-list poll loop (up to 20s) that waits until
the VMX is no longer registered as running, then retry deleteVM up
to 3x with 0/3/6s backoff before falling back to directory removal.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Wraps Invoke-CIJob.ps1 with nsinnounp params, verifies artifact exists.
Optional -VerifyArtifact flag expands zip and checks for expected
DLL/EXE names. Supports -Commit for pinned SHA testing.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Previous fix removed --depth 1 entirely when $Commit was set, causing
full clones on every workflow run (github.sha is always non-empty).
Better approach: keep --depth 1 (fast path), detect checkout failure,
then fetch --unshallow and retry. Only pays full-clone cost when the
requested commit is actually older than HEAD.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Invoke-RemoteBuild.ps1:
- Custom build command: replace buffered collect-then-print with
Write-Host streaming inside Invoke-Command (WinRM forwards Write-Host
in real-time to the host console)
- Set PYTHONUNBUFFERED=1 in the remote session to prevent Python from
holding lines in its internal buffer
- Same streaming approach for dotnet restore and dotnet build paths
- Return value is now just int exit code instead of hashtable
Invoke-CIJob.ps1:
- Add -LogDir parameter (default F:\CI\Logs)
- Add -LogRetentionDays parameter (default 30)
- Start-Transcript at job start -> F:\CI\Logs\<JobId>\invoke-ci.log
- Stop-Transcript in finally block (guaranteed execution)
- Auto-purge log directories older than LogRetentionDays after each job