Skip to content

0.5.0 —— 第三台机器不再是例外 (#3) #17

0.5.0 —— 第三台机器不再是例外 (#3)

0.5.0 —— 第三台机器不再是例外 (#3) #17

Workflow file for this run

name: CI
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
# Three jobs, and the first is ONE job written once: the gate this layer is
# judged by is that a single probe source runs on machines that genuinely
# differ, so the rows below differ only in a triple and an emulator. If they
# ever need to differ in anything else, the abstraction has failed and this
# workflow is where that becomes visible.
#
# ⭐ THE THIRD ROW IS WHAT TURNS THE GATE INTO EVIDENCE. riscv64 and aarch64 are
# both load/store RISC machines with a weak memory model and a fixed instruction
# width, so an interface that fits both may fit because it is right or because
# they are alike. x86_64 is neither: variable-length instructions, total store
# order — under which three of the four barriers need no instruction at all —
# and an interrupt mechanism that is a table of 256 gates rather than a base
# register. What survives all three is an abstraction.
jobs:
gate:
name: the probe runs on ${{ matrix.arch }}
runs-on: ubuntu-24.04
timeout-minutes: 40
strategy:
fail-fast: false
matrix:
include:
- { arch: riscv64, triple: riscv64-none-elf, qemu: 'xim:qemu-riscv' }
- { arch: aarch64, triple: aarch64-none-elf, qemu: 'xim:qemu-arm' }
# ⭐ ALL THREE ROWS NAME AN xlings PACKAGE FROM 0.5.0, AND THE THIRD
# ONE TOOK A NEW PACKAGE TO GET THERE.
#
# xPack publishes QEMU per target family and has no x86 build, so
# until now this row installed `qemu-system-x86` from apt and ran the
# emulator by hand. That made it the one row whose result depended on
# what Ubuntu happened to ship. `mcpplibs/qemu-x86` builds
# `qemu-system-x86_64` from source for five hosts, `xim:qemu-x86`
# carries it, and the row is now the same shape as the other two.
- { arch: x86_64, triple: x86_64-none-elf, qemu: 'xim:qemu-x86' }
env:
MCPP_VERSION: 2026.8.21.1
XLINGS_VERSION: v2026.8.17.2
XLINGS_NON_INTERACTIVE: '1'
steps:
- uses: actions/checkout@v4
- name: Install xlings
run: |
curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh \
| bash -s "$XLINGS_VERSION"
echo "$HOME/.xlings/subos/current/bin" >> "$GITHUB_PATH"
- name: Install mcpp
run: |
# ⚠️ A LOOP, BECAUSE ONE `xlings update` CAN RETURN A STALE INDEX
# WITHOUT SAYING SO.
#
# The index is published as an artifact behind a pointer, and that
# pointer propagates asynchronously after a version bump is merged.
# Measured on release day: an update run four minutes after the merge
# printed `index updated`, and the install then failed with
#
# package 'mcpp@<ver>' not found in the synced index
# (xim@artifact:<an older sha>, ...), synced 0 seconds ago
#
# Nothing had gone wrong. The update fetched the PREVIOUS artifact,
# and "synced 0 seconds ago" describes when it was fetched rather than
# what it contains — which is why the message reads as freshness.
#
# So this is not a retry around flakiness; it is the wait that a
# single update does not perform. A pin naming a version that was
# never published still fails, after the last attempt, and says which
# of the two situations it is.
for attempt in 1 2 3 4 5 6; do
xlings update > /dev/null 2>&1 || true
if xlings install "mcpp@$MCPP_VERSION" -y -g; then break; fi
if [ "$attempt" = 6 ]; then
echo "::error::mcpp@$MCPP_VERSION never appeared in the index (6 attempts over 5 minutes). If it was just released, the pointer has not propagated; if the pin names a version that was never published, it never will."
exit 1
fi
echo "the index has not caught up yet (attempt $attempt of 6); waiting 60s"
sleep 60
done
mcpp --version
mcpp self config --mirror GLOBAL
# ⚠️ TWICE, AND THE FIRST IS ALLOWED TO FAIL. THIS IS NOT SUPERSTITION.
#
# mcpp installs the target's C library LAZILY — during a build, from the
# target's own row. So the very first build on a machine that has never
# targeted this triple compiles sources that need its headers before they
# exist, and dies on `'stdio.h' file not found` or `'inttypes.h' file not
# found` pointing inside a dependency. The second build has them. mcpp
# says as much itself for the sibling case: "expected on the first build
# in a fresh MCPP_HOME; a second build resolves it."
#
# ⚠️ Deliberately NOT `xlings install xim:picolibc-riscv`. That would make
# the target world exist without going through the engine's own install,
# and a regression in that install is exactly what this repository would
# then stop noticing. A warm-up build exercises it; a manual install
# replaces it.
# ⚠️ BOTH HOMES, AND THIS IS NOT BELT-AND-BRACES. The shim on PATH
# dispatches against whichever home owns it, while `mcpp run` starts the
# runner through mcpp's own — an emulator present in only one of them
# answers "not installed" from the other.
#
# The three packages are different builds, one per target family:
# `qemu-riscv` carries the RISC-V emulators, `qemu-arm` the Arm ones and
# `qemu-x86` the x86_64 one. Measured; no single package runs all three.
- name: Install the emulator
run: |
xlings install ${{ matrix.qemu }} -y
XLINGS_HOME="$HOME/.mcpp/registry" xlings install ${{ matrix.qemu }} -y
- name: The layer builds for ${{ matrix.arch }}
run: |
# Twice, the first allowed to fail: the toolchain payload is installed
# during a build, so the first build on a machine that has never
# targeted this triple is the one that installs it.
# ⚠️ THE BACKEND, NOT THE ROOT. Since 0.4.0 the root is BOTH the
# interface package and the workspace, so building it would build the
# interface; what this step wants is the machine half. Building the
# backend for this target pulls the ABI it implements and is what the
# probe below will link against.
( cd backends/${{ matrix.arch }} && mcpp build --target ${{ matrix.triple }} ) || true
( cd backends/${{ matrix.arch }} && mcpp build --target ${{ matrix.triple }} )
# ⚠️ THE THIRD ASSERTION IS THE ONE THAT CATCHES A HALF-CORRECT BACKEND.
#
# A switch that saved the return address and the stack pointer and nothing
# else would reach the task and deliver its argument — the first two lines
# would appear — and would corrupt the caller's callee-saved registers.
# `before=1234` is a `volatile` local read after the round trip, and it is
# what fails when that happens.
- name: The probe switches, returns, and preserves callee-saved registers
working-directory: examples/switch
run: |
set -euo pipefail
# ⭐ ONE PATH FOR ALL THREE MACHINES. Until 0.5.0 this step branched:
# two rows went through `mcpp run` and x86_64 built and then invoked
# qemu by hand, because `build.mcpp` could configure no runner for a
# package that did not exist. The branch is gone, and with it the
# asymmetry that made the third machine the least-tested one.
mcpp run --target ${{ matrix.triple }} 2>&1 | tee run.log
grep -q "task: arg=42" run.log
grep -q "witness=7 before=1234" run.log
grep -q "switch ok" run.log
grep -q "trap: back, witness=1" run.log
grep -q "cpu: percpu round-trips" run.log
# ⚠️ THE TEMPLATE IS RENDERED BY HAND HERE, AND IT HAS TO BE.
#
# `mcpp new --template` resolves the package from the INDEX and takes no
# path, so asking it for a template this commit ADDS would resolve the
# previously published version and fail on a template that version does
# not carry. The CI for a new template could never pass before the
# template was published, which is the wrong way round.
#
# What belongs to this repository is the template's CONTENT: that the
# files it ships generate a project which builds for all three machines.
# That the scaffolder can fetch it is mcpp's own concern and mcpp's own
# tests.
#
# ⚠️ The dependency is rewritten to a path. The rendered manifest names
# `openarch = "<this version>"`, which is correct for a user and
# unresolvable here — this version is not published yet, and a check that
# silently fell back to the previous one would be testing the wrong tree.
#
# ⚠️ IN THE `gate` JOB'S riscv64 ROW, AND THE PLACEMENT IS THE FIX FOR A
# MISTAKE. 0.4.0 put this in `host-encoders`, which installs no emulator —
# so the check could only ever assert `mcpp build`, and a generated project
# that ran on nothing passed it. The emulator lives here.
#
# One row rather than all three: the template's CONTENT is
# host-independent, and the rewrite below puts `$PWD` into a manifest,
# which on a Windows runner is `/d/a/openarch/openarch` under Git Bash
# while mcpp wants a native path — a mismatch this repository has been
# bitten by before.
- name: The template generates a project that builds for all three machines and runs
if: matrix.arch == 'riscv64'
run: |
set -euo pipefail
T=templates/three-machines
V=$(grep -m1 '^version' mcpp.toml | cut -d'"' -f2)
D=$(mktemp -d); mkdir -p "$D/src"
for f in mcpp.toml README.md build.mcpp; do
sed -e "s/{{project\.name}}/k/g" -e "s/{{self\.version}}/$V/g" \
"$T/$f.in" > "$D/$f"
done
cp "$T"/*.ld "$D/"
cp "$T"/src/* "$D/src/"
sed -i.bak "s|openarch = \"$V\"|openarch = { path = \"$PWD\" }|" "$D/mcpp.toml"
rm -f "$D/mcpp.toml.bak"
grep -q "path = " "$D/mcpp.toml" || { cat "$D/mcpp.toml"; echo "the dependency rewrite did not apply"; exit 1; }
for t in riscv64-none-elf aarch64-none-elf x86_64-none-elf; do
( cd "$D" && mcpp build --target "$t" > /dev/null 2>&1 ) || true
( cd "$D" && mcpp build --target "$t" ) \
|| { echo "the template does not build for $t"; exit 1; }
done
echo "the template builds for riscv64, aarch64 and x86_64"
# ⚠️ AND IT MUST RUN, NOT ONLY BUILD — THIS ASSERTION IS HERE BECAUSE
# ITS ABSENCE SHIPPED A BROKEN QUICK START.
#
# 0.4.0's check stopped at `mcpp build`, so a generated project that
# compiled for all three machines and ran on none of them passed. What
# was missing is not in the sources: `mcpp.toml` declares the emulator
# packages under `[xlings] deps`, which is not an install trigger, so
# on a machine that has not installed them `build.mcpp` finds nothing
# and configures no runner. Measured against a freshly unpacked mcpp.
#
# This job installs the emulator in an earlier step, so the run below
# exercises the path a user gets AFTER the README's one-time install.
# What it cannot cover is the before — see the note in the README, and
# the comment in build.mcpp on why a build program cannot warn.
( cd "$D" && mcpp run --target riscv64-none-elf 2>&1 | tee run.log ) \
|| { cat "$D/run.log"; echo "the generated project does not run"; exit 1; }
grep -q "switch ok" "$D/run.log" \
|| { cat "$D/run.log"; echo "the generated project ran without reaching the end"; exit 1; }
echo "the template's project runs, not only builds"
# The same source produced that output. Asserted rather than trusted: a
# probe that had quietly grown a per-architecture branch would still pass
# every line above, and the gate would be measuring two programs.
- name: The probe is one source, and the layering is a directory tree
run: |
set -euo pipefail
test -f examples/switch/src/main.cpp
# ⚠️ THIS CHECK USED TO READ "NO ARCHITECTURE MACRO APPEARS AT ALL",
# AND IT WAS WRONG IN A WAY THAT COST TWO RELEASES OF RED CI.
#
# The probe must name an architecture in exactly one place: the trap
# instruction. `ebreak`, `brk #0` and `int3` are three spellings of
# one idea and there is no portable fourth. When the trap interface
# landed in 0.3.0 the old check began failing on every push, correctly
# by its own wording and wrongly by its intent — and it kept failing
# through 0.3.1 because nobody read the runs.
#
# What the gate actually claims is that the probe is not TWO PROGRAMS.
# So: one conditional, and nothing inside it but instructions. A
# second conditional, or a line of logic inside the first, is the
# thing this is here to catch.
NCOND=$(grep -c '^#if defined(__' examples/switch/src/main.cpp || true)
if [ "$NCOND" != "1" ]; then
grep -n '^#if defined(__' examples/switch/src/main.cpp || true
echo "the probe has $NCOND architecture conditionals; it may have exactly one, for the trap instruction"
exit 1
fi
STRAY=$(awk '
/^#if defined\(__/ || /^#elif defined\(__/ { inb=1; next }
/^#else/ { next }
/^#endif/ { inb=0; next }
inb && $0 !~ /^[[:space:]]*(\/\/)?[[:space:]]*$/ \
&& $0 !~ /asm volatile/ && $0 !~ /^#[[:space:]]*error/ \
&& $0 !~ /^[[:space:]]*\/\// { print NR": "$0 }
' examples/switch/src/main.cpp)
if [ -n "$STRAY" ]; then
echo "$STRAY"
echo "the architecture conditional contains something other than an instruction"
exit 1
fi
echo "the probe names an architecture once, to emit a trap instruction"
# ⚠️ THE SEPARATION IS ASSERTED, NOT TRUSTED TO THE DIRECTORY NAMES.
#
# Until 0.3.1 the backends lived under `src/arch/<arch>/` in the same
# package as the specification, and the layering was a convention held
# up by a path. The split made it a dependency graph; these two checks
# make it a test.
if grep -rqE '\basm\b|__asm' src; then
echo "the interface contains an instruction; it must not"
exit 1
fi
if grep -rq 'export module' backends; then
echo "a backend exports a module; it must reach the interface only through the ABI"
exit 1
fi
echo "the interface owns modules and no instruction; backends own instructions and no module"
# ⚠️ THE ROOT IS BOTH A PACKAGE AND A WORKSPACE, AND THAT IS WHAT
# MAKES A CONSUMER'S SIDE ONE LINE. A virtual workspace would put the
# interface in a member directory and `openarch = "0.4.0"` would have
# to name it. Asserted because the two tables are ordinary TOML and
# deleting one would leave a manifest that still builds.
grep -q '^\[package\]' mcpp.toml
grep -q '^\[workspace\]' mcpp.toml
# The two faces reach the same library. The C one is a header a
# consumer includes; the C++ one is a module it imports. A face that
# disappeared would not fail any build in this repository except the
# test that names it.
test -f abi/include/mcpplibs/openarch.h
grep -q 'export module mcpplibs.openarch;' src/openarch.cppm
echo "one package, two faces, three backends"
# ---------------------------------------------------------------------------
# The half no emulator can check.
#
# A mis-shifted field in a page-table entry still maps something, still boots,
# and faults later somewhere that names neither the encoder nor the entry. The
# encoders are pure functions in per-architecture namespaces, so a host build
# holds BOTH and compares them — which is what several of the assertions are,
# and what no single-target build could express.
host-encoders:
name: the page-table encoders agree (${{ matrix.os }})
runs-on: ${{ matrix.os }}
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
os: [ubuntu-24.04, macos-14, windows-2022]
defaults:
run:
shell: bash
env:
MCPP_VERSION: 2026.8.21.1
XLINGS_VERSION: v2026.8.17.2
XLINGS_NON_INTERACTIVE: '1'
steps:
- uses: actions/checkout@v4
- name: Install xlings (Unix)
if: runner.os != 'Windows'
run: |
curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh \
| bash -s "$XLINGS_VERSION"
echo "$HOME/.xlings/subos/current/bin" >> "$GITHUB_PATH"
- name: Install xlings (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
irm https://d2learn.org/xlings-install.ps1.txt | iex
"$env:USERPROFILE\.xlings\subos\current\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
- name: Install mcpp
run: |
for attempt in 1 2 3 4 5 6; do
xlings update > /dev/null 2>&1 || true
if xlings install "mcpp@$MCPP_VERSION" -y -g; then break; fi
if [ "$attempt" = 6 ]; then
echo "::error::mcpp@$MCPP_VERSION never appeared in the index (6 attempts over 5 minutes)."
exit 1
fi
echo "the index has not caught up yet (attempt $attempt of 6); waiting 60s"
sleep 60
done
mcpp --version
mcpp self config --mirror GLOBAL
# ⚠️ NO FEATURE FLAGS, AND THAT IS THE OUTCOME OF A MEASUREMENT. The
# encoders were feature-selected translation units until two facts ended
# that design: a file named by any feature belongs to it exclusively (with
# the feature inactive it is not compiled even when a `cfg` block lists it,
# and the library build still reports success), and compiling both
# unconditionally instead put 450 bytes of the foreign encoder into a
# riscv64 image whose whole probe is about 2000. They are `inline` in a
# header now: a target build instantiates only what it calls — measured,
# zero foreign symbols in either image — and a host build that calls both
# gets both, with nothing to activate.
# ⚠️ THREE ENCODERS SINCE 0.4.0, AND THE THIRD SETTLED A QUESTION THE
# FIRST TWO LEFT OPEN. `openarch.pte` owns `MAIR_EL1` because aarch64's
# entry holds an INDEX into it rather than a memory type, while riscv's
# holds the type itself — one against one, and "this layer owns the
# attribute register" could fairly be called a workaround for aarch64.
# x86_64 does the same thing with `IA32_PAT`, on a machine that shares no
# lineage with it, so the majority is now two to one the other way.
- name: The encoders compile here and agree
run: mcpp test
# ---------------------------------------------------------------------------
# The cross-compilation is performed FROM three systems, not only from Linux.
#
# Every target in this repository is a cross target, which makes the host a
# separate axis from the target: the compiler, the target C library and the
# emulator are payloads mcpp resolves for whichever system it is running on.
# A package that has only ever been built from Linux is a package whose
# consumers must use Linux, and nothing in these sources says so.
#
# ⚠️ A TOOLCHAIN AXIS IS ABSENT HERE, AND THAT IS MEASURED RATHER THAN
# ASSUMED. The row for a bare-metal triple names its compiler, and the
# command-line override does not displace it: `--toolchain gcc@16.1.0` on a
# `riscv64-none-elf` build resolves llvm@22.1.8 regardless. A matrix over
# compiler families would therefore run the same compiler on every row and
# report coverage it does not have. The toolchain axis belongs where the
# choice is real — openkal, whose declarations are compiled by three families
# on three systems.
#
# ⚠️ BUILD ONLY, AND DELIBERATELY. Behaviour is asserted once, above, under an
# emulator. Booting the same image from three systems would be a statement
# about the emulator rather than about this package, and "the image does what
# the README says" does not become more true for having been observed from
# macOS.
portability:
name: cross-builds from ${{ matrix.os }}
runs-on: ${{ matrix.os }}
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
os: [macos-14, windows-2022]
defaults:
run:
shell: bash
env:
MCPP_VERSION: 2026.8.21.1
XLINGS_VERSION: v2026.8.17.2
XLINGS_NON_INTERACTIVE: '1'
steps:
- uses: actions/checkout@v4
- name: Install xlings (Unix)
if: runner.os != 'Windows'
run: |
curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh \
| bash -s "$XLINGS_VERSION"
echo "$HOME/.xlings/subos/current/bin" >> "$GITHUB_PATH"
- name: Install xlings (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
irm https://d2learn.org/xlings-install.ps1.txt | iex
# The installer amends the user's environment; no later step in this
# job reads it back, so the directory is named here.
"$env:USERPROFILE\.xlings\subos\current\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
- name: Install mcpp
run: |
# ⚠️ A LOOP, BECAUSE ONE `xlings update` CAN RETURN A STALE INDEX
# WITHOUT SAYING SO.
#
# The index is published as an artifact behind a pointer, and that
# pointer propagates asynchronously after a version bump is merged.
# Measured on release day: an update run four minutes after the merge
# printed `index updated`, and the install then failed with
#
# package 'mcpp@<ver>' not found in the synced index
# (xim@artifact:<an older sha>, ...), synced 0 seconds ago
#
# Nothing had gone wrong. The update fetched the PREVIOUS artifact,
# and "synced 0 seconds ago" describes when it was fetched rather than
# what it contains — which is why the message reads as freshness.
#
# So this is not a retry around flakiness; it is the wait that a
# single update does not perform. A pin naming a version that was
# never published still fails, after the last attempt, and says which
# of the two situations it is.
for attempt in 1 2 3 4 5 6; do
xlings update > /dev/null 2>&1 || true
if xlings install "mcpp@$MCPP_VERSION" -y -g; then break; fi
if [ "$attempt" = 6 ]; then
echo "::error::mcpp@$MCPP_VERSION never appeared in the index (6 attempts over 5 minutes). If it was just released, the pointer has not propagated; if the pin names a version that was never published, it never will."
exit 1
fi
echo "the index has not caught up yet (attempt $attempt of 6); waiting 60s"
sleep 60
done
mcpp --version
mcpp self config --mirror GLOBAL
- name: The arch mechanisms cross-build
run: |
# ⚠️ TWICE, AND THE FIRST IS ALLOWED TO FAIL — every row of this
# matrix is a machine that has never targeted this triple, which is
# precisely where mcpp's lazy install of the target C library shows.
# The first build compiles sources needing its headers before they
# exist; the second has them. A single build here reported a
# portability failure that was really a cold-machine one.
mcpp build --target riscv64-none-elf || true
mcpp build --target riscv64-none-elf