Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions .github/actions/mask-visual-studio/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
name: Mask Visual Studio
description: >
Make every MSVC discovery strategy of msvc.cppm fail on a runner that ships
Visual Studio, and assert that they do. The runner is disposable, so this is
both safe and closer to "absent" than any env-only trick.

inputs:
attempts:
description: How many times one rename is retried before it counts as denied
required: false
default: "5"
retry-delay-ms:
description: Milliseconds between two attempts at the same rename
required: false
default: "2000"

runs:
using: composite
steps:
- name: Mask Visual Studio
shell: pwsh
env:
MASK_ATTEMPTS: ${{ inputs.attempts }}
MASK_RETRY_DELAY_MS: ${{ inputs.retry-delay-ms }}
run: |
$ErrorActionPreference = 'Continue'

# All three of msvc.cppm's discovery strategies converge on
# <vsRoot>\VC\Tools\MSVC — vswhere returns an installationPath that
# find_latest_msvc_tools then resolves through it, the env strategy
# checks it explicitly, and the well-known-path scan tests for it — so
# masking that directory is what makes this runner answer "no Visual
# Studio" to all three.
#
# The rename is refused outright on some hosts: "Access to the path
# '...\18\Enterprise\VC' is denied", while the same script on the same
# runner image commits the same rename minutes earlier or later (image
# windows-2025-vs2026 20260925.250 renamed it in one run and was
# refused in another). A refusal is therefore retried, and then
# answered one level deeper: a handle into VC does not block renaming
# VC\Tools, and the postcondition below only needs VC\Tools\MSVC gone.
# What outlasts both is reported, never swallowed.
$attempts = [int]$env:MASK_ATTEMPTS
$delay = [int]$env:MASK_RETRY_DELAY_MS

function Mask-Path([string]$Path) {
for ($i = 1; $i -le $attempts; $i++) {
try {
Rename-Item -LiteralPath $Path -NewName "$(Split-Path $Path -Leaf).masked" -ErrorAction Stop
Write-Host "masked $Path"
return $true
} catch {
if ($i -lt $attempts) {
Start-Sleep -Milliseconds $delay
} else {
Write-Host " denied after $attempts attempts: $Path — $($_.Exception.Message)"
}
}
}
return $false
}

$vswhere = "C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe"
if (Test-Path $vswhere) { [void](Mask-Path $vswhere) }

# -Path with a trailing wildcard segment lists the CONTENTS of the
# matches, not the matches themselves, so `…\*\*\VC` would hand back
# VC's children. Resolve-Path returns the directories themselves.
$vsRoots = @(Resolve-Path "C:\Program Files*\Microsoft Visual Studio\*\*\VC" `
-ErrorAction SilentlyContinue)
foreach ($p in $vsRoots) { [void](Mask-Path $p.Path) }

# The fallback, and the reason it is enough: vswhere's resolution and
# the well-known-path scan both end at VC\Tools\MSVC, so masking Tools
# answers "no Visual Studio" as well as masking VC does.
$tools = @(Resolve-Path "C:\Program Files*\Microsoft Visual Studio\*\*\VC\Tools" `
-ErrorAction SilentlyContinue)
foreach ($p in $tools) { [void](Mask-Path $p.Path) }

foreach ($v in @('VSINSTALLDIR','VCINSTALLDIR','VCToolsInstallDir',
'VS170COMNTOOLS','VS160COMNTOOLS','VS150COMNTOOLS')) {
"$v=" | Out-File -Append -FilePath $env:GITHUB_ENV -Encoding utf8
}

