Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
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
798 changes: 798 additions & 0 deletions .agents/docs/2026-09-11-sdk-toolchains-and-ios-local-verification.md

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion .agents/docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ superseded_by: 2026-09-07-....md # when status is superseded
---
```

277 records.
278 records.

## By subject

Expand Down Expand Up @@ -46,6 +46,7 @@ Records that declare one. Everything else is listed by date below.

### targets

- [SDK toolchains, the payload/engine seam, and openkal across iOS, Android and Web](2026-09-11-sdk-toolchains-and-ios-local-verification.md) — landed
- [Where a platform's knowledge belongs: iOS, Android and Web across the engine, the index and the plugins](2026-09-11-platform-targets-design-review.md) — active

### triage
Expand All @@ -57,6 +58,7 @@ Records that declare one. Everything else is listed by date below.
### 2026-09

- [Six open issues: what each one actually is, and what would answer it](2026-09-11-six-open-issues-analysis.md) — active
- [SDK toolchains, the payload/engine seam, and openkal across iOS, Android and Web](2026-09-11-sdk-toolchains-and-ios-local-verification.md) — landed
- [Where a platform's knowledge belongs: iOS, Android and Web across the engine, the index and the plugins](2026-09-11-platform-targets-design-review.md) — active
- [The category the plugin taxonomy does not name, and what a platform actually decomposes into](2026-09-11-distribution-plugins-and-platform-decomposition.md) — active
- [Two answers and two silences: the scanner's second grammar, and the manifest keys nothing reads](2026-09-09-two-answers-and-two-silences.md) — active
Expand Down
49 changes: 48 additions & 1 deletion .github/tools/check_target_tiers.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,22 @@
ROOT / "docs/zh/21-the-target-triple.md",
]

# THE FIFTH COPY WAS IN A TEST, AND A CHECK OVER DOCUMENTS CANNOT SEE IT.
#
# `aarch64-linux-android` became `verified`; the row moved, all four documents
# moved, this script reported "OK: 29 target tiers agree across 4 documents"
# -- and `test_toolchain_triple.cpp` went on asserting `preview`, because a
# literal in a test is in neither set. It was caught by running the suite,
# which is luck rather than a check.
#
# So the test file is a fifth document here. Its tier claims are written as
# `std::pair{"<target>", "<tier>"}` for exactly this reason: one line carrying
# both halves is a shape this script can read, and the alternative -- a target
# named on one line and its tier asserted three lines below -- is not.
tests = [
ROOT / "tests/unit/test_toolchain_triple.cpp",
]

fail = False
for doc in docs:
if not doc.exists():
Expand Down Expand Up @@ -75,6 +91,37 @@
fail = True
print(f" {doc.relative_to(ROOT)}: {len(seen)} of {len(rows)} rows")

# The test file, by the one-line rule described above. Unlike a document it is
# not required to name every row: a test states the claims it has evidence for,
# and a row with no assertion is not a row asserted wrongly. What IS required
# is that every claim it does make agrees.
for t in tests:
if not t.exists():
print(f"ERROR: {t.relative_to(ROOT)} is missing")
fail = True
continue
claimed = {}
for line in t.read_text().splitlines():
lits = re.findall(r'"([A-Za-z0-9_.+-]+)"', line)
tier = next((l for l in lits if l in TIERS), None)
if tier is None:
continue
for name in lits:
if name in rows:
claimed[name] = tier
if not claimed:
print(f"ERROR: {t.relative_to(ROOT)} is listed here but claims no "
f"tier; either its assertions changed shape or this list is stale")
fail = True
continue
for name, tier in sorted(claimed.items()):
if rows[name] != tier:
print(f"ERROR: {t.relative_to(ROOT)}: {name} asserted as "
f"'{tier}', the table says '{rows[name]}'")
fail = True
print(f" {t.relative_to(ROOT)}: {len(claimed)} row(s) claimed")

if fail:
sys.exit(1)
print(f"OK: {len(rows)} target tiers agree across {len(docs)} documents")
print(f"OK: {len(rows)} target tiers agree across {len(docs)} documents "
f"and {len(tests)} test file(s)")
309 changes: 309 additions & 0 deletions .github/workflows/ci-macos-ios.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,309 @@
name: ci-macos-ios

# The iOS rows, measured on the only machine that can answer for them.
#
# iOS needs an ecosystem compiler and a LOCATED SDK: `xim:llvm` emits arm64
# Mach-O for an iOS deployment target, and only the machine's Xcode can supply
# the iPhoneOS / iPhoneSimulator headers and stub libraries, which are not
# redistributable. The simulator runtime is the same category. So every claim
# about these three rows is a claim about a macOS runner, and this job is where
# they are made.
#
# Kept out of ci-macos.yml deliberately: that job asserts mcpp's default quiet
# output shape and tacking a differently-shaped leg onto it has broken that
# assertion before.

on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
workflow_dispatch:

concurrency:
group: ci-macos-ios-${{ github.ref }}
cancel-in-progress: true

jobs:
# THE PREMISES, AS A PROBE THAT RUNS ON REQUEST.
#
# This job measured what the iOS rows were scheduled on: that a GitHub macOS
# runner ships both SDKs and a bootable simulator, that `simctl spawn` takes a
# bare Mach-O, that the payload's `clang++.cfg` names the macOS SDK, and
# which C++ runtime an iOS link can use. Every answer is now encoded -- in
# `simctl-run`, in `--no-default-config` on the Apple cross path, and in the
# MachO contract cell -- and asserted by `ios-engine` below.
#
# Every step continues on error, because a probe's value is the complete set
# of answers. That is also why it does not run on every change: a job that
# cannot fail shown as a green check beside the gate reads as a second gate.
# It stays available for the day a runner image changes one of the premises.
ios-host-surface:
name: iOS - what this runner actually provides
if: github.event_name == 'workflow_dispatch'
runs-on: macos-15
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/setup-macos-llvm

- name: "Host surface: the two located SDKs and the simulator runtime"
continue-on-error: true
run: |
set -x
xcode-select -p
xcrun --sdk iphoneos --show-sdk-path
xcrun --sdk iphoneos --show-sdk-version
xcrun --sdk iphonesimulator --show-sdk-path
xcrun --sdk iphonesimulator --show-sdk-version
set +x
echo "--- runtimes ---"
xcrun simctl list runtimes
echo "--- devices available ---"
xcrun simctl list devices available

# THE PAYLOAD'S CLANG READS A CONFIG FILE, AND THAT CONFIG NAMES A
# DIFFERENT SDK. Measured with `-isysroot <iPhoneSimulator18.5.sdk>` on
# the command line and the cfg not suppressed:
#
# ld64.lld: error: /Library/Developer/CommandLineTools/SDKs/
# MacOSX.sdk/usr/lib/libc++.tbd(/usr/lib/libc++.1.dylib) is
# incompatible with arm64 (iOS Simulator18.0.0)
#
# which is why mcpp's Apple cross path carries `--no-default-config`.
- name: "The payload's default config, which is why --no-default-config"
continue-on-error: true
run: |
set -x
ls -la "$LLVM_ROOT/bin/"*.cfg || true
for f in "$LLVM_ROOT/bin/"*.cfg; do echo "=== $f"; cat "$f"; done || true

# WHETHER A BARE MACH-O CAN BE RUN AT ALL, which decided how much the
# runner program has to do: `simctl launch` needs an installed .app,
# `simctl spawn` takes an executable.
- name: "Device: is one bootable, and does spawn take a bare executable"
continue-on-error: true
run: |
set -uo pipefail
UDID=$(xcrun simctl list devices available \
| grep -A50 -- '-- iOS' \
| grep -m1 -oE '[0-9A-F]{8}-[0-9A-F-]{27}' || true)
echo "udid=[$UDID]"
if [ -z "$UDID" ]; then
echo "NO-IOS-SIMULATOR-DEVICE"
exit 0
fi
xcrun simctl boot "$UDID" || true
xcrun simctl bootstatus "$UDID" -b 2>&1 | tail -3 || true
cat > /tmp/hello.cpp << 'CPP'
#include <cstdio>
int main() { std::puts("1-2-3"); return 0; }
CPP
SDK=$(xcrun --sdk iphonesimulator --show-sdk-path)
if ! "$LLVM_ROOT/bin/clang++" -std=c++23 --no-default-config \
-target arm64-apple-ios18.0-simulator \
-isysroot "$SDK" -o /tmp/hello /tmp/hello.cpp; then
echo "SIM-COMPILE-FAILED"
exit 0
fi
file /tmp/hello
otool -l /tmp/hello | grep -A5 LC_BUILD_VERSION || true
echo "--- simctl spawn on a bare Mach-O ---"
if xcrun simctl spawn "$UDID" /tmp/hello; then
echo "SPAWN-OK"
else
echo "SPAWN-FAILED exit=$?"
fi

# THE C++ RUNTIME QUESTION. macOS links the PAYLOAD's static libc++ so
# that mcpp's deployment floor is real; that archive is built for macOS,
# and ld64 refuses an object built for one platform in a link for
# another. Both routes are tried because "which one works" is the fact
# the contract table needed.
- name: "C++ runtime: SDK libc++ versus the payload static archive"
continue-on-error: true
run: |
set -x
SDK=$(xcrun --sdk iphoneos --show-sdk-path)
cat > /tmp/cxx.cpp << 'CPP'
#include <string>
#include <cstdio>
int main() { std::string s = "1-2-3"; std::puts(s.c_str()); return 0; }
CPP
echo "--- (a) SDK libc++, dynamic ---"
if "$LLVM_ROOT/bin/clang++" -std=c++23 --no-default-config \
-target arm64-apple-ios18.0 \
-isysroot "$SDK" -o /tmp/cxx-sdk /tmp/cxx.cpp; then
otool -L /tmp/cxx-sdk
echo "SDK-LIBCXX-OK"
else
echo "SDK-LIBCXX-FAILED"
fi
echo "--- (b) payload static libc++ ---"
if "$LLVM_ROOT/bin/clang++" -std=c++23 --no-default-config \
-target arm64-apple-ios18.0 \
-isysroot "$SDK" -nostdlib++ \
"$LLVM_ROOT/lib/libc++.a" "$LLVM_ROOT/lib/libc++abi.a" \
-o /tmp/cxx-static /tmp/cxx.cpp; then
echo "PAYLOAD-STATIC-OK"
else
echo "PAYLOAD-STATIC-FAILED"
fi
set +x

# THE GATE. Every step here fails the job when its claim does not hold.
#
# The first version of this job was a probe too -- every step continued on
# error -- and it stayed that way after the rows it measured moved to
# `verified` and `preview`. That left a verified tier with no check that
# could turn red: a regression in the Apple cross path would have printed
# `RUN-THROUGH-RUNNER-FAILED` inside a green job.
ios-engine:
name: iOS - mcpp builds and the simulator runs it
runs-on: macos-15
timeout-minutes: 40
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- uses: ./.github/actions/setup-macos-llvm

- name: Build mcpp from source (self-host)
run: |
export MCPP_VENDORED_XLINGS="$XLINGS_BIN"
"$MCPP" build
echo "MCPP_DEV=$(ls -td "$PWD"/target/*/*/bin/mcpp | head -1)" >> "$GITHUB_ENV"

# NO TOOLCHAIN IS DECLARED, AND THAT IS WHAT MAKES THIS THE USER'S PATH.
#
# While the rows were `planned` the fixture had to write
# `[target.<row>] toolchain = "llvm@22.1.8"` to get past the tier gate,
# which meant it measured an override and never the row's own pin. The
# rows are now `verified` and `preview` and resolve `llvm@22.1.8` by
# themselves, so the fixture says nothing and the default is what is
# measured.
- name: "Fixture: a project that imports std and prints 1-2-3"
run: |
set -euo pipefail
mkdir -p /tmp/iostest/src
cat > /tmp/iostest/mcpp.toml << 'TOML'
[package]
name = "iostest"
version = "0.1.0"

[build]
ios_deployment_target = "18.0"

# THE RUNNER IS AN ARGV PREFIX AND THE SESSION BELONGS TO A
# PACKAGE. `simctl-run` comes from `xim:apple-simulator-tools`; it
# chooses a device, boots it if it is not booted, waits, spawns, and
# returns the program's own exit status. The device row keeps
# `runner` unset: an artefact cannot be run off an iOS device without
# a signature the developer owns.
[target.aarch64-ios-sim]
runner = ["simctl-run"]

# Declared at the top level here and not in examples/13, because a
# tool declaration is not conditional on a target and this package
# exists for macOS alone. This fixture is macOS-only, so it can say
# it; a portable manifest cannot.
[xlings.workspace]
"xim:apple-simulator-tools" = ""
TOML
cat > /tmp/iostest/src/main.cpp << 'CPP'
import std;
int main() {
std::vector<int> v{3, 1, 2};
std::ranges::sort(v);
std::print("{}-{}-{}\n", v[0], v[1], v[2]);
}
CPP
cat /tmp/iostest/mcpp.toml

# ONE ASSERTION FOR THE THREE ARTEFACTS. A build that succeeds cannot
# tell an iOS binary from a macOS one; LC_BUILD_VERSION can. Each
# value is read from the load command and compared whole, so an empty
# reading is a failure and not a pass.
cat > /tmp/assert-artefact.sh << 'SH'
#!/usr/bin/env bash
set -uo pipefail
target=$1 arch=$2 platform=$3 minos=$4
arts=(/tmp/iostest/target/"$target"/*/bin/iostest)
art=${arts[0]}
if [ ! -f "$art" ]; then
echo "FAIL: $target produced no artefact under /tmp/iostest/target/$target"
exit 1
fi
desc=$(file "$art")
lc=$(otool -l "$art")
echo "$desc"
grep -A5 LC_BUILD_VERSION <<<"$lc" || true
got_platform=$(awk '/cmd LC_BUILD_VERSION/{f=1} f && $1=="platform"{print $2; exit}' <<<"$lc")
got_minos=$(awk '/cmd LC_BUILD_VERSION/{f=1} f && $1=="minos"{print $2; exit}' <<<"$lc")
fail=0
if ! grep -qE "Mach-O 64-bit executable ${arch}\$" <<<"$desc"; then
echo "FAIL: $target is not a Mach-O $arch executable"; fail=1
fi
if [ "$got_platform" != "$platform" ]; then
echo "FAIL: $target LC_BUILD_VERSION platform is '$got_platform', expected $platform"; fail=1
fi
if [ "$got_minos" != "$minos" ]; then
echo "FAIL: $target minos is '$got_minos', expected $minos from ios_deployment_target"; fail=1
fi
[ "$fail" = 0 ] && echo "ok: $target is Mach-O $arch, platform $platform, minos $minos"
exit "$fail"
SH
chmod +x /tmp/assert-artefact.sh

# THE DEVICE ROW. Nothing runs it -- that needs a signature the developer
# owns -- so the claim is the artefact: `platform 2` is IOS. The refusal
# for a machine WITHOUT the SDK is asserted by tests/e2e/641 on every
# non-Apple host, which is the only place the SDK is genuinely absent:
# pointing `DEVELOPER_DIR` at nothing here measured nothing, because
# `xcrun` falls back to the recorded developer directory.
- name: "aarch64-ios: the artefact names the iOS platform"
run: |
set -euo pipefail
cd /tmp/iostest
"$MCPP_DEV" build --target aarch64-ios
/tmp/assert-artefact.sh aarch64-ios arm64 2 18.0

# `platform 7` is IOSSIMULATOR. The number is what separates this row from
# the device row; the architecture does not.
- name: "aarch64-ios-sim: the artefact names the simulator platform"
run: |
set -euo pipefail
cd /tmp/iostest
"$MCPP_DEV" build --target aarch64-ios-sim
/tmp/assert-artefact.sh aarch64-ios-sim arm64 7 18.0

# THE SUPPORTED PATH, which is what the `verified` tier claims: a runner
# the manifest declares and a program a package provides. The program's
# own line is compared whole. An iOS-simulator Mach-O does not execute on
# the macOS host directly, so the line appearing at all means the
# simulator ran it.
- name: "aarch64-ios-sim: mcpp run prints 1-2-3 through the runner"
run: |
set -uo pipefail
cd /tmp/iostest
out=$("$MCPP_DEV" run --target aarch64-ios-sim 2>&1) && rc=0 || rc=$?
printf '%s\n' "$out" | tail -20
if [ "$rc" -ne 0 ]; then
echo "FAIL: mcpp run --target aarch64-ios-sim exited $rc"
exit 1
fi
if ! grep -qx '1-2-3' <<<"$out"; then
echo "FAIL: the program's output line '1-2-3' is absent"
exit 1
fi
echo "ok: mcpp run --target aarch64-ios-sim printed 1-2-3 and exited 0"

# THE THIRD ROW, ON A HOST THAT CANNOT RUN IT. A simulator runs the host's
# architecture and this runner is Apple silicon, so the claim is the
# artefact alone -- which is exactly the `preview` tier the row carries.
- name: "x86_64-ios-sim: the artefact, on a host that cannot run it"
run: |
set -euo pipefail
cd /tmp/iostest
"$MCPP_DEV" build --target x86_64-ios-sim
/tmp/assert-artefact.sh x86_64-ios-sim x86_64 7 18.0
Loading
Loading