From 7dff7d3dba599f32028f644ab968bb4191c5e55f Mon Sep 17 00:00:00 2001 From: Simone Date: Tue, 12 May 2026 21:12:07 +0200 Subject: [PATCH] feat(sprint2): fix critical and high-priority bugs C1: composite action selects Linux/Windows template from GITEA_CI_LINUX_TEMPLATE_PATH based on resolved guest OS; build-nsis.yml adds belt-and-suspenders guest-os param. C2: redact ExtraGuestEnv secrets in Invoke-RemoteBuild.ps1 (envPrefix never logged). H2: composite action outputs block corrected; duplicate artifact-name input removed. H3: Linux Mode 2 PAT now uses http.extraHeader (mirrors Windows branch, drops GIT_CLONE_URL). H6: Setup-Host.ps1 directory array updated to canonical layout (adds State, ip-leases, keys, Cache\pip, WinBuild2025/2022/LinuxBuild2404; removes obsolete WinBuild). H7: Backup-CITemplate.ps1 default TemplatePath -> WinBuild2025; -AllTemplates switch added. H8: Setup-Host.ps1 adds StrictHostKeyChecking=accept-new and Step 8 to replicate SSH config to LocalSystem profile for act_runner service account. H11: runner/config.yaml adds GITEA_CI_SCRIPT_ROOT env var; action.yml uses it in place of hardcoded N:\ path. Also: Invoke-RemoteBuild.ps1 skips 7-Zip/Compress-Archive when artifact source dir does not exist (fixes burn-in smoke runs with exit-0 build command and no output). --- Setup-Host.ps1 | 71 +++++++++++++++++- gitea/actions/local-ci-build/action.yml | 56 ++++++++++++-- gitea/workflows/build-nsis.yml | 1 + runner/config.yaml | 2 + scripts/Backup-CITemplate.ps1 | 97 ++++++++++++++---------- scripts/Invoke-RemoteBuild.ps1 | 98 ++++++++++++++++--------- scripts/Invoke-RetentionPolicy.ps1 | 14 ++++ 7 files changed, 258 insertions(+), 81 deletions(-) diff --git a/Setup-Host.ps1 b/Setup-Host.ps1 index b2a38f0..c3003fa 100644 --- a/Setup-Host.ps1 +++ b/Setup-Host.ps1 @@ -114,13 +114,20 @@ if (-not $ActRunnerConfigYaml) { $ActRunnerConfigYaml = Join-Path $scriptDir 'ru # ── Step 1: Create F:\CI\ directory tree ───────────────────────────────────── Write-Step "Creating CI directory tree under $CIRoot" +# Canonical layout — see AGENTS.md §Struttura directory host $dirs = @( "$CIRoot\BuildVMs", "$CIRoot\Artifacts", "$CIRoot\Logs", - "$CIRoot\Templates\WinBuild", + "$CIRoot\Templates\WinBuild2025", + "$CIRoot\Templates\WinBuild2022", + "$CIRoot\Templates\LinuxBuild2404", + "$CIRoot\State", + "$CIRoot\State\ip-leases", + "$CIRoot\keys", "$CIRoot\act_runner\logs", "$CIRoot\Cache\NuGet", + "$CIRoot\Cache\pip", "$CIRoot\RunnerWork", "$CIRoot\ISO" ) @@ -306,6 +313,7 @@ Host gitea-ci Port $GiteaSSHPort User git IdentityFile ~/.ssh/id_rsa + StrictHostKeyChecking accept-new "@ $existingConfig = '' @@ -318,7 +326,66 @@ Host gitea-ci } else { Add-Content -Path $sshConfig -Value $aliasBlock -Encoding UTF8 Write-OK "SSH alias 'gitea-ci' written to $sshConfig" - Write-Warn "Ensure your public SSH key is registered in Gitea under User Settings → SSH Keys." + Write-Warn "Ensure your public SSH key is registered in Gitea under User Settings -> SSH Keys." + } +} + +# ── Step 8: Replicate SSH config to LocalSystem profile ────────────────────── +# act_runner runs as LocalSystem; its SSH home differs from the interactive +# user's USERPROFILE. Copy .ssh/config and the private key so that +# 'ssh gitea-ci' works when invoked from act_runner jobs. +if ($SkipSSHConfig) { + Write-Host "`n=== SSH LocalSystem replication SKIPPED (-SkipSSHConfig) ===" -ForegroundColor Yellow +} +else { + Write-Step "Replicating SSH config to LocalSystem profile (H8 — act_runner fix)" + + $localSystemSsh = Join-Path $env:SystemRoot 'System32\config\systemprofile\.ssh' + $sourceSshDir = Join-Path $env:USERPROFILE '.ssh' + $sourceConfigPath = Join-Path $sourceSshDir 'config' + $sourceKeyPath = Join-Path $sourceSshDir 'id_rsa' + $sourceKnownHosts = Join-Path $sourceSshDir 'known_hosts' + + if (-not (Test-Path $localSystemSsh)) { + New-Item -ItemType Directory -Path $localSystemSsh -Force | Out-Null + Write-OK "Created $localSystemSsh" + } + + # Copy config + if (Test-Path $sourceConfigPath) { + Copy-Item -Path $sourceConfigPath -Destination (Join-Path $localSystemSsh 'config') -Force + Write-OK "SSH config copied to $localSystemSsh\config" + } + else { + Write-Warn "Source SSH config not found at $sourceConfigPath — skipping config copy." + } + + # Copy private key and lock down permissions + if (Test-Path $sourceKeyPath) { + $destKeyPath = Join-Path $localSystemSsh 'id_rsa' + Copy-Item -Path $sourceKeyPath -Destination $destKeyPath -Force + + $acl = New-Object System.Security.AccessControl.FileSecurity + $acl.SetAccessRuleProtection($true, $false) + $acl.AddAccessRule( + (New-Object System.Security.AccessControl.FileSystemAccessRule( + 'NT AUTHORITY\SYSTEM', 'FullControl', 'Allow'))) + $acl.AddAccessRule( + (New-Object System.Security.AccessControl.FileSystemAccessRule( + 'BUILTIN\Administrators', 'FullControl', 'Allow'))) + Set-Acl -Path $destKeyPath -AclObject $acl + Write-OK "SSH private key copied and ACLs locked to SYSTEM/Administrators at $destKeyPath" + } + else { + Write-Warn "SSH private key not found at $sourceKeyPath — skipping key copy." + Write-Warn "Generate a key with: ssh-keygen -t rsa -b 4096 -f $sourceKeyPath -N ''" + Write-Warn "Then re-run Setup-Host.ps1." + } + + # Copy known_hosts if it exists (avoids first-connect host-key prompt) + if (Test-Path $sourceKnownHosts) { + Copy-Item -Path $sourceKnownHosts -Destination (Join-Path $localSystemSsh 'known_hosts') -Force + Write-OK "known_hosts copied to LocalSystem profile." } } diff --git a/gitea/actions/local-ci-build/action.yml b/gitea/actions/local-ci-build/action.yml index 221e3b1..58e2bcc 100644 --- a/gitea/actions/local-ci-build/action.yml +++ b/gitea/actions/local-ci-build/action.yml @@ -129,6 +129,17 @@ inputs: are never echoed to the log. required: false default: '{}' + + use-shared-cache: + description: > + Set to "true" to redirect NuGet and pip caches to VMware shared folders + on the host (F:\CI\Cache\NuGet and F:\CI\Cache\pip). + Requires Set-TemplateSharedFolders.ps1 to have been run on the template + and VMware Tools HGFS driver present in the guest. + required: false + default: 'false' + +outputs: artifact-path: description: > Host-side directory where Invoke-CIJob.ps1 placed collected artifacts. @@ -162,12 +173,13 @@ runs: INPUT_JOB_ID_SUFFIX: ${{ inputs.job-id-suffix }} INPUT_REPO_URL: ${{ inputs.repo-url }} INPUT_EXTRA_GUEST_ENV_JSON: ${{ inputs.extra-guest-env-json }} + INPUT_USE_SHARED_CACHE: ${{ inputs.use-shared-cache }} run: | #Requires -Version 5.1 Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' - $ciScriptsDir = 'N:\Code\Workspace\Local-CI-CD-System\scripts' + $ciScriptsDir = $env:GITEA_CI_SCRIPT_ROOT # Build job ID — append suffix when provided (matrix disambiguation) $jobId = '${{ github.run_id }}-${{ github.run_attempt }}' @@ -199,6 +211,37 @@ runs: } } + # Resolve guest OS: honor explicit input; for Auto, infer from RUNNER_LABELS + $resolvedGuestOS = $env:INPUT_GUEST_OS + if ($resolvedGuestOS -eq 'Auto') { + if ($env:RUNNER_LABELS -match 'linux') { + $resolvedGuestOS = 'Linux' + } else { + $resolvedGuestOS = 'Windows' + } + } + + # Select template path: explicit input overrides OS-based runner env var + $resolvedTemplatePath = $env:INPUT_TEMPLATE_PATH + if (-not $resolvedTemplatePath) { + if ($resolvedGuestOS -eq 'Linux') { + $resolvedTemplatePath = $env:GITEA_CI_LINUX_TEMPLATE_PATH + } else { + $resolvedTemplatePath = $env:GITEA_CI_TEMPLATE_PATH + } + } + + # Select snapshot name: explicit input > OS-based default + # GITEA_CI_SNAPSHOT_NAME applies to Windows (template-refresh override) + $resolvedSnapshotName = $env:INPUT_SNAPSHOT_NAME + if (-not $resolvedSnapshotName) { + if ($resolvedGuestOS -eq 'Linux') { + $resolvedSnapshotName = 'BaseClean-Linux' + } else { + $resolvedSnapshotName = if ($env:GITEA_CI_SNAPSHOT_NAME) { $env:GITEA_CI_SNAPSHOT_NAME } else { 'BaseClean' } + } + } + # Build parameter hashtable for Invoke-CIJob.ps1 $params = @{ JobId = $jobId @@ -207,14 +250,15 @@ runs: Commit = '${{ github.sha }}' BuildCommand = $env:INPUT_BUILD_COMMAND GuestArtifactSource = $env:INPUT_ARTIFACT_SOURCE - GuestOS = $env:INPUT_GUEST_OS + GuestOS = $resolvedGuestOS Configuration = $env:INPUT_CONFIGURATION } if ($env:INPUT_SUBMODULES -eq 'true') { $params['Submodules'] = $true } - if ($env:INPUT_USE_GIT_CLONE -eq 'true') { $params['UseGitClone'] = $true } - if ($env:INPUT_TEMPLATE_PATH) { $params['TemplatePath'] = $env:INPUT_TEMPLATE_PATH } - if ($env:INPUT_SNAPSHOT_NAME) { $params['SnapshotName'] = $env:INPUT_SNAPSHOT_NAME } + if ($env:INPUT_USE_GIT_CLONE -eq 'true') { $params['UseGitClone'] = $true } + if ($env:INPUT_USE_SHARED_CACHE -eq 'true') { $params['UseSharedCache'] = $true } + if ($resolvedTemplatePath) { $params['TemplatePath'] = $resolvedTemplatePath } + if ($resolvedSnapshotName) { $params['SnapshotName'] = $resolvedSnapshotName } if ($extraGuestEnv.Count -gt 0) { $params['ExtraGuestEnv'] = $extraGuestEnv } & "$ciScriptsDir\Invoke-CIJob.ps1" @params @@ -243,4 +287,4 @@ runs: path: ${{ steps.invoke-ci.outputs.artifact-path }}\*.log retention-days: 3 if-no-files-found: ignore - + diff --git a/gitea/workflows/build-nsis.yml b/gitea/workflows/build-nsis.yml index 5e0c0f3..4d5dc94 100644 --- a/gitea/workflows/build-nsis.yml +++ b/gitea/workflows/build-nsis.yml @@ -30,6 +30,7 @@ jobs: build-command: 'python build_plugin.py --final --dist-dir dist' artifact-source: 'dist' submodules: 'true' + guest-os: ${{ matrix.target == 'linux' && 'Linux' || 'Windows' }} # SSH URL alias configured in host ~/.ssh/config — same for both runners repo-url: 'ssh://gitea-ci/Simone/nsis-plugin-nsinnounp.git' # Suffix disambiguates artifact dirs: F:\CI\Artifacts\{run_id}-{attempt}-windows diff --git a/runner/config.yaml b/runner/config.yaml index 3df5923..d5cd66e 100644 --- a/runner/config.yaml +++ b/runner/config.yaml @@ -33,6 +33,8 @@ runner: GITEA_CI_LINUX_TEMPLATE_PATH: "F:\\CI\\Templates\\LinuxBuild2404\\LinuxBuild2404.vmx" # SSH private key for Linux guest authentication GITEA_CI_SSH_KEY_PATH: "F:\\CI\\keys\\ci_linux" + # Root directory of the local-ci-system repository on this host (scripts\ subdir) + GITEA_CI_SCRIPT_ROOT: "N:\\Code\\Workspace\\Local-CI-CD-System\\scripts" # Optional: load additional env vars from a file (one KEY=VALUE per line) # env_file: "F:\\CI\\runner.env" diff --git a/scripts/Backup-CITemplate.ps1 b/scripts/Backup-CITemplate.ps1 index 911833f..7c950b3 100644 --- a/scripts/Backup-CITemplate.ps1 +++ b/scripts/Backup-CITemplate.ps1 @@ -11,13 +11,18 @@ Restore procedure (after a bad refresh): Stop-Service act_runner - Remove-Item 'F:\CI\Templates\WinBuild' -Recurse -Force - Copy-Item 'F:\CI\Backups\Template_' 'F:\CI\Templates\WinBuild' -Recurse + Remove-Item 'F:\CI\Templates\WinBuild2025' -Recurse -Force + Copy-Item 'F:\CI\Backups\Template_' 'F:\CI\Templates\WinBuild2025' -Recurse Start-Service act_runner .PARAMETER TemplatePath Directory containing the template VM files (.vmx, .vmdk, snapshots). - Default: F:\CI\Templates\WinBuild + Default: F:\CI\Templates\WinBuild2025 + +.PARAMETER AllTemplates + Back up every subdirectory under F:\CI\Templates\ in a single pass. + When set, TemplatePath is ignored. Backup folder names include the template + directory name: Template__. .PARAMETER BackupBaseDir Directory under which timestamped backup folders are created. @@ -40,28 +45,28 @@ # Keep 5 backups .\Backup-CITemplate.ps1 -KeepCount 5 + + # Back up all three templates + .\Backup-CITemplate.ps1 -AllTemplates #> [CmdletBinding(SupportsShouldProcess)] param( - [string] $TemplatePath = 'F:\CI\Templates\WinBuild', + [string] $TemplatePath = 'F:\CI\Templates\WinBuild2025', [string] $BackupBaseDir = 'F:\CI\Backups', [ValidateRange(1, 20)] [int] $KeepCount = 3, - [switch] $SkipRunnerStop + [switch] $SkipRunnerStop, + + [switch] $AllTemplates ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' -if (-not (Test-Path $TemplatePath -PathType Container)) { - throw "Template directory not found: $TemplatePath" -} - -$timestamp = Get-Date -Format 'yyyyMMdd_HHmmss' -$backupDest = Join-Path $BackupBaseDir "Template_$timestamp" +$timestamp = Get-Date -Format 'yyyyMMdd_HHmmss' if (-not (Test-Path $BackupBaseDir)) { New-Item -ItemType Directory -Path $BackupBaseDir -Force | Out-Null @@ -82,36 +87,54 @@ if (-not $SkipRunnerStop) { } try { - # ── Step 2: Copy template directory ─────────────────────────────────────── - if ($PSCmdlet.ShouldProcess($TemplatePath, "Copy to $backupDest")) { - Write-Host "[Backup-CITemplate] Copying $TemplatePath -> $backupDest ..." - $sw = [System.Diagnostics.Stopwatch]::StartNew() - Copy-Item -Path $TemplatePath -Destination $backupDest -Recurse -Force - $sw.Stop() - - # Size sum without Measure-Object.Sum (Set-StrictMode safe) - $backupFiles = Get-ChildItem $backupDest -Recurse -File -ErrorAction SilentlyContinue - $totalBytes = [long]0 - if ($backupFiles) { foreach ($f in $backupFiles) { $totalBytes += $f.Length } } - $sizeMB = [math]::Round($totalBytes / 1MB, 0) - - Write-Host "[Backup-CITemplate] Backup complete: $backupDest ($sizeMB MB, $($sw.Elapsed.TotalSeconds.ToString('F0'))s)" + # ── Steps 2+3: Backup and prune one or all templates ────────────────────── + $templatesToBackup = @() + if ($AllTemplates) { + $templatesToBackup = @(Get-ChildItem 'F:\CI\Templates' -Directory -ErrorAction SilentlyContinue) + if ($templatesToBackup.Count -eq 0) { throw 'No template directories found under F:\CI\Templates' } + } else { + if (-not (Test-Path $TemplatePath -PathType Container)) { + throw "Template directory not found: $TemplatePath" + } + $templatesToBackup = @(Get-Item $TemplatePath) } - # ── Step 3: Prune old backups ────────────────────────────────────────────── - $existing = Get-ChildItem $BackupBaseDir -Directory -ErrorAction SilentlyContinue | - Where-Object { $_.Name -like 'Template_????????_??????' } | - Sort-Object LastWriteTime -Descending + foreach ($tmpl in $templatesToBackup) { + $nameTag = if ($AllTemplates) { "$($tmpl.Name)_$timestamp" } else { $timestamp } + $backupDest = Join-Path $BackupBaseDir "Template_$nameTag" - if ($existing.Count -gt $KeepCount) { - $toRemove = $existing | Select-Object -Skip $KeepCount - foreach ($old in $toRemove) { - if ($PSCmdlet.ShouldProcess($old.FullName, 'Remove old backup')) { - Write-Host "[Backup-CITemplate] Pruning old backup: $($old.Name)" - Remove-Item $old.FullName -Recurse -Force - } + # ── Step 2: Copy template directory ─────────────────────────────────── + if ($PSCmdlet.ShouldProcess($tmpl.FullName, "Copy to $backupDest")) { + Write-Host "[Backup-CITemplate] Copying $($tmpl.FullName) -> $backupDest ..." + $sw = [System.Diagnostics.Stopwatch]::StartNew() + Copy-Item -Path $tmpl.FullName -Destination $backupDest -Recurse -Force + $sw.Stop() + + # Size sum without Measure-Object.Sum (Set-StrictMode safe) + $backupFiles = Get-ChildItem $backupDest -Recurse -File -ErrorAction SilentlyContinue + $totalBytes = [long]0 + if ($backupFiles) { foreach ($f in $backupFiles) { $totalBytes += $f.Length } } + $sizeMB = [math]::Round($totalBytes / 1MB, 0) + + Write-Host "[Backup-CITemplate] Backup complete: $backupDest ($sizeMB MB, $($sw.Elapsed.TotalSeconds.ToString('F0'))s)" + } + + # ── Step 3: Prune old backups for this template ──────────────────────── + $prunePattern = if ($AllTemplates) { "Template_$($tmpl.Name)_????????_??????" } else { 'Template_????????_??????' } + $existing = Get-ChildItem $BackupBaseDir -Directory -ErrorAction SilentlyContinue | + Where-Object { $_.Name -like $prunePattern } | + Sort-Object LastWriteTime -Descending + + if ($existing.Count -gt $KeepCount) { + $toRemove = $existing | Select-Object -Skip $KeepCount + foreach ($old in $toRemove) { + if ($PSCmdlet.ShouldProcess($old.FullName, 'Remove old backup')) { + Write-Host "[Backup-CITemplate] Pruning old backup: $($old.Name)" + Remove-Item $old.FullName -Recurse -Force + } + } + Write-Host "[Backup-CITemplate] Retained $KeepCount backup(s) for $($tmpl.Name)." } - Write-Host "[Backup-CITemplate] Retained $KeepCount backup(s)." } } finally { diff --git a/scripts/Invoke-RemoteBuild.ps1 b/scripts/Invoke-RemoteBuild.ps1 index 281a13d..e894cf1 100644 --- a/scripts/Invoke-RemoteBuild.ps1 +++ b/scripts/Invoke-RemoteBuild.ps1 @@ -136,6 +136,10 @@ param( # SSH username (used when GuestOS=Linux). [string] $SshUser = 'ci_build', + # Persistent known_hosts file for SSH calls (CI jobs). + # Pass an empty string to disable host-key checking (template provisioning only). + [string] $SshKnownHostsFile = 'F:\CI\State\known_hosts', + # Work directory inside the Linux guest. [string] $GuestLinuxWorkDir = '/opt/ci/build', @@ -176,6 +180,7 @@ if ($GuestOS -eq 'Linux') { # Step 1: Clean work dir (parent permissions handled by template setup) Invoke-SshCommand -IP $IPAddress -User $SshUser -KeyPath $SshKeyPath ` + -KnownHostsFile $SshKnownHostsFile ` -Command "rm -rf '$GuestLinuxWorkDir' && mkdir -p '$GuestLinuxWorkDir'" if ($hostCloneMode) { @@ -193,10 +198,12 @@ if ($GuestOS -eq 'Linux') { try { Write-Host "[Invoke-RemoteBuild] Copying source archive to guest:$guestTar ..." Copy-SshItem -Source $hostTar -Destination $guestTar ` - -IP $IPAddress -User $SshUser -KeyPath $SshKeyPath -Direction ToGuest + -IP $IPAddress -User $SshUser -KeyPath $SshKeyPath ` + -KnownHostsFile $SshKnownHostsFile -Direction ToGuest Write-Host "[Invoke-RemoteBuild] Extracting source in guest ($GuestLinuxWorkDir) ..." Invoke-SshCommand -IP $IPAddress -User $SshUser -KeyPath $SshKeyPath ` + -KnownHostsFile $SshKnownHostsFile ` -Command "tar -xzf '$guestTar' -C '$GuestLinuxWorkDir' && rm -f '$guestTar'" } finally { @@ -208,36 +215,41 @@ if ($GuestOS -eq 'Linux') { $cloneCmd = "git clone --depth 1 --branch '$CloneBranch'" if ($CloneSubmodules) { $cloneCmd += ' --recurse-submodules' } - # PAT injection for private repos - $cloneTargetUrl = $CloneUrl - if ($GiteaCredentialTarget -ne '') { - try { - Import-Module CredentialManager -ErrorAction Stop - $pat = Get-StoredCredential -Target $GiteaCredentialTarget -ErrorAction Stop - if ($pat) { - $cloneTargetUrl = $CloneUrl -replace '(https?://)', ('$1' + [uri]::EscapeDataString($pat.UserName) + ':' + [uri]::EscapeDataString($pat.GetNetworkCredential().Password) + '@') - } - } catch { - Write-Warning "[Invoke-RemoteBuild] Could not load PAT from Credential Manager ($GiteaCredentialTarget) — cloning without auth (public repo?)." - } - } - $cloneCmd += " '$CloneUrl' '$GuestLinuxWorkDir'" # non-authed URL for logging Write-Host "[Invoke-RemoteBuild] Cloning $CloneUrl into guest $GuestLinuxWorkDir ..." - if ($cloneTargetUrl -ne $CloneUrl) { - # PAT injected via env var inside SSH session — never in args - $envCloneCmd = "export GIT_CLONE_URL='$cloneTargetUrl'; git clone --depth 1 --branch '$CloneBranch'" - if ($CloneSubmodules) { $envCloneCmd += ' --recurse-submodules' } - $envCloneCmd += " `"`$GIT_CLONE_URL`" '$GuestLinuxWorkDir'; unset GIT_CLONE_URL" - Invoke-SshCommand -IP $IPAddress -User $SshUser -KeyPath $SshKeyPath -Command $envCloneCmd + # PAT injection via git http.extraHeader — PAT never appears in argv or /proc/environ + $authHeader = '' + if ($GiteaCredentialTarget -ne '') { + try { + Import-Module CredentialManager -ErrorAction Stop + $credObj = Get-StoredCredential -Target $GiteaCredentialTarget -ErrorAction Stop + if ($credObj) { + $b64 = [Convert]::ToBase64String( + [System.Text.Encoding]::UTF8.GetBytes( + "$($credObj.UserName):$($credObj.GetNetworkCredential().Password)")) + $authHeader = "Authorization: Basic $b64" + } + } catch { + Write-Warning "[Invoke-RemoteBuild] Could not load PAT from Credential Manager ($GiteaCredentialTarget) — cloning without auth (public repo?)." + } + } + + if ($authHeader -ne '') { + $authCloneCmd = "git -c credential.helper= -c 'http.extraHeader=$authHeader' clone --depth 1 --branch '$CloneBranch'" + if ($CloneSubmodules) { $authCloneCmd += ' --recurse-submodules' } + $authCloneCmd += " '$CloneUrl' '$GuestLinuxWorkDir'" + Invoke-SshCommand -IP $IPAddress -User $SshUser -KeyPath $SshKeyPath ` + -KnownHostsFile $SshKnownHostsFile -Command $authCloneCmd } else { - Invoke-SshCommand -IP $IPAddress -User $SshUser -KeyPath $SshKeyPath -Command $cloneCmd + Invoke-SshCommand -IP $IPAddress -User $SshUser -KeyPath $SshKeyPath ` + -KnownHostsFile $SshKnownHostsFile -Command $cloneCmd } # Checkout specific commit if requested if ($CloneCommit -ne '') { Invoke-SshCommand -IP $IPAddress -User $SshUser -KeyPath $SshKeyPath ` + -KnownHostsFile $SshKnownHostsFile ` -Command "git -C '$GuestLinuxWorkDir' checkout '$CloneCommit'" } } @@ -254,16 +266,20 @@ if ($GuestOS -eq 'Linux') { } else { $buildCmd = "${envPrefix}cd '$GuestLinuxWorkDir' && make" } - Write-Host "[Invoke-RemoteBuild] Running build: $buildCmd" - Invoke-SshCommand -IP $IPAddress -User $SshUser -KeyPath $SshKeyPath -Command $buildCmd + $displayCmd = if ($BuildCommand -ne '') { $BuildCommand } else { 'make' } + Write-Host "[Invoke-RemoteBuild] Running build (env vars redacted): cd '$GuestLinuxWorkDir' && $displayCmd" + Invoke-SshCommand -IP $IPAddress -User $SshUser -KeyPath $SshKeyPath ` + -KnownHostsFile $SshKnownHostsFile -Command $buildCmd # Step 4: Stage artifact tree under $GuestLinuxOutputDir for SCP collection $outDir = $GuestLinuxOutputDir Invoke-SshCommand -IP $IPAddress -User $SshUser -KeyPath $SshKeyPath ` + -KnownHostsFile $SshKnownHostsFile ` -Command "rm -rf '$outDir' && mkdir -p '$outDir'" $artSrc = if ($GuestArtifactSource -ne '') { $GuestArtifactSource } else { 'dist' } Invoke-SshCommand -IP $IPAddress -User $SshUser -KeyPath $SshKeyPath ` + -KnownHostsFile $SshKnownHostsFile ` -Command "if [ -d '$GuestLinuxWorkDir/$artSrc' ]; then cp -r '$GuestLinuxWorkDir/$artSrc/.' '$outDir/'; elif [ -e '$GuestLinuxWorkDir/$artSrc' ]; then cp '$GuestLinuxWorkDir/$artSrc' '$outDir/'; else echo '[Invoke-RemoteBuild] WARNING: artifact source not found: $artSrc' >&2; fi" Write-Host "[Invoke-RemoteBuild] Linux build complete." @@ -432,14 +448,19 @@ try { New-Item -ItemType Directory -Path $archiveDir -Force | Out-Null } $srcPath = Join-Path $workDir $artifactSrc - $7zip = 'C:\Program Files\7-Zip\7z.exe' - if (Test-Path $7zip) { - Write-Host "[Build] Compressing artifacts with 7-Zip..." - & $7zip a -mmt=on -mx1 $artifactZip $srcPath 2>&1 | Where-Object { $_ -notmatch '^7-Zip' } | ForEach-Object { Write-Host $_ } - if ($LASTEXITCODE -ne 0) { throw "7-Zip failed" } + if (-not (Test-Path $srcPath)) { + Write-Host "[Build] Artifact source '$srcPath' not found — skipping packaging." } else { - Compress-Archive -Path $srcPath -DestinationPath $artifactZip -Force + $7zip = 'C:\Program Files\7-Zip\7z.exe' + if (Test-Path $7zip) { + Write-Host "[Build] Compressing artifacts with 7-Zip..." + & $7zip a -mmt=on -mx1 $artifactZip $srcPath 2>&1 | Where-Object { $_ -notmatch '^7-Zip' } | ForEach-Object { Write-Host $_ } + if ($LASTEXITCODE -ne 0) { throw "7-Zip failed" } + } + else { + Compress-Archive -Path $srcPath -DestinationPath $artifactZip -Force + } } } @@ -483,14 +504,19 @@ try { if (-not (Test-Path $archiveDir)) { New-Item -ItemType Directory -Path $archiveDir -Force | Out-Null } - $7zip = 'C:\Program Files\7-Zip\7z.exe' - if (Test-Path $7zip) { - Write-Host "[Build] Compressing output with 7-Zip..." - & $7zip a -mmt=on -mx1 $artifactZip "$outputDir\*" 2>&1 | Where-Object { $_ -notmatch '^7-Zip' } | ForEach-Object { Write-Host $_ } - if ($LASTEXITCODE -ne 0) { throw "7-Zip failed" } + if (-not (Test-Path $outputDir)) { + Write-Host "[Build] Output dir '$outputDir' not found — skipping packaging." } else { - Compress-Archive -Path "$outputDir\*" -DestinationPath $artifactZip -Force + $7zip = 'C:\Program Files\7-Zip\7z.exe' + if (Test-Path $7zip) { + Write-Host "[Build] Compressing output with 7-Zip..." + & $7zip a -mmt=on -mx1 $artifactZip "$outputDir\*" 2>&1 | Where-Object { $_ -notmatch '^7-Zip' } | ForEach-Object { Write-Host $_ } + if ($LASTEXITCODE -ne 0) { throw "7-Zip failed" } + } + else { + Compress-Archive -Path "$outputDir\*" -DestinationPath $artifactZip -Force + } } } diff --git a/scripts/Invoke-RetentionPolicy.ps1 b/scripts/Invoke-RetentionPolicy.ps1 index 745a22a..db8168a 100644 --- a/scripts/Invoke-RetentionPolicy.ps1 +++ b/scripts/Invoke-RetentionPolicy.ps1 @@ -124,6 +124,20 @@ if (Test-Path $leasesDir) { } } +# ── Stale vm-start.lock cleanup ───────────────────────────────────────────── +# The lock is released by Invoke-CIJob.ps1 normally. On host crash it persists; +# the OS-level lock is gone after reboot but the file blocks subsequent IP-acquire +# retries for 10 minutes and confuses operators. Remove if older than 30 minutes. +$lockFile = 'F:\CI\State\vm-start.lock' +$lockCutoff = (Get-Date).AddMinutes(-30) +if ((Test-Path $lockFile) -and (Get-Item $lockFile).LastWriteTime -lt $lockCutoff) { + if ($PSCmdlet.ShouldProcess($lockFile, 'Remove stale vm-start.lock')) { + $lockAgeMin = [int]((Get-Date) - (Get-Item $lockFile).LastWriteTime).TotalMinutes + Remove-Item $lockFile -Force -ErrorAction SilentlyContinue + Write-Host "[RetentionPolicy] Removed stale vm-start.lock (age: ${lockAgeMin} min)" + } +} + # ── Summary ─────────────────────────────────────────────────────────────────── $freeGBAfter = if ($drive) { [math]::Round((Get-PSDrive -Name $DriveLetter -ErrorAction SilentlyContinue).Free / 1GB, 1) } else { $freeGB } $delta = [math]::Round($freeGBAfter - $freeGB, 1)