# Check the mask's own postcondition here, where the cause is obvious,
# instead of letting it surface three steps later as a confusing pass.
$remaining = Get-ChildItem "C:\Program Files*\Microsoft Visual Studio\*\*\VC\Tools\MSVC" `
-Directory -ErrorAction SilentlyContinue
if ($remaining) {
Write-Host "FAIL: VC tools still present after masking:"
$remaining | ForEach-Object { Write-Host " $($_.FullName)" }
exit 1
}
Write-Host "Visual Studio masked: no VC\Tools\MSVC remains."
125 changes: 110 additions & 15 deletions .github/tools/git_clone_retry.sh
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@
# and their attention, a slow true red costs 50 seconds.
#
# Usage: git_clone_retry.sh [git clone args...] <url> <dir>
# git_clone_retry.sh --rev <ref> <url> # print the commit it names
# git_clone_retry.sh --commit <sha> <url> <dir> # that commit, exactly
# Env: GIT_CLONE_ATTEMPTS (default 4), GIT_CLONE_BACKOFF (default "5 15 30")
set -euo pipefail

Expand All @@ -50,28 +52,121 @@ read -r -a BACKOFF <<< "${GIT_CLONE_BACKOFF:-5 15 30}"
# different reason than the first and the log stops making sense.
dest="${*: -1}"

# `cmd || rc=$?`, NOT `if cmd; then …; fi; rc=$?`.
#
# After an `if`, `$?` is the status of the IF STATEMENT — which is 0 when the
# body did not run. Written that way this script exits 0 on a clone that never
# succeeded, i.e. it turns a hard failure into a green step with a missing
# checkout. Caught by asserting the exit code in the helper's own test rather
# than by reading the output, which looked correct.
#
# ONE COPY OF IT, for all three verbs below. They back off over the same
# sequence and give up in the same words, so the retry is a property of the
# helper rather than of whichever verb was written last.
failed() { # $1 = attempt number (1-based), $2 = exit status of that attempt
if (( $1 == ATTEMPTS )); then
echo "git_clone_retry: giving up after $ATTEMPTS attempts (last exit $2)" >&2
exit "$2"
fi
delay="${BACKOFF[$(( $1 - 1 < ${#BACKOFF[@]} ? $1 - 1 : ${#BACKOFF[@]} - 1 ))]}"
echo "git_clone_retry: attempt $1/$ATTEMPTS failed (exit $2); retrying in ${delay}s" >&2
sleep "$delay"
}

# ── TWO MORE VERBS, AND WHY THEY ARE IN THIS FILE ──────────────────────────
#
# `--rev` and `--commit` exist because "clone this branch" stopped being one
# act the moment four jobs needed it. Every `build` job of `openkal-cross.yml`
# cloned `main` of the index and of the runtime of its own accord, on runners
# that become available at different moments — the macOS leg waits in a queue
# the others do not — and the `run` job then asserted that all four had built
# the same sources, and failed the run when they had not. On 2026-10-13 that
# assertion was right to fail and the change under test had nothing to do with
# it: the run started at 12:43 UTC, its build jobs at 12:52:08 through
# 13:01:33, and mcpp-index@main took four commits between 12:56:12 and
# 13:17:58 — so clones taken minutes apart straddled pushes, and the run job
# reported `index sources differ`.
#
# The repair is to resolve the ref ONCE, at the start of the run, and have
# every host materialise that commit. Writing the SHA into the workflow would
# be the pin nobody is reminded to move that `openkal-cross.yml` warns about in
# its own header, so the resolution is a job — and these are the two primitives
# it needs: ask what a ref names, and get exactly that commit.

# `--rev <ref> <url>`: print the commit the ref names.
#
# `ls-remote` rather than clone-and-rev-parse: what the caller wants is one
# number, and the tree behind it is tens of megabytes on both of the
# repositories this is pointed at.
if [[ "${1:-}" == "--rev" ]]; then
[ "$#" -eq 3 ] || { echo "git_clone_retry: usage: --rev <ref> <url>" >&2; exit 2; }
ref="$2"; url="$3"
# A bare name is a BRANCH. `refs/…` and `HEAD` are taken as written, so a
# tag or a fully qualified ref can be asked for too.
case "$ref" in
refs/*|HEAD) want="$ref" ;;
*) want="refs/heads/$ref" ;;
esac
for (( i = 1; i <= ATTEMPTS; i++ )); do
rc=0
# stderr is NOT captured: what git says about a URL it cannot reach is
# the diagnostic, and it is the message a plain clone would have shown.
listing=$(git ls-remote "$url" "$want") || rc=$?
if (( rc == 0 )); then
sha=$(printf '%s\n' "$listing" | awk -v w="$want" '$2 == w { print $1; exit }')
# No such ref is an ANSWER, not a blip: the host was reached and
# said the ref is not there, so retrying only delays the report.
[ -n "$sha" ] || { echo "git_clone_retry: $url has no $want" >&2; exit 1; }
printf '%s\n' "$sha"
exit 0
fi
failed "$i" "$rc"
done
fi

# `--commit <sha> <url> <dir>`: materialise exactly that commit, and nothing
# the branch did afterwards.
#
# NOT `git clone --branch <sha>`: that argument takes a branch or a tag, and a
# commit is neither. `init` + `fetch --depth 1 origin <sha>` is the shape that
# names a commit, and however far the branch moves after the resolution, it
# costs one commit's tree once.
if [[ "${1:-}" == "--commit" ]]; then
[ "$#" -eq 4 ] || { echo "git_clone_retry: usage: --commit <sha> <url> <dir>" >&2; exit 2; }
sha="$2"; url="$3" dest="$4"
[[ "$sha" =~ ^[0-9a-f]{40}$ ]] \
|| { echo "git_clone_retry: --commit wants a 40-hex commit, got '$sha'" >&2; exit 2; }
for (( i = 1; i <= ATTEMPTS; i++ )); do
rc=0
{
git -c init.defaultBranch=main init --quiet "$dest" &&
git -C "$dest" remote add origin "$url" &&
git -C "$dest" fetch --quiet --depth 1 origin "$sha" &&
git -C "$dest" checkout --quiet --detach FETCH_HEAD
} || rc=$?
if (( rc == 0 )); then
exit 0
fi
# Nothing a failed attempt made survives it, for the reason the clone
# path gives below about its partial clone: a tree half-made by this
# attempt is not a retry, it is a second failure with a different cause.
# And a caller that gives up should not find a directory claiming to be
# the checkout it asked for.
rm -rf -- "$dest"
failed "$i" "$rc"
done
fi

for (( i = 1; i <= ATTEMPTS; i++ )); do
# `cmd || rc=$?`, NOT `if cmd; then …; fi; rc=$?`.
#
# After an `if`, `$?` is the status of the IF STATEMENT — which is 0 when
# the body did not run. Written that way this script exits 0 on a clone that
# never succeeded, i.e. it turns a hard failure into a green step with a
# missing checkout. Caught by asserting the exit code in the helper's own
# test rather than by reading the output, which looked correct.
rc=0
git clone "$@" || rc=$?
if (( rc == 0 )); then
exit 0
fi
if (( i == ATTEMPTS )); then
echo "git_clone_retry: giving up after $ATTEMPTS attempts (last exit $rc)" >&2
exit "$rc"
fi
if [[ -n "$dest" && "$dest" != -* && -e "$dest" ]]; then
# Nothing to remove on the last attempt: there is no retry to prepare for.
if (( i < ATTEMPTS )) && [[ -n "$dest" && "$dest" != -* && -e "$dest" ]]; then
echo "git_clone_retry: removing partial '$dest' before retrying" >&2
rm -rf -- "$dest"
fi
delay="${BACKOFF[$(( i - 1 < ${#BACKOFF[@]} ? i - 1 : ${#BACKOFF[@]} - 1 ))]}"
echo "git_clone_retry: attempt $i/$ATTEMPTS failed (exit $rc); retrying in ${delay}s" >&2
sleep "$delay"
failed "$i" "$rc"
done
39 changes: 6 additions & 33 deletions .github/workflows/ci-fresh-install.yml
Original file line number Diff line number Diff line change
Expand Up @@ -751,40 +751,13 @@ jobs:
steps:
- uses: actions/checkout@v4

# The mask retries a refused rename — "denied" is a state of the host,
# not of the image — and then masks VC\Tools, which is where all three
# of msvc.cppm's discovery strategies end. Its postcondition, no
# VC\Tools\MSVC left, is what keeps an incomplete mask from reading as a
# green no-Visual-Studio install.
- name: Mask Visual Studio
shell: pwsh
run: |
$ErrorActionPreference = 'Continue'

# All three of msvc.cppm's discovery strategies converge on
# <vsRoot>\VC\Tools\MSVC, so mask the VC directory rather than the
# Visual Studio root: the root is held open on the runner and
# renaming it is denied, while VC one level down renames fine.
# The runner is disposable, so this is both safe and closer to
# "absent" than any env-only trick would be.
$vswhere = "C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe"
if (Test-Path $vswhere) { Rename-Item $vswhere "vswhere.exe.masked" }

Get-ChildItem "C:\Program Files*\Microsoft Visual Studio\*\*\VC" `
-Directory -ErrorAction SilentlyContinue | ForEach-Object {
Rename-Item $_.FullName "$($_.Name).masked" -ErrorAction SilentlyContinue
}

