Skip to content

emit(ruby): root a constant its own namespace shadows, without losing… #1456

emit(ruby): root a constant its own namespace shadows, without losing…

emit(ruby): root a constant its own namespace shadows, without losing… #1456

Workflow file for this run

name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
pages: write
id-token: write
# Only one Pages deployment at a time.
concurrency:
group: pages
cancel-in-progress: false
env:
CARGO_TERM_COLOR: always
# The Rails apps the /ide/ + /playground/ demos ship (in-browser). Pinned
# so the page — and any blog post's claims about it — stay stable against
# upstream churn; bump deliberately. Each bundle embeds the app's LICENSE
# and its commit (bundle-src.mjs). Lobsters is the ruby-bench benchmark
# copy (MIT); campfire is Basecamp's ONCE Campfire (MIT) — the write+push
# app (Action Cable, turbo_stream broadcasts) and the current climb;
# Mastodon (AGPL) is the scale case.
MASTODON_SHA: 163f96cee4dea23365bff9b433871e68d20d9ee7
RUBY_BENCH_SHA: d771f81f1ce9db51376e03ca7b8e6a83160556d4
CAMPFIRE_SHA: 2aa4141077141335e79cb0eac5a0eec1a57b2772
jobs:
# -------------------------------------------------------------------
# Generate the real-blog fixture once, upload as artifact. Every
# downstream test job downloads + extracts it — pay the Ruby/Rails
# install cost once per CI run instead of in each of seven jobs.
# -------------------------------------------------------------------
generate-fixture:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: "3.4"
- name: Install Rails
run: gem install rails
- name: Generate fixtures/real-blog
run: bin/rh fixture
- name: Pack fixture
run: |
tar --exclude='./fixtures/real-blog/tmp' \
--exclude='./fixtures/real-blog/log' \
-czf real-blog.tar.gz fixtures/real-blog
- name: Upload fixture
uses: actions/upload-artifact@v7
with:
name: real-blog-fixture
path: real-blog.tar.gz
retention-days: 1
# -------------------------------------------------------------------
# Build the in-browser compiler wasm (roundhouse_wasm.wasm) from source
# and upload it as an artifact for build-site. Runs in PARALLEL with
# generate-fixture — they're independent (the wasm embeds the compiler;
# the fixture is runtime input fed in the browser), so this multi-minute
# LTO build hides behind work build-site already waits on, adding ~0 to
# the critical path to deploy. Rebuilds via the in-repo vendored
# ruby-rbs-sys (wasm/vendor/ — wasm32 build support upstream-pending as
# ruby/rbs#2992), so the 3.8 MB binary is NOT committed and the published
# demos (/playground/, /studio/) always track main. The only external
# need is the WASI SDK (a manual tarball, cached here).
# -------------------------------------------------------------------
build-wasm:
runs-on: ubuntu-latest
env:
WASI_SDK_VERSION: "33.0"
steps:
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-wasip1
- uses: Swatinem/rust-cache@v2
with:
workspaces: wasm
- name: Install libclang (host bindgen)
run: sudo apt-get update && sudo apt-get install -y libclang-dev
- name: Cache WASI SDK
id: wasi-sdk
uses: actions/cache@v4
with:
path: /opt/wasi-sdk
key: wasi-sdk-${{ env.WASI_SDK_VERSION }}-x86_64-linux
- name: Install WASI SDK
if: steps.wasi-sdk.outputs.cache-hit != 'true'
run: |
ver="${WASI_SDK_VERSION}"; major="${ver%%.*}"
url="https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-${major}/wasi-sdk-${ver}-x86_64-linux.tar.gz"
curl -fsSL "$url" -o /tmp/wasi-sdk.tar.gz
sudo mkdir -p /opt/wasi-sdk
sudo tar -xzf /tmp/wasi-sdk.tar.gz -C /opt/wasi-sdk --strip-components=1
- name: Build roundhouse_wasm.wasm
env:
WASI_SDK_PATH: /opt/wasi-sdk
run: cargo build --release --target wasm32-wasip1 --manifest-path wasm/Cargo.toml
- name: Upload wasm artifact
uses: actions/upload-artifact@v7
with:
name: roundhouse-wasm
path: wasm/target/wasm32-wasip1/release/roundhouse_wasm.wasm
retention-days: 1
# -------------------------------------------------------------------
# Build the spinel AOT compiler from matz/spinel@master and publish
# the toolchain as an artifact. Downstream toolchain-spinel /
# compare-spinel jobs download + chmod +x and either prepend to PATH
# or pass SPINEL=.../spinel to the scaffold Makefile (runtime/spinel/
# scaffold/Makefile honors SPINEL ?= spinel).
#
# The single `spinel` binary resolves its `spinel_rbs_extract` sidecar
# and `lib/` relative to its own directory, so the tree ships as a unit.
# Tracking master rather than pinning a SHA — bump to a pin if upstream
# churn turns flaky.
# -------------------------------------------------------------------
build-spinel:
# Re-enabled 2026-06-17 (framework-tests-spinel 5/5 + compare-spinel 7/7
# green again on matz/spinel master). Tracks matz/spinel master UNPINNED,
# so it can go red independently of any roundhouse change; continue-on-error
# keeps that advisory rather than failing the whole run (the downstream
# framework-tests / compare-spinel jobs are likewise advisory). If a build
# failure here cascades to skipped/failed downstream jobs, that's fine — the
# signal is still visible without blocking unrelated work. (toolchain-spinel
# stays gated below pending matz/spinel#1447.)
continue-on-error: true
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
with:
repository: matz/spinel
ref: master
path: spinel-src
- name: make deps (fetch vendored libprism)
run: make -C spinel-src deps
- name: make all
run: make -C spinel-src all
- name: Stage artifact tree
# `make all` now builds a single `spinel` binary (a top-level symlink
# to bin/spinel) plus the `spinel_rbs_extract` sidecar under build/;
# the legacy spinel_parse/spinel_analyze/spinel_codegen split moved
# under legacy/ and is no longer produced. (The build also emits
# bin/spinel-{doctor,reduce,flatten}, which roundhouse doesn't ship.)
# The single binary locates both `spinel_rbs_extract` and `lib/`
# relative to the directory it is invoked from, so they must ship
# alongside it. The extractor is REQUIRED for the --rbs path
# (toolchain-spinel / compare-spinel); without it, --rbs is silently
# a no-op and the seeded path goes untested. `cp -L` dereferences the
# symlink to copy the real binary.
# `spin` (spinel's project tool, a native binary spinel compiles
# from tools/spin.rb) ships too: the spinel archive is a spin
# package and smoke-spinel's README blocks drive `spin build` /
# `spin test`. spin locates the compiler as its PATH sibling.
# `packages/` ships as well: the compiler resolves bundled spin
# packages (json, set, …) at `<lib-parent>/packages` — a plain
# `require "json"` in runtime code (runtime/spinel/json.rb since
# 72029f0a) silently resolves NOTHING without it, and every
# spinel job goes red on `undefined method 'generate'`.
run: |
mkdir spinel-dist
cp -L spinel-src/spinel spinel-dist/spinel
cp spinel-src/bin/spin spinel-dist/spin
cp spinel-src/build/spinel_rbs_extract spinel-dist/
cp -R spinel-src/lib spinel-dist/lib
cp -R spinel-src/packages spinel-dist/packages
- name: Upload spinel toolchain
uses: actions/upload-artifact@v7
with:
name: spinel-dist
path: spinel-dist
retention-days: 1
# -------------------------------------------------------------------
# Library correctness. Runs the default (non-ignored) test suite:
# ingest / analyze / IR round-trip / per-target emit snapshot tests.
# -------------------------------------------------------------------
unit:
needs: generate-fixture
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- uses: actions/download-artifact@v8
with:
name: real-blog-fixture
- name: Extract fixture
run: tar -xzf real-blog.tar.gz
- name: cargo test
run: cargo test --all-targets
# -------------------------------------------------------------------
# One job per target language. Each generates a project from the
# fixtures in-process and runs the target's real toolchain over it.
#
# SHRUNK by the smoke consolidation (see the smoke-<target> matrix):
# the smoke jobs run each archive's full build + model/controller
# test + Playwright legs from the published tgz, so toolchain jobs
# whose only coverage was real-blog build/test (go, rust, kotlin,
# swift) are retired — their harnesses remain as dev-loop tools
# (cargo test --test <t>_toolchain -- --ignored). The survivors stay
# for coverage smoke can't reach: tiny-blog gates (crystal, elixir,
# python — the absent-feature shape real-blog can't test) and
# typescript's dual-profile tsc + node:test (async coloring).
# -------------------------------------------------------------------
toolchain-crystal:
needs: generate-fixture
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- uses: actions/download-artifact@v8
with:
name: real-blog-fixture
- name: Extract fixture
run: tar -xzf real-blog.tar.gz
- name: Install Crystal
uses: crystal-lang/install-crystal@v1
with:
crystal: latest
- name: cargo test --test crystal_toolchain -- --ignored
run: cargo test --test crystal_toolchain -- --ignored --nocapture
framework-tests-swift:
needs: generate-fixture
# Per-target framework-test conformance gate for the Swift target:
# transpiles runtime/ruby/test/**/*_test.rb to XCTest classes and runs
# them under `swift test`. Complements smoke-swift (the archive's
# real-blog suite): this one runs the framework runtime's own
# assertions, catching adapter drift the app tests miss. Sibling of
# framework-tests-kotlin. NOTE: needs a full toolchain with XCTest —
# Linux toolchains bundle it; macOS would need Xcode (not just CLT).
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- uses: swift-actions/setup-swift@v2
with:
swift-version: '6.1'
- name: Install libsqlite3-dev (CSQLite systemLibrary)
run: sudo apt-get update -q && sudo apt-get install -y -q libsqlite3-dev
# Scoped to the green subset (inflector + router + view_helpers —
# the same three kotlin runs). The remaining two canonical gates
# (errors / ac_base) are wired in the test file but fail on the
# SAME emit-gap families kotlin defers (class-reflection
# `< StandardError`; controller-body typing). Drop names as those
# gaps close. Tracked in #34.
- name: cargo test --test framework_tests_swift (green subset)
run: cargo test --test framework_tests_swift -- --ignored --nocapture inflector router view_helpers
# toolchain-typescript: tsc parity + node:test on real-blog,
# under both the default (`node-sync`, better-sqlite3) profile
# AND the `node-async` libsql profile that exercises async
# coloring. The filter `real_blog` excludes the still-
# aspirational tiny_blog gate (broken on main, pre-existing) and
# the diagnostic `dump_transpiled_blog_tsc_errors` non-asserting
# helper. Drop the filter once tiny_blog reaches parity.
toolchain-typescript:
needs: generate-fixture
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- uses: actions/download-artifact@v8
with:
name: real-blog-fixture
- name: Extract fixture
run: tar -xzf real-blog.tar.gz
- name: Install Node
uses: actions/setup-node@v5
with:
node-version: '20'
- name: cargo test --test typescript_toolchain real_blog -- --ignored
run: cargo test --test typescript_toolchain real_blog -- --ignored --nocapture
framework-tests-typescript:
# Per-target framework-test conformance gate. Transpiles
# runtime/ruby/test/**/*_test.rb to TS and runs under tsx.
# Independent of the real-blog fixture (each test builds a
# minimal in-process App), so this parallelizes freely
# against generate-fixture / toolchain-* jobs. Catches
# per-target adapter contract drift (e.g., is_exists vs
# exists rename, regex anchor translation) before it
# cascades into real-blog runtime failures. Sibling of the
# disabled toolchain-typescript above; runs on the same
# Node setup but a much smaller and faster scope (~12s vs
# minutes). See project_framework_test_transpile.md.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Install Node
uses: actions/setup-node@v5
with:
node-version: '20'
- name: cargo test --test framework_tests_typescript -- --ignored
run: cargo test --test framework_tests_typescript -- --ignored --nocapture
# Browser smoke for the TypeScript *SharedWorker* deployment profile
# (juntos) — distinct from the tsx-server smoke-typescript job below.
# Harness + README live in tests/browser_smoke/. The pipeline (driven
# by `npm test` → scripts/prebuild.mjs → playwright.config.ts):
# 1. emit real-blog under `--profile worker` (cargo run emit_preview),
# 2. npm install + vite-build the emitted SPA into .emitted/dist,
# 3. `npm run preview` serves it; Playwright drives headless Chromium.
# The spec opens its OWN SharedWorker port and dispatches synthetic
# GET/POST/DELETE/422 requests + a multi-tab BroadcastChannel probe,
# asserting no 5xx. This is the only job that exercises the emitted
# framework runtime *inside a SharedWorker*, so it catches portability
# gaps the server target can't see: Node-only globals (Buffer/process/
# require) in transpiled output, missing browser polyfills, broken
# MessagePort / BroadcastChannel wiring, installDb ordering. Builds
# from source (needs generate-fixture, like toolchain-typescript),
# NOT from the published archive. Playwright config gates on $CI
# (GitHub sets it) → fresh server + forbidOnly + github reporter.
browser-smoke-typescript:
needs: generate-fixture
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- uses: actions/download-artifact@v8
with:
name: real-blog-fixture
- name: Extract fixture
run: tar -xzf real-blog.tar.gz
- name: Install Node
uses: actions/setup-node@v5
with:
node-version: '20'
- name: Install harness deps
run: npm ci
working-directory: tests/browser_smoke
- name: Install Playwright Chromium (+ system deps)
run: npx playwright install --with-deps chromium
working-directory: tests/browser_smoke
- name: Run SharedWorker browser smoke (emit → vite build → drive)
run: npm test
working-directory: tests/browser_smoke
- name: Upload Playwright report on failure
if: failure()
uses: actions/upload-artifact@v7
with:
name: browser-smoke-playwright-report
path: |
tests/browser_smoke/playwright-report
tests/browser_smoke/test-results
retention-days: 7
if-no-files-found: ignore
# The /ide/ demo, driven in a real chromium: analyze the pinned
# Mastodon bundle in the wasm worker, then assert the demo beats —
# typed hover (@account → Account, incl. inside a HAML template),
# typed completion (@status. → account: Account?), related files via
# the inferred render graph, and the coverage ledger being on. This
# regression-guards the published demo (and the blog post's claims):
# a change that mutes any beat fails CI before it reaches Pages.
# Deterministic inputs (pinned SHA + fresh wasm), so blocking.
browser-smoke-ide:
needs: [build-wasm, generate-fixture]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/download-artifact@v8
with:
name: roundhouse-wasm
path: wasm/lib
- uses: actions/download-artifact@v8
with:
name: real-blog-fixture
- name: Extract fixture
run: tar -xzf real-blog.tar.gz
- name: Install Node
uses: actions/setup-node@v5
with:
node-version: '20'
- name: Cache Mastodon source bundle
id: mastodon-src
uses: actions/cache@v4
with:
path: wasm/ide/app-src.json
key: mastodon-app-src-${{ env.MASTODON_SHA }}-v2
- name: Fetch pinned Mastodon + bundle sources
if: steps.mastodon-src.outputs.cache-hit != 'true'
run: |
set -euo pipefail
curl -fsSL "https://codeload.github.com/mastodon/mastodon/tar.gz/${MASTODON_SHA}" -o /tmp/mastodon.tar.gz
mkdir -p /tmp/mastodon && tar -xzf /tmp/mastodon.tar.gz -C /tmp/mastodon --strip-components=1
node wasm/ide/bundle-src.mjs /tmp/mastodon wasm/ide/app-src.json \
--name mastodon --commit "${MASTODON_SHA}" \
--open app/controllers/statuses_controller.rb
# The other two picker apps, so verify-ide.mjs can exercise the app
# switch (blog + lobsters + mastodon). Mirrors the build-site assembly.
- name: Cache lobsters source bundle
id: lobsters-src
uses: actions/cache@v4
with:
path: /tmp/app-lobsters.json
key: lobsters-app-src-${{ env.RUBY_BENCH_SHA }}-v1
- name: Fetch pinned ruby-bench + bundle lobsters (cache miss)
if: steps.lobsters-src.outputs.cache-hit != 'true'
run: |
set -euo pipefail
curl -fsSL "https://codeload.github.com/ruby/ruby-bench/tar.gz/${RUBY_BENCH_SHA}" -o /tmp/ruby-bench.tar.gz
mkdir -p /tmp/lobsters
tar -xzf /tmp/ruby-bench.tar.gz -C /tmp/lobsters --strip-components=3 "ruby-bench-${RUBY_BENCH_SHA}/benchmarks/lobsters"
node wasm/ide/bundle-src.mjs /tmp/lobsters /tmp/app-lobsters.json \
--name lobsters --commit "${RUBY_BENCH_SHA}" \
--open app/controllers/stories_controller.rb
- name: Cache campfire source bundle
id: campfire-src
uses: actions/cache@v4
with:
path: /tmp/app-campfire.json
key: campfire-app-src-${{ env.CAMPFIRE_SHA }}-v1
- name: Fetch pinned once-campfire + bundle sources (cache miss)
if: steps.campfire-src.outputs.cache-hit != 'true'
run: |
set -euo pipefail
curl -fsSL "https://codeload.github.com/basecamp/once-campfire/tar.gz/${CAMPFIRE_SHA}" -o /tmp/campfire.tar.gz
mkdir -p /tmp/campfire && tar -xzf /tmp/campfire.tar.gz -C /tmp/campfire --strip-components=1
node wasm/ide/bundle-src.mjs /tmp/campfire /tmp/app-campfire.json \
--name campfire --commit "${CAMPFIRE_SHA}" \
--open app/models/message.rb
- name: Assemble picker bundles + manifest
run: |
set -euo pipefail
node wasm/ide/bundle-src.mjs fixtures/real-blog wasm/ide/app-blog.json \
--name blog --open app/controllers/articles_controller.rb
node wasm/ide/bundle-src.mjs fixtures/roda-blog wasm/lib/app-roda.json \
--name roda-blog --open app.rb
cp /tmp/app-lobsters.json wasm/ide/app-lobsters.json
cp /tmp/app-campfire.json wasm/ide/app-campfire.json
printf '%s\n' '{"default":"mastodon","apps":[{"name":"blog","label":"Rails blog","src":"app-blog.json"},{"name":"lobsters","label":"Lobsters","src":"app-lobsters.json"},{"name":"campfire","label":"Campfire","src":"app-campfire.json"},{"name":"mastodon","label":"Mastodon","src":"app-src.json"}]}' > wasm/ide/apps.json
# Playground picker (lib/): lobsters + mastodon alongside the committed
# blog fixture, so verify-playground exercises the app switch + the
# off-thread Mastodon transpile.
cp /tmp/app-lobsters.json wasm/lib/app-lobsters.json
cp /tmp/app-campfire.json wasm/lib/app-campfire.json
cp wasm/ide/app-src.json wasm/lib/app-mastodon.json
printf '%s\n' '{"default":"blog","apps":[{"name":"blog","label":"Rails blog","src":"fixture.json","open":"app/models/article.rb"},{"name":"roda","label":"Roda + Sequel blog","src":"app-roda.json","open":"app.rb"},{"name":"lobsters","label":"Lobsters","src":"app-lobsters.json","open":"app/models/story.rb"},{"name":"campfire","label":"Campfire","src":"app-campfire.json","open":"app/models/message.rb"},{"name":"mastodon","label":"Mastodon","src":"app-mastodon.json","open":"app/models/status.rb"}]}' > wasm/lib/apps.json
- name: Install harness deps (Playwright)
run: npm ci
working-directory: tests/browser_smoke
- name: Install Playwright Chromium (+ system deps)
run: npx playwright install --with-deps chromium
working-directory: tests/browser_smoke
- name: Serve wasm/ and verify the /ide/ + /playground/ pages
run: |
set -euo pipefail
(cd wasm && python3 -m http.server 8099 >/tmp/ide-server.log 2>&1 &)
sleep 1
(cd wasm/ide && node verify-ide.mjs)
# Same shared worker/client, exercised through the emit surface incl.
# the off-thread Mastodon transpile.
(cd wasm/playground && node verify-playground.mjs)
framework-tests-crystal:
# Per-target framework-test conformance gate for the Crystal
# target. Transpiles runtime/ruby/test/**/*_test.rb through
# crystal::emit and runs the result under `crystal spec`.
# Independent of the real-blog fixture (each test builds a
# minimal in-process App with a single TestModule), so this
# parallelizes freely against generate-fixture / toolchain-*
# jobs. Catches per-target adapter contract drift and Crystal
# strict-typing regressions (NamedTuple/Hash bridging,
# nullable narrowing, etc.) before they cascade into real-blog
# runtime failures. Sibling of framework-tests-typescript /
# framework-tests-ruby.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Install Crystal
uses: crystal-lang/install-crystal@v1
with:
crystal: latest
- name: cargo test --test framework_tests_crystal -- --ignored
run: cargo test --test framework_tests_crystal -- --ignored --nocapture
framework-tests-kotlin:
# Per-target framework-test conformance gate for the Kotlin target.
# Transpiles runtime/ruby/test/**/*_test.rb through kotlin::emit and
# runs the emitted JUnit-5 spec under `gradle test`. Independent of the
# real-blog fixture (each test builds a minimal in-process App with a
# single TestModule), so this parallelizes freely against
# generate-fixture / toolchain-* jobs. Complements smoke-kotlin (the
# archive's real-blog suite): this one runs the framework runtime's
# own assertions, catching adapter drift the app tests miss. Sibling
# of framework-tests-typescript / framework-tests-crystal.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '21'
- uses: gradle/actions/setup-gradle@v4
with:
gradle-version: '9.5.1'
# Scoped to the green subset (inflector + router + view_helpers). The
# remaining two canonical gates (errors / ac_base) are wired in the
# test file but still fail on deeper kotlin emit gaps (class-reflection
# `< StandardError`; controller-body typing) — see the comment there.
# Same filter convention as toolchain-typescript's tiny_blog exclusion;
# drop names as those gaps close. Tracked in #34.
- name: cargo test --test framework_tests_kotlin (green subset)
run: cargo test --test framework_tests_kotlin -- --ignored --nocapture inflector router view_helpers
framework-tests-ruby:
# Per-target framework-test conformance gate for the Ruby target
# (CRuby/MRI). Transpiles runtime/ruby/test/**/*_test.rb through
# ruby::emit_spinel (named for historical reasons; emits the
# Ruby-shape output that runs under stock CRuby). Catches
# per-target lowering regressions (test-class-shape rewrites,
# fixture refs, parent-class swap) that the source-side
# `framework_ruby_tests_pass` gate can't see because it runs the
# Ruby verbatim. Sibling of framework-tests-typescript above and
# framework-tests-spinel below.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- uses: ruby/setup-ruby@v1
with:
ruby-version: '3.4'
bundler-cache: true
working-directory: ./runtime/spinel/scaffold
- name: cargo test --test framework_tests_ruby -- --ignored
run: cargo test --test framework_tests_ruby -- --ignored --nocapture
# Per-target framework-test conformance gate for the spinel AOT
# target. Same emit pipeline as framework-tests-ruby, but each
# emitted test is compiled to a native binary via `spinel --rbs sig
# <file>.rb -o <bin>` and executed; the autorun shim returns
# non-zero on any assertion failure.
#
# Status (2026-06-02, spinel a782696): all 5 expected green locally.
# matz/spinel #915 (the old `return NULL` blocker named in earlier
# revisions of this comment) has landed, so errors/ac_base/router/
# inflector pass. The `view_helpers` stand-in previously hit two
# spinel mis-compiles of the inline `Article < ActiveRecord::Base`;
# both are now worked around roundhouse-side:
# (a) the `Article` ctor's `id` param was widened to sp_RbVal because
# the inherited `ActiveRecord::Base.create(attrs={})` factory
# calls `new(attrs)` with a Hash — spinel unifies that poly call
# site with `Article.new(7)` (Integer) into one C param type. The
# body `self.id = id` then inlined the inherited attr_accessor
# setter as a direct write to the narrow `mrb_int` id field, so
# `mrb_int = sp_RbVal` failed to compile. Filed as matz/spinel#1275
# with a 19-line standalone repro (no RBS; the optional default
# and the `?Integer` RBS are both red herrings — a required param
# reproduces identically). WORKED AROUND: the stand-in now assigns
# `@id = id` (direct ivar write) instead of `self.id = id`; the
# direct-write path widens the field to match, so all four targets
# (ruby/crystal/ts/spinel) agree. Real generated models are
# unaffected — they use the Hash-`attrs` ctor shape, which doesn't
# trigger the unification. The spinel-side fix still tracked in #1275.
# (b) the `[]` field-lookup returns Integer|String across branches
# and spinel commits to one C type instead of boxing — filed as
# matz/spinel#1255 (clean minimal repro). ADDRESSED roundhouse-
# side: inner test stand-ins now emit an `.rbs` sidecar
# (`sig/test/<stem>_inner.rbs`) with `def []: ... -> (Integer |
# String)`, the annotation the issue's own repro confirms makes
# spinel box. So (b) no longer needs the spinel fix and no longer
# appears in the log.
# #1275 and #1255 have since closed upstream (2026-07 audit); (a)'s
# direct ivar write is idiomatic-ctor Ruby and (b)'s sidecar is typed
# surface we want anyway, so neither is reverted. continue-on-error
# stays ON for the same structural reason as toolchain-spinel below:
# every spinel job tracks matz/spinel master UNPINNED, so it surfaces
# regressions without letting upstream churn gate unrelated work.
# `ar_base` is disabled in-test (b453fcf) pending real-sqlite
# rewiring of the test_helper.
framework-tests-spinel:
# Re-enabled 2026-06-17 (5/5 green on matz/spinel master). Advisory.
needs: build-spinel
runs-on: ubuntu-latest
continue-on-error: true
steps:
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- uses: actions/download-artifact@v8
with:
name: spinel-dist
path: spinel-dist
- name: Stage spinel binaries
run: |
chmod +x spinel-dist/spinel spinel-dist/spin spinel-dist/spinel_rbs_extract
echo "$PWD/spinel-dist" >> $GITHUB_PATH
- name: cargo test --test framework_tests_spinel -- --ignored
run: cargo test --test framework_tests_spinel -- --ignored --nocapture
toolchain-elixir:
needs: generate-fixture
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- uses: actions/download-artifact@v8
with:
name: real-blog-fixture
- name: Extract fixture
run: tar -xzf real-blog.tar.gz
- name: Install Elixir
uses: erlef/setup-beam@v1
with:
elixir-version: '1.18'
otp-version: '27'
- name: cargo test --test elixir_toolchain -- --ignored
run: cargo test --test elixir_toolchain -- --ignored --nocapture
# -------------------------------------------------------------------
# (toolchain-ruby retired by the smoke consolidation: smoke-ruby's
# README § Test runs the same five driver suites from the published
# archive. The harness remains a dev-loop tool:
# cargo test --test ruby_toolchain -- --ignored.)
# -------------------------------------------------------------------
# Spinel-AOT toolchain. Same emit pipeline as toolchain-ruby (lowered
# real-blog + target-specific tests), but the runner is the spinel
# AOT compiler from build-spinel. Each emitted test file compiles to
# a native binary via `$SPINEL <file>.rb -o <bin>`; each binary
# runs and asserts exit 0 (autorun shim emits explicit per-test
# dispatch — no minitest at_exit).
#
# Requires libsqlite3-dev at link time (the binaries `-lsqlite3`).
# ubuntu-latest ships libsqlite3-0 (runtime) but not the dev linker
# symlink, so we install it explicitly.
# Briefly went green after matz/spinel#631 closed (bffabac); then
# matz/spinel c66d00a landed the shape-A fix for #634 and surfaced
# a follow-on over-widening: @location ivar (nil-default + typed
# writers) now reads as sp_RbVal, breaking sp_StrStrHash_set's
# const char* slot in the Location-header path. Comment on #634
# (issuecomment-4503927146) tracks the consumer-side narrow.
# Drop continue-on-error when matz lands the body-driven post-pass.
# Re-enabled 2026-06-02 (was `if: false`). The real-blog app now
# compiles under spinel and passes 9/9 model tests after the from_stmt
# void*/mrb_int fix (5aae416). One spinel-specific runtime assertion
# remains in articles_controller_test (the same emitted source passes
# under CRuby in toolchain-ruby — an AOT runtime divergence, not an
# emit bug), so the job stays advisory via continue-on-error until that
# closes. Running it (vs `if: false`) keeps the model-suite progress
# visible and catches regressions in the compile path.
toolchain-spinel:
# Un-gated 2026-06-18: matz/spinel#1447 (the `||=`-into-poly-setter →
# null-Flash segfault in the controller integration test) closed
# upstream, clearing the last blocker on the real-blog AOT suite (the
# void-tail compile error was already fixed in #1444). Stays advisory
# via continue-on-error — like every spinel job it tracks matz/spinel
# master UNPINNED, so it surfaces regressions without gating unrelated
# work; if master churns red, the signal is visible but non-blocking.
needs: [generate-fixture, build-spinel]
runs-on: ubuntu-latest
continue-on-error: true
steps:
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- uses: actions/download-artifact@v8
with:
name: real-blog-fixture
- name: Extract fixture
run: tar -xzf real-blog.tar.gz
- uses: actions/download-artifact@v8
with:
name: spinel-dist
path: spinel-dist
- name: Stage spinel binaries
run: |
chmod +x spinel-dist/spinel spinel-dist/spin spinel-dist/spinel_rbs_extract
echo "$PWD/spinel-dist" >> $GITHUB_PATH
- name: Install libsqlite3-dev
run: sudo apt-get update -qq && sudo apt-get install -y libsqlite3-dev
- uses: ruby/setup-ruby@v1
with:
ruby-version: '3.4'
bundler-cache: true
working-directory: ./runtime/spinel/scaffold
- name: cargo test --test spinel_toolchain -- --ignored
run: cargo test --test spinel_toolchain -- --ignored --nocapture
# On failure, capture spinel's actual generated C for the failing
# controller test. The failure is at RUNTIME (the C compiles under
# spinel's `-Wno-all`), so spinel reaps its transient out.c — we
# regenerate it emit-only (`spinel -c`) from the leftover emit
# scratch, then surface the type mismatch with a plain gcc compile
# (no `-Wno-all`). This is the environment-independent repro for the
# spinel issue: the input `.rb`/`.rbs` plus the wrong C spinel emitted
# here (e.g. `dispatch_request(sp_SymPolyHash*)` fed an `sp_StrIntHash*`),
# which other environments don't reproduce.
- name: Capture spinel codegen on failure
if: failure()
run: |
set +e
SC=$(find "${TMPDIR:-/tmp}" /tmp "$RUNNER_TEMP" -maxdepth 3 -type d \
-name 'roundhouse-spinel-real-blog' 2>/dev/null | head -1)
echo "emit scratch: $SC"
mkdir -p ci-spinel-repro
cc --version > ci-spinel-repro/cc-version.txt 2>&1
uname -a >> ci-spinel-repro/cc-version.txt
spinel-dist/spinel --version >> ci-spinel-repro/cc-version.txt 2>&1 || true
if [ -z "$SC" ]; then echo "scratch not found"; exit 0; fi
T=test/controllers/articles_controller_test.rb
( cd "$SC" && spinel -c --rbs sig "$T" -o /tmp/out.c ) \
> ci-spinel-repro/spinel-emit.log 2>&1
cp /tmp/out.c ci-spinel-repro/articles_controller_test.c 2>/dev/null
# Compiler-agnostic evidence of the mismatch (plain gcc, no -Wno-all):
gcc -fsyntax-only -I"$PWD/spinel-dist/lib" /tmp/out.c \
> ci-spinel-repro/gcc-warnings.txt 2>&1
# The emitted sources that reproduce it (small, self-contained).
for d in test sig app config runtime; do
[ -d "$SC/$d" ] && cp -r "$SC/$d" "ci-spinel-repro/$d"
done
echo "captured:"; ls -R ci-spinel-repro | head -40
- name: Upload spinel codegen repro
if: failure()
uses: actions/upload-artifact@v7
with:
name: spinel-toolchain-repro
path: ci-spinel-repro
retention-days: 7
toolchain-python:
# Re-enabled 2026-06-06: the transpiled framework set is degrade-free,
# the emitted real-blog unittest is green (21/21), and `compare python`
# matches Rails 5/5. Python version tracks the local dev environment
# (system `python3`, which the `python_toolchain` test shells out to);
# `from __future__ import annotations` keeps the emit version-agnostic
# back to 3.11 (the emitted `requires-python`).
needs: generate-fixture
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- uses: actions/download-artifact@v8
with:
name: real-blog-fixture
- name: Extract fixture
run: tar -xzf real-blog.tar.gz
- name: Install Python
uses: actions/setup-python@v6
with:
python-version: '3.14'
- name: cargo test --test python_toolchain -- --ignored
run: cargo test --test python_toolchain -- --ignored --nocapture
toolchain-csharp:
needs: generate-fixture
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- uses: actions/download-artifact@v8
with:
name: real-blog-fixture
- name: Extract fixture
run: tar -xzf real-blog.tar.gz
- uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
# `dotnet build` of the emitted ASP.NET Core project — the C# analog of
# crystal's `--no-codegen` / go's `vet`: compiles the model layer, the
# transpiled framework runtime, and the primitives together.
- name: cargo test --test csharp_toolchain -- --ignored
run: cargo test --test csharp_toolchain -- --ignored --nocapture
# -------------------------------------------------------------------
# DOM equivalence: boot Rails + the target server side-by-side and
# diff response bodies via tools/compare. The toolchain jobs above
# only verify that the emitted code compiles and passes its native
# tests; these jobs verify it actually behaves the same as Rails on
# live HTTP. One job per target — same matrix as toolchain.
# -------------------------------------------------------------------
# compare: one job per compile-and-boot target, DOM-diffing the live
# transpiled server against Rails over HTTP. Matrixed from nine
# near-identical jobs; every per-target difference (toolchain install +
# versions) is a guarded step below. ruby/setup-ruby runs LAST on every
# target so its MRI 3.4 wins PATH when Rails boots — required by the JVM
# targets (setup-java/gradle otherwise leave system Ruby 3.2 resolving
# bin/rails; see the old compare-kotlin note) and harmless everywhere
# else. fail-fast off so one target's failure doesn't cancel the rest
# (matches the old independent jobs). ruby/jruby/spinel stay separate
# below: two setup-ruby steps / an extra build-spinel dependency don't
# fit this shape.
compare:
needs: generate-fixture
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
target: [rust, crystal, kotlin, swift, csharp, typescript, go, elixir, python]
steps:
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- uses: actions/download-artifact@v8
with:
name: real-blog-fixture
- name: Extract fixture
run: tar -xzf real-blog.tar.gz
- name: Install Crystal
if: matrix.target == 'crystal'
uses: crystal-lang/install-crystal@v1
with:
crystal: latest
- name: Install JDK (Kotlin)
if: matrix.target == 'kotlin'
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '21'
- name: Install Gradle (Kotlin)
if: matrix.target == 'kotlin'
uses: gradle/actions/setup-gradle@v4
with:
gradle-version: '9.5.1'
- name: Install Swift
if: matrix.target == 'swift'
uses: swift-actions/setup-swift@v2
with:
swift-version: '6.1'
- name: Install libsqlite3-dev (Swift CSQLite systemLibrary)
if: matrix.target == 'swift'
run: sudo apt-get update -q && sudo apt-get install -y -q libsqlite3-dev
- name: Install .NET (C#)
if: matrix.target == 'csharp'
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Install Node (TypeScript)
if: matrix.target == 'typescript'
uses: actions/setup-node@v5
with:
node-version: '20'
- name: Install Go
if: matrix.target == 'go'
uses: actions/setup-go@v6
with:
go-version: '1.24'
cache: false
- name: Install Elixir
if: matrix.target == 'elixir'
uses: erlef/setup-beam@v1
with:
elixir-version: '1.18'
otp-version: '27'
- name: Install Python
if: matrix.target == 'python'
uses: actions/setup-python@v6
with:
python-version: '3.14'
- name: Install uv (Python)
if: matrix.target == 'python'
uses: astral-sh/setup-uv@v7
# setup-ruby LAST — see the job comment (MRI 3.4 must win PATH).
- uses: ruby/setup-ruby@v1
with:
ruby-version: '3.4'
bundler-cache: true
working-directory: ./fixtures/real-blog
- name: scripts/compare ${{ matrix.target }}
run: scripts/compare ${{ matrix.target }}
# compare-ruby: same shape as every other compare-* — Puma+Rack
# server bound to :3000 via ruby_overlay's config.ru, hit over
# HTTP. Two ruby setups: the fixture's Gemfile (Rails reference)
# and the scaffold's Gemfile (Puma + Rack target). Node is needed
# for tailwindcss asset compilation; no target compile step
# (Ruby is interpreted).
compare-ruby:
needs: generate-fixture
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- uses: actions/download-artifact@v8
with:
name: real-blog-fixture
- name: Extract fixture
run: tar -xzf real-blog.tar.gz
- uses: ruby/setup-ruby@v1
with:
ruby-version: '3.4'
bundler-cache: true
working-directory: ./fixtures/real-blog
- uses: ruby/setup-ruby@v1
with:
ruby-version: '3.4'
bundler-cache: true
working-directory: ./runtime/spinel/scaffold
- uses: actions/setup-node@v5
with:
node-version: '20'
- name: scripts/compare ruby
run: scripts/compare ruby
# compare-jruby: same emitted tree as compare-ruby but served by Puma
# on the JVM via JRuby with the JDBC db backend (runtime/db.rb =
# db_jruby.rb). The Rails reference runs under CRuby 3.4 (the default
# `ruby`); the target is launched with an explicit `jruby`, so the two
# setup-ruby steps are ordered jruby-first / CRuby-last to leave 3.4 as
# the default. JRuby must be >= 10 — the runtime uses Fiber[:k] storage
# (Ruby 3.2+), which JRuby 9.4 (Ruby 3.1) lacks. No node/tailwind:
# assets aren't built (the DOM diff compares asset tags, not files —
# see scripts/compare's jruby branch).
compare-jruby:
needs: generate-fixture
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- uses: actions/download-artifact@v8
with:
name: real-blog-fixture
- name: Extract fixture
run: tar -xzf real-blog.tar.gz
# JRuby 10 requires Java 21+ (it refuses to boot on older JDKs).
# setup-ruby provisions JRuby but not the JDK, so pin Java first.
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '21'
# JRuby provisioning only — no bundler-cache. The committed scaffold
# Gemfile is MRI-only (`gem "sqlite3"`), so it can't be bundled under
# JRuby; `scripts/compare jruby` resolves the JRuby bundle
# (jdbc-sqlite3) against the emitted tree's own Gemfile instead.
- uses: ruby/setup-ruby@v1
with:
ruby-version: 'jruby-10.0'
- uses: ruby/setup-ruby@v1
with:
ruby-version: '3.4'
bundler-cache: true
working-directory: ./fixtures/real-blog
- name: scripts/compare jruby
run: scripts/compare jruby
# compare-spinel: DOM-diff against a live AOT-compiled binary served
# over Tep::Server (FFI HTTP/1.1). The spinel-transpile Makefile
# target vendors the Tep transport (precompiles sphttp.o + sed-
# substitutes @TEP_SPHTTP_O@ in net.rb) before AOT-compile; the
# scaffold's `make build` then drives `spinel main.rb -o build/blog`.
# 7/7 paths match against Rails as of matz/spinel#1017 (PR #1018)
# + #1020 (commit 23d9a18). `continue-on-error` is still on because
# spinel master can churn faster than this job rebuilds against it;
# drop it once the upstream cadence settles.
compare-spinel:
# Re-enabled 2026-06-17 (7/7 paths match Rails on matz/spinel master). Advisory.
needs: [generate-fixture, build-spinel]
runs-on: ubuntu-latest
continue-on-error: true
steps:
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- uses: actions/download-artifact@v8
with:
name: real-blog-fixture
- name: Extract fixture
run: tar -xzf real-blog.tar.gz
- uses: actions/download-artifact@v8
with:
name: spinel-dist
path: spinel-dist
- name: Stage spinel binaries
run: |
chmod +x spinel-dist/spinel spinel-dist/spin spinel-dist/spinel_rbs_extract
echo "$PWD/spinel-dist" >> $GITHUB_PATH
- name: Install libsqlite3-dev
run: sudo apt-get update -qq && sudo apt-get install -y libsqlite3-dev
- uses: ruby/setup-ruby@v1
with:
ruby-version: '3.4'
bundler-cache: true
working-directory: ./fixtures/real-blog
- uses: ruby/setup-ruby@v1
with:
ruby-version: '3.4'
bundler-cache: true
working-directory: ./runtime/spinel/scaffold
- uses: actions/setup-node@v5
with:
node-version: '20'
- name: scripts/compare spinel
run: scripts/compare spinel
# -------------------------------------------------------------------
# smoke-<target>: the archive runs its own README. Each published
# <target>.tgz is the complete test artifact — the transpiled app,
# its model/controller test suite, and the Playwright e2e suite
# (e2e/, injected by project::ensure_e2e with a webServer config
# that boots the archive's own binary and seeds its own db/seed.sql).
# scripts/smoke extracts the tgz and executes the README's ```sh
# blocks verbatim — docs-as-contract: if the README is wrong, the
# job is red. (The go pilot caught `go build .` colliding with the
# app/ source dir, a missing-storage/ boot panic, and four more
# never-executed README instructions across targets.)
#
# Each smoke job subsumes the target's old toolchain-<t> build+test
# legs (README § Build/Test) and e2e-<t> (README § End-to-end). The
# toolchain harnesses remain as dev-loop tools:
# cargo test --test <t>_toolchain -- --ignored.
#
# Per-job content = toolchain setup only; no per-target build/seed/
# boot knowledge lives here (that's the point — it's all in the
# archive). The repo-side playwright install pre-warms the browser
# cache + system deps so the README's own `npx playwright install
# chromium` (same ^1.49 range) is a cache hit.
#
# ruby/jruby ride smoke too: their scaffold document ships as
# SPECIMEN.md and the generated quick-start takes README.md. Only
# spinel keeps the scaffold README top-level (specimen doc + matz's
# extraction surface; not in the smoke matrix).
# -------------------------------------------------------------------
smoke:
needs: build-site
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
target: [rust, crystal, kotlin, swift, csharp, typescript, go, elixir, python, ruby, jruby]
steps:
- uses: actions/checkout@v5
- uses: actions/download-artifact@v8
with:
name: browse-archives
path: browse
- name: Install Rust
if: matrix.target == 'rust'
uses: dtolnay/rust-toolchain@stable
- name: Install Crystal
if: matrix.target == 'crystal'
uses: crystal-lang/install-crystal@v1
with:
crystal: latest
- name: Install JDK (Kotlin, JRuby)
if: matrix.target == 'kotlin' || matrix.target == 'jruby'
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '21'
- name: Install Gradle (Kotlin)
if: matrix.target == 'kotlin'
uses: gradle/actions/setup-gradle@v4
with:
gradle-version: '9.5.1'
- name: Install Swift
if: matrix.target == 'swift'
uses: swift-actions/setup-swift@v2
with:
swift-version: '6.1'
- name: Install libsqlite3-dev (Swift CSQLite systemLibrary)
if: matrix.target == 'swift'
run: sudo apt-get update -q && sudo apt-get install -y -q libsqlite3-dev
- name: Install .NET (C#)
if: matrix.target == 'csharp'
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Install Go
if: matrix.target == 'go'
uses: actions/setup-go@v6
with:
go-version: '1.24'
cache: false
- name: Install Elixir
if: matrix.target == 'elixir'
uses: erlef/setup-beam@v1
with:
elixir-version: '1.18'
otp-version: '27'
- name: Install Python
if: matrix.target == 'python'
uses: actions/setup-python@v6
with:
python-version: '3.14'
- name: Install uv (Python)
if: matrix.target == 'python'
uses: astral-sh/setup-uv@v7
- name: Install Ruby (MRI 3.4)
if: matrix.target == 'ruby'
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.4'
- name: Install JRuby 10
if: matrix.target == 'jruby'
uses: ruby/setup-ruby@v1
with:
ruby-version: 'jruby-10.0'
- uses: actions/setup-node@v5
with:
node-version: '20'
- name: Pre-warm Playwright (chromium + system deps)
run: |
cd e2e
npm install --no-audit --no-fund
npx playwright install --with-deps chromium
- name: scripts/smoke ${{ matrix.target }}
run: scripts/smoke --tgz browse/${{ matrix.target }}.tgz ${{ matrix.target }}
# spinel: the archive's README § Build AOT-compiles main.rb → build/blog
# with the spinel toolchain (build-spinel artifact); § Test runs the
# scaffold Makefile's `spinel-test` (each emitted test → a native binary);
# § End-to-end boots build/blog under Playwright. Unlike ruby it needs no
# MRI/bundler — static assets ship prebuilt in the archive (ensure_static_
# assets, like jruby) so Build is the bare native compile. Toolchain =
# spinel on PATH + a C compiler + libsqlite3-dev (the binary links
# -lsqlite3) + Node for Playwright. Advisory (continue-on-error) like every
# spinel job — it tracks matz/spinel master UNPINNED. needs build-spinel
# for the compiler and build-site for the published spinel.tgz.
smoke-spinel:
needs: [build-site, build-spinel]
runs-on: ubuntu-latest
continue-on-error: true
steps:
- uses: actions/checkout@v5
- uses: actions/download-artifact@v8
with:
name: browse-archives
path: browse
- uses: actions/download-artifact@v8
with:
name: spinel-dist
path: spinel-dist
- name: Stage spinel binaries
run: |
chmod +x spinel-dist/spinel spinel-dist/spin spinel-dist/spinel_rbs_extract
echo "$PWD/spinel-dist" >> $GITHUB_PATH
- name: Install libsqlite3-dev
run: sudo apt-get update -qq && sudo apt-get install -y libsqlite3-dev
- uses: actions/setup-node@v5
with:
node-version: '20'
- name: Pre-warm Playwright (chromium + system deps)
run: |
cd e2e
npm install --no-audit --no-fund
npx playwright install --with-deps chromium
- name: scripts/smoke spinel
run: scripts/smoke --tgz browse/spinel.tgz spinel
# -------------------------------------------------------------------
# Build the Pages site: per-target manifests (real-blog fixture) +
# static HTML/CSS/JS from site/. Per matz/spinel#314, the archives at
# rubys.github.io/roundhouse/browse/<target>.tgz are matz's primary
# extraction surface for spinel-side bug repros — they need to
# publish *current emit* even when per-target toolchain/compare jobs
# fail (broken emit is exactly what's interesting to investigate).
#
# So build-site only gates on the fixture being available. It does
# not gate on unit / toolchain-* / compare-* — a flaky elixir CI run
# or a known-broken target shouldn't stall the archive workflow, and
# the archive itself is available via the workflow-run artifact even
# when unit fails. Pages publication (the `deploy` job below) does
# gate on unit, so a broken library can't push to the live site.
#
# build-site runs on PRs too (no `if: main`). Building the archives is
# harmless off-main and catches archive-generation breakage on the PR;
# PUBLISHING is the boundary, and that lives entirely in `deploy`
# (gated `if: main`). It also uploads the `browse-archives` artifact so
# the e2e-<target> jobs can boot the exact built bytes without
# rebuilding the site.
# -------------------------------------------------------------------
build-site:
needs: [generate-fixture, build-wasm]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- uses: actions/download-artifact@v8
with:
name: real-blog-fixture
- name: Extract fixture
run: tar -xzf real-blog.tar.gz
# The compiler wasm, built fresh from source by the parallel build-wasm
# job (no committed binary). Lands in _site/lib/ for /playground/+/studio/.
- uses: actions/download-artifact@v8
with:
name: roundhouse-wasm
path: wasm-artifact
# Compile the static asset graph once and inject it into every emit
# archive's static/assets/ (via ROUNDHOUSE_ASSETS_DIR) so the published
# archives are self-contained — no build step required by a downloader.
# The asset set is identical across targets (same Rails-derived HTML +
# same importmap pins), so one build from the fixture serves all.
#
# Two halves, both landing in build/assets/:
# - tailwind.css — compiled by the Tailwind v4 CLI (Node).
# - the JS importmap graph — turbo.min.js / stimulus.min.js /
# stimulus-loading.js (copied out of the turbo-rails / stimulus-rails
# gem dirs) plus the fixture's own app/javascript (application.js +
# controllers/*). These are the modules the emitted layout's
# `javascript_importmap_tags` references at /assets/*; without them
# the ES-module graph 404s and Turbo never boots (so turbo_comment +
# action_cable can't pass). Mirrors the scaffold Makefile's `assets`
# rule, which ruby/spinel/jruby run in-archive.
# collect_asset_files (src/project.rs) walks build/assets/ recursively
# and preserves subdirs, so controllers/*.js land at
# static/assets/controllers/*.js — matching the `controllers/*` pins.
- name: Install Node
uses: actions/setup-node@v5
with:
node-version: '20'
- name: Install Ruby (for the turbo-rails / stimulus-rails JS bundles)
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.4'
bundler-cache: true
working-directory: ./runtime/spinel/scaffold
- name: Build static asset graph for archives
run: |
mkdir -p build/assets/controllers
# Tailwind CSS.
(cd e2e && npm install --no-audit --no-fund)
printf '@import "tailwindcss";\n@source "%s/fixtures/real-blog/app";\n' "$PWD" \
> e2e/.tailwind-input.css
(cd e2e && npx @tailwindcss/cli --input .tailwind-input.css \
--output "$GITHUB_WORKSPACE/build/assets/tailwind.css" --minify)
rm -f e2e/.tailwind-input.css
# Gem-bundled JS (turbo-rails + stimulus-rails). Gem dirs are
# queried at build time so a version bump needs no edit here.
turbo_dir=$(cd runtime/spinel/scaffold && bundle exec ruby -e \
'puts Gem::Specification.find_by_name("turbo-rails").gem_dir')
stimulus_dir=$(cd runtime/spinel/scaffold && bundle exec ruby -e \
'puts Gem::Specification.find_by_name("stimulus-rails").gem_dir')
cp "$turbo_dir/app/assets/javascripts/turbo.min.js" build/assets/
cp "$stimulus_dir/app/assets/javascripts/stimulus.min.js" build/assets/
cp "$stimulus_dir/app/assets/javascripts/stimulus-loading.js" build/assets/
# App JS from the fixture (the importmap entry + Stimulus controllers).
cp fixtures/real-blog/app/javascript/application.js build/assets/
cp fixtures/real-blog/app/javascript/controllers/*.js build/assets/controllers/
echo "baked asset graph:"; (cd build/assets && find . -type f | sort)
- name: cargo run --bin build-site
env:
ROUNDHOUSE_ASSETS_DIR: build/assets
run: cargo run --release --bin build-site
# Live SharedWorker blog demo → _site/blog/. Emits real-blog under
# the worker profile, vite-builds it with ROUNDHOUSE_BASE set to the
# Pages mount path, and drops dist/ alongside the static site. The
# app runs entirely in the browser (SharedWorker + sqlite-wasm via
# the opfs-sahpool VFS) — no server, and crucially no COOP/COEP
# cross-origin-isolation headers, so it serves straight off Pages.
# Deep-link reloads are handled by site/404.html (already copied
# into _site by build-site) bouncing back to the shell's decoder.
# ROUNDHOUSE_BASE here must match the base hard-coded in
# site/404.html.
- name: Build live SharedWorker blog demo (→ _site/blog)
env:
ROUNDHOUSE_BASE: /roundhouse/blog/
run: |
set -euo pipefail
DEMO="$(mktemp -d)"
cargo run --release --quiet --bin emit_preview -- \
--target typescript --profile worker --out "$DEMO" fixtures/real-blog
(cd "$DEMO" && npm install --no-audit --no-fund --silent && npm run build)
mkdir -p _site/blog
cp -R "$DEMO"/dist/. _site/blog/
echo "blog demo files:"; find _site/blog -maxdepth 2 -type f | sort
# In-browser playground + studio demos → _site/{lib,playground,studio}/.
# Where the blog ships the *emitted app*, these ship the *compiler itself*
# as wasm. The two surfaces share ../lib/ (driver + editor + tree widget +
# bundler + the compiler wasm + the seed fixture), so the served tree must
# keep lib/ a sibling of the surface dirs (the pages import ../lib/).
# /playground/ = edit Ruby → emitted code, any target; /studio/ = edit
# Ruby → run the emitted TS blog live (rung D). fixture.json is regenerated
# from the just-built real-blog so it tracks create-blog. The compiler wasm
# is built fresh from source by the parallel build-wasm job and pulled in
# as an artifact (NOT committed) — see wasm/vendor/ruby-rbs-sys/README.md
# and the "Publishing to Pages" section of docs/browser-demo-plan.md. Pure
# static files (no npm/vite, no COOP/COEP), so they serve straight off Pages.
- name: Bundle the in-browser playground + studio demos (→ _site/lib, _site/playground, _site/studio)
run: |
set -euo pipefail
mkdir -p _site/lib _site/playground _site/studio
# Shared lib: code modules + the freshly-built compiler wasm (from the
# build-wasm artifact — not committed; see that job + wasm/vendor/).
cp wasm/lib/transpile.mjs \
wasm/lib/analyzer.mjs \
wasm/lib/engine.mjs \
wasm/lib/worker.mjs \
wasm/lib/wasm-client.mjs \
wasm/lib/wasi-shim.mjs \
wasm/lib/editor.js \
wasm/lib/tree.js \
wasm/lib/bundle.mjs \
wasm/lib/test-runtime.mjs \
wasm/lib/sourcemap.mjs \
wasm/lib/app-host.mjs \
_site/lib/
cp wasm-artifact/roundhouse_wasm.wasm _site/lib/
# Fresh seed app from the just-generated real-blog fixture (→ lib).
(cd wasm/browser-spike && node gen-fixture.mjs)
cp wasm/browser-spike/fixture.json _site/lib/fixture.json
# Each surface: its own HTML + entry module(s). Studio also ships its
# app-host service worker (sw.js), which serves the running app.
cp wasm/playground/index.html wasm/playground/playground.js _site/playground/
cp wasm/studio/index.html wasm/studio/studio.js wasm/studio/sw.js _site/studio/
echo "lib files:"; find _site/lib -type f | sort
echo "playground files:"; find _site/playground -type f | sort
echo "studio files:"; find _site/studio -type f | sort
# The /ide/ demo: Monaco over the analyzer wasm in a Web Worker,
# preloaded with Mastodon at $MASTODON_SHA (source-only bundle,
# LICENSE + commit embedded — AGPL source redistribution). Same
# bundle + cache key the browser-smoke-ide job verified.
- name: Cache Mastodon source bundle
id: mastodon-src-site
uses: actions/cache@v4
with:
path: wasm/ide/app-src.json
key: mastodon-app-src-${{ env.MASTODON_SHA }}-v2
- name: Fetch pinned Mastodon + bundle sources (cache miss)
if: steps.mastodon-src-site.outputs.cache-hit != 'true'
run: |
set -euo pipefail
curl -fsSL "https://codeload.github.com/mastodon/mastodon/tar.gz/${MASTODON_SHA}" -o /tmp/mastodon.tar.gz
mkdir -p /tmp/mastodon && tar -xzf /tmp/mastodon.tar.gz -C /tmp/mastodon --strip-components=1
node wasm/ide/bundle-src.mjs /tmp/mastodon wasm/ide/app-src.json \
--name mastodon --commit "${MASTODON_SHA}" \
--open app/controllers/statuses_controller.rb
- name: Bundle the in-browser IDE demo (→ _site/ide)
run: |
set -euo pipefail
mkdir -p _site/ide
cp wasm/ide/index.html wasm/ide/ide.js _site/ide/
cp wasm/ide/app-src.json _site/ide/
echo "ide files:"; find _site/ide -type f | sort
# App-picker bundles + manifests. Both surfaces gained an app selector;
# each fetches an apps.json listing the shipped source bundles and swaps
# them in-place (analyze/transpile are stateless per call). Mastodon is
# /ide/-only — its strict-mode ingest gaps (alias, class<<self) hard-fail
# the /playground/'s full-transpile path, whereas /ide/'s analysis is
# gap-tolerant. Lobsters (ruby-bench, MIT) ships to both; the blog ships
# to /ide/ as a source bundle (the playground reuses lib/fixture.json).
- name: Cache lobsters source bundle
id: lobsters-src-site
uses: actions/cache@v4
with:
path: /tmp/app-lobsters.json
key: lobsters-app-src-${{ env.RUBY_BENCH_SHA }}-v1
- name: Fetch pinned ruby-bench + bundle lobsters (cache miss)
if: steps.lobsters-src-site.outputs.cache-hit != 'true'
run: |
set -euo pipefail
curl -fsSL "https://codeload.github.com/ruby/ruby-bench/tar.gz/${RUBY_BENCH_SHA}" -o /tmp/ruby-bench.tar.gz
mkdir -p /tmp/lobsters
tar -xzf /tmp/ruby-bench.tar.gz -C /tmp/lobsters --strip-components=3 "ruby-bench-${RUBY_BENCH_SHA}/benchmarks/lobsters"
node wasm/ide/bundle-src.mjs /tmp/lobsters /tmp/app-lobsters.json \
--name lobsters --commit "${RUBY_BENCH_SHA}" \
--open app/controllers/stories_controller.rb
- name: Cache campfire source bundle
id: campfire-src-site
uses: actions/cache@v4
with:
path: /tmp/app-campfire.json
key: campfire-app-src-${{ env.CAMPFIRE_SHA }}-v1
- name: Fetch pinned once-campfire + bundle sources (cache miss)
if: steps.campfire-src-site.outputs.cache-hit != 'true'
run: |
set -euo pipefail
curl -fsSL "https://codeload.github.com/basecamp/once-campfire/tar.gz/${CAMPFIRE_SHA}" -o /tmp/campfire.tar.gz
mkdir -p /tmp/campfire && tar -xzf /tmp/campfire.tar.gz -C /tmp/campfire --strip-components=1
node wasm/ide/bundle-src.mjs /tmp/campfire /tmp/app-campfire.json \
--name campfire --commit "${CAMPFIRE_SHA}" \
--open app/models/message.rb
- name: Assemble app-picker bundles + manifests (→ _site/lib, _site/ide)
run: |
set -euo pipefail
# Blog source bundle for /ide/ (the playground reuses lib/fixture.json).
node wasm/ide/bundle-src.mjs fixtures/real-blog /tmp/app-blog.json \
--name blog --open app/controllers/articles_controller.rb
cp /tmp/app-lobsters.json _site/lib/app-lobsters.json
cp /tmp/app-lobsters.json _site/ide/app-lobsters.json
cp /tmp/app-blog.json _site/ide/app-blog.json
# campfire: the write+push app (Action Cable channels, model-side
# broadcast_*_to, turbo_stream views) and the current climb — the
# picker's fourth app, transpiling partially with the gap ledger.
cp /tmp/app-campfire.json _site/lib/app-campfire.json
cp /tmp/app-campfire.json _site/ide/app-campfire.json
# Mastodon ships to BOTH now: /ide/ analyzes it, and /playground/
# transpiles it off the main thread (partial output + gap diagnostics —
# the emit runs in the shared worker, so the multi-second pass never
# freezes the tab; see wasm/lib/wasm-client.mjs).
cp wasm/ide/app-src.json _site/lib/app-mastodon.json
# roda-blog: the Roda + Sequel exemplar (issue #67) — the picker's
# second CLEAN app, transpiled through the roda front-end.
node wasm/ide/bundle-src.mjs fixtures/roda-blog _site/lib/app-roda.json \
--name roda-blog --open app.rb
# Playground manifest: blog + roda (clean) + lobsters + campfire + mastodon (partial → honest ledger).
printf '%s\n' '{"default":"blog","apps":[{"name":"blog","label":"Rails blog","src":"fixture.json","open":"app/models/article.rb"},{"name":"roda","label":"Roda + Sequel blog","src":"app-roda.json","open":"app.rb"},{"name":"lobsters","label":"Lobsters","src":"app-lobsters.json","open":"app/models/story.rb"},{"name":"campfire","label":"Campfire","src":"app-campfire.json","open":"app/models/message.rb"},{"name":"mastodon","label":"Mastodon","src":"app-mastodon.json","open":"app/models/status.rb"}]}' > _site/lib/apps.json
# IDE manifest: all four (analysis is gap-tolerant; Mastodon default).
printf '%s\n' '{"default":"mastodon","apps":[{"name":"blog","label":"Rails blog","src":"app-blog.json"},{"name":"lobsters","label":"Lobsters","src":"app-lobsters.json"},{"name":"campfire","label":"Campfire","src":"app-campfire.json"},{"name":"mastodon","label":"Mastodon","src":"app-src.json"}]}' > _site/ide/apps.json
echo "lib app bundles:"; ls -1 _site/lib/*.json | sort
echo "ide app bundles:"; ls -1 _site/ide/*.json | sort
# Bench results page — fetched from the Hetzner publishing endpoint
# (cron-driven, populates bench/results/latest on each run). The
# render step degrades gracefully: if Hetzner is unreachable, the
# `_site/bench/` directory simply isn't created and the landing
# page's link 404s until the next CI cycle picks up fresh data.
- name: Fetch latest bench data
id: fetch-bench
env:
BENCH_URL: https://roundbench.showcase.party
run: |
mkdir -p _site/bench
if curl -fsSL --max-time 30 "$BENCH_URL/summary.json" -o _site/bench/summary.json; then
curl -fsSL --max-time 30 "$BENCH_URL/env.json" -o _site/bench/env.json || true
curl -fsSL --max-time 30 "$BENCH_URL/per-run.json" -o _site/bench/per-run.json || true
curl -fsSL --max-time 30 "$BENCH_URL/summary.md" -o _site/bench/summary.md || true
echo "bench_data=present" >> "$GITHUB_OUTPUT"
else
echo "::warning::bench data unavailable at $BENCH_URL; skipping bench page render"
rmdir _site/bench
echo "bench_data=absent" >> "$GITHUB_OUTPUT"
fi
- name: Render bench page
if: steps.fetch-bench.outputs.bench_data == 'present'
run: scripts/bench-report _site/bench/summary.json _site/bench/index.html
# Lobsters bench results page — same Hetzner endpoint, /lobsters/ path
# (roundbench.showcase.party/lobsters/ → _site/bench/lobsters/). Fetched
# and rendered independently of the blog bench above: it renders
# whenever lobsters data is reachable even if the blog data isn't, and
# degrades the same way (no dir, link 404s until the next cycle).
# The nightly serves the LANE layout (matrix.json at the run root, one
# subdir per lane — what `bench-lobsters LANES…` writes); the flat
# summary.json fetch remains as the legacy fallback so a rollback on
# the bench box doesn't blank the page.
- name: Fetch latest lobsters bench data
id: fetch-lobsters-bench
env:
BENCH_URL: https://roundbench.showcase.party/lobsters
run: |
mkdir -p _site/bench/lobsters
fetch() { curl -fsSL --max-time 30 "$1" -o "$2" || { rm -f "$2"; return 1; }; }
if fetch "$BENCH_URL/matrix.json" _site/bench/lobsters/matrix.json; then
fetch "$BENCH_URL/env.json" _site/bench/lobsters/env.json || true
# Emitted-tree tarballs (~390 KB + ~820 KB). Mirrored so Pages is
# the download source: the bench box is timing runs, and serving a
# multi-megabyte download mid-measurement perturbs the very numbers
# the archive is evidence for. The report links them relatively, so
# this copy links to this copy. `|| true` — runs predating the
# tarballs still render, they just have nothing to link.
for f in spinel-tree.tgz ruby-tree.tgz; do
fetch "$BENCH_URL/$f" "_site/bench/lobsters/$f" || true
done
# spinel included: the AOT lane is rendered on the bench box's own
# page, and without its summary.json here the mirrored page drops
# the AOT bars, the spinel parity badge and the AOT toolchain
# provenance — a quieter page that reads as a complete one.
for lane in rails rails-int rails-jruby ruby ruby-int jruby spinel; do
mkdir -p "_site/bench/lobsters/$lane"
for f in summary.json rails.json env.json summary.md; do
fetch "$BENCH_URL/$lane/$f" "_site/bench/lobsters/$lane/$f" || true
done
rmdir "_site/bench/lobsters/$lane" 2>/dev/null || true
done
# A matrix with only the Rails lanes (a partial nightly where the
# roundhouse emit lane failed to produce a summary) is unrenderable
# — lobsters-report aborts without the canonical ruby/ruby-int lane.
# Treat that like absent data and skip the render, rather than
# failing the whole build-site job on a bench-box hiccup.
if [ -f _site/bench/lobsters/ruby/summary.json ] || [ -f _site/bench/lobsters/ruby-int/summary.json ]; then
echo "lobsters_bench_data=present" >> "$GITHUB_OUTPUT"
else
echo "::warning::lobsters matrix present but no ruby emit lane summary at $BENCH_URL; skipping lobsters bench page render"
rm -rf _site/bench/lobsters
rmdir _site/bench 2>/dev/null || true
echo "lobsters_bench_data=absent" >> "$GITHUB_OUTPUT"
fi
elif fetch "$BENCH_URL/summary.json" _site/bench/lobsters/summary.json; then
fetch "$BENCH_URL/rails.json" _site/bench/lobsters/rails.json || true
fetch "$BENCH_URL/env.json" _site/bench/lobsters/env.json || true
fetch "$BENCH_URL/sequence.json" _site/bench/lobsters/sequence.json || true
fetch "$BENCH_URL/summary.md" _site/bench/lobsters/summary.md || true
echo "lobsters_bench_data=present" >> "$GITHUB_OUTPUT"
else
echo "::warning::lobsters bench data unavailable at $BENCH_URL; skipping lobsters bench page render"
rmdir _site/bench/lobsters
rmdir _site/bench 2>/dev/null || true
echo "lobsters_bench_data=absent" >> "$GITHUB_OUTPUT"
fi
- name: Render lobsters bench page
if: steps.fetch-lobsters-bench.outputs.lobsters_bench_data == 'present'
# The report detects the layout from the directory contents.
run: scripts/lobsters-report _site/bench/lobsters _site/bench/lobsters/index.html
# Lobsters conformance — upstream HEAD's own RSpec suite run against
# an emit of the same checkout (scripts/lobsters-specs on the bench
# box, published to roundbench.showcase.party/lobsters-specs/). The
# JSON is fetched and rendered HERE rather than fetching the page the
# box already rendered: the renderer lives in this repo and evolves
# with it, so a fix to the report reaches the site on the next CI run
# instead of waiting for the box's next cron cycle.
#
# Deliberately ungated on content. If the box publishes a run whose
# canary failed or whose checkout was dirty, the page renders WITH the
# alarm banner that says so — suppressing it would defeat the point of
# having one.
- name: Fetch latest lobsters conformance data
id: fetch-lobsters-specs
env:
BENCH_URL: https://roundbench.showcase.party/lobsters-specs
run: |
mkdir -p _site/bench/lobsters-specs
if curl -fsSL --max-time 30 "$BENCH_URL/summary.json" -o _site/bench/lobsters-specs/summary.json; then
echo "specs_data=present" >> "$GITHUB_OUTPUT"
else
echo "::warning::lobsters conformance data unavailable at $BENCH_URL; skipping conformance page render"
rm -rf _site/bench/lobsters-specs
rmdir _site/bench 2>/dev/null || true
echo "specs_data=absent" >> "$GITHUB_OUTPUT"
fi
- name: Render lobsters conformance page
if: steps.fetch-lobsters-specs.outputs.specs_data == 'present'
run: scripts/lobsters-spec-report _site/bench/lobsters-specs _site/bench/lobsters-specs/index.html
# The per-target archives, for the e2e-<target> jobs to download and
# boot — the exact bytes published to rubys.github.io/roundhouse.
- name: Upload browse archives
uses: actions/upload-artifact@v7
with:
name: browse-archives
path: _site/browse/*.tgz
- name: Upload Pages artifact
uses: actions/upload-pages-artifact@v5
with:
path: _site
deploy:
needs:
- build-site
- unit
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v5