From 36c6ff825899240f02bbddf6928f92c0f6d6641f Mon Sep 17 00:00:00 2001 From: tebako-ci Date: Wed, 30 Sep 2026 15:11:57 +0800 Subject: [PATCH] ci: the spawn-edge acceptance harness (tebako-runtime-ruby#211) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A minimal per-line gate for the driver spawn-plan bug class — ruby#121's argv[0] drop at plan apply, tebako#691's array-spawn of an exposed payload command failing days after the line shipped. No factory gate exercised "payload array-spawns a child through a spec-32 kind: executable edge"; the class surfaced only in a full metanorma compile. ci/spawn-edge presses two tiny fixture payloads (a consumer whose manifest carries the expose: edge, a provider exposing a command that echoes its argv), stages a scratch store with the runtime pair under test + the provider payload (the manifest mirror read back from the pressed image), and boots the consumer entry, which array-spawns the exposed command as system() and IO.popen legs and asserts the child received the exact argv. The scripts build nothing: the runtime arrives as the factory leg's artifacts, the press/readback tool is the leg's pin-verified tfs CLI. Verified locally on macos-arm64 against the published packages: ruby 4.0.7 and 3.3.12 of tebako 0.16.32 report SPAWN-EDGE-ACCEPTANCE-OK; the pre-fix 0.16.29 runtime fails the gate with the exact bug signature (the child's flag parse shifted, its provider mount lost, the entry resolving against the env image). --- ci/spawn-edge/README.md | 89 ++++++++++ ci/spawn-edge/fixtures/consumer-manifest.yaml | 42 +++++ ci/spawn-edge/fixtures/provider-manifest.yaml | 35 ++++ ci/spawn-edge/fixtures/spawn-edge-echo.rb | 10 ++ ci/spawn-edge/fixtures/spawn-edge-probe.rb | 70 ++++++++ ci/spawn-edge/run-msys.sh | 154 ++++++++++++++++ ci/spawn-edge/run.sh | 164 ++++++++++++++++++ 7 files changed, 564 insertions(+) create mode 100644 ci/spawn-edge/README.md create mode 100644 ci/spawn-edge/fixtures/consumer-manifest.yaml create mode 100644 ci/spawn-edge/fixtures/provider-manifest.yaml create mode 100644 ci/spawn-edge/fixtures/spawn-edge-echo.rb create mode 100644 ci/spawn-edge/fixtures/spawn-edge-probe.rb create mode 100755 ci/spawn-edge/run-msys.sh create mode 100755 ci/spawn-edge/run.sh diff --git a/ci/spawn-edge/README.md b/ci/spawn-edge/README.md new file mode 100644 index 0000000..b748aec --- /dev/null +++ b/ci/spawn-edge/README.md @@ -0,0 +1,89 @@ +# ci/spawn-edge — the spawn-edge acceptance gate (spec-32 executable edge) + +The **regression tripwire** for the driver spawn-plan bug class: +tamatebako/ruby#121 (`tfs_spawn_plan_apply` dropping the plan's argv[0], +so the child's flag parse shifted and its mounts fell out) and +tamatebako/tebako#691 (`system("xml2rfc", …)` from a dispatched payload +failing with "spawn plan failed without a message") both shipped green +through every lighter gate and surfaced **days later in a full metanorma +compile** — no factory gate exercised "a payload array-spawns a child +through a spec-32 `kind: executable` edge". This harness is that gate. +It is deliberately tiny: two fixture payloads, two spawn forms, seconds +per leg — not a suite. + +## What it proves + +The consumer payload's manifest declares + +```yaml +requires: + - kind: executable + name: spawn-edge-echo + payload: spawn-edge-provider + constraint: ">= 1.0" + expose: [spawn-edge-echo] + critical: true +``` + +and its entry script (`fixtures/spawn-edge-probe.rb`) array-spawns the +exposed name twice — `system("spawn-edge-echo", "alpha", "beta gamma", +"--flag=x", out: …)` and the `IO.popen` pipe form. The runtime's spawn +hook plans the PROVIDER payload's own dispatch as the child (the provider +image mounted at `/` in the child, the exposed entrypoint run there, the +child's runtime resolved cache-only from the scratch store); the provider +command (`fixtures/spawn-edge-echo.rb`) echoes its argv, and the probe +asserts the child received the exact vector — the space-carrying token +proves no shell re-split, the flag-shaped token proves no option +re-parse, and a shifted/garbled plan (the argv[0] class) mismatches +loudly. A plan failure raises in the parent and is named in the PROBE +line, never an unhandled backtrace. + +One `PROBE spawn-edge ok|fail ` line per leg +(`system-array`, `popen-array`); the harness pins both plus the child's +echoed argv line. Verdict: `SPAWN-EDGE-ACCEPTANCE-OK ()` +(`SPAWN-EDGE-MSYS-ACCEPTANCE-OK` on windows); a named `FAIL spawn-edge +(…)` otherwise. + +## Inputs + +Both scripts **build nothing** — the runtime under test is the factory +build leg's own artifact set, the press/readback tool is the leg's +pin-verified tfs CLI: + +| env | meaning | +|---|---| +| `RUNTIME_PKG_DIR` | the leg's runtime-packages dir (one `tebako-runtime---[.exe]` + its `.tfs`; on windows also the package-named ruby `.dll`) | +| `RUBY_VERSION` | the leg's ruby version (e.g. `4.0.7`) | +| `TEBAKO_VERSION` | the leg's tebako version (e.g. `0.16.33`) | +| `TFS_CLI` | the leg's pin-verified tfs CLI | +| `SCRATCH` | optional; default `/tmp/spawn-edge[-msys]-scratch--` | + +The scripts stage a scratch tebako store (spec 05 §3's grammar): the +leg's runtime pair under `runtimes/ruby---/` (on windows +renamed to the scan's synthesized `.exe` spelling, with the PE-named DLL +copy beside it) and the provider under `payloads/spawn-edge-provider/`, +its manifest mirror **copied from the pressed image** (`tfs cat` — the +store's embedded-wins rule, and the readback doubles as the press +assertion). The parent boot is a hand-rolled dispatch, so the spawn +resolves the spec 32 §5 **unlocked** edge cache-only — no +`TEBAKO_SPAWN_LOCK` is composed. + +## Run + +```sh +RUNTIME_PKG_DIR=/path/to/runtime-packages RUBY_VERSION=4.0.7 \ + TEBAKO_VERSION=0.16.33 TFS_CLI=/path/to/tfs \ + ci/spawn-edge/run.sh # POSIX (linux-gnu, macos; musl inside alpine) + +# windows (msys shell): +RUNTIME_PKG_DIR=… RUBY_VERSION=4.0.7 TEBAKO_VERSION=0.16.33 \ + TFS_CLI=/path/to/tfs.exe ci/spawn-edge/run-msys.sh +``` + +Consumed by the runtime factory's build legs +(tebako-runtime-ruby's `_build-platform.yml`), which run the gate per +ruby line × platform after the boot smoke and before the leg-complete +marker — a red gate ships nothing. Runnable by hand against any built or +published runtime package with the same inputs; delete the scratch dir to +re-run from scratch (every stage rebuilds on every run regardless — all +of it is sub-second). diff --git a/ci/spawn-edge/fixtures/consumer-manifest.yaml b/ci/spawn-edge/fixtures/consumer-manifest.yaml new file mode 100644 index 0000000..797f312 --- /dev/null +++ b/ci/spawn-edge/fixtures/consumer-manifest.yaml @@ -0,0 +1,42 @@ +# Payload manifest for the spawn-edge CONSUMER image (spec 03), pressed to +# /__tpkg__/manifest.yaml by run.sh / run-msys.sh. The digest placeholders +# are stamped by mkimage at press time (spec 03 §7 fixed-point rule). +# +# The consumer carries the edge under test (spec 32 §1, schema_minor 5): +# a `kind: executable` requirement whose `expose:` list opens the SPAWN +# surface — a bare-name spawn of `spawn-edge-echo` from the probe is +# planned as the provider payload's own spec-17 dispatch in a child +# process. `payload:` pins the provider by name (the store holds exactly +# one candidate, but the pin keeps the edge unambiguous by construction); +# `critical: true` is the producer obligation — a reader predating +# schema_minor 5 must refuse the edge, never skip it silently. The edge +# declares no `mount:` — the provider's VFS surface stays out of the +# parent (spawn-only, the shape the metanorma→xml2rfc edge ships). +identity: + schema_version: 1 + kind: app + name: spawn-edge-consumer + version: "1.0.0" + producer: {tool: spawn-edge, tool_version: "1"} + created: "2026-09-30T00:00:00Z" + digest: + tree_hash: "sha256:0000000000000000000000000000000000000000000000000000000000000000" + blob_sha256: "0000000000000000000000000000000000000000000000000000000000000000" + signing: {state: unsigned} + encryption: {state: none} +provides: + entrypoints: + - name: spawn-edge-probe + path: /spawn-edge-probe.rb + # Declarative only — the harness execs the leg's runtime directly; + # nothing resolves through this constraint. + runtime_requirement: {engine: ruby, constraint: ">= 3.1, < 5.0"} + platforms: universal + capabilities: {exec: true, read: true} +requires: + - kind: executable + name: spawn-edge-echo + payload: spawn-edge-provider + constraint: ">= 1.0" + expose: [spawn-edge-echo] + critical: true diff --git a/ci/spawn-edge/fixtures/provider-manifest.yaml b/ci/spawn-edge/fixtures/provider-manifest.yaml new file mode 100644 index 0000000..3ffe94b --- /dev/null +++ b/ci/spawn-edge/fixtures/provider-manifest.yaml @@ -0,0 +1,35 @@ +# Payload manifest for the spawn-edge PROVIDER image (spec 03), pressed to +# /__tpkg__/manifest.yaml by run.sh / run-msys.sh. The digest placeholders +# are stamped by mkimage at press time (spec 03 §7 fixed-point rule: the +# tree hash excludes /__tpkg__/; blob_sha256 stays as authored — advisory +# producer provenance, never a verification input for an embedded +# manifest). +# +# The provider is the spawn TARGET: its single entrypoint carries a +# runtime_requirement (spec 32 §1 — a runtime-less entry has no spawn +# form), and the driver's plan reads this declaration from the store +# manifest mirror (spec 32 §2, locked — run.sh installs exactly the +# pressed, stamped copy as the mirror). +identity: + schema_version: 1 + kind: app + name: spawn-edge-provider + version: "1.0.0" + producer: {tool: spawn-edge, tool_version: "1"} + created: "2026-09-30T00:00:00Z" + digest: + tree_hash: "sha256:0000000000000000000000000000000000000000000000000000000000000000" + blob_sha256: "0000000000000000000000000000000000000000000000000000000000000000" + signing: {state: unsigned} + encryption: {state: none} +provides: + entrypoints: + - name: spawn-edge-echo + path: /spawn-edge-echo.rb + # The whole ruby catalog: the gate runs per ruby line, and the + # child's runtime resolves cache-only against this constraint. + runtime_requirement: {engine: ruby, constraint: ">= 3.1, < 5.0"} + # Pressed per leg and never distributed; the platform axis is not what + # this acceptance exercises. + platforms: universal + capabilities: {exec: true, read: true} diff --git a/ci/spawn-edge/fixtures/spawn-edge-echo.rb b/ci/spawn-edge/fixtures/spawn-edge-echo.rb new file mode 100644 index 0000000..5a20e96 --- /dev/null +++ b/ci/spawn-edge/fixtures/spawn-edge-echo.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +# spawn-edge-echo.rb — the provider payload's exposed command, dispatched +# as a child process through the spec-32 kind: executable edge's spawn +# surface. Echoes its argv on one line; the verdict belongs to the +# consumer (spawn-edge-probe.rb), which compares byte-for-byte. The +# self-locating line proves the child booted with the provider image +# mounted ("/" on POSIX, "A:/" on msys) rather than anything host-side. +puts "SPAWN-EDGE-CHILD argv=#{ARGV.inspect}" +puts "SPAWN-EDGE-CHILD file=#{__FILE__}" diff --git a/ci/spawn-edge/fixtures/spawn-edge-probe.rb b/ci/spawn-edge/fixtures/spawn-edge-probe.rb new file mode 100644 index 0000000..db0db2e --- /dev/null +++ b/ci/spawn-edge/fixtures/spawn-edge-probe.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +# spawn-edge-probe.rb — the consumer payload's entry. Exercises the +# spec-32 spawn surface: this payload's manifest declares a +# `kind: executable` edge with `expose: [spawn-edge-echo]`, so a bare-name +# spawn of "spawn-edge-echo" is intercepted by the runtime's spawn hook +# and re-planned as the provider payload's own dispatch (the provider +# image co-mounted in the child, the exposed entrypoint run there). Two +# legs, both array-form (never a shell — a shell string is a different, +# unplanned surface): +# +# system-array — system("spawn-edge-echo", "alpha", "beta gamma", +# "--flag=x", out: ), the metanorma/xml2rfc +# call shape (tebako#691); +# popen-array — IO.popen(["spawn-edge-echo", ...]) — the pipe form. +# +# Each leg asserts the child exited 0 and echoed the EXACT argv (the +# space-carrying token proves the argv vector survives un-re-split; the +# flag-shaped token proves no option re-parse). The regression this +# tripwire exists for (ruby#121: the plan's argv[0] dropped at apply, so +# the child's flag parse shifted and its mounts/entry fell out) fails +# both legs at once. A plan failure raises in the parent — the rescue +# names it instead of dying on an unhandled exception line. +# +# Prints one `PROBE spawn-edge ok|fail ` line per leg plus +# a PROBE-DIAG line carrying the captured child output (the proof log is +# the only place the child's stdout lands for the redirect/pipe forms). +# Exits 1 on the first failed leg, 0 when both pass. + +COMMAND = "spawn-edge-echo" +ARGS = ["alpha", "beta gamma", "--flag=x"].freeze +WANT = "SPAWN-EDGE-CHILD argv=#{ARGS.inspect}".freeze + +def fail!(leg, detail) + puts "PROBE spawn-edge #{leg} fail #{detail}" + exit 1 +end + +def judge(leg, ok, out) + # Raw lines, not inspect: the proof log is the only place the child's + # stdout lands for the redirect/pipe forms, and the harness pins the + # child's echo line verbatim. + out.each_line { |line| puts "PROBE-DIAG #{leg} child: #{line}" } + fail!(leg, "the child did not exit 0 — its stderr rides this log") unless ok + unless out.lines.map(&:chomp).include?(WANT) + fail!(leg, "child argv mismatch — want #{WANT.inspect}, got #{out.strip.inspect}") + end + puts "PROBE spawn-edge #{leg} ok" +end + +# Leg 1: array-form system(), the child's stdout captured through a spawn +# redirect (cwd is the harness-owned run dir — no absolute path crosses +# the spawn boundary, so nothing rides the carried-mount rewrite). +begin + capture = File.expand_path("spawn-edge-system.out", Dir.pwd) + ok = system(COMMAND, *ARGS, out: capture) + out = File.file?(capture) ? File.read(capture) : "" +rescue StandardError => e + fail!("system-array", "the spawn raised #{e.class}: #{e.message.lines.first.to_s.strip}") +end +judge("system-array", ok == true, out) + +# Leg 2: array-form IO.popen (the pipe twin). +begin + out = IO.popen([COMMAND, *ARGS], &:read) + status = $? +rescue StandardError => e + fail!("popen-array", "the spawn raised #{e.class}: #{e.message.lines.first.to_s.strip}") +end +judge("popen-array", status&.success? == true, out) diff --git a/ci/spawn-edge/run-msys.sh b/ci/spawn-edge/run-msys.sh new file mode 100755 index 0000000..722cfc0 --- /dev/null +++ b/ci/spawn-edge/run-msys.sh @@ -0,0 +1,154 @@ +#!/bin/bash +# ci/spawn-edge/run-msys.sh — the spawn-edge acceptance gate, WINDOWS leg. +# The msys twin of run.sh: same fixtures, same store staging, same pinned +# PROBE lines and verdict, against the factory leg's windows artifacts. +# See run.sh's header for the gate's contract; this header owns only the +# windows mechanics: +# +# * The PE-named DLL. The package carries the ruby DLL under the unique +# package name (.dll); the staged store exe's PE imports +# resolve it only as -ucrt-ruby.dll next to the exe. +# The store entry gets the copy (the factory boot smoke's +# materialize_ruby_dll is the mirrored rule). +# * The store exe name. The factory ships windows exes SUFFIX-LESS (the +# release index's filename spelling), but the staged entry carries no +# release index, so the store scan derives the synthesized name — +# tebako-runtime---.exe — and the copy is renamed to +# it. The scan's platform grammar reads the triplet off the exe name, +# so the store dir names the same triplet. +# * Path discipline. The runtime exe and tfs.exe are NATIVE windows +# binaries: MSYS2_ARG_CONV_EXCL='*' / MSYS2_ENV_CONV_EXCL='*' disable +# the msys conversion layers outright, and every value crossing into +# a native binary is spelled in final form BY HAND — host paths +# through cygpath -m (the w() helper), VFS paths (/--spellings inside +# images) raw. The jail grammar is colon-sensitive — nothing here +# feeds a heuristic. TMP/TMPDIR/TEMP are conv_envvars and stay POSIX +# (the boundary converts them; see ci/spec22-gems/run-msys.sh's +# header for the two-layer mechanism). +# +# Usage: ci/spawn-edge/run-msys.sh +# +# Required env: as run.sh (RUNTIME_PKG_DIR, RUBY_VERSION, TEBAKO_VERSION, +# TFS_CLI — the published/leg-cached windows tfs.exe). +# Overridable: SCRATCH (default /tmp/spawn-edge-msys-scratch--, +# POSIX spelling). + +set -euo pipefail +export MSYS2_ARG_CONV_EXCL='*' +export MSYS2_ENV_CONV_EXCL='*' + +RUNTIME_PKG_DIR="${RUNTIME_PKG_DIR:?run-msys.sh: RUNTIME_PKG_DIR (the leg runtime-packages dir) is required}" +RUBY_VERSION="${RUBY_VERSION:?run-msys.sh: RUBY_VERSION (the leg ruby version) is required}" +TEBAKO_VERSION="${TEBAKO_VERSION:?run-msys.sh: TEBAKO_VERSION (the leg tebako version) is required}" +TFS_CLI="${TFS_CLI:?run-msys.sh: TFS_CLI (the windows tfs.exe) is required}" + +case "$(uname -s)" in + MINGW*|MSYS*) ;; + *) echo "FAIL spawn-edge (run-msys.sh is msys-only; uname: $(uname -s)) — use run.sh on POSIX" >&2; exit 64 ;; +esac + +SELF_DIR="$(cd "$(dirname "$0")" && pwd)" + +step() { echo "== spawn-edge-msys step: $*"; } +die() { echo "FAIL spawn-edge-msys ($*)" >&2; exit 1; } +# Native-windows (mixed) spelling for values that cross the env/argv +# boundary into the runtime exe or tfs.exe. +w() { cygpath -m "$1"; } + +sha256_file() { sha256sum "$1" | awk '{print $1}'; } + +# --- 0. the leg's artifacts --------------------------------------------------- +PKG_BASE="tebako-runtime-$TEBAKO_VERSION-$RUBY_VERSION" +pkg="$(find "$RUNTIME_PKG_DIR" -maxdepth 1 \( -name "$PKG_BASE-windows-ucrt64" -o -name "$PKG_BASE-windows-ucrt64.exe" \ + -o -name "$PKG_BASE-windows-ucrt-arm64" -o -name "$PKG_BASE-windows-ucrt-arm64.exe" \) | head -1)" +[ -n "$pkg" ] || die "no runtime exe $PKG_BASE-windows-ucrt64[.exe]|-ucrt-arm64[.exe] under $RUNTIME_PKG_DIR" +exe_stem="${pkg%.exe}" +triplet="${exe_stem##*"$PKG_BASE"-}" +[ -f "$exe_stem.tfs" ] || die "no env image at $exe_stem.tfs" +[ -f "$exe_stem.dll" ] || die "no package ruby DLL at $exe_stem.dll" +[ -x "$TFS_CLI" ] || [ -f "$TFS_CLI" ] || die "tfs CLI not at $TFS_CLI" +case "$triplet" in + *-ucrt-arm64) CPU_TAG=aarch64 ;; + *-ucrt64) CPU_TAG=x64 ;; + *) die "no arch-readable triplet on $(basename "$pkg")" ;; +esac +# -ucrt-ruby.dll — ruby configure's RUBY_SO_NAME for a mingw +# host; = 0 (factory RubyVersion#msys_dll_name owns +# the name; mirrored here — a bash harness cannot flow it, and a drift +# dies loudly at boot). +ABI="$(echo "$RUBY_VERSION" | awk -F. '{printf "%d%d0", $1, $2}')" +PE_DLL="${CPU_TAG}-ucrt-ruby${ABI}.dll" + +SCRATCH="${SCRATCH:-/tmp/spawn-edge-msys-scratch-$RUBY_VERSION-$triplet}" +mkdir -p "$SCRATCH"/{tmp,run} +step "leg runtime: $(basename "$pkg") (triplet $triplet, PE DLL $PE_DLL); scratch $SCRATCH" + +# --- 1. press the fixture payload images -------------------------------------- +PROVIDER_IMG="$SCRATCH/spawn-edge-provider.tfs" +CONSUMER_IMG="$SCRATCH/spawn-edge-consumer.tfs" +for side in provider consumer; do + tree="$SCRATCH/$side-tree" + rm -rf "$tree"; mkdir -p "$tree/__tpkg__" + case "$side" in + provider) cp "$SELF_DIR/fixtures/spawn-edge-echo.rb" "$tree/" ;; + consumer) cp "$SELF_DIR/fixtures/spawn-edge-probe.rb" "$tree/" ;; + esac + cp "$SELF_DIR/fixtures/$side-manifest.yaml" "$tree/__tpkg__/manifest.yaml" +done +step "press the fixture payload images (tfs mkimage, default format)" +"$TFS_CLI" mkimage "$(w "$SCRATCH/provider-tree")" --output "$(w "$PROVIDER_IMG")" >/dev/null +"$TFS_CLI" mkimage "$(w "$SCRATCH/consumer-tree")" --output "$(w "$CONSUMER_IMG")" >/dev/null + +# --- 2. stage the scratch store ------------------------------------------------- +HOME_DIR="$SCRATCH/tebako-home" +RT_DIR="$HOME_DIR/runtimes/ruby-$RUBY_VERSION-$TEBAKO_VERSION-$triplet" +PAYLOAD_DIR="$HOME_DIR/payloads/spawn-edge-provider" +STORE_EXE="$RT_DIR/$PKG_BASE-$triplet.exe" +STORE_IMG="$RT_DIR/$PKG_BASE-$triplet.tfs" +step "stage the scratch store (runtime pair + PE-named DLL + provider payload)" +rm -rf "$HOME_DIR" +mkdir -p "$RT_DIR" "$PAYLOAD_DIR" +cp "$pkg" "$STORE_EXE" +cp "$exe_stem.dll" "$RT_DIR/$PE_DLL" +cp "$exe_stem.tfs" "$STORE_IMG" +echo "$(sha256_file "$STORE_IMG") $PKG_BASE-$triplet.tfs" > "$STORE_IMG.sha256" +echo "$(sha256_file "$STORE_EXE") $PKG_BASE-$triplet.exe" > "$STORE_EXE.sha256" +cp "$PROVIDER_IMG" "$PAYLOAD_DIR/1.0.0.tfs" +echo "$(sha256_file "$PAYLOAD_DIR/1.0.0.tfs") 1.0.0.tfs" > "$PAYLOAD_DIR/1.0.0.tfs.sha256" +"$TFS_CLI" cat "$(w "$PROVIDER_IMG")" /__tpkg__/manifest.yaml > "$PAYLOAD_DIR/1.0.0.manifest.yaml" \ + || die "the provider image carries no readable /__tpkg__/manifest.yaml — the press dropped it" +grep -q "name: spawn-edge-echo" "$PAYLOAD_DIR/1.0.0.manifest.yaml" \ + || die "the provider manifest mirror lost the exposed entrypoint" +grep -q "expose:" <("$TFS_CLI" cat "$(w "$CONSUMER_IMG")" /__tpkg__/manifest.yaml) \ + || die "the consumer image lost the expose: edge — the press would prove nothing" + +# --- 3. the proof run ------------------------------------------------------------- +# env -i is deliberately ABSENT here (unlike ci/spec22-gems): the gate's +# question is the spawn plan, not hermeticity, and the runner's inherited +# baseline is the survivable windows env floor. TMP/TEMP stay POSIX +# (conv_envvars — the boundary converts); the TEBAKO_* values are w()'d. +step "boot the consumer payload and spawn through the executable edge" +set +e +( + cd "$SCRATCH/run" + TMP="$SCRATCH/tmp" \ + TMPDIR="$SCRATCH/tmp" \ + TEMP="$SCRATCH/tmp" \ + TEBAKO_HOME="$(w "$HOME_DIR")" \ + TEBAKO_RUNTIME_IMAGE="$(w "$exe_stem.tfs")" \ + "$pkg" --tebako-image "$(w "$CONSUMER_IMG"):-:/" --tebako-entry /spawn-edge-probe.rb +) > "$SCRATCH/proof.log" 2>&1 +st=$? +set -e +cat "$SCRATCH/proof.log" + +# --- 4. the pinned verdicts --------------------------------------------------------- +[ "$st" -eq 0 ] || die "the probe exited $st (the proof log above names the leg)" +for leg in system-array popen-array; do + grep -q "^PROBE spawn-edge $leg ok" "$SCRATCH/proof.log" \ + || die "the $leg leg did not report ok (the proof log above)" +done +grep -qF 'SPAWN-EDGE-CHILD argv=["alpha", "beta gamma", "--flag=x"]' "$SCRATCH/proof.log" \ + || die "the child's echoed argv never reached the log (the PROBE-DIAG lines above)" + +echo "SPAWN-EDGE-MSYS-ACCEPTANCE-OK $RUBY_VERSION ($triplet)" diff --git a/ci/spawn-edge/run.sh b/ci/spawn-edge/run.sh new file mode 100755 index 0000000..8cdd5f7 --- /dev/null +++ b/ci/spawn-edge/run.sh @@ -0,0 +1,164 @@ +#!/bin/bash +# ci/spawn-edge/run.sh — the spawn-edge acceptance gate, POSIX legs +# (linux-gnu, linux-musl inside its alpine container, macos). +# +# The tripwire for the driver spawn-plan bug class (ruby#121's argv[0] +# drop at plan apply; tebako#691's `system("xml2rfc", …)` from a +# dispatched payload): a CONSUMER payload whose manifest carries a spec-32 +# `kind: executable` edge with `expose: [spawn-edge-echo]` array-spawns +# the exposed provider command, and the probe asserts the child received +# the exact argv. A full metanorma compile is days of signal latency; this +# gate runs in seconds, per ruby line, against the leg's fresh runtime, +# before anything publishes. +# +# The script BUILDS NOTHING (the run-msys.sh contract): the runtime under +# test arrives as the factory build leg's runtime-packages/ dir and the +# press/readback tooling is the leg's own pin-verified tfs CLI. It: +# +# 1. presses the two fixture payload images (provider + consumer) in +# the CLI's default format (limnifs — never a --format pin); +# 2. stages a scratch tebako store (spec 05 §3): the leg's runtime pair +# under runtimes/ruby---/ and the provider under +# payloads/spawn-edge-provider/, the manifest mirror COPIED from the +# pressed image (the store's "embedded wins" rule — the mirror is +# the embedded manifest, stamped digests included); +# 3. boots the leg's runtime with the consumer image mounted at "/" and +# the probe as the entry, TEBAKO_HOME pointed at the scratch store — +# a hand-rolled dispatch, so the spawn resolves cache-only through +# the store (spec 32 §5's unlocked edge; no TEBAKO_SPAWN_LOCK); +# 4. pins the probe's PROBE lines and the child's echoed argv. +# +# Usage: ci/spawn-edge/run.sh +# +# Required env: +# RUNTIME_PKG_DIR — the leg's runtime-packages dir (one +# tebako-runtime---[.exe] + its .tfs) +# RUBY_VERSION — the leg's ruby version (e.g. 4.0.7) +# TEBAKO_VERSION — the leg's tebako version (e.g. 0.16.33) +# TFS_CLI — the leg's pin-verified tfs CLI (press + readback) +# Overridable: +# SCRATCH (default: /tmp/spawn-edge-scratch--) +# +# Verdict: `SPAWN-EDGE-ACCEPTANCE-OK ()` on success; a +# named `FAIL spawn-edge (…)` line otherwise. Everything transient lives +# under $SCRATCH; delete it to re-run from scratch (the script rebuilds +# every stage on every run — all of it is sub-second). + +set -euo pipefail + +RUNTIME_PKG_DIR="${RUNTIME_PKG_DIR:?run.sh: RUNTIME_PKG_DIR (the leg runtime-packages dir) is required}" +RUBY_VERSION="${RUBY_VERSION:?run.sh: RUBY_VERSION (the leg ruby version) is required}" +TEBAKO_VERSION="${TEBAKO_VERSION:?run.sh: TEBAKO_VERSION (the leg tebako version) is required}" +TFS_CLI="${TFS_CLI:?run.sh: TFS_CLI (the pin-verified tfs CLI) is required}" + +case "$(uname -s)" in + MINGW*|MSYS*|CYGWIN*) echo "FAIL spawn-edge (run.sh is POSIX-only; the windows leg is run-msys.sh)" >&2; exit 64 ;; +esac + +SELF_DIR="$(cd "$(dirname "$0")" && pwd)" + +step() { echo "== spawn-edge step: $*"; } +die() { echo "FAIL spawn-edge ($*)" >&2; exit 1; } + +sha256_file() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + else + shasum -a 256 "$1" | awk '{print $1}' + fi +} + +# --- 0. the leg's artifacts ------------------------------------------------- +PKG_BASE="tebako-runtime-$TEBAKO_VERSION-$RUBY_VERSION" +exe="$(find "$RUNTIME_PKG_DIR" -maxdepth 1 -name "$PKG_BASE-*" \ + ! -name "*.tfs" ! -name "*.sha256" ! -name "*.origin" ! -name "*.abi" \ + ! -name "*.dll" ! -name "*.yaml" ! -name "*.json" | head -1)" +[ -n "$exe" ] || die "no runtime exe $PKG_BASE-* under $RUNTIME_PKG_DIR" +[ -f "$exe.tfs" ] || die "no env image at $exe.tfs" +triplet="${exe##*"$PKG_BASE"-}" +case "$triplet" in + ""|*/*) die "cannot read the triplet off $(basename "$exe")" ;; +esac +[ -x "$TFS_CLI" ] || [ -f "$TFS_CLI" ] || die "tfs CLI not at $TFS_CLI" + +SCRATCH="${SCRATCH:-/tmp/spawn-edge-scratch-$RUBY_VERSION-$triplet}" +mkdir -p "$SCRATCH"/{tmp,run} +step "leg runtime: $(basename "$exe") (triplet $triplet); scratch $SCRATCH" + +# --- 1. press the fixture payload images ------------------------------------ +# Default format (limnifs) — the only first-class writer; never a +# --format pin. The manifest rides the tree at __tpkg__/manifest.yaml. +PROVIDER_IMG="$SCRATCH/spawn-edge-provider.tfs" +CONSUMER_IMG="$SCRATCH/spawn-edge-consumer.tfs" +for side in provider consumer; do + tree="$SCRATCH/$side-tree" + rm -rf "$tree"; mkdir -p "$tree/__tpkg__" + case "$side" in + provider) cp "$SELF_DIR/fixtures/spawn-edge-echo.rb" "$tree/" ;; + consumer) cp "$SELF_DIR/fixtures/spawn-edge-probe.rb" "$tree/" ;; + esac + cp "$SELF_DIR/fixtures/$side-manifest.yaml" "$tree/__tpkg__/manifest.yaml" +done +step "press the fixture payload images (tfs mkimage, default format)" +"$TFS_CLI" mkimage "$SCRATCH/provider-tree" --output "$PROVIDER_IMG" >/dev/null +"$TFS_CLI" mkimage "$SCRATCH/consumer-tree" --output "$CONSUMER_IMG" >/dev/null + +# --- 2. stage the scratch store --------------------------------------------- +# spec 05 §3's grammar: runtimes/---/ holding the +# exe + env image + the image's sha256 trust anchor (the scan requires +# exactly these), and payloads//.tfs + .tfs.sha256 + +# .manifest.yaml. The mirror is the PRESSED manifest (tfs cat readback — +# embedded wins, and the readback doubles as the press assertion). +HOME_DIR="$SCRATCH/tebako-home" +RT_DIR="$HOME_DIR/runtimes/ruby-$RUBY_VERSION-$TEBAKO_VERSION-$triplet" +PAYLOAD_DIR="$HOME_DIR/payloads/spawn-edge-provider" +step "stage the scratch store (runtime pair + provider payload)" +rm -rf "$HOME_DIR" +mkdir -p "$RT_DIR" "$PAYLOAD_DIR" +cp "$exe" "$RT_DIR/$PKG_BASE-$triplet" +chmod 0755 "$RT_DIR/$PKG_BASE-$triplet" +cp "$exe.tfs" "$RT_DIR/$PKG_BASE-$triplet.tfs" +chmod 0444 "$RT_DIR/$PKG_BASE-$triplet.tfs" +echo "$(sha256_file "$RT_DIR/$PKG_BASE-$triplet.tfs") $PKG_BASE-$triplet.tfs" > "$RT_DIR/$PKG_BASE-$triplet.tfs.sha256" +echo "$(sha256_file "$RT_DIR/$PKG_BASE-$triplet") $PKG_BASE-$triplet" > "$RT_DIR/$PKG_BASE-$triplet.sha256" +cp "$PROVIDER_IMG" "$PAYLOAD_DIR/1.0.0.tfs" +chmod 0444 "$PAYLOAD_DIR/1.0.0.tfs" +echo "$(sha256_file "$PAYLOAD_DIR/1.0.0.tfs") 1.0.0.tfs" > "$PAYLOAD_DIR/1.0.0.tfs.sha256" +"$TFS_CLI" cat "$PROVIDER_IMG" /__tpkg__/manifest.yaml > "$PAYLOAD_DIR/1.0.0.manifest.yaml" \ + || die "the provider image carries no readable /__tpkg__/manifest.yaml — the press dropped it" +grep -q "name: spawn-edge-echo" "$PAYLOAD_DIR/1.0.0.manifest.yaml" \ + || die "the provider manifest mirror lost the exposed entrypoint" +grep -q "expose:" <("$TFS_CLI" cat "$CONSUMER_IMG" /__tpkg__/manifest.yaml) \ + || die "the consumer image lost the expose: edge — the press would prove nothing" + +# --- 3. the proof run --------------------------------------------------------- +# The parent boots the LEG's package pair (the artifact under test), the +# consumer image at "/" (the first triple — the entry resolves against +# it), TEBAKO_HOME at the scratch store. The probe's spawn of +# spawn-edge-echo is planned against the store: the provider image mounts +# in the child at "/", the child's runtime is the SAME pair, resolved +# cache-only from runtimes/. TMPDIR scopes ruby's Dir.* tempfile surface +# into the scratch. +step "boot the consumer payload and spawn through the executable edge" +set +e +( + cd "$SCRATCH/run" + TEBAKO_HOME="$HOME_DIR" \ + TEBAKO_RUNTIME_IMAGE="$exe.tfs" \ + TMPDIR="$SCRATCH/tmp" \ + "$exe" --tebako-image "$CONSUMER_IMG:-:/" --tebako-entry /spawn-edge-probe.rb +) > "$SCRATCH/proof.log" 2>&1 +st=$? +set -e +cat "$SCRATCH/proof.log" + +# --- 4. the pinned verdicts --------------------------------------------------- +[ "$st" -eq 0 ] || die "the probe exited $st (the proof log above names the leg)" +for leg in system-array popen-array; do + grep -q "^PROBE spawn-edge $leg ok" "$SCRATCH/proof.log" \ + || die "the $leg leg did not report ok (the proof log above)" +done +grep -qF 'SPAWN-EDGE-CHILD argv=["alpha", "beta gamma", "--flag=x"]' "$SCRATCH/proof.log" \ + || die "the child's echoed argv never reached the log (the PROBE-DIAG lines above)" + +echo "SPAWN-EDGE-ACCEPTANCE-OK $RUBY_VERSION ($triplet)"