foreach ($v in @('VSINSTALLDIR','VCINSTALLDIR','VCToolsInstallDir',
'VS170COMNTOOLS','VS160COMNTOOLS','VS150COMNTOOLS')) {
"$v=" | Out-File -Append -FilePath $env:GITHUB_ENV -Encoding utf8
}

# Check the mask's own postcondition here, where the cause is
# obvious, rather than letting it surface later as a confusing pass.
$left = Get-ChildItem "C:\Program Files*\Microsoft Visual Studio\*\*\VC\Tools\MSVC" `
-Directory -ErrorAction SilentlyContinue
if ($left) {
Write-Host "FAIL: VC tools still present after masking:"
$left | ForEach-Object { Write-Host " $($_.FullName)" }
exit 1
}
Write-Host "Visual Studio masked: no VC\Tools\MSVC remains."
uses: ./.github/actions/mask-visual-studio

- name: Install xlings
shell: pwsh
Expand Down
50 changes: 5 additions & 45 deletions .github/workflows/ci-windows.yml
Original file line number Diff line number Diff line change
Expand Up @@ -143,52 +143,12 @@ jobs:
echo "MCPP_SELF=$MCPP_SELF" >> "$GITHUB_ENV"

# Masked before anything else touches Visual Studio, so no process of
# ours is holding a handle into it.
# ours is holding a handle into it. The mask retries a refused rename
# (a state of the host, not of the image) and then masks VC\Tools; its
# postcondition — no VC\Tools\MSVC left — is what keeps an incomplete
# mask from reading as a green "bare Windows" row.
- name: Mask Visual Studio
shell: pwsh
run: |
$ErrorActionPreference = 'Continue'

# All three of msvc.cppm's discovery strategies converge on
# <vsRoot>\VC\Tools\MSVC — vswhere returns an installationPath that
# find_latest_msvc_tools then resolves through it, the env strategy
# checks it explicitly, and the well-known-path scan tests for it.
# So mask the VC directory rather than the Visual Studio root: the
# root is held open on the runner and renaming it is denied, while
# VC one level down renames fine. The runner is disposable, so this
# is both safe and closer to "absent" than any env-only trick.
$vswhere = "C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe"
if (Test-Path $vswhere) { Rename-Item $vswhere "vswhere.exe.masked" }

# -Path with a trailing wildcard segment lists the CONTENTS of the
# matches, not the matches themselves, so `…\*\*\VC` would hand back
# VC's children. Resolve-Path returns the directories themselves.
# Errors are reported, not swallowed: a silent failure here is how
# the first attempt "masked" nothing and still looked fine.
Resolve-Path "C:\Program Files*\Microsoft Visual Studio\*\*\VC" `
-ErrorAction SilentlyContinue | ForEach-Object {
$p = $_.Path
Write-Host "masking $p"
try { Rename-Item -LiteralPath $p -NewName "VC.masked" -ErrorAction Stop }
catch { Write-Host " rename failed: $($_.Exception.Message)" }
}

