diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 163a80f7..872bb982 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,9 +15,15 @@ on: branches: [main] pull_request: workflow_dispatch: + inputs: + mcpp_ref: + description: "Branch of mcpp-community/mcpp to build and test against (empty = the released pin)" + required: false + default: "" env: - MCPP_VERSION: 2026.8.26.2 + MCPP_SOURCE_REF: ${{ github.event.inputs.mcpp_ref || vars.MCPP_SOURCE_REF }} + MCPP_VERSION: 2026.8.27.1 XLINGS_VERSION: v2026.8.17.2 XLINGS_NON_INTERACTIVE: '1' @@ -45,17 +51,31 @@ jobs: echo "$HOME/.xlings/subos/current/bin" >> "$GITHUB_PATH" - name: Install mcpp + run: bash tools/install-mcpp.sh + + # ⭐ THE ENGINE EVERY STEP BELOW WILL USE, COMPARED AGAINST THE ONE BUILT. + # + # The step above appends a directory to GITHUB_PATH, which governs the + # steps that follow it, so that step cannot observe its own effect. Whether + # the appended spelling is one the runner accepts is a property of the + # runner and differs between hosts. Left unasserted, a cross-validation run + # on a host that ignores it builds this ecosystem with the released engine + # and reports the result as though the change under review had been tested. + - name: The engine on PATH is the one under review 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"; exit 1 - fi - sleep 60 - done - mcpp --version - mcpp self config --mirror GLOBAL + set -euo pipefail + if [ -z "${MCPP_UNDER_REVIEW:-}" ]; then + echo " no source reference: this run tests $(mcpp --version)" + exit 0 + fi + on_path=$(mcpp --version | awk '{print $2}') + if [ "$on_path" != "$MCPP_UNDER_REVIEW" ]; then + echo "::error::PATH resolves mcpp $on_path, and the build under review is $MCPP_UNDER_REVIEW" + echo " the directory appended to GITHUB_PATH did not take effect on this host" + command -v mcpp + exit 1 + fi + echo " every step below runs $on_path, built from $MCPP_SOURCE_REF" - name: Select the toolchain run: | @@ -63,6 +83,11 @@ jobs: mcpp toolchain install "${spec%@*}" "${spec#*@}" mcpp toolchain default "$spec" + # THE C LIBRARY THIS RUNTIME IS CONFIGURED FOR, AS WRITTEN ON THE BRANCH + # UNDER TEST RATHER THAN AS PUBLISHED. The reasoning is in the script. + - name: The stack, as written on this branch + run: bash tools/branch-graph.sh '${{ github.head_ref || github.ref_name }}' + - name: The runtime builds run: mcpp build @@ -85,6 +110,102 @@ jobs: cd examples/import-std && mcpp run 2>&1 | tee out.log grep -q 'import std above openkal: 2 4 7' out.log + # WHAT __config_site DECLARES AND WHAT THE PORT BENEATH PROVIDES ARE + # RECONCILED HERE, RATHER THAN BEING CHECKED ONCE AND ASSUMED AFTERWARDS. + # + # `__config_site` is this package's statement about the environment it was + # configured for, and the environment is openkal-musl. A statement that + # drifts from what the port actually provides does not fail to build and + # does not fail to link: it produces a program that takes a path the + # environment cannot support, and reports nothing. + # + # `_LIBCPP_HAS_TERMINAL` is the position where that happened. It gates + # `std::__is_posix_terminal`, which is `isatty(fileno(stream))` and nothing + # else. The port answered `isatty` with TCGETS while musl asks with + # TIOCGWINSZ, so every `isatty` returned 0 --- for a real terminal as + # readily as for a pipe --- and `std::print` never took its terminal path. + # Nothing failed; a program deciding on colour or on line buffering decided + # wrongly and in silence. + # + # The remedy was to fix the port rather than to withdraw the declaration, + # so the declaration is now true and this step is what keeps it true. + # + # THE CRITERION IS A RELATION AND NOT A VALUE. Asserting "a terminal is + # detected" would need a terminal; asserting "a pipe is not" would pass + # throughout the defect. What must hold is that the two DIFFER, and that + # they differ the way the system's own C library says they do. + - name: What the runtime declares is what the port provides + run: | + set -euo pipefail + d="$(mktemp -d)"; mkdir -p "$d/src" + cat > "$d/mcpp.toml" < "$d/src/main.cpp" <<'CPP' + #include + #include + // The declaration this step exists to reconcile. If the package stops + // claiming a terminal, the program says so rather than failing to + // compile: the point is to compare the claim against the behaviour, + // and a build error would compare nothing. + int main() { + #if defined(_LIBCPP_HAS_TERMINAL) && _LIBCPP_HAS_TERMINAL + std::printf("declared=1 isatty=%d\n", isatty(1)); + #else + std::printf("declared=0 isatty=%d\n", isatty(1)); + #endif + } + CPP + sed -i 's/^ //' "$d/src/main.cpp" + ( cd "$d" && mcpp build ) + bin="$(find "$d/target" -type f -name termdecl | head -1)" + test -n "$bin" || { echo "::error::the probe did not build"; exit 1; } + + # The control: the system's own C library, through the same harness. + # Without it a `script` that fails to allocate a pty would make the + # runtime look wrong. + printf '#include \n#include \nint main(void){ printf("%%d\\n", isatty(1)); return 0; }\n' > "$d/ctrl.c" + cc "$d/ctrl.c" -o "$d/ctrl" + ctrl_pipe="$("$d/ctrl" | cat | tr -d '\r')" + ctrl_tty="$(script -qec "$d/ctrl" /dev/null | tr -d '\r' | head -1)" + [ "$ctrl_pipe" = 0 ] && [ "$ctrl_tty" = 1 ] \ + || { echo "::error::the harness cannot tell a pty from a pipe (control gave $ctrl_pipe/$ctrl_tty)" + exit 1; } + + out_pipe="$("$bin" | cat | tr -d '\r')" + out_tty="$(script -qec "$bin" /dev/null | tr -d '\r' | head -1)" + echo " control: pipe=$ctrl_pipe tty=$ctrl_tty" + echo " runtime: $out_pipe / $out_tty" + + declared="${out_pipe#declared=}"; declared="${declared%% *}" + pipe_v="${out_pipe##*isatty=}" + tty_v="${out_tty##*isatty=}" + + if [ "$declared" = 1 ]; then + # The claim is that a terminal can be detected, so the two must + # differ and must differ as the system's own library does. + [ "$pipe_v" = "$ctrl_pipe" ] && [ "$tty_v" = "$ctrl_tty" ] \ + || { echo "::error::the runtime declares _LIBCPP_HAS_TERMINAL but the port answers $pipe_v/$tty_v where the system answers $ctrl_pipe/$ctrl_tty" + exit 1; } + echo " ok the declaration holds: a terminal is distinguished from a pipe" + else + # The claim is that it cannot. Then it must not appear to: a + # declaration of 0 beside a working isatty is also a drift, and the + # remedy is to raise the declaration rather than leave it stale. + [ "$tty_v" = "$ctrl_tty" ] \ + && { echo "::error::the runtime declares no terminal support while the port detects one; the declaration is stale" + exit 1; } + echo " ok the declaration holds: no terminal support is claimed and none is present" + fi + # ⭐⭐ THE SAME PROGRAM ON A MACHINE WITH NO OPERATING SYSTEM. # # Everything above this step runs on a host, and a host has a C library, a @@ -345,23 +466,49 @@ jobs: "$env:USERPROFILE\.xlings\subos\current\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append - name: Install mcpp + run: bash tools/install-mcpp.sh + + # ⭐ THE ENGINE EVERY STEP BELOW WILL USE, COMPARED AGAINST THE ONE BUILT. + # + # The step above appends a directory to GITHUB_PATH, which governs the + # steps that follow it, so that step cannot observe its own effect. Whether + # the appended spelling is one the runner accepts is a property of the + # runner and differs between hosts. Left unasserted, a cross-validation run + # on a host that ignores it builds this ecosystem with the released engine + # and reports the result as though the change under review had been tested. + - name: The engine on PATH is the one under review 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" - exit 1 - fi - sleep 60 - done - mcpp self config --mirror GLOBAL + set -euo pipefail + if [ -z "${MCPP_UNDER_REVIEW:-}" ]; then + echo " no source reference: this run tests $(mcpp --version)" + exit 0 + fi + on_path=$(mcpp --version | awk '{print $2}') + if [ "$on_path" != "$MCPP_UNDER_REVIEW" ]; then + echo "::error::PATH resolves mcpp $on_path, and the build under review is $MCPP_UNDER_REVIEW" + echo " the directory appended to GITHUB_PATH did not take effect on this host" + command -v mcpp + exit 1 + fi + echo " every step below runs $on_path, built from $MCPP_SOURCE_REF" + + # ⭐ THE SAME ENGINE AND THE SAME STACK AS THE LINUX JOB, FROM A DIFFERENT + # HOST. This job had neither: it installed the released engine and + # resolved this ecosystem from the index, so a change spanning these + # repositories was validated on one host of three and reported as + # validated everywhere. + - name: Select the toolchain + run: | + set -euo pipefail # ⚠️ INSTALL, THEN SELECT. `toolchain default` names a toolchain and # does not fetch one, so selecting an absent payload fails with # `llvm@22.1.8 is not installed` — measured on both rows of this job. mcpp toolchain install llvm 22.1.8 mcpp toolchain default 'llvm@22.1.8' + - name: The stack, as written on this branch + run: bash tools/branch-graph.sh '${{ github.head_ref || github.ref_name }}' + - name: Every target, from this host run: | set -euo pipefail diff --git a/.gitignore b/.gitignore index 8e503533..569cbfe3 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,9 @@ mcpp.lock # remove a file that is ALREADY TRACKED. This repository had two: one at the # root and one under `examples/import-std/`. The first cleanup found only the # root one, because the scan it used anchored the path at the beginning. + +# The working trees the workflow substitutes for published dependencies. +.musl/ +.spec/ +.impl/ +.openkal-*/ diff --git a/mcpp.toml b/mcpp.toml index 9bcdc470..465702a3 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,7 +1,7 @@ [package] namespace = "mcpplibs" name = "openkal-llvm-runtime" -version = "0.1.3" +version = "0.2.0" description = "LLVM's C++ runtime libraries — libc++, libc++abi and libunwind — configured for openkal-musl rather than for a host C library." license = "Apache-2.0" authors = ["mcpplibs"] @@ -198,7 +198,7 @@ sources = [ cflags = ["-DDISABLE_AARCH64_FMV=1"] [dependencies] -openkal-musl = "0.3.5" +openkal-musl = "0.4.0" [build] cxx_standard = "c++23" diff --git a/tools/branch-graph.sh b/tools/branch-graph.sh new file mode 100755 index 00000000..61c6bdb6 --- /dev/null +++ b/tools/branch-graph.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +# +# THE ECOSYSTEM AS WRITTEN ON A BRANCH, RATHER THAN AS PUBLISHED. +# +# This package declares openkal-musl by version, which is what a published +# manifest must say. A change that spans the two repositories cannot be tested +# that way: the version named here does not exist in the index until the other +# half is released, and the run fails with +# +# E_NOT_FOUND: package 'openkal-musl@' not found in the synced index +# +# --- which reads as a mistake in this manifest and is nothing of the kind. The +# remedy is to put the working trees in place of the versions, for the whole +# graph rather than for its first edge. +# +# ⚠️ WHY THIS IS A SCRIPT AND NOT A STEP. It was a step, in one job of two, and +# the other job resolved from the index and failed exactly as above the moment +# the versions moved. Two copies of a procedure that must agree are two copies +# that will not; one file called twice cannot drift. +# +# ⚠️ THE SUBSTITUTED PATHS ARE RELATIVE, AND DELIBERATELY SO. +# +# An absolute path names a directory of one machine. A manifest carrying one has +# been committed in this ecosystem and published, and every consumer resolving +# it was handed a path that exists nowhere. Relative paths cannot express that +# mistake. They are also the only form that works unchanged on all three hosts: +# `pwd` under MSYS reports `/d/a/...`, which is not a path the engine resolves, +# so an absolute form would need a Windows-only conversion here. +# +# ⚠️ `sed -i` IS NOT PORTABLE. BSD sed, which is macOS's, reads the argument +# after -i as a backup suffix; the same command that edits a file on Linux +# consumes the next expression on macOS. In-place editing goes through a +# temporary file below for that reason. +set -euo pipefail + +branch="${1:-}" +[ -n "$branch" ] || { echo "usage: ${0##*/} " >&2; exit 2; } + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$root" + +# Clones a repository of this ecosystem, preferring the branch under test when +# that repository has one of the same name. +fetch() { + local repo="$1" dir="$2" + rm -rf "$dir" + git clone --quiet "https://github.com/mcpplibs/$repo.git" "$dir" + if git -C "$dir" rev-parse --verify --quiet "origin/$branch" > /dev/null; then + git -C "$dir" checkout --quiet "origin/$branch" + printf ' %-22s %s %s\n' "$repo" "$branch" "$(git -C "$dir" rev-parse --short HEAD)" + else + printf ' %-22s default branch %s (it has no %s)\n' \ + "$repo" "$(git -C "$dir" rev-parse --short HEAD)" "$branch" + fi +} + +# In-place editing that both GNU and BSD sed perform identically. +edit() { + local file="$1" expr="$2" + sed -E "$expr" "$file" > "$file.substituted" + mv "$file.substituted" "$file" +} + +echo "the stack under test:" +fetch openkal-musl .musl +fetch openkal .spec + +manifests=(mcpp.toml .musl/mcpp.toml) + +# ⚠️ EVERY BACKEND THE C LIBRARY NAMES, DISCOVERED RATHER THAN LISTED. +# +# openkal-musl names a backend per target --- linux, macos, windows, opensbi --- +# each conditional on the target it serves. Their versions all move with a change +# that spans these repositories, so each one left unsubstituted fails the same +# way, one link further down: +# +# E_NOT_FOUND: package 'openkal-linux@0.6.0' (the host build) +# E_NOT_FOUND: package 'openkal-opensbi@0.2.0' (the bare-metal one) +# +# The first was fixed by naming it, and the second appeared. A list written by +# hand is a list that is one entry short, so the set is read out of the manifest. +for backend in $(grep -oE '^openkal-[a-z]+ = \{ version' .musl/mcpp.toml | cut -d' ' -f1); do + fetch "$backend" ".$backend" + + # The backend reaches the specification too, by whatever form its own + # manifest uses. From /./ the specification is ../.spec. + edit ".$backend/mcpp.toml" 's|^openkal = .*$|openkal = { path = "../.spec" }|' + + edit .musl/mcpp.toml \ + "s|^$backend = \\{ version = \"[^\"]*\"(.*)\$|$backend = { path = \"../.$backend\"\\1|" + grep -q "path = \"../.$backend\"" .musl/mcpp.toml \ + || { echo "::error::$backend was not substituted"; exit 1; } + + manifests+=(".$backend/mcpp.toml") +done + +edit .musl/mcpp.toml 's|^openkal = .*$|openkal = { path = "../.spec" }|' +edit mcpp.toml 's|^openkal-musl = .*$|openkal-musl = { path = "./.musl" }|' + +# The substitution is asserted rather than assumed. One that matched nothing +# would leave the manifest naming a version, the resolver would fetch a +# published C library, and the run would report on that one while appearing to +# report on this branch. +grep -q 'path = "./.musl"' mcpp.toml \ + || { echo "::error::the C library substitution matched nothing"; exit 1; } +grep -q 'path = "../.spec"' .musl/mcpp.toml \ + || { echo "::error::the specification substitution matched nothing"; exit 1; } + +# ⭐ THE CHECK THAT WOULD HAVE CAUGHT EVERY FAILURE ABOVE AT ITS FIRST OCCURRENCE: +# nothing anywhere in the substituted graph still names a version. The three +# defects this file records were each found by a build failing one link further +# down than the last; this asks the whole graph at once. +if grep -nE '^openkal[a-z-]* = ("|\{ version)' "${manifests[@]}"; then + echo "::error::something in the graph still names a version rather than a tree" + exit 1 +fi + +echo "the whole stack names working trees; nothing in it names a version" diff --git a/tools/install-mcpp.sh b/tools/install-mcpp.sh new file mode 100755 index 00000000..f587ac49 --- /dev/null +++ b/tools/install-mcpp.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# +# THE mcpp THIS RUN IS TO BE JUDGED BY, PUT ON PATH. +# +# Two things are wanted of the same procedure, and which one applies is decided +# by MCPP_SOURCE_REF rather than by which job is asking: +# +# unset --- the released engine the pin names, which is what an ordinary run +# of this repository tests. +# set --- the engine built from that reference, which is how a change to +# mcpp is validated against this ecosystem before it is merged. +# +# ⚠️ THE PIN MAY NAME A RELEASE THIS RUN IS VALIDATING, and so may not exist. In +# that case the bootstrap takes whatever the index has; it is only the compiler +# that compiles the compiler, and the build it produces is what goes on PATH. +# +# ⚠️ WHY THIS IS A SCRIPT AND NOT A STEP. It was a step in one job of this +# workflow and absent from another, so that job installed a released engine and +# built manifests written for an unreleased one. The engine accepts a manifest +# key it does not know without failing, so the mismatch does not announce +# itself: the build proceeds and the semantics the key asks for are absent. +set -euo pipefail + +: "${MCPP_VERSION:?the pin this repository tests against}" + +if [ -n "${MCPP_SOURCE_REF:-}" ]; then + xlings update > /dev/null 2>&1 || true + xlings install mcpp -y -g +else + 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" + exit 1 + fi + sleep 60 + done +fi +mcpp --version +mcpp self config --mirror GLOBAL + +[ -n "${MCPP_SOURCE_REF:-}" ] || exit 0 + +# CROSS-VALIDATION: BUILD THE mcpp UNDER REVIEW AND USE THAT ONE. +# +# The released mcpp installed above is the bootstrap that compiles it; mcpp +# builds itself and there is no other compiler for it here. +src="${RUNNER_TEMP:-/tmp}/mcpp-src" +[ -d "$src" ] || git clone --quiet --depth 1 \ + --branch "$MCPP_SOURCE_REF" \ + https://github.com/mcpp-community/mcpp.git "$src" + +# The clone's own workspace pin must not decide which mcpp builds it. The +# `.xlings.json` at mcpp's root pins the mcpp that compiles mcpp and does not +# move when mcpp is released, so a build inside the checkout obeys it and tries +# to install a version the index may no longer carry. What is wanted is the +# source compiled by the mcpp installed above, which is what removing it leaves. +rm -f "$src/.xlings.json" + +# ⚠️ THE PRODUCT OF THIS BUILD IS IDENTIFIED BY ABSENCE, NOT BY RECENCY. +# +# `target/` holds one directory per configuration and accumulates them, and a +# restored cache writes every timestamp to the moment of extraction. Both +# `find | head -1` and `ls -t | head -1` have selected a stale binary here; the +# one chosen had an interpreter naming a payload that no longer existed, and the +# job failed with `cannot execute: required file not found`, which reads as a +# broken commit and is nothing of the kind. Deleting first makes what remains +# the product of this build, and the count is asserted rather than assumed. +find "$src/target" -type f \( -name mcpp -o -name mcpp.exe \) -delete 2>/dev/null || true +( cd "$src" && mcpp build --release ) + +built=$(find "$src/target" -type f \( -name mcpp -o -name mcpp.exe \)) +count=$(printf '%s\n' "$built" | grep -c . || true) +if [ "$count" != 1 ]; then + echo "::error::expected exactly one binary from $MCPP_SOURCE_REF, found $count" + printf '%s\n' "$built" | sed 's/^/ /' + exit 1 +fi + +echo "$(cd "$(dirname "$built")" && pwd)" >> "$GITHUB_PATH" +echo "under review: $("$built" --version) (from $MCPP_SOURCE_REF)" + +# ⚠️ THE APPEND ABOVE IS NOT EVIDENCE THAT THE APPEND TOOK EFFECT. +# +# `GITHUB_PATH` governs the steps that follow, so nothing observable in this one +# can distinguish a directory that wins from a directory that is ignored, and a +# build with the wrong engine looks exactly like a build with the right one +# until something depends on the difference. This is not a hypothetical form of +# doubt on every host: `pwd` under MSYS reports `/d/a/...`, and whether the +# runner accepts that spelling is a property of the runner rather than of this +# script. +# +# The version is therefore carried forward so that a following step can compare +# it against what PATH resolves. That step is the criterion; this line only +# supplies the value it needs. +echo "MCPP_UNDER_REVIEW=$("$built" --version | awk '{print $2}')" >> "$GITHUB_ENV"