Skip to content

ci-fresh-install

ci-fresh-install #205

name: ci-fresh-install
# Fresh install CI — validates the released mcpp binary via xlings.
# Simulates a real first-time user on a clean machine (no caches).
#
# For each platform, tests every supported toolchain:
# 1. mcpp new hello → mcpp run (basic project)
# 2. mcpp build (build mcpp itself from source)
#
# This workflow tests released mcpp, not PR code.
# It runs on release publish, manual trigger, and daily schedule.
on:
# NOTE: `release: published` never fires from the release pipeline — the
# release is created by release.yml with GITHUB_TOKEN, and GitHub
# suppresses workflow triggers from GITHUB_TOKEN-generated events. Kept
# only for releases created manually outside the pipeline. The reliable
# post-release hook is `workflow_run` below: a platform-generated event,
# exempt from that suppression, and requiring no cross-repo PAT.
release:
types: [ published ]
workflow_run:
workflows: [ release ]
types: [ completed ]
workflow_dispatch:
schedule:
# Run daily at 06:00 UTC to catch issues from xlings/runner updates
- cron: '0 6 * * *'
concurrency:
group: ci-fresh-install
cancel-in-progress: false # use false to test in PRs, true to only test released mcpp
# The version under test is DERIVED, once, by the resolve-version job below,
# and every install job consumes that one value.
#
# Two things had to hold, and a hardcoded pin only bought the first:
#
# 1. It must be an explicit version string. Bare `xlings install mcpp`
# resolves "newest in the runner's index copy", so a runner whose copy
# lags silently tests an OLDER binary and reports green. Naming the
# version makes a lagging index fail loudly with `version not found`.
#
# 2. The version the index guard waits for must be the version the jobs
# install. On 2026-07-21 they disagreed: the guard reported "index tracks
# 0.0.102" and the jobs installed 0.0.100 ten seconds later, which then met
# an index whose floor was 0.0.101 (#265). The guard already derived the
# real answer from the releases API and threw it away.
#
# Deriving once and feeding both satisfies (1) and makes (2) structurally
# impossible, instead of relying on a human to keep two hand-edited numbers in
# step with a third that moves on its own. `xlings install mcpp@<derived>` is
# every bit as explicit as `mcpp@<literal>`.
#
# NOT to be confused with the .xlings.json workspace pin, which this used to be
# kept equal to. That one is the BOOTSTRAP compiler for the self-host builds and
# has a different requirement — it must be a released mcpp that can build the
# CURRENT source tree — so it stays hand-maintained. See docs/09-release.md.
jobs:
# ──────────────────────────────────────────────────────────────────
# Linux: gcc@16.1.0, musl-gcc@16.1.0, llvm@20.1.7
# ──────────────────────────────────────────────────────────────────
# A4: post-release runs race the xim-pkgindex bump (a PR a maintainer
# merges asynchronously) — the 0.0.85 fresh-install failed with
# "version not found: available 0.0.84" 55s after the bump merged.
# Convert the race into a bounded wait: poll the index's mcpp.lua until
# it carries the released version (<=15 min), then let every job run.
# Non-workflow_run triggers (manual, cron, PR) skip the wait.
wait-index:
runs-on: ubuntu-latest
if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }}
timeout-minutes: 20
outputs:
version: ${{ steps.resolve.outputs.version }}
steps:
# Derive on EVERY trigger, not just post-release. "The newest published
# release" is the version under test whether we got here from the release
# pipeline, from cron, or by hand — only the WAIT below is specific to a
# post-release run, because only then can the index legitimately lag.
- name: Resolve the version under test (newest published release)
id: resolve
run: |
VER=$(curl -fsSL "https://api.github.com/repos/mcpp-community/mcpp/releases/latest" \
| python3 -c "import json,sys; print(json.load(sys.stdin)['tag_name'].lstrip('v'))")
# A blank version would silently degrade `mcpp@$VER` into bare `mcpp`,
# i.e. straight back to "newest in the runner's index copy" — the exact
# failure this job exists to prevent. Refuse instead.
case "$VER" in
''|*[!0-9.]*) echo "::error::could not resolve a version from the releases API (got '$VER')"; exit 1 ;;
esac
echo "version=$VER" >> "$GITHUB_OUTPUT"
echo "version under test: $VER"
- name: Wait for the PUBLISHED index artifact to track the released mcpp
if: ${{ github.event_name == 'workflow_run' }}
env:
VER: ${{ steps.resolve.outputs.version }}
run: |
# Poll the ARTIFACT, not the git file.
#
# This used to curl raw.githubusercontent.com/openxlings/xim-pkgindex
# — the index's git source of truth, which updates the instant the
# bump PR merges. But the jobs install from the PUBLISHED ARTIFACT
# (xlings-res/xim-index → pointer → tarball), and that channel lags
# git by however long `Publish Index Artifact` plus release-CDN
# propagation takes. Measured on the 2026.8.3.5 release: this guard
# reported ready, and all 11 jobs then failed with
# [error] package 'mcpp@2026.8.3.5' not found
# A guard that measures a channel nobody installs from is not a guard.
#
# This narrows the window; it cannot close it, because the CDN
# propagates per edge and the runner that polls is not the runner
# that installs. install_released_mcpp.sh retries from the consumer
# side for exactly that residue — this step exists so the retry is
# rarely needed, not so it can be removed.
echo "released: $VER — waiting for the published index artifact..."
for i in $(seq 1 40); do
ptr=$(curl -fsSL "https://github.com/xlings-res/xim-index/releases/download/latest/xim-index-latest.json" 2>/dev/null || true)
# One line on purpose: an indented heredoc inside a YAML block
# scalar is a trap — unindented content silently ends the block.
name=$(printf '%s' "$ptr" | python3 -c 'import json,sys; d=json.load(sys.stdin); n=d.get("indexes",{}).get("xim",d); print(n.get("artifact",{}).get("name",""))' 2>/dev/null || true)
if [ -n "$name" ] && curl -fsSL \
"https://github.com/xlings-res/xim-index/releases/download/latest/$name" \
| tar -xzO --wildcards '*pkgs/m/mcpp.lua' 2>/dev/null | grep -q "\"$VER\""; then
echo "published index artifact ($name) tracks $VER (after $((i*30))s)"; exit 0
fi
sleep 30
done
echo "::error::the published index artifact never tracked $VER within 20min — check that the xim-pkgindex bump PR merged AND that Publish Index Artifact ran"
exit 1
- name: No wait needed (manual/cron trigger)
if: ${{ github.event_name != 'workflow_run' }}
run: echo "not a post-release run; skipping index wait"
linux-fresh:
needs: [wait-index]
name: Linux fresh install
if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }}
runs-on: ubuntu-24.04
timeout-minutes: 60
env:
# The one derived value (see the header comment): every install job names
# the SAME version the index guard waited for, so the two cannot disagree.
MCPP_PIN: ${{ needs.wait-index.outputs.version }}
# Verbose every mcpp invocation — fresh-install is the cold index/sandbox
# bootstrap path, exactly where extra diagnostics matter (src/cli.cppm).
MCPP_VERBOSE: "1"
steps:
- uses: actions/checkout@v4
- name: Install xlings + mcpp
env:
XLINGS_NON_INTERACTIVE: '1'
run: |
curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.4.1
echo "$HOME/.xlings/subos/current/bin" >> "$GITHUB_PATH"
- name: Install mcpp and config mirror
shell: bash
run: |
# ONE implementation for "make the released mcpp@X be what `mcpp`
# runs, and prove it" — see .github/tools/install_released_mcpp.sh
# for the three defects the inline version had (workspace pin, no
# activation, and waiting on the wrong index channel).
bash .github/tools/install_released_mcpp.sh "${MCPP_PIN}" "$(pwd)"
mcpp self config --mirror GLOBAL
echo "mcpp debug info:"
which mcpp
cat $HOME/.xlings/.xlings.json
- name: "Default: mcpp new → run"
run: |
cd "$(mktemp -d)"
mcpp new hello_gcc
cd hello_gcc
mcpp run
# Template packages exercise the sha256-pinned mcpp-index fetch
# path (user report: `mcpp new ... --template imgui` failed with
# fetch 'imgui@0.0.6' exit 1 on hosts without a sha256sum binary).
- name: "Template: mcpp new --template imgui (fetch path)"
run: |
cd "$(mktemp -d)"
mcpp new abc1 --template imgui
test -f abc1/mcpp.toml
- name: "Default: build mcpp"
run: |
mcpp clean
mcpp run
- name: "musl-gcc: mcpp new → run"
run: |
mcpp toolchain install gcc 16.1.0-musl
mcpp toolchain default gcc@16.1.0-musl
cd "$(mktemp -d)"
mcpp new hello_musl
cd hello_musl
mcpp run
- name: "musl-gcc: build mcpp"
run: |
mcpp toolchain default gcc@16.1.0-musl
mcpp clean
mcpp run
- name: "gcc 16: mcpp new → run"
run: |
mcpp toolchain install gcc 16.1.0
mcpp toolchain default gcc@16.1.0
cd "$(mktemp -d)"
mcpp new hello_gcc16
cd hello_gcc16
mcpp run
- name: "gcc 16: build mcpp"
run: |
mcpp toolchain default gcc@16.1.0
mcpp clean
mcpp run
- name: "LLVM: mcpp new → run"
run: |
mcpp toolchain install llvm 20.1.7
mcpp toolchain default llvm@20.1.7
cd "$(mktemp -d)"
mcpp new hello_llvm
cd hello_llvm
mcpp run
- name: "LLVM: build mcpp"
run: |
mcpp toolchain default llvm@20.1.7
mcpp clean
mcpp run
# ──────────────────────────────────────────────────────────────────
# Newer/rolling-glibc distros — reproduction surface for the
# bundled-glibc-vs-host-libtinfo `sh:` crash (host glibc > bundled).
# Plus older-glibc legs (the safe reverse direction) proving the
# musl-static mcpp + self-contained toolchain run end-to-end on old
# hosts. Runs the released mcpp inside distro containers.
# ──────────────────────────────────────────────────────────────────
linux-distro-matrix:
needs: [wait-index]
name: Linux distro (${{ matrix.distro }})
if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }}
runs-on: ubuntu-24.04
container:
image: ${{ matrix.image }}
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
# findutils (find) is mandatory on every leg: quick_install.sh
# locates the extracted xlings dir with `find`, so a missing find
# fails the install with exit 127 *before* mcpp ever runs. Minimal
# images (opensuse/tumbleweed) ship without it; arch's base merely
# bundles it by luck. List it explicitly everywhere — don't rely on
# the base image.
include:
- distro: fedora-latest
image: fedora:latest
setup: dnf -y install curl bash tar gzip xz git findutils binutils file glibc-langpack-en
- distro: arch
image: archlinux:latest
setup: pacman -Sy --noconfirm curl bash tar gzip xz git findutils binutils file
- distro: tumbleweed
image: opensuse/tumbleweed:latest
setup: zypper -n install curl bash tar gzip xz git findutils binutils file
- distro: debian-testing
image: debian:testing
setup: apt-get update && apt-get -y install curl bash tar gzip xz-utils git ca-certificates binutils findutils file
- distro: ubuntu-2004
image: ubuntu:20.04
setup: apt-get update && DEBIAN_FRONTEND=noninteractive apt-get -y install curl bash tar gzip xz-utils git ca-certificates binutils findutils file
- distro: debian-11
image: debian:11
setup: apt-get update && apt-get -y install curl bash tar gzip xz-utils git ca-certificates binutils findutils file
env:
# The one derived value (see the header comment): every install job names
# the SAME version the index guard waited for, so the two cannot disagree.
MCPP_PIN: ${{ needs.wait-index.outputs.version }}
XLINGS_NON_INTERACTIVE: '1'
HOME: /root
steps:
- uses: actions/checkout@v4
- name: Install prerequisites (${{ matrix.distro }})
run: ${{ matrix.setup }}
- name: Install xlings + mcpp
run: |
curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.4.1
# Deliberately NOT writing to $GITHUB_PATH here. On container
# images that declare no PATH in their config (opensuse/
# tumbleweed), appending a single dir to GITHUB_PATH makes the
# runner exec later steps' `sh` with only that dir on PATH —
# `sh` (in /usr/bin) vanishes and the next step dies with
# `exec: "sh": ... not found` / exit 127. Each step below already
# exports PATH itself, so the append is redundant anyway.
- name: Configure mcpp
shell: bash
run: |
export PATH="$HOME/.xlings/subos/current/bin:$PATH"
# ONE implementation for "make the released mcpp@X be what `mcpp`
# runs, and prove it" — see .github/tools/install_released_mcpp.sh
# for the three defects the inline version had (workspace pin, no
# activation, and waiting on the wrong index channel).
bash .github/tools/install_released_mcpp.sh "${MCPP_PIN}" "$(pwd)"
mcpp self config --mirror GLOBAL
- name: "Regression: new → run (loader env must not crash /bin/sh)"
run: |
export PATH="$HOME/.xlings/subos/current/bin:$PATH"
cd "$(mktemp -d)"
mcpp new hello_distro
cd hello_distro
mcpp run
- name: "Self-containment: produced binary uses bundled loader"
run: |
export PATH="$HOME/.xlings/subos/current/bin:$PATH"
cd "$(mktemp -d)" && mcpp new hc && cd hc && mcpp build
bin="$(find target -type f -name hc | head -1)"
interp="$(file "$bin" | grep -o 'interpreter [^,]*' | awk '{print $2}')"
echo "interp=$interp"
case "$interp" in
*/.mcpp/*|*/registry/*|*xpkgs*) echo "OK bundled loader" ;;
*) echo "FAIL host loader: $interp"; exit 1 ;;
esac
# ──────────────────────────────────────────────────────────────────
# macOS: llvm@20.1.7
# ──────────────────────────────────────────────────────────────────
macos-fresh:
needs: [wait-index]
name: macOS fresh install
if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }}
# macos-14: the support floor (mcpp ≥0.0.50 / xlings ≥0.4.50 ship
# minos=14.0 static-libc++ binaries). A fresh install passing here
# is the continuous proof of the macOS 14 floor — and of host-tool
# independence (this image has no sha256sum; macos-15 does).
runs-on: macos-14
timeout-minutes: 30
env:
# The one derived value (see the header comment): every install job names
# the SAME version the index guard waited for, so the two cannot disagree.
MCPP_PIN: ${{ needs.wait-index.outputs.version }}
steps:
- uses: actions/checkout@v4
- name: Install xlings
env:
XLINGS_NON_INTERACTIVE: '1'
run: |
# Pinned to kXlingsVersion like every other bootstrap (see
# .github/tools/check_version_pins.sh). Two floors this image needs,
# both long satisfied — do not pin below them:
# v0.4.50+: first xlings whose macosx binary runs on macOS 14
# (older ones carry minos=15 and refuse to start).
# v0.4.51+: in-process sha256 — this image has no sha256sum
# binary, so pinned fetches failed before it.
curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.4.1
echo "$HOME/.xlings/subos/current/bin" >> "$GITHUB_PATH"
- name: Install mcpp and config mirror
shell: bash
run: |
# ONE implementation for "make the released mcpp@X be what `mcpp`
# runs, and prove it" — see .github/tools/install_released_mcpp.sh
# for the three defects the inline version had (workspace pin, no
# activation, and waiting on the wrong index channel).
bash .github/tools/install_released_mcpp.sh "${MCPP_PIN}" "$(pwd)"
mcpp self config --mirror GLOBAL
echo "mcpp debug info:"
which mcpp
cat $HOME/.xlings/.xlings.json
- name: "LLVM: mcpp new → run"
run: |
cd "$(mktemp -d)"
mcpp new hello_mac
cd hello_mac
mcpp run
# Template packages exercise the sha256-pinned mcpp-index fetch
# path — this is what broke on hosts without a sha256sum binary
# (stock macOS / bare Windows) before xlings 0.4.51 hashed
# in-process.
- name: "Template: mcpp new --template imgui (fetch path)"
run: |
cd "$(mktemp -d)"
mcpp new abc1 --template imgui
test -f abc1/mcpp.toml
- name: "LLVM: build mcpp"
run: |
mcpp clean
mcpp run
# ──────────────────────────────────────────────────────────────────
# Windows WITH Visual Studio: llvm@20.1.7 + MSVC STL
#
# Two images, because the OS version is a real variable for a tool that
# touches the UCRT, the Windows SDK and long paths. GitHub publishes no
# Windows 10/11 CLIENT image, so these Server builds are the closest
# available stand-ins: windows-2022 is the Win10 21H2 kernel generation,
# windows-2025 the Win11 24H2 one. What they cannot cover is genuinely
# client-only behaviour — UAC prompts, Defender real-time scanning, the
# long-path policy default — which needs a self-hosted runner.
# ──────────────────────────────────────────────────────────────────
windows-fresh:
needs: [wait-index]
name: Windows fresh install (${{ matrix.image }})
if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }}
runs-on: ${{ matrix.image }}
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
image: [windows-2022, windows-2025]
env:
# The one derived value (see the header comment): every install job names
# the SAME version the index guard waited for, so the two cannot disagree.
MCPP_PIN: ${{ needs.wait-index.outputs.version }}
steps:
- uses: actions/checkout@v4
- name: Install xlings
shell: pwsh
env:
XLINGS_NON_INTERACTIVE: '1'
run: |
irm https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.ps1 | iex
$xlingsbin = "$env:USERPROFILE\.xlings\subos\current\bin"
$env:PATH = "$xlingsbin;$env:PATH"
$xlingsbin | Out-File -Append -FilePath $env:GITHUB_PATH -Encoding utf8
- name: Install mcpp and config mirror
shell: bash
run: |
# ONE implementation for "make the released mcpp@X be what `mcpp`
# runs, and prove it" — see .github/tools/install_released_mcpp.sh
# for the three defects the inline version had (workspace pin, no
# activation, and waiting on the wrong index channel).
bash .github/tools/install_released_mcpp.sh "${MCPP_PIN}" "$(pwd)"
mcpp self config --mirror GLOBAL
cat "$USERPROFILE/.xlings/.xlings.json" || true
- name: "LLVM: mcpp new → run"
shell: pwsh
run: |
$tmp = New-TemporaryFile | ForEach-Object { Remove-Item $_; New-Item -ItemType Directory -Path $_ }
Set-Location $tmp
mcpp new hello_win
Set-Location hello_win
mcpp run
# Template packages exercise the sha256-pinned mcpp-index fetch
# path (user report: `mcpp new abc1 --template imgui` failed with
# fetch 'imgui@0.0.6' exit 1 on bare Windows — no sha256sum binary
# outside git-bash; fixed by xlings 0.4.51 in-process hashing).
- name: "Template: mcpp new --template imgui (fetch path)"
shell: pwsh
run: |
$tmp = New-TemporaryFile | ForEach-Object { Remove-Item $_; New-Item -ItemType Directory -Path $_ }
Set-Location $tmp
mcpp new abc1 --template imgui
if (!(Test-Path abc1/mcpp.toml)) { exit 1 }
- name: "LLVM: build mcpp"
shell: pwsh
run: |
mcpp clean
mcpp run
# ──────────────────────────────────────────────────────────────────
# Windows WITHOUT Visual Studio — the shape of an ordinary user's machine
#
# A stock Windows install ships the UCRT runtime DLLs and nothing else: the
# MSVC STL and the Windows SDK arrive only with Visual Studio's "Desktop
# development with C++" workload. mcpp's Windows default targeted the MSVC
# ABI, so `mcpp new && mcpp build` failed on every such box — and no CI job
# could see it, because every GitHub Windows image ships Visual Studio.
#
# There is no VS-free runner, so the image is masked instead. The risk with
# masking is a false green: miss one of msvc.cppm's three discovery
# strategies (vswhere, environment, well-known paths) and mcpp still finds
# MSVC, takes the ordinary path, and the job passes while proving nothing.
# e2e 182 opens by asserting `mcpp toolchain default msvc` FAILS, which
# turns exactly that into a hard failure.
# ──────────────────────────────────────────────────────────────────
windows-nomsvc-fresh:
needs: [wait-index]
name: Windows fresh install (no Visual Studio)
if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }}
runs-on: windows-2025
timeout-minutes: 30
env:
MCPP_PIN: ${{ needs.wait-index.outputs.version }}
steps:
- uses: actions/checkout@v4
- 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."
- name: Install xlings
shell: pwsh
env:
XLINGS_NON_INTERACTIVE: '1'
run: |
irm https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.ps1 | iex
$xlingsbin = "$env:USERPROFILE\.xlings\subos\current\bin"
$env:PATH = "$xlingsbin;$env:PATH"
$xlingsbin | Out-File -Append -FilePath $env:GITHUB_PATH -Encoding utf8
- name: Install mcpp and config mirror
shell: bash
run: |
# ONE implementation for "make the released mcpp@X be what `mcpp`
# runs, and prove it" — see .github/tools/install_released_mcpp.sh
# for the three defects the inline version had (workspace pin, no
# activation, and waiting on the wrong index channel).
bash .github/tools/install_released_mcpp.sh "${MCPP_PIN}" "$(pwd)"
mcpp self config --mirror GLOBAL
# The self-check, the fallback, persistence, a self-contained exe, and
# the refusal to overrule an explicit [toolchain] — all in e2e 182, so
# the assertions live with the tests rather than in workflow YAML.
- name: "No Visual Studio: fallback to winlibs GCC (e2e 182)"
shell: bash
run: |
MCPP="$(command -v mcpp)" bash tests/e2e/182_windows_no_msvc_fallback.sh