foreach ($v in @('VSINSTALLDIR','VCINSTALLDIR','VCToolsInstallDir',
'VS170COMNTOOLS','VS160COMNTOOLS','VS150COMNTOOLS')) {
"$v=" | Out-File -Append -FilePath $env:GITHUB_ENV -Encoding utf8
}

# Check the mask's own postcondition here, where the cause is
# obvious, instead of letting it surface three steps later as a
# confusing pass.
$left = Get-ChildItem "C:\Program Files*\Microsoft Visual Studio\*\*\VC\Tools\MSVC" `
-Directory -ErrorAction SilentlyContinue
if ($left) {
Write-Host "FAIL: VC tools still present after masking:"
$left | ForEach-Object { Write-Host " $($_.FullName)" }
exit 1
}
Write-Host "Visual Studio masked: no VC\Tools\MSVC remains."
uses: ./.github/actions/mask-visual-studio

# After masking, so nothing this action does can be holding Visual
# Studio open. It installs xlings (which mcpp resolves the winlibs
Expand Down
20 changes: 4 additions & 16 deletions .github/workflows/measure-windows-tool-crt.yml
Original file line number Diff line number Diff line change
Expand Up @@ -84,24 +84,12 @@ jobs:
name: measure-candidate
path: candidate

# The same mask as ci-windows.yml's bare-Windows row, applied before
# anything touches Visual Studio.
# The same mask as ci-windows.yml's bare-Windows row — one action, so
# the two rows cannot drift apart — applied before anything touches
# Visual Studio.
- name: Mask Visual Studio
if: matrix.row == 'bare'
shell: pwsh
run: |
$ErrorActionPreference = 'Continue'
$vswhere = "C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe"
if (Test-Path $vswhere) { Rename-Item $vswhere "vswhere.exe.masked" }
Resolve-Path "C:\Program Files*\Microsoft Visual Studio\*\*\VC" -ErrorAction SilentlyContinue | ForEach-Object {
try { Rename-Item -LiteralPath $_.Path -NewName "VC.masked" -ErrorAction Stop }
catch { Write-Host " rename failed: $($_.Exception.Message)" }
}
foreach ($v in @('VSINSTALLDIR','VCINSTALLDIR','VCToolsInstallDir','VS170COMNTOOLS','VS160COMNTOOLS','VS150COMNTOOLS')) {
"$v=" | Out-File -Append -FilePath $env:GITHUB_ENV -Encoding utf8
}
$left = Get-ChildItem "C:\Program Files*\Microsoft Visual Studio\*\*\VC\Tools\MSVC" -Directory -ErrorAction SilentlyContinue
if ($left) { Write-Host "FAIL: VC tools still present after masking"; exit 1 }
uses: ./.github/actions/mask-visual-studio

- uses: ./.github/actions/bootstrap-mcpp

Expand Down
Loading
Loading