Skip to content

Commit c58d61e

Browse files
authored
SDK toolchains, the payload/engine seam, and openkal across iOS, Android and Web (2026.9.11.4) (#612)
Implements .agents/docs/2026-09-11-sdk-toolchains-and-ios-local-verification.md. See the PR description for what changes, the defects the implementation found, and what was recorded and not closed.
1 parent b718f49 commit c58d61e

43 files changed

Lines changed: 3719 additions & 243 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.agents/docs/2026-09-11-sdk-toolchains-and-ios-local-verification.md

Lines changed: 798 additions & 0 deletions
Large diffs are not rendered by default.

.agents/docs/README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ superseded_by: 2026-09-07-....md # when status is superseded
1818
---
1919
```
2020

21-
277 records.
21+
278 records.
2222

2323
## By subject
2424

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

4747
### targets
4848

49+
- [SDK toolchains, the payload/engine seam, and openkal across iOS, Android and Web](2026-09-11-sdk-toolchains-and-ios-local-verification.md) — landed
4950
- [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
5051

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

5960
- [Six open issues: what each one actually is, and what would answer it](2026-09-11-six-open-issues-analysis.md) — active
61+
- [SDK toolchains, the payload/engine seam, and openkal across iOS, Android and Web](2026-09-11-sdk-toolchains-and-ios-local-verification.md) — landed
6062
- [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
6163
- [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
6264
- [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

.github/tools/check_target_tiers.py

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,22 @@
4040
ROOT / "docs/zh/21-the-target-triple.md",
4141
]
4242

43+
# THE FIFTH COPY WAS IN A TEST, AND A CHECK OVER DOCUMENTS CANNOT SEE IT.
44+
#
45+
# `aarch64-linux-android` became `verified`; the row moved, all four documents
46+
# moved, this script reported "OK: 29 target tiers agree across 4 documents"
47+
# -- and `test_toolchain_triple.cpp` went on asserting `preview`, because a
48+
# literal in a test is in neither set. It was caught by running the suite,
49+
# which is luck rather than a check.
50+
#
51+
# So the test file is a fifth document here. Its tier claims are written as
52+
# `std::pair{"<target>", "<tier>"}` for exactly this reason: one line carrying
53+
# both halves is a shape this script can read, and the alternative -- a target
54+
# named on one line and its tier asserted three lines below -- is not.
55+
tests = [
56+
ROOT / "tests/unit/test_toolchain_triple.cpp",
57+
]
58+
4359
fail = False
4460
for doc in docs:
4561
if not doc.exists():
@@ -75,6 +91,37 @@
7591
fail = True
7692
print(f" {doc.relative_to(ROOT)}: {len(seen)} of {len(rows)} rows")
7793

94+
# The test file, by the one-line rule described above. Unlike a document it is
95+
# not required to name every row: a test states the claims it has evidence for,
96+
# and a row with no assertion is not a row asserted wrongly. What IS required
97+
# is that every claim it does make agrees.
98+
for t in tests:
99+
if not t.exists():
100+
print(f"ERROR: {t.relative_to(ROOT)} is missing")
101+
fail = True
102+
continue
103+
claimed = {}
104+
for line in t.read_text().splitlines():
105+
lits = re.findall(r'"([A-Za-z0-9_.+-]+)"', line)
106+
tier = next((l for l in lits if l in TIERS), None)
107+
if tier is None:
108+
continue
109+
for name in lits:
110+
if name in rows:
111+
claimed[name] = tier
112+
if not claimed:
113+
print(f"ERROR: {t.relative_to(ROOT)} is listed here but claims no "
114+
f"tier; either its assertions changed shape or this list is stale")
115+
fail = True
116+
continue
117+
for name, tier in sorted(claimed.items()):
118+
if rows[name] != tier:
119+
print(f"ERROR: {t.relative_to(ROOT)}: {name} asserted as "
120+
f"'{tier}', the table says '{rows[name]}'")
121+
fail = True
122+
print(f" {t.relative_to(ROOT)}: {len(claimed)} row(s) claimed")
123+
78124
if fail:
79125
sys.exit(1)
80-
print(f"OK: {len(rows)} target tiers agree across {len(docs)} documents")
126+
print(f"OK: {len(rows)} target tiers agree across {len(docs)} documents "
127+
f"and {len(tests)} test file(s)")

.github/workflows/ci-macos-ios.yml

Lines changed: 309 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,309 @@
1+
name: ci-macos-ios
2+
3+
# The iOS rows, measured on the only machine that can answer for them.
4+
#
5+
# iOS needs an ecosystem compiler and a LOCATED SDK: `xim:llvm` emits arm64
6+
# Mach-O for an iOS deployment target, and only the machine's Xcode can supply
7+
# the iPhoneOS / iPhoneSimulator headers and stub libraries, which are not
8+
# redistributable. The simulator runtime is the same category. So every claim
9+
# about these three rows is a claim about a macOS runner, and this job is where
10+
# they are made.
11+
#
12+
# Kept out of ci-macos.yml deliberately: that job asserts mcpp's default quiet
13+
# output shape and tacking a differently-shaped leg onto it has broken that
14+
# assertion before.
15+
16+
on:
17+
push:
18+
branches: [ main ]
19+
pull_request:
20+
branches: [ main ]
21+
workflow_dispatch:
22+
23+
concurrency:
24+
group: ci-macos-ios-${{ github.ref }}
25+
cancel-in-progress: true
26+
27+
jobs:
28+
# THE PREMISES, AS A PROBE THAT RUNS ON REQUEST.
29+
#
30+
# This job measured what the iOS rows were scheduled on: that a GitHub macOS
31+
# runner ships both SDKs and a bootable simulator, that `simctl spawn` takes a
32+
# bare Mach-O, that the payload's `clang++.cfg` names the macOS SDK, and
33+
# which C++ runtime an iOS link can use. Every answer is now encoded -- in
34+
# `simctl-run`, in `--no-default-config` on the Apple cross path, and in the
35+
# MachO contract cell -- and asserted by `ios-engine` below.
36+
#
37+
# Every step continues on error, because a probe's value is the complete set
38+
# of answers. That is also why it does not run on every change: a job that
39+
# cannot fail shown as a green check beside the gate reads as a second gate.
40+
# It stays available for the day a runner image changes one of the premises.
41+
ios-host-surface:
42+
name: iOS - what this runner actually provides
43+
if: github.event_name == 'workflow_dispatch'
44+
runs-on: macos-15
45+
timeout-minutes: 30
46+
steps:
47+
- uses: actions/checkout@v4
48+
- uses: ./.github/actions/setup-macos-llvm
49+
50+
- name: "Host surface: the two located SDKs and the simulator runtime"
51+
continue-on-error: true
52+
run: |
53+
set -x
54+
xcode-select -p
55+
xcrun --sdk iphoneos --show-sdk-path
56+
xcrun --sdk iphoneos --show-sdk-version
57+
xcrun --sdk iphonesimulator --show-sdk-path
58+
xcrun --sdk iphonesimulator --show-sdk-version
59+
set +x
60+
echo "--- runtimes ---"
61+
xcrun simctl list runtimes
62+
echo "--- devices available ---"
63+
xcrun simctl list devices available
64+
65+
# THE PAYLOAD'S CLANG READS A CONFIG FILE, AND THAT CONFIG NAMES A
66+
# DIFFERENT SDK. Measured with `-isysroot <iPhoneSimulator18.5.sdk>` on
67+
# the command line and the cfg not suppressed:
68+
#
69+
# ld64.lld: error: /Library/Developer/CommandLineTools/SDKs/
70+
# MacOSX.sdk/usr/lib/libc++.tbd(/usr/lib/libc++.1.dylib) is
71+
# incompatible with arm64 (iOS Simulator18.0.0)
72+
#
73+
# which is why mcpp's Apple cross path carries `--no-default-config`.
74+
- name: "The payload's default config, which is why --no-default-config"
75+
continue-on-error: true
76+
run: |
77+
set -x
78+
ls -la "$LLVM_ROOT/bin/"*.cfg || true
79+
for f in "$LLVM_ROOT/bin/"*.cfg; do echo "=== $f"; cat "$f"; done || true
80+
81+
# WHETHER A BARE MACH-O CAN BE RUN AT ALL, which decided how much the
82+
# runner program has to do: `simctl launch` needs an installed .app,
83+
# `simctl spawn` takes an executable.
84+
- name: "Device: is one bootable, and does spawn take a bare executable"
85+
continue-on-error: true
86+
run: |
87+
set -uo pipefail
88+
UDID=$(xcrun simctl list devices available \
89+
| grep -A50 -- '-- iOS' \
90+
| grep -m1 -oE '[0-9A-F]{8}-[0-9A-F-]{27}' || true)
91+
echo "udid=[$UDID]"
92+
if [ -z "$UDID" ]; then
93+
echo "NO-IOS-SIMULATOR-DEVICE"
94+
exit 0
95+
fi
96+
xcrun simctl boot "$UDID" || true
97+
xcrun simctl bootstatus "$UDID" -b 2>&1 | tail -3 || true
98+
cat > /tmp/hello.cpp << 'CPP'
99+
#include <cstdio>
100+
int main() { std::puts("1-2-3"); return 0; }
101+
CPP
102+
SDK=$(xcrun --sdk iphonesimulator --show-sdk-path)
103+
if ! "$LLVM_ROOT/bin/clang++" -std=c++23 --no-default-config \
104+
-target arm64-apple-ios18.0-simulator \
105+
-isysroot "$SDK" -o /tmp/hello /tmp/hello.cpp; then
106+
echo "SIM-COMPILE-FAILED"
107+
exit 0
108+
fi
109+
file /tmp/hello
110+
otool -l /tmp/hello | grep -A5 LC_BUILD_VERSION || true
111+
echo "--- simctl spawn on a bare Mach-O ---"
112+
if xcrun simctl spawn "$UDID" /tmp/hello; then
113+
echo "SPAWN-OK"
114+
else
115+
echo "SPAWN-FAILED exit=$?"
116+
fi
117+
118+
# THE C++ RUNTIME QUESTION. macOS links the PAYLOAD's static libc++ so
119+
# that mcpp's deployment floor is real; that archive is built for macOS,
120+
# and ld64 refuses an object built for one platform in a link for
121+
# another. Both routes are tried because "which one works" is the fact
122+
# the contract table needed.
123+
- name: "C++ runtime: SDK libc++ versus the payload static archive"
124+
continue-on-error: true
125+
run: |
126+
set -x
127+
SDK=$(xcrun --sdk iphoneos --show-sdk-path)
128+
cat > /tmp/cxx.cpp << 'CPP'
129+
#include <string>
130+
#include <cstdio>
131+
int main() { std::string s = "1-2-3"; std::puts(s.c_str()); return 0; }
132+
CPP
133+
echo "--- (a) SDK libc++, dynamic ---"
134+
if "$LLVM_ROOT/bin/clang++" -std=c++23 --no-default-config \
135+
-target arm64-apple-ios18.0 \
136+
-isysroot "$SDK" -o /tmp/cxx-sdk /tmp/cxx.cpp; then
137+
otool -L /tmp/cxx-sdk
138+
echo "SDK-LIBCXX-OK"
139+
else
140+
echo "SDK-LIBCXX-FAILED"
141+
fi
142+
echo "--- (b) payload static libc++ ---"
143+
if "$LLVM_ROOT/bin/clang++" -std=c++23 --no-default-config \
144+
-target arm64-apple-ios18.0 \
145+
-isysroot "$SDK" -nostdlib++ \
146+
"$LLVM_ROOT/lib/libc++.a" "$LLVM_ROOT/lib/libc++abi.a" \
147+
-o /tmp/cxx-static /tmp/cxx.cpp; then
148+
echo "PAYLOAD-STATIC-OK"
149+
else
150+
echo "PAYLOAD-STATIC-FAILED"
151+
fi
152+
set +x
153+
154+
# THE GATE. Every step here fails the job when its claim does not hold.
155+
#
156+
# The first version of this job was a probe too -- every step continued on
157+
# error -- and it stayed that way after the rows it measured moved to
158+
# `verified` and `preview`. That left a verified tier with no check that
159+
# could turn red: a regression in the Apple cross path would have printed
160+
# `RUN-THROUGH-RUNNER-FAILED` inside a green job.
161+
ios-engine:
162+
name: iOS - mcpp builds and the simulator runs it
163+
runs-on: macos-15
164+
timeout-minutes: 40
165+
steps:
166+
- uses: actions/checkout@v4
167+
with:
168+
submodules: recursive
169+
- uses: ./.github/actions/setup-macos-llvm
170+
171+
- name: Build mcpp from source (self-host)
172+
run: |
173+
export MCPP_VENDORED_XLINGS="$XLINGS_BIN"
174+
"$MCPP" build
175+
echo "MCPP_DEV=$(ls -td "$PWD"/target/*/*/bin/mcpp | head -1)" >> "$GITHUB_ENV"
176+
177+
# NO TOOLCHAIN IS DECLARED, AND THAT IS WHAT MAKES THIS THE USER'S PATH.
178+
#
179+
# While the rows were `planned` the fixture had to write
180+
# `[target.<row>] toolchain = "llvm@22.1.8"` to get past the tier gate,
181+
# which meant it measured an override and never the row's own pin. The
182+
# rows are now `verified` and `preview` and resolve `llvm@22.1.8` by
183+
# themselves, so the fixture says nothing and the default is what is
184+
# measured.
185+
- name: "Fixture: a project that imports std and prints 1-2-3"
186+
run: |
187+
set -euo pipefail
188+
mkdir -p /tmp/iostest/src
189+
cat > /tmp/iostest/mcpp.toml << 'TOML'
190+
[package]
191+
name = "iostest"
192+
version = "0.1.0"
193+
194+
[build]
195+
ios_deployment_target = "18.0"
196+
197+
# THE RUNNER IS AN ARGV PREFIX AND THE SESSION BELONGS TO A
198+
# PACKAGE. `simctl-run` comes from `xim:apple-simulator-tools`; it
199+
# chooses a device, boots it if it is not booted, waits, spawns, and
200+
# returns the program's own exit status. The device row keeps
201+
# `runner` unset: an artefact cannot be run off an iOS device without
202+
# a signature the developer owns.
203+
[target.aarch64-ios-sim]
204+
runner = ["simctl-run"]
205+
206+
# Declared at the top level here and not in examples/13, because a
207+
# tool declaration is not conditional on a target and this package
208+
# exists for macOS alone. This fixture is macOS-only, so it can say
209+
# it; a portable manifest cannot.
210+
[xlings.workspace]
211+
"xim:apple-simulator-tools" = ""
212+
TOML
213+
cat > /tmp/iostest/src/main.cpp << 'CPP'
214+
import std;
215+
int main() {
216+
std::vector<int> v{3, 1, 2};
217+
std::ranges::sort(v);
218+
std::print("{}-{}-{}\n", v[0], v[1], v[2]);
219+
}
220+
CPP
221+
cat /tmp/iostest/mcpp.toml
222+
223+
# ONE ASSERTION FOR THE THREE ARTEFACTS. A build that succeeds cannot
224+
# tell an iOS binary from a macOS one; LC_BUILD_VERSION can. Each
225+
# value is read from the load command and compared whole, so an empty
226+
# reading is a failure and not a pass.
227+
cat > /tmp/assert-artefact.sh << 'SH'
228+
#!/usr/bin/env bash
229+
set -uo pipefail
230+
target=$1 arch=$2 platform=$3 minos=$4
231+
arts=(/tmp/iostest/target/"$target"/*/bin/iostest)
232+
art=${arts[0]}
233+
if [ ! -f "$art" ]; then
234+
echo "FAIL: $target produced no artefact under /tmp/iostest/target/$target"
235+
exit 1
236+
fi
237+
desc=$(file "$art")
238+
lc=$(otool -l "$art")
239+
echo "$desc"
240+
grep -A5 LC_BUILD_VERSION <<<"$lc" || true
241+
got_platform=$(awk '/cmd LC_BUILD_VERSION/{f=1} f && $1=="platform"{print $2; exit}' <<<"$lc")
242+
got_minos=$(awk '/cmd LC_BUILD_VERSION/{f=1} f && $1=="minos"{print $2; exit}' <<<"$lc")
243+
fail=0
244+
if ! grep -qE "Mach-O 64-bit executable ${arch}\$" <<<"$desc"; then
245+
echo "FAIL: $target is not a Mach-O $arch executable"; fail=1
246+
fi
247+
if [ "$got_platform" != "$platform" ]; then
248+
echo "FAIL: $target LC_BUILD_VERSION platform is '$got_platform', expected $platform"; fail=1
249+
fi
250+
if [ "$got_minos" != "$minos" ]; then
251+
echo "FAIL: $target minos is '$got_minos', expected $minos from ios_deployment_target"; fail=1
252+
fi
253+
[ "$fail" = 0 ] && echo "ok: $target is Mach-O $arch, platform $platform, minos $minos"
254+
exit "$fail"
255+
SH
256+
chmod +x /tmp/assert-artefact.sh
257+
258+
# THE DEVICE ROW. Nothing runs it -- that needs a signature the developer
259+
# owns -- so the claim is the artefact: `platform 2` is IOS. The refusal
260+
# for a machine WITHOUT the SDK is asserted by tests/e2e/641 on every
261+
# non-Apple host, which is the only place the SDK is genuinely absent:
262+
# pointing `DEVELOPER_DIR` at nothing here measured nothing, because
263+
# `xcrun` falls back to the recorded developer directory.
264+
- name: "aarch64-ios: the artefact names the iOS platform"
265+
run: |
266+
set -euo pipefail
267+
cd /tmp/iostest
268+
"$MCPP_DEV" build --target aarch64-ios
269+
/tmp/assert-artefact.sh aarch64-ios arm64 2 18.0
270+
271+
# `platform 7` is IOSSIMULATOR. The number is what separates this row from
272+
# the device row; the architecture does not.
273+
- name: "aarch64-ios-sim: the artefact names the simulator platform"
274+
run: |
275+
set -euo pipefail
276+
cd /tmp/iostest
277+
"$MCPP_DEV" build --target aarch64-ios-sim
278+
/tmp/assert-artefact.sh aarch64-ios-sim arm64 7 18.0
279+
280+
# THE SUPPORTED PATH, which is what the `verified` tier claims: a runner
281+
# the manifest declares and a program a package provides. The program's
282+
# own line is compared whole. An iOS-simulator Mach-O does not execute on
283+
# the macOS host directly, so the line appearing at all means the
284+
# simulator ran it.
285+
- name: "aarch64-ios-sim: mcpp run prints 1-2-3 through the runner"
286+
run: |
287+
set -uo pipefail
288+
cd /tmp/iostest
289+
out=$("$MCPP_DEV" run --target aarch64-ios-sim 2>&1) && rc=0 || rc=$?
290+
printf '%s\n' "$out" | tail -20
291+
if [ "$rc" -ne 0 ]; then
292+
echo "FAIL: mcpp run --target aarch64-ios-sim exited $rc"
293+
exit 1
294+
fi
295+
if ! grep -qx '1-2-3' <<<"$out"; then
296+
echo "FAIL: the program's output line '1-2-3' is absent"
297+
exit 1
298+
fi
299+
echo "ok: mcpp run --target aarch64-ios-sim printed 1-2-3 and exited 0"
300+
301+
# THE THIRD ROW, ON A HOST THAT CANNOT RUN IT. A simulator runs the host's
302+
# architecture and this runner is Apple silicon, so the claim is the
303+
# artefact alone -- which is exactly the `preview` tier the row carries.
304+
- name: "x86_64-ios-sim: the artefact, on a host that cannot run it"
305+
run: |
306+
set -euo pipefail
307+
cd /tmp/iostest
308+
"$MCPP_DEV" build --target x86_64-ios-sim
309+
/tmp/assert-artefact.sh x86_64-ios-sim x86_64 7 18.0

0 commit comments

Comments
 (0)