From 2d18193d38978799126e08b471973145b379d282 Mon Sep 17 00:00:00 2001 From: Wanderingss <1624155937@qq.com> Date: Tue, 27 Oct 2026 08:00:14 +0800 Subject: [PATCH] =?UTF-8?q?fix(ci):=20=E5=8E=BB=E6=8E=89=20actions/checkou?= =?UTF-8?q?t=20=E5=A4=96=E7=BD=91=E4=BE=9D=E8=B5=96=EF=BC=8CMSVC=20?= =?UTF-8?q?=E6=8E=A2=E6=B5=8B=E6=94=B9=20vswhere=20=E5=B9=B6=E9=92=89?= =?UTF-8?q?=E4=BD=8F=E5=B7=A5=E5=85=B7=E9=9B=86=E4=B8=96=E4=BB=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 两个 job 的第 1 步原先是 `uses: actions/checkout@v4`。`uses:` 的解析发生在 Prepare all required actions 阶段——先于任何步骤、无条件联网(默认 github.com), 且每个 job 各自解析进自己的 .cache/act/、跨 job 不复用。这条链在本机实测 不稳:run 36(10-21)/ 41(10-25)/ 42(10-26)三次都是 ~45 s 后整 job 死亡、 零钩子输出,而同一分钟手工 curl 同一地址返回 200——手工可达 ≠ CI 可用(解析器 跑在 SYSTEM 下)。Gitea 1.25.4 又去掉了自定义 DEFAULT_ACTIONS_URL,服务端没有 可拧的键,唯一归本仓库管的修法就是不再依赖它:两个 job 第 1 步换成原生 git 全量检出。 关键选择(依据与实测表见 docs/CI/远程CI完整指南.md 同文 §5.3,此处只记结论): - 完整 refspec + 全量,不是 --depth 1:提交信息门禁要解 base..head 的 range; 且目标 sha 不在历史里就 ::error:: 硬失败,绝不默默构建一棵错的树; - clean -ffd 而非 -ffdx:-x 会删被忽略文件,而 runner 工作区跨运行复用它们; - GIT_TERMINAL_PROMPT=0 + 空 credential.helper:否则认证挑战会等人类输入, 把 30 分钟 job 超时白烧掉,表现得像 CI 挂了; - 令牌只走 per-invocation -c http.extraheader(Basic + x-access-token,与 checkout@v4 同拼法),不落 .git/config、不回显;仓库公开时匿名即可; - 不抓 tag:本仓库 tag 数 0、无脚本用 describe --tags(实测)。 另修 scripts/ci_runner_bootstrap.ps1 的 Find-MsvcCl。旧版只扫 %ProgramFiles% 派生根且取首个命中:本机 C 盘 VS2019 已于今日 01:00 卸载(C 盘 x64 cl.exe 实测 0 个),旧版因此是「找不到 → 只 Warn → 不写」,机器级变量一直指着那条失效路径, 而 harness 对「显式设置但无效」是硬失败、不做自动回退。现在改为 vswhere 取实例 (能看见非默认盘)+ 工具集版本降序 + -MsvcToolset 前缀钉住世代(默认 14.51., 与覆盖率基线同版本;钉不上连 WARN 再回落最新,绝不静默)+ -MsvcLatest; -SetEnv 与 -Status 同步纳入 DTU_MS_COVERAGE。 验证(内层一律 powershell.exe 5.1,忠实复现 runner 执行方式):临时工作区六项 A/B/C/D/E/F——E 是执行**从 ci.yml 里抽出的正文**,测的就是文件那份字节。 A rc=0、commits=665、refspec 完整、分离 HEAD;B 复用工作区后 dirty_files=0、 被忽略文件保留、17ece86..HEAD 解出 18 个提交;C rc=1 且 HEAD 不动;D 坏令牌 fatal: unable to get password 秒失败;令牌改造后再跑 ANON/BOGUS/ANON2=0/1/0。 ci_local 全量:L0.5 = 40 runner、3188/3188 断言、fail=0(MSVC 14.51); test-coverage 与 ui-golden 均 Passed。.ps1 非 ASCII 字节 0、语法错误 0、无 BOM、 LF;ci.yml 无 BOM 无 CR、13 步、0 个 run 块含非 ASCII、uses: 计数 0。 --- .gitea/workflows/ci.yml | 205 ++++++++++++++++++++++++++++++-- scripts/ci_runner_bootstrap.ps1 | 160 +++++++++++++++++++++---- 2 files changed, 338 insertions(+), 27 deletions(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index f5b53ba0..2f748f89 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -125,11 +125,105 @@ jobs: name: L0 静态门禁 (pre-commit) runs-on: windows steps: - - uses: actions/checkout@v4 - with: - # 钩子要对比基线与历史(doc-links 等读工作区即可,但 gitlint/cubemx 需完整历史) - fetch-depth: 0 - + - name: 检出源码(原生 git;不依赖 github.com 上的 action) + shell: powershell + env: + DTU_REPO_URL: ${{ github.server_url }}/${{ github.repository }}.git + DTU_REPO_REF: ${{ github.ref }} + DTU_REPO_SHA: ${{ github.sha }} + DTU_REPO_TOKEN: ${{ github.token }} + run: | + # Pure ASCII only (see file header). + # + # WHY NOT `uses: actions/checkout`: the runner resolves every `uses:` by + # contacting the actions URL during "Prepare all required actions", BEFORE + # the first step runs. On this intranet runner that call intermittently dies + # with a connect timeout -- runs 36 (10-21) / 41 (10-25) / 42 (10-26) each + # burned ~45 s and produced ZERO hook output, while a manual curl to the same + # URL minutes later returned 200. Manual reachability is not CI health: the + # resolver runs as SYSTEM and the runner caches nothing across jobs (each job + # re-resolves into its own .cache/act/). Gitea 1.25.4 also removed the + # custom DEFAULT_ACTIONS_URL knob, so the only fix this repo owns is to stop + # depending on the public resolver at all. + # + # KEEP IN SYNC with the same step in the other job. This logic deliberately + # does NOT live in scripts/*.ps1 (unlike the Python / MSVC probes): the file + # is not checked out yet at the moment it would be needed -- chicken and egg. + # + # NOTE on $ErrorActionPreference: left at Continue on purpose. Under Windows + # PowerShell 5.1 a native command that writes to stderr while EAP is Stop + # raises NativeCommandError -- git legitimately writes progress to stderr. + if (-not $env:DTU_REPO_SHA) { Write-Host '::error::DTU_REPO_SHA is empty'; exit 1 } + if (-not $env:DTU_REPO_URL) { Write-Host '::error::DTU_REPO_URL is empty'; exit 1 } + $ws = (Get-Location).Path + Write-Host "workspace: $ws" + if (-not (Test-Path -LiteralPath (Join-Path $ws '.git'))) { + git init -q . + if ($LASTEXITCODE -ne 0) { Write-Host '::error::git init failed'; exit 1 } + } + $rem = git remote get-url origin 2>$null + if ($LASTEXITCODE -eq 0 -and $rem) { + git remote set-url origin $env:DTU_REPO_URL + } else { + git remote add origin $env:DTU_REPO_URL + } + if ($LASTEXITCODE -ne 0) { Write-Host '::error::configuring remote origin failed'; exit 1 } + # Full refspec, NOT --depth 1: the commit-message gate resolves base..head + # ranges and several gates diff against history, so a shallow clone turns + # them into "unknown revision" failures. Tags are skipped on purpose: this + # repository has zero tags and no script uses `git describe --tags` + # (measured 2026-10-27). + git config remote.origin.fetch '+refs/heads/*:refs/remotes/origin/*' + # Never let a fetch wait for a human: the runner has no console and a + # credential prompt would burn the whole 30 m job timeout while looking like + # a hung CI. Terminal prompts off + no credential helper means an auth + # problem fails immediately with a message instead of blocking. Anonymous + # fetch works here (the repository is public; measured HTTP 200 on + # /info/refs?service=git-upload-pack), so CI must not start depending on a + # credential store it cannot answer. + $env:GIT_TERMINAL_PROMPT = '0' + # The token, when the instance provides one, travels only as a per-invocation + # -c override: it never lands in .git/config and is never echoed in logs. + # Anonymous fetch works too (the repository is public), so CI does not start + # depending on token availability. + $auth = @('-c', 'credential.helper=', '-c', 'credential.interactive=false') + if ($env:DTU_REPO_TOKEN) { + # Same scheme actions/checkout uses against Gitea git-http: Basic with a + # placeholder user and the token as the password. (Bearer is the GitHub + # *API* style, not the git-http style.) Honest caveat: this branch CANNOT + # be verified here -- the repository is public, so an anonymous fetch + # succeeds and any token we fabricate locally only proves a bad one is + # rejected. If the repository ever goes private this is the first line to + # check; the failure mode is a loud rc=1, never a silent fallback. + $pair = 'x-access-token:' + $env:DTU_REPO_TOKEN + $b64 = [System.Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes($pair)) + $auth += @('-c', ('http.extraheader=AUTHORIZATION: Basic ' + $b64)) + } + # PS 5.1 wraps each native stderr line into an ErrorRecord, turning two real + # log lines into eight of CategoryInfo/FullyQualifiedErrorId noise. Flattening + # with "$_" keeps the log readable; $LASTEXITCODE is unaffected. + $fetchArgs = $auth + @('fetch', '--prune', '--no-tags', 'origin') + & git @fetchArgs 2>&1 | ForEach-Object { "$_" } + if ($LASTEXITCODE -ne 0) { Write-Host "::error::git fetch origin failed (exit $LASTEXITCODE)"; exit 1 } + # Some refs (PR merge commits, refs outside refs/heads) are not covered by the + # branch refspec -- fall back to an explicit ref fetch, then INSIST the wanted + # SHA is really present, rather than silently building the wrong tree. + git rev-parse --verify -q ($env:DTU_REPO_SHA + '^{commit}') 2>$null | Out-Null + if ($LASTEXITCODE -ne 0) { + Write-Host 'sha not covered by the branch refspec -- fetching the event ref explicitly' + $refArgs = $auth + @('fetch', '--prune', '--no-tags', 'origin', $env:DTU_REPO_REF) + & git @refArgs 2>&1 | ForEach-Object { "$_" } + } + git rev-parse --verify -q ($env:DTU_REPO_SHA + '^{commit}') 2>$null | Out-Null + if ($LASTEXITCODE -ne 0) { Write-Host ('::error::' + $env:DTU_REPO_SHA + ' is not present in the fetched history'); exit 1 } + & git checkout -q --detach $env:DTU_REPO_SHA 2>&1 | ForEach-Object { "$_" } + if ($LASTEXITCODE -ne 0) { Write-Host '::error::detached checkout failed'; exit 1 } + git reset --hard -q $env:DTU_REPO_SHA + if ($LASTEXITCODE -ne 0) { Write-Host '::error::reset --hard failed'; exit 1 } + # -ffd, deliberately not -ffdx: -x would delete IGNORED files, and the runner + # reuses those across runs (pip / pre-commit caches live in the workspace). + git clean -ffd -q + Write-Host ('checkout ok: ' + (git rev-parse --short HEAD) + ' | commits=' + (git rev-list --count HEAD) + ' | dirty_files=' + ((git status --porcelain | Measure-Object).Count)) - name: 环境自检(Python / cppcheck / pre-commit) shell: powershell run: | @@ -399,8 +493,105 @@ jobs: name: L0.5 主机侧单测 (MSVC) runs-on: windows steps: - - uses: actions/checkout@v4 - + - name: 检出源码(原生 git;与 l0-gates 同一实现口径) + shell: powershell + env: + DTU_REPO_URL: ${{ github.server_url }}/${{ github.repository }}.git + DTU_REPO_REF: ${{ github.ref }} + DTU_REPO_SHA: ${{ github.sha }} + DTU_REPO_TOKEN: ${{ github.token }} + run: | + # Pure ASCII only (see file header). + # + # WHY NOT `uses: actions/checkout`: the runner resolves every `uses:` by + # contacting the actions URL during "Prepare all required actions", BEFORE + # the first step runs. On this intranet runner that call intermittently dies + # with a connect timeout -- runs 36 (10-21) / 41 (10-25) / 42 (10-26) each + # burned ~45 s and produced ZERO hook output, while a manual curl to the same + # URL minutes later returned 200. Manual reachability is not CI health: the + # resolver runs as SYSTEM and the runner caches nothing across jobs (each job + # re-resolves into its own .cache/act/). Gitea 1.25.4 also removed the + # custom DEFAULT_ACTIONS_URL knob, so the only fix this repo owns is to stop + # depending on the public resolver at all. + # + # KEEP IN SYNC with the same step in the other job. This logic deliberately + # does NOT live in scripts/*.ps1 (unlike the Python / MSVC probes): the file + # is not checked out yet at the moment it would be needed -- chicken and egg. + # + # NOTE on $ErrorActionPreference: left at Continue on purpose. Under Windows + # PowerShell 5.1 a native command that writes to stderr while EAP is Stop + # raises NativeCommandError -- git legitimately writes progress to stderr. + if (-not $env:DTU_REPO_SHA) { Write-Host '::error::DTU_REPO_SHA is empty'; exit 1 } + if (-not $env:DTU_REPO_URL) { Write-Host '::error::DTU_REPO_URL is empty'; exit 1 } + $ws = (Get-Location).Path + Write-Host "workspace: $ws" + if (-not (Test-Path -LiteralPath (Join-Path $ws '.git'))) { + git init -q . + if ($LASTEXITCODE -ne 0) { Write-Host '::error::git init failed'; exit 1 } + } + $rem = git remote get-url origin 2>$null + if ($LASTEXITCODE -eq 0 -and $rem) { + git remote set-url origin $env:DTU_REPO_URL + } else { + git remote add origin $env:DTU_REPO_URL + } + if ($LASTEXITCODE -ne 0) { Write-Host '::error::configuring remote origin failed'; exit 1 } + # Full refspec, NOT --depth 1: the commit-message gate resolves base..head + # ranges and several gates diff against history, so a shallow clone turns + # them into "unknown revision" failures. Tags are skipped on purpose: this + # repository has zero tags and no script uses `git describe --tags` + # (measured 2026-10-27). + git config remote.origin.fetch '+refs/heads/*:refs/remotes/origin/*' + # Never let a fetch wait for a human: the runner has no console and a + # credential prompt would burn the whole 30 m job timeout while looking like + # a hung CI. Terminal prompts off + no credential helper means an auth + # problem fails immediately with a message instead of blocking. Anonymous + # fetch works here (the repository is public; measured HTTP 200 on + # /info/refs?service=git-upload-pack), so CI must not start depending on a + # credential store it cannot answer. + $env:GIT_TERMINAL_PROMPT = '0' + # The token, when the instance provides one, travels only as a per-invocation + # -c override: it never lands in .git/config and is never echoed in logs. + # Anonymous fetch works too (the repository is public), so CI does not start + # depending on token availability. + $auth = @('-c', 'credential.helper=', '-c', 'credential.interactive=false') + if ($env:DTU_REPO_TOKEN) { + # Same scheme actions/checkout uses against Gitea git-http: Basic with a + # placeholder user and the token as the password. (Bearer is the GitHub + # *API* style, not the git-http style.) Honest caveat: this branch CANNOT + # be verified here -- the repository is public, so an anonymous fetch + # succeeds and any token we fabricate locally only proves a bad one is + # rejected. If the repository ever goes private this is the first line to + # check; the failure mode is a loud rc=1, never a silent fallback. + $pair = 'x-access-token:' + $env:DTU_REPO_TOKEN + $b64 = [System.Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes($pair)) + $auth += @('-c', ('http.extraheader=AUTHORIZATION: Basic ' + $b64)) + } + # PS 5.1 wraps each native stderr line into an ErrorRecord, turning two real + # log lines into eight of CategoryInfo/FullyQualifiedErrorId noise. Flattening + # with "$_" keeps the log readable; $LASTEXITCODE is unaffected. + $fetchArgs = $auth + @('fetch', '--prune', '--no-tags', 'origin') + & git @fetchArgs 2>&1 | ForEach-Object { "$_" } + if ($LASTEXITCODE -ne 0) { Write-Host "::error::git fetch origin failed (exit $LASTEXITCODE)"; exit 1 } + # Some refs (PR merge commits, refs outside refs/heads) are not covered by the + # branch refspec -- fall back to an explicit ref fetch, then INSIST the wanted + # SHA is really present, rather than silently building the wrong tree. + git rev-parse --verify -q ($env:DTU_REPO_SHA + '^{commit}') 2>$null | Out-Null + if ($LASTEXITCODE -ne 0) { + Write-Host 'sha not covered by the branch refspec -- fetching the event ref explicitly' + $refArgs = $auth + @('fetch', '--prune', '--no-tags', 'origin', $env:DTU_REPO_REF) + & git @refArgs 2>&1 | ForEach-Object { "$_" } + } + git rev-parse --verify -q ($env:DTU_REPO_SHA + '^{commit}') 2>$null | Out-Null + if ($LASTEXITCODE -ne 0) { Write-Host ('::error::' + $env:DTU_REPO_SHA + ' is not present in the fetched history'); exit 1 } + & git checkout -q --detach $env:DTU_REPO_SHA 2>&1 | ForEach-Object { "$_" } + if ($LASTEXITCODE -ne 0) { Write-Host '::error::detached checkout failed'; exit 1 } + git reset --hard -q $env:DTU_REPO_SHA + if ($LASTEXITCODE -ne 0) { Write-Host '::error::reset --hard failed'; exit 1 } + # -ffd, deliberately not -ffdx: -x would delete IGNORED files, and the runner + # reuses those across runs (pip / pre-commit caches live in the workspace). + git clean -ffd -q + Write-Host ('checkout ok: ' + (git rev-parse --short HEAD) + ' | commits=' + (git rev-list --count HEAD) + ' | dirty_files=' + ((git status --porcelain | Measure-Object).Count)) - name: 探测 MSVC(VS BuildTools / Community 等) id: msvc shell: powershell diff --git a/scripts/ci_runner_bootstrap.ps1 b/scripts/ci_runner_bootstrap.ps1 index 253f20eb..9e378a15 100644 --- a/scripts/ci_runner_bootstrap.ps1 +++ b/scripts/ci_runner_bootstrap.ps1 @@ -49,7 +49,11 @@ .PARAMETER SetEnv Discover and write MACHINE-level DTU_PRECOMMIT_PYTHON / DTU_CPPCHECK / - DTU_MSVC_CL. Existing values are preserved unless -Force. + DTU_MSVC_CL / DTU_MS_COVERAGE. Existing values are preserved unless + -Force. MSVC discovery goes through vswhere (so VS on a non-default + drive is found) and pins the toolset with -MsvcToolset, because the + coverage baseline was recorded with one specific MSVC version -- see + the remote-CI guide (docs/CI/) section 4.4. .PARAMETER StartNow Start the task after registering it. @@ -65,6 +69,16 @@ .PARAMETER Force With -SetEnv, overwrite existing machine-level values. +.PARAMETER MsvcToolset + Toolset version PREFIX to prefer when writing DTU_MSVC_CL (default + '14.51.'). 'newest available' is deliberately NOT the rule: the host + unit-test coverage baseline was measured with MSVC 14.51 and the + denominator changes with the compiler generation. When no toolset + matches the prefix the script warns and falls back to the newest. + +.PARAMETER MsvcLatest + Ignore -MsvcToolset and take the newest MSVC toolset found. + .OUTPUTS Progress lines. Exit codes: 0 = ok, 1 = precondition failed, 2 = not elevated. @@ -80,7 +94,9 @@ param( [switch]$StartNow, [switch]$Status, [switch]$DryRun, - [switch]$Force + [switch]$Force, + [string]$MsvcToolset = '14.51.', + [switch]$MsvcLatest ) $ErrorActionPreference = 'Stop' @@ -138,24 +154,115 @@ function Find-Cppcheck { return $null } -function Find-MsvcCl { - $pf = $env:ProgramFiles - $pf86 = ${env:ProgramFiles(x86)} - if ([string]::IsNullOrWhiteSpace($pf)) { $pf = 'C:\Program Files' } - if ([string]::IsNullOrWhiteSpace($pf86)) { $pf86 = 'C:\Program Files (x86)' } - $roots = @() - foreach ($year in @('2019', '2022', '2026', '18')) { - foreach ($edition in @('BuildTools', 'Community', 'Professional', 'Enterprise')) { - $roots += (Join-Path $pf86 "Microsoft Visual Studio\$year\$edition") - $roots += (Join-Path $pf "Microsoft Visual Studio\$year\$edition") +function Find-VsInstances { + # VS install roots, newest first, via vswhere. This matters because + # vswhere knows about NON-DEFAULT INSTALL DRIVES: this machine keeps + # VS 18 BuildTools under E:, which a %ProgramFiles%-derived probe can + # never see. Returns an empty array when vswhere is unusable so the + # caller can fall back to the classic (incomplete) root scan. + $vswhere = $env:DTU_VSWHERE + if ([string]::IsNullOrWhiteSpace($vswhere)) { + $pf86 = [Environment]::GetFolderPath('ProgramFilesX86') + if ([string]::IsNullOrWhiteSpace($pf86)) { $pf86 = 'C:\Program Files (x86)' } + $vswhere = Join-Path $pf86 'Microsoft Visual Studio\Installer\vswhere.exe' + } + if (-not (Test-Path -LiteralPath $vswhere)) { return @() } + $raw = & $vswhere -all -products * -format json 2>$null + if ($LASTEXITCODE -ne 0 -or -not $raw) { return @() } + $list = @() + foreach ($i in @($raw | ConvertFrom-Json)) { + if (-not $i -or -not $i.installationPath) { continue } + if (-not (Test-Path -LiteralPath $i.installationPath)) { continue } + $v = [version]'0.0' + if ($i.installationVersion) { [void][version]::TryParse([string]$i.installationVersion, [ref]$v) } + $list += [pscustomobject]@{ Root = [string]$i.installationPath; Version = $v } + } + return @($list | Sort-Object -Property Version -Descending) +} + +function Find-MsvcClCandidates { + # Every x64-host cl.exe on this machine, tagged with its toolset version. + # Comparison operators in PowerShell are case-insensitive by default, so + # -match / -notmatch cover HostX64 / Hostx64 spelling differences. + $cands = @() + $roots = @(Find-VsInstances | ForEach-Object { $_.Root }) + if ($roots.Count -eq 0) { + Warn 'vswhere unusable -- falling back to %ProgramFiles% roots; VS installs on other drives are INVISIBLE to that probe' + $pf = [Environment]::GetFolderPath('ProgramFiles') + $pf86 = [Environment]::GetFolderPath('ProgramFilesX86') + if ([string]::IsNullOrWhiteSpace($pf)) { $pf = 'C:\Program Files' } + if ([string]::IsNullOrWhiteSpace($pf86)) { $pf86 = 'C:\Program Files (x86)' } + foreach ($year in @('18', '2026', '2022', '2019')) { + foreach ($edition in @('BuildTools', 'Community', 'Professional', 'Enterprise')) { + foreach ($base in @($pf, $pf86)) { + $r = Join-Path $base ('Microsoft Visual Studio\' + $year + '\' + $edition) + if (Test-Path -LiteralPath $r) { $roots += $r } + } + } } } foreach ($r in $roots) { - if (-not (Test-Path -LiteralPath $r)) { continue } - $cl = Get-ChildItem -LiteralPath $r -Recurse -Filter 'cl.exe' -ErrorAction SilentlyContinue | - Where-Object { $_.FullName -like '*Hostx64\x64*' } | - Select-Object -First 1 - if ($cl) { return $cl.FullName } + $msvc = Join-Path $r 'VC\Tools\MSVC' + if (-not (Test-Path -LiteralPath $msvc)) { continue } + foreach ($f in (Get-ChildItem -LiteralPath $msvc -Recurse -Filter 'cl.exe' -ErrorAction SilentlyContinue)) { + $full = $f.FullName + if ($full -notmatch '\\bin\\Hostx64\\x64\\cl\.exe$') { continue } + $toolset = '' + if ($full -match '\\VC\\Tools\\MSVC\\([^\\]+)\\bin\\') { $toolset = $Matches[1] } + $ver = [version]'0.0' + [void][version]::TryParse($toolset, [ref]$ver) + $cands += [pscustomobject]@{ Cl = $full; Toolset = $toolset; Version = $ver; VsRoot = $r } + } + } + return @($cands) +} + +function Find-MsvcCl { + # Rewritten 2026-10-27. The previous version walked a hard-coded root list + # derived from %ProgramFiles% and returned the FIRST hit: with VS2019 + # (MSVC 14.29) and a newer VS both present it handed back 14.29, and it + # could not see the E: install at all. "First hit" is never right for a + # compiler: the coverage baseline (scripts/test_coverage_baseline.json) + # was recorded with MSVC 14.51 and the instrumented denominator moves when + # the compiler generation moves (measured: App/util/sha256.c is 105 lines + # under gcc vs 114 under MSVC -- see the host coverage policy doc, 2.1). + # Hence the default is a PINNED toolset prefix, not "newest". + param([string]$ToolsetPrefix = '', [switch]$Latest) + $cands = @(Find-MsvcClCandidates) + if ($cands.Count -eq 0) { return $null } + $sorted = @($cands | Sort-Object -Property Version -Descending) + $seen = (@($sorted | ForEach-Object { $_.Toolset } | Select-Object -Unique)) -join ', ' + if (-not $Latest -and $ToolsetPrefix) { + $hit = @($sorted | Where-Object { $_.Toolset -like ($ToolsetPrefix + '*') }) + if ($hit.Count -gt 0) { + Info ('MSVC pinned to toolset prefix ' + $ToolsetPrefix + '* -> ' + $hit[0].Toolset + ' (' + $hit.Count + ' of ' + $cands.Count + ' candidate(s); toolsets found: ' + $seen + ')') + return $hit[0].Cl + } + Warn ('no MSVC toolset matching prefix ' + $ToolsetPrefix + ' on this machine (found: ' + $seen + ')') + Warn 'falling back to the NEWEST toolset -- that changes the coverage denominator; read the remote-CI guide (docs/CI/) section 4.4 before accepting it' + } + Info ('using newest MSVC toolset ' + $sorted[0].Toolset + ' of ' + $cands.Count + ' candidate(s); toolsets found: ' + $seen) + return $sorted[0].Cl +} + +function Find-CoverageConsole { + # Microsoft.CodeCoverage.Console.exe ships ONLY inside Visual Studio + # (measured 2026-10-27: NuGet candidates contain no such .exe, or only the + # legacy Team Tools CodeCoverage.exe whose CLI differs). Its location + # inside a VS install is a fixed relative path: + param([string]$ClPath = '') + $rel = 'Common7\IDE\Extensions\Microsoft\CodeCoverage.Console\Microsoft.CodeCoverage.Console.exe' + $roots = @() + if ($ClPath) { + $vsRoot = '' + # cl.exe = \VC\Tools\MSVC\\bin\Hostx64\x64\cl.exe + if ($ClPath -match '^(.*)\\VC\\Tools\\MSVC\\') { $vsRoot = $Matches[1] } + if ($vsRoot) { $roots += $vsRoot } + } + foreach ($i in @(Find-VsInstances)) { $roots += $i.Root } + foreach ($r in ($roots | Where-Object { $_ } | Select-Object -Unique)) { + $cand = Join-Path $r $rel + if (Test-Path -LiteralPath $cand) { return $cand } } return $null } @@ -273,7 +380,7 @@ if ($Status) { if (Test-Gitea $GiteaUrl) { Ok "Gitea reachable at $GiteaUrl" } else { Fail "Gitea NOT reachable at $GiteaUrl" } - foreach ($n in @('DTU_PRECOMMIT_PYTHON', 'DTU_CPPCHECK', 'DTU_MSVC_CL')) { + foreach ($n in @('DTU_PRECOMMIT_PYTHON', 'DTU_CPPCHECK', 'DTU_MSVC_CL', 'DTU_MS_COVERAGE')) { $v = Get-MachineEnv $n if ($v) { Ok ("MACHINE {0} = {1}" -f $n, $v) } else { Warn "MACHINE $n not set (CI auto-discovery still applies; see scripts/ci_find_python.ps1)" } @@ -359,8 +466,21 @@ if ($SetEnv) { if ($cpp) { Set-MachineEnv 'DTU_CPPCHECK' $cpp 'DTU_CPPCHECK' } else { Warn 'cppcheck not found (install Cppcheck.Cppcheck, or set DTU_CPPCHECK manually)' } - $cl = Find-MsvcCl - if ($cl) { Set-MachineEnv 'DTU_MSVC_CL' $cl 'DTU_MSVC_CL' } + $cl = Find-MsvcCl -ToolsetPrefix $MsvcToolset -Latest:$MsvcLatest + if ($cl) { + Set-MachineEnv 'DTU_MSVC_CL' $cl 'DTU_MSVC_CL' + # Pin the coverage collector too, and pin it INSIDE the same VS as the + # chosen cl.exe: the gate derives it from the compiler ancestor anyway, + # but an explicit machine value also covers the case where somebody + # later points DTU_MSVC_CL at another install. Both vars must be + # changed together (docs/CI/ guide, section 4.4). + $cov = Find-CoverageConsole -ClPath $cl + if ($cov) { Set-MachineEnv 'DTU_MS_COVERAGE' $cov 'DTU_MS_COVERAGE' } + else { + Warn 'Microsoft.CodeCoverage.Console.exe NOT found -- the test-coverage gate FAILS with [ENV] exit 2 (by the 2026-10-24 ruling it does not skip).' + Warn 'install VS BuildTools 17.9+/18.x including Microsoft.VisualStudio.PackageGroup.TestTools.CodeCoverage, then re-run -SetEnv' + } + } else { Warn 'MSVC cl.exe (Hostx64\x64) not found -- L0.5 will degrade to "skipped" without it' } Info 'machine env vars are read when a PROCESS STARTS: the daemon must be restarted below'