Skip to content

RFC(勿合入): 适配 HarmonyOS/OpenHarmony —— 可重定向的 clang + 平台 SDK 作 sysroot #471

RFC(勿合入): 适配 HarmonyOS/OpenHarmony —— 可重定向的 clang + 平台 SDK 作 sysroot

RFC(勿合入): 适配 HarmonyOS/OpenHarmony —— 可重定向的 clang + 平台 SDK 作 sysroot #471

name: cross-build-test
# mcpp cross-build test — the single source of truth for "which CROSS-build
# target combinations mcpp supports", verified end-to-end.
#
# Cross = host arch ≠ target arch. Verification targets are mcpp ITSELF and
# xlings (real, self-hosting C++23 module projects), cross-built from source for
# each target triple, arch-checked, and smoke-run under qemu-user.
#
# ── Supported cross matrix (built + verified below) ────────────────────────
# target | toolchain | host→target | run
# ----------------------|----------------------------------|---------------|-----
# aarch64-linux-musl | aarch64-linux-musl-gcc@16.1.0 | x86_64→arm64 | qemu
# x86_64-w64-mingw32 | mingw-cross-gcc@16.1.0 (MSVCRT) | linux→windows | wine
# x86_64-linux-musl | x86_64-linux-musl-gcc@16.1.0 | windows→linux | linux job
#
# The mingw row is OS-cross (same arch, different OS/ABI: ELF→PE), so it lives
# in its own job below with wine verification instead of the qemu arch matrix.
# See .agents/docs/2026-07-15-mingw-linux-cross-windows-design.md.
#
# The windows→linux row is the MIRROR of that one, and its verification has no
# wine-equivalent: a Windows runner cannot execute the ELF it just produced.
# So it is split across TWO jobs — build on windows-latest, upload the artefact,
# then download and really run it on ubuntu. Static assertions alone would not
# do: "it linked" has never implied "it runs" (see the elfpatch incident in
# .agents/docs/, and 2026-08-03-windows-host-linux-cross-design.md §6.1).
# The artefact is a fully static musl ELF (no PT_INTERP), so the consumer job
# needs neither qemu nor a matching loader.
#
# mcpp resolves a cross `--target <triple>-musl` build to the triple-named cross
# gcc musl toolchain from the xlings ecosystem (xim:<triple>-gcc, see
# src/build/prepare.cppm). Output is a fully static musl ELF (no PT_INTERP),
# which also makes the aarch64 artefact runnable natively in Termux/Android —
# qemu-aarch64 is the CI proxy for "does this cross artefact actually execute".
#
# ── NOT here ───────────────────────────────────────────────────────────────
# * Same-arch builds (host arch == target arch) are NOT cross. The native musl
# static build `--target x86_64-linux-musl` (x86_64 host) is exercised by
# ci-linux.yml's "Toolchain: musl-gcc" step, and release.yml for the static
# release artefact. Keep them there; this file is cross-arch only.
#
# ── Related, but deliberately NOT here ────────────────────────────────────
# * llvm/clang cross : WIRED — Toolchain::crossTarget retargets a clang
# driver with `--target=` + an external sysroot. Its
# first consumer is HarmonyOS, and that row lives in
# ci-harmonyos.yml rather than this matrix for one
# reason: it needs a ~2.5 GB vendor SDK mcpp cannot
# ship, and a failure to obtain that SDK must never be
# confusable with a failure of mcpp's own cross matrix.
# See .agents/docs/2026-08-04-harmonyos-target-design.md.
#
# ── Planned cross rows (documented; NOT yet wired in mcpp — keep as comments) ─
# * riscv64-linux-musl: add once xim:riscv64-linux-musl-gcc ships to
# xlings-res + xim-pkgindex.
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
workflow_dispatch:
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
cross-build:
name: cross-build ${{ matrix.target }} (mcpp + xlings)
runs-on: ubuntu-24.04
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
include:
- target: aarch64-linux-musl
file_arch: "ARM aarch64"
qemu_bin: qemu-aarch64-static
env:
MCPP_HOME: /home/runner/.mcpp
# Verbose every mcpp invocation for richer CI diagnostics (src/cli.cppm).
MCPP_VERBOSE: "1"
steps:
- uses: actions/checkout@v4
- name: Cache mcpp sandbox
uses: actions/cache@v4
with:
path: ~/.mcpp
key: mcpp-sandbox-${{ runner.os }}-cross-${{ matrix.target }}-${{ hashFiles('mcpp.toml', '.xlings.json') }}
restore-keys: |
mcpp-sandbox-${{ runner.os }}-cross-${{ matrix.target }}-
- name: Cache xlings
uses: actions/cache@v4
with:
path: ~/.xlings
key: xlings-${{ runner.os }}-v2-${{ hashFiles('.xlings.json') }}
restore-keys: |
xlings-${{ runner.os }}-v2-
- name: Install qemu-user-static
run: |
sudo apt-get update -qq
sudo apt-get install -y qemu-user-static
${{ matrix.qemu_bin }} --version | head -1
- name: Bootstrap mcpp via xlings
env:
XLINGS_NON_INTERACTIVE: '1'
# Must equal `pinned::kXlingsVersion` (src/xlings.cppm) and the
# xlings the release bundles — enforced by
# .github/tools/check_version_pins.sh.
#
# Floors worth remembering. 0.4.67 carried the
# multi-index_repo install fix (openxlings/xlings#374); 0.4.68 adds
# per-repo index artifact sources (openxlings/xlings#377) so the
# mcpplibs index syncs via artifact with git as fallback (mcpp#269);
# 0.4.69 keys the index by (namespace, name) so two packages sharing
# a short name in ONE index are both addressable (openxlings/xlings#381)
# — the floor for SPEC-001 short-name descriptors.
# A past 0.4.61 "download 404
# for mcpp@<pin>" was NOT a version bug — the xlings-res/mcpp GitHub
# release assets were uploaded in a broken state (records present,
# blobs missing → 404 on GET); re-uploaded clean. The stale-INDEX
# half is handled by the marker-clear below.
XLINGS_VERSION: '2026.8.4.1'
run: |
tarball="xlings-${XLINGS_VERSION}-linux-x86_64.tar.gz"
curl -fsSL -o "/tmp/${tarball}" \
"https://github.com/d2learn/xlings/releases/download/v${XLINGS_VERSION}/${tarball}"
tar -xzf "/tmp/${tarball}" -C /tmp
"/tmp/xlings-${XLINGS_VERSION}-linux-x86_64/subos/default/bin/xlings" self install
export PATH="$HOME/.xlings/subos/default/bin:$PATH"
xlings --version
# Force a real index re-sync even on a warm cache: drop the TTL refresh
# markers so `xlings update` actually pulls the latest index (sees the
# current bootstrap pin) while the toolchain payloads stay cached.
find "$HOME/.xlings" -name '.xlings-index-cache.json' -delete 2>/dev/null || true
xlings config --mirror GLOBAL 2>/dev/null || true
xlings update -y 2>/dev/null || xlings update 2>/dev/null || true
# MCPP_BOOT is what actually runs the bootstrap build below, so it —
# not just MCPP — has to be the pinned binary. It used to be the shim
# in subos/default/bin, which resolves to whatever version xvm has
# selected; pinning only MCPP would have looked right and changed
# nothing.
MCPP_BOOT=$(bash "$GITHUB_WORKSPACE/.github/tools/install_pinned_mcpp.sh" "$GITHUB_WORKSPACE")
echo "MCPP=$MCPP_BOOT" >> "$GITHUB_ENV"
echo "XLINGS_BIN=$HOME/.xlings/subos/default/bin/xlings" >> "$GITHUB_ENV"
echo "MCPP_BOOT=$MCPP_BOOT" >> "$GITHUB_ENV"
- name: Self-host build (bootstrap mcpp -> fresh host mcpp)
run: |
export MCPP_VENDORED_XLINGS="$XLINGS_BIN"
"$XLINGS_BIN" config --mirror GLOBAL 2>/dev/null || true
"$MCPP_BOOT" self config --mirror GLOBAL 2>/dev/null || true
"$MCPP_BOOT" build
MCPP=$(realpath "$(find target -type f -name mcpp -printf '%T@ %p\n' | sort -rn | head -1 | cut -d' ' -f2)")
test -x "$MCPP"
"$MCPP" self config --mirror GLOBAL
echo "MCPP=$MCPP" >> "$GITHUB_ENV"
- name: "Cross-build mcpp -> ${{ matrix.target }}"
run: |
export MCPP_VENDORED_XLINGS="$XLINGS_BIN"
"$MCPP" build --target ${{ matrix.target }}
bin=$(find target/${{ matrix.target }} -type f -name mcpp | head -1)
[ -n "$bin" ] || { echo "no mcpp artefact for ${{ matrix.target }}"; exit 1; }
echo "== file =="; file "$bin"
file "$bin" | grep -q "${{ matrix.file_arch }}" || { echo "expected ${{ matrix.file_arch }}"; exit 1; }
file "$bin" | grep -q "statically linked" || { echo "expected static"; exit 1; }
echo "MCPP_XBIN=$bin" >> "$GITHUB_ENV"
- name: "Cross-build xlings -> ${{ matrix.target }}"
run: |
export MCPP_VENDORED_XLINGS="$XLINGS_BIN"
git clone --depth 1 https://github.com/openxlings/xlings /tmp/xlings-src
cd /tmp/xlings-src
"$MCPP" self config --mirror GLOBAL 2>/dev/null || true
"$MCPP" build --target ${{ matrix.target }}
xbin=$(find target/${{ matrix.target }} -type f -name xlings | head -1)
[ -n "$xbin" ] || { echo "no xlings artefact for ${{ matrix.target }}"; exit 1; }
echo "== file =="; file "$xbin"
file "$xbin" | grep -q "${{ matrix.file_arch }}" || { echo "expected ${{ matrix.file_arch }}"; exit 1; }
file "$xbin" | grep -q "statically linked" || { echo "expected static"; exit 1; }
echo "XLINGS_XBIN=$xbin" >> "$GITHUB_ENV"
- name: "Smoke-run cross artefacts under qemu"
run: |
RUN="${{ matrix.qemu_bin }}"
# mcpp is self-contained, so --version runs cleanly under bare qemu —
# this is the hard execution proof for the cross artefact.
echo "== mcpp --version =="
mver=$($RUN "$MCPP_XBIN" --version)
echo "$mver"; echo "$mver" | grep -q "mcpp" || { echo "mcpp --version failed"; exit 1; }
# xlings expects a real runtime environment (sandbox/config) and may
# exit non-zero on a bare `--version` under qemu; its ELF arch + static
# linkage were already asserted in the build step, so treat execution
# here as best-effort rather than gating.
echo "== xlings --version (best-effort under qemu) =="
xver=$($RUN "$XLINGS_XBIN" --version 2>&1 || true)
echo "$xver"
# ── Linux → Windows MinGW cross (OS-cross, same arch: ELF→PE) ─────────────
# Builds a demo project for x86_64-w64-mingw32 with the from-source GCC-16
# MSVCRT cross toolchain, asserts the artefact is a fully-static PE, and runs
# it under wine. Delegated to the e2e harness (tests/e2e/102_mingw_cross_wine.sh,
# `# requires: mingw-cross wine`) so the run_all cap-gating stays the single
# source of truth. See 2026-07-15-mingw-linux-cross-windows-design.md Part C.
mingw-cross-wine:
name: mingw-cross linux→windows (build + wine run)
runs-on: ubuntu-24.04
timeout-minutes: 60
env:
MCPP_HOME: /home/runner/.mcpp
MCPP_VERBOSE: "1"
steps:
- uses: actions/checkout@v4
- name: Cache mcpp sandbox
uses: actions/cache@v4
with:
path: ~/.mcpp
key: mcpp-sandbox-${{ runner.os }}-mingw-cross-${{ hashFiles('mcpp.toml', '.xlings.json') }}
restore-keys: |
mcpp-sandbox-${{ runner.os }}-mingw-cross-
- name: Cache xlings
uses: actions/cache@v4
with:
path: ~/.xlings
key: xlings-${{ runner.os }}-v2-${{ hashFiles('.xlings.json') }}
restore-keys: |
xlings-${{ runner.os }}-v2-
# wine 的包集固定不变 —— 缓存整个 .deb 依赖闭包,命中时跳过 apt update
# 与下载(每轮省 ~1-2min)。镜像月度更新可能改变依赖缺口,dpkg -i 失败时
# 由 apt-get -f 兜底并重新回填缓存。
- name: Cache wine debs
uses: actions/cache@v4
with:
path: ~/wine-debs
key: wine-debs-${{ runner.os }}-ubuntu24.04-v1
- name: Install wine
run: |
sudo dpkg --add-architecture i386 || true
if ls ~/wine-debs/*.deb >/dev/null 2>&1; then
sudo dpkg -i ~/wine-debs/*.deb 2>/dev/null \
|| { sudo apt-get update -qq; sudo apt-get install -f -y; }
else
sudo apt-get update -qq
sudo apt-get install -y --download-only wine64 wine \
|| sudo apt-get install -y --download-only wine
mkdir -p ~/wine-debs
cp /var/cache/apt/archives/*.deb ~/wine-debs/ 2>/dev/null || true
sudo apt-get install -y wine64 wine || sudo apt-get install -y wine
fi
wine --version
- name: Bootstrap mcpp via xlings
env:
XLINGS_NON_INTERACTIVE: '1'
XLINGS_VERSION: '2026.8.4.1'
run: |
tarball="xlings-${XLINGS_VERSION}-linux-x86_64.tar.gz"
curl -fsSL -o "/tmp/${tarball}" \
"https://github.com/d2learn/xlings/releases/download/v${XLINGS_VERSION}/${tarball}"
tar -xzf "/tmp/${tarball}" -C /tmp
"/tmp/xlings-${XLINGS_VERSION}-linux-x86_64/subos/default/bin/xlings" self install
export PATH="$HOME/.xlings/subos/default/bin:$PATH"
find "$HOME/.xlings" -name '.xlings-index-cache.json' -delete 2>/dev/null || true
xlings config --mirror GLOBAL 2>/dev/null || true
xlings update -y 2>/dev/null || xlings update 2>/dev/null || true
# MCPP_BOOT is what actually runs the bootstrap build below, so it —
# not just MCPP — has to be the pinned binary. It used to be the shim
# in subos/default/bin, which resolves to whatever version xvm has
# selected; pinning only MCPP would have looked right and changed
# nothing.
MCPP_BOOT=$(bash "$GITHUB_WORKSPACE/.github/tools/install_pinned_mcpp.sh" "$GITHUB_WORKSPACE")
echo "MCPP=$MCPP_BOOT" >> "$GITHUB_ENV"
echo "XLINGS_BIN=$HOME/.xlings/subos/default/bin/xlings" >> "$GITHUB_ENV"
echo "MCPP_BOOT=$MCPP_BOOT" >> "$GITHUB_ENV"
- name: Self-host build (fresh host mcpp)
run: |
export MCPP_VENDORED_XLINGS="$XLINGS_BIN"
"$MCPP_BOOT" self config --mirror GLOBAL 2>/dev/null || true
"$MCPP_BOOT" build
MCPP=$(realpath "$(find target -type f -name mcpp -printf '%T@ %p\n' | sort -rn | head -1 | cut -d' ' -f2)")
test -x "$MCPP"
"$MCPP" self config --mirror GLOBAL
echo "MCPP=$MCPP" >> "$GITHUB_ENV"
- name: Install mingw-cross toolchain
run: |
export MCPP_VENDORED_XLINGS="$XLINGS_BIN"
"$MCPP" toolchain install mingw-cross 16.1.0
- name: "e2e: cross-build + wine run"
run: |
export MCPP_VENDORED_XLINGS="$XLINGS_BIN"
bash tests/e2e/102_mingw_cross_wine.sh
# ── windows → linux ───────────────────────────────────────────────────────
# The mirror of mingw-cross-wine. Two jobs because a Windows runner cannot
# execute the ELF it produces; the artefact is handed to a Linux job and
# really run there.
windows-host-linux-cross:
name: windows→linux cross-build (windows host)
runs-on: windows-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/bootstrap-mcpp
with:
# Do NOT restore target/ in a cross job. This job builds twice — once
# for the host, then once for x86_64-linux-musl — and a restored BMI
# tree makes the second build read `std` BMIs that no longer match
# what the dependency BMIs were compiled against:
#
# mcpplibs.cmdline: error: import 'std' has CRC mismatch
#
# GCC bakes a CRC of each imported module's BMI into the importer, so
# the two have to come from the same build round; a cache that
# restores one without the other is not a partial speedup, it is an
# unbuildable tree. It reproduced on rerun, and only in this job —
# the other two cross jobs below cache ~/.mcpp and ~/.xlings but
# deliberately never target/, which is the convention this now follows.
#
# It stayed hidden until a PR touched neither mcpp.toml nor
# .xlings.json: those two files key the sandbox cache, so every
# earlier run had been a cold miss.
cache-target: 'false'
- name: Build mcpp from source (self-host)
shell: bash
run: |
export MCPP_VENDORED_XLINGS="$XLINGS_BIN"
"$MCPP" build
# Newest, not first: target/ is cache-restored and keeps a directory
# per build fingerprint, so `find | head -1` can hand back the
# PREVIOUS release's binary (that is how a 0.0.106 build ran 0.0.105).
MCPP_SELF=$(find target -name "mcpp.exe" -path "*/bin/*" -printf "%T@ %p\n" \
| sort -rn | head -1 | cut -d" " -f2-)
test -n "$MCPP_SELF" || { echo "FAIL: no mcpp.exe"; exit 1; }
MCPP_SELF=$(cd "$(dirname "$MCPP_SELF")" && pwd)/$(basename "$MCPP_SELF")
"$MCPP_SELF" --version
echo "MCPP_SELF=$MCPP_SELF" >> "$GITHUB_ENV"
- name: Install the linux-musl cross toolchain (windows-hosted canadian)
shell: bash
run: |
export MCPP_VENDORED_XLINGS="$XLINGS_BIN"
"$MCPP_SELF" toolchain install gcc 16.1.0 --target x86_64-linux-musl
# The target row must now be visible on a Windows host — this is the
# host gate from design §1.2 having been lifted, asserted rather than
# eyeballed.
"$MCPP_SELF" toolchain list | tee /tmp/tclist.txt
grep -q "x86_64-linux-musl" /tmp/tclist.txt \
|| { echo "FAIL: linux-musl target not listed on windows host"; exit 1; }
- name: "Cross-build mcpp -> x86_64-linux-musl"
shell: bash
run: |
export MCPP_VENDORED_XLINGS="$XLINGS_BIN"
"$MCPP_SELF" build --target x86_64-linux-musl
# Scope the search to the TARGET's output tree — target/ also holds the
# host build from the previous step, and both are named "mcpp*".
#
# The artefact carries a `.exe` suffix even though it is an ELF:
# plan.cppm's target_output() spells the suffix from
# mcpp::platform::exe_suffix, a HOST constant. That is the same
# host-decides-target confusion as B2, and it is symmetric — a
# Linux→Windows cross produces a PE with no `.exe` today. Renaming the
# output is a behaviour change that would touch the mingw e2e and any
# user script, so it is filed as follow-up rather than folded in here;
# match both spellings so this job is correct either way.
OUT=$(find target/x86_64-linux-musl -type f -path "*/bin/*" \
\( -name "mcpp" -o -name "mcpp.exe" \) -printf "%T@ %p\n" \
| sort -rn | head -1 | cut -d" " -f2-)
if [ -z "$OUT" ]; then
echo "FAIL: no cross artefact produced; tree was:"
find target/x86_64-linux-musl -type f -path "*/bin/*" | head -20
exit 1
fi
cp "$OUT" mcpp-linux-musl
ls -la mcpp-linux-musl
- uses: actions/upload-artifact@v4
with:
name: mcpp-x86_64-linux-musl-from-windows
path: mcpp-linux-musl
retention-days: 1
windows-host-linux-cross-run:
name: windows→linux artefact really runs (linux)
needs: windows-host-linux-cross
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/download-artifact@v4
with:
name: mcpp-x86_64-linux-musl-from-windows
- name: Assert it is a static ELF, then run it
run: |
set -euo pipefail
chmod +x mcpp-linux-musl
file mcpp-linux-musl
# B2 regression gate (design §1.3): before the fix, `-static` was
# decided by a HOST constant (`supports_full_static = is_linux`), so
# a Windows host emitted a NON-static binary here. Written as a
# positive `grep -q` on purpose: `! cmd | grep` is exempt from
# errexit and can never fail (see build-mcpp-helper-self-containment).
file mcpp-linux-musl | grep -q "ELF 64-bit LSB"
file mcpp-linux-musl | grep -q "x86-64"
file mcpp-linux-musl | grep -q "statically linked"
# A static musl ELF has no PT_INTERP at all — the stronger form of
# the same claim, and independent of `file`'s wording.
readelf -l mcpp-linux-musl > hdrs.txt
if grep -q "INTERP" hdrs.txt; then
echo "FAIL: artefact has a PT_INTERP segment — not statically linked"
grep -A2 "INTERP" hdrs.txt
exit 1
fi
# Linked ≠ runs. This is the whole point of the second job.
./mcpp-linux-musl --version
./mcpp-linux-musl --help > /dev/null
echo "OK: windows-built linux artefact executes natively"