From 38f6088e45b6b0133a8d91b656efa240a46cec21 Mon Sep 17 00:00:00 2001 From: tebako-ci Date: Mon, 7 Sep 2026 15:31:06 +0200 Subject: [PATCH] jruby/02: the jruby runtime feedstock (spec 33; the first universal runtime image) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tebako-packages/jruby scaffold: jruby 10.1.1.0 (ruby 4.0.0, Build-Jdk-Spec 21) on the 2.5.0 owner line. - The first UNIVERSAL runtime image: jruby-dist ships ~26 platforms' jffi (probed), so ONE image tebako-runtime-2.5.0-10.1.1.0-universal.tfs is built once by ROLE=image; the per-triplet ROLE=pair legs pair the 2.5.0 launcher exe + write shards whose image.filename names the universal asset (tpkg::runtime_store::entry_asset_names flows the shard's image.filename verbatim — zero product change). - L1 manifest in the one-or-many provides form (tpkg::manifest::RuntimeProvides is Vec — the model's own multi-platform shape). - Owner edge `java >= 21` with NO implementation key (jruby brings its own JIT; temurin default, graalvm legal). owner_smoke pins the openjdk v2.5.0 temurin pair. - argv_template carries -Djruby.home={mount}: LOAD-BEARING, probed on the bare host — without it `require "json"` fails ("`RubyGems' were not loaded"); with it JSON.generate works (same shape as truffleruby's llvm.home token). - Shard carries "abi": "none" so native-ext payloads fail at dispatch with the resolver's named abi-mismatch instead of crashing late; the cext negative cell in tools/build proves it. - Windows leg: named follow-up (the universal image already serves it; it lands with its CI leg). - Tools block: tfs + tebako-shim pins from the v2.5.0 monolith; NO link-unit (the jvm layout grants no preload). Local gate green: image leg (fetch, two-anchor sha256 vs the upstream Maven Central sidecar, stage, symlink dereference, manifest+layout embed, mkimage, read-back) + pair leg (wrapper exe, shard, composed smoke through the shim: "hello from jruby 4.0.0" + JSON.generate OK + the cext negative cell). --- .github/workflows/build-payload.yml | 279 +++++++++++++ .gitignore | 1 + manifests/layout.yaml | 20 + manifests/registry.yaml | 37 ++ manifests/runtime.yaml | 64 +++ recipe.yml | 159 +++++++ tools/build | 625 ++++++++++++++++++++++++++++ tools/pins.rb | 158 +++++++ 8 files changed, 1343 insertions(+) create mode 100644 .github/workflows/build-payload.yml create mode 100644 .gitignore create mode 100644 manifests/layout.yaml create mode 100644 manifests/registry.yaml create mode 100644 manifests/runtime.yaml create mode 100644 recipe.yml create mode 100755 tools/build create mode 100755 tools/pins.rb diff --git a/.github/workflows/build-payload.yml b/.github/workflows/build-payload.yml new file mode 100644 index 0000000..6e1839a --- /dev/null +++ b/.github/workflows/build-payload.yml @@ -0,0 +1,279 @@ +name: build-payload +# jruby feedstock: image (once, UNIVERSAL) → pair (per triplet) → (on +# tags) publish. spec 33's runtime-on-runtime form: the env image is the +# jruby home (bytecode + every platform's jffi stub — ONE universal +# asset), composed ON the published temurin java owner at dispatch. +# +# kind: runtime (spec 28/29/33): the image job ships the universal .tfs +# (+ sidecar + the release-tier manifest); each pair leg ships the +# wrapper exe (tebako-runtime-launcher, byte-identical to +# tamatebako/tebako's own asset) + sidecar + the per-triplet release +# shard (image.filename = the universal asset — entry_asset_names flows +# it verbatim, no product change). The pair legs' smokes: the spec 33 §5 +# standalone fail-closed gate (exit 65) + the COMPOSED wire (shim → jruby +# → temurin owner) + the cext negative cell (a native-abi payload fails +# by name at dispatch — the shard's abi:"none" sentinel; TODO.jruby/02's +# acceptance). The publish job uploads all of it, derives the monoliths +# (manifest.json + SHA256SUMS.txt, trr#140) from the shards + sidecars, +# renders the registry entry from manifests/registry.yaml, and lands it +# by bot PR. `tebako publish` is NOT used: the CLI refuses kind: runtime. +# +# PIN DISCIPLINE (the owner's rule): this workflow carries NO version or +# digest literals. recipe.yml is the SSOT — the `tools:` block pins the +# tfs CLI + tebako-shim (release + per-platform sha256, cross-checked +# against the release's own SHA256SUMS), the `runtime:` block pins the +# wrapper's product line, flavors.jvm.owner_smoke pins the published +# temurin pair (two-anchor verified at fetch); tools/pins.rb reads them +# into $GITHUB_ENV. +on: + push: + branches: [main] + tags: ["*"] + pull_request: + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.ref_name }} + cancel-in-progress: true + +permissions: + contents: write + pull-requests: write + +jobs: + image: + # The UNIVERSAL env image, built once (the dist has no per-platform + # variants; mkimage is not byte-reproducible across legs — the + # created timestamp — so ONE leg's image is THE published asset). + name: image / universal + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Read the toolchain pins (recipe.yml is the SSOT) + shell: bash + run: ruby tools/pins.rb linux-gnu-x86_64 jvm --env >> "$GITHUB_ENV" + - name: Fetch the imager (pinned by recipe.yml, sha256-verified) + shell: bash + env: + TFS_BIN: .packager/tfs + run: | + set -euo pipefail + mkdir -p .packager + base="https://github.com/tamatebako/tebako/releases/download/$TEBAKO_RELEASE" + curl -sL --fail -o "$TFS_BIN" "$base/$TFS_ASSET" + curl -sL --fail -o SHA256SUMS "$base/SHA256SUMS" + echo "$TFS_SHA256 $TFS_BIN" | sha256sum -c - + # The pin must agree with the release's own sums (both anchored). + got=$(awk -v a="$TFS_ASSET" '$2 == a {print $1}' SHA256SUMS) + [ "$got" = "$TFS_SHA256" ] || { echo "::error::pin mismatch for $TFS_ASSET: recipe.yml=$TFS_SHA256 release=${got:-ABSENT}"; exit 1; } + chmod +x "$TFS_BIN" + "$TFS_BIN" > /dev/null + - name: Build the universal env image + shell: bash + env: + ROLE: image + TFS_CLI: .packager/tfs + run: tools/build + - name: Upload the image artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: image-universal + path: out/image/ + retention-days: 7 + + pair: + # Per triplet: the wrapper exe + sidecar + the per-triplet shard, and + # the smokes against THIS platform's published temurin owner. + name: pair / ${{ matrix.platform.triplet }} + runs-on: ${{ matrix.platform.os }} + needs: image + strategy: + fail-fast: false + matrix: + # Identity only. The matrix MIRRORS recipe.yml's + # flavors.jvm.platforms coverage (the recipe is the SSOT; the + # registry rows name the same list). windows-ucrt64 is a named + # follow-up (the universal image already serves it). + platform: + - triplet: aarch64-macos + asset_platform: macos-arm64 + os: macos-14 + - triplet: x86_64-linux-gnu + asset_platform: linux-gnu-x86_64 + os: ubuntu-24.04 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Read the toolchain pins (recipe.yml is the SSOT) + shell: bash + run: ruby tools/pins.rb "${{ matrix.platform.asset_platform }}" jvm --env >> "$GITHUB_ENV" + - name: Fetch the imager (pinned by recipe.yml, sha256-verified) + shell: bash + env: + TFS_BIN: .packager/tfs + run: | + set -euo pipefail + mkdir -p .packager + base="https://github.com/tamatebako/tebako/releases/download/$TEBAKO_RELEASE" + curl -sL --fail -o "$TFS_BIN" "$base/$TFS_ASSET" + curl -sL --fail -o SHA256SUMS "$base/SHA256SUMS" + echo "$TFS_SHA256 $TFS_BIN" | { sha256sum -c - 2>/dev/null || shasum -a 256 -c -; } + got=$(awk -v a="$TFS_ASSET" '$2 == a {print $1}' SHA256SUMS) + [ "$got" = "$TFS_SHA256" ] || { echo "::error::pin mismatch for $TFS_ASSET: recipe.yml=$TFS_SHA256 release=${got:-ABSENT}"; exit 1; } + chmod +x "$TFS_BIN" + "$TFS_BIN" > /dev/null + - name: Fetch the wrapper (pinned by recipe.yml) + shell: bash + env: + WRAPPER_BIN: .packager/tebako-runtime-launcher + run: | + set -euo pipefail + mkdir -p .packager + base="https://github.com/tamatebako/tebako/releases/download/$WRAPPER_RELEASE" + curl -sL --fail -o "$WRAPPER_BIN" "$base/$WRAPPER_ASSET" + echo "$WRAPPER_SHA256 $WRAPPER_BIN" | { sha256sum -c - 2>/dev/null || shasum -a 256 -c -; } + chmod +x "$WRAPPER_BIN" + echo "WRAPPER_BIN=$PWD/.packager/tebako-runtime-launcher" >> "$GITHUB_ENV" + # The dispatcher for the composed smoke (spec 33's real wire: shim + # → dep runtime → owner runtime). Same two-anchor rule as the + # imager. + - name: Fetch the dispatcher (tebako-shim, pinned by recipe.yml) + shell: bash + run: | + set -euo pipefail + mkdir -p .packager + base="https://github.com/tamatebako/tebako/releases/download/$TEBAKO_RELEASE" + curl -sL --fail -o .packager/tebako-shim "$base/$TEBAKO_SHIM_ASSET" + curl -sL --fail -o SHA256SUMS "$base/SHA256SUMS" + echo "$TEBAKO_SHIM_SHA256 .packager/tebako-shim" | { sha256sum -c - 2>/dev/null || shasum -a 256 -c -; } + got=$(awk -v a="$TEBAKO_SHIM_ASSET" '$2 == a {print $1}' SHA256SUMS) + [ "$got" = "$TEBAKO_SHIM_SHA256" ] || { echo "::error::pin mismatch for $TEBAKO_SHIM_ASSET: recipe.yml=$TEBAKO_SHIM_SHA256 release=${got:-ABSENT}"; exit 1; } + chmod +x .packager/tebako-shim + echo "SHIM_BIN=$PWD/.packager/tebako-shim" >> "$GITHUB_ENV" + # The image leg's universal image (the pair leg never rebuilds it — + # one image, one digest). + - name: Download the universal image artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: image-universal + path: out/image/ + - name: Pair the wrapper + run the smokes + shell: bash + env: + ROLE: pair + TRIPLET: ${{ matrix.platform.triplet }} + ASSET_PLATFORM: ${{ matrix.platform.asset_platform }} + TFS_CLI: .packager/tfs + IMAGE_TARBALL: out/image + # WRAPPER_BIN / SHIM_BIN flow through $GITHUB_ENV — process env, + # not the static env: context. + run: tools/build + - name: Upload payload + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: payload-${{ matrix.platform.triplet }} + path: out/pair-${{ matrix.platform.triplet }}/ + retention-days: 7 + + release: + name: publish + if: startsWith(github.ref, 'refs/tags/') + needs: [image, pair] + runs-on: macos-14 + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Collect payloads (all legs) + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: "*" + path: out/ + merge-multiple: false + - name: Restore layout + shell: bash + run: | + set -e + for d in out/payload-*/; do + p="${d#out/payload-}"; p="${p%/}" + mkdir -p "out/pair-$p" + mv "$d"* "out/pair-$p/" + rmdir "$d" + done + # out/image-universal/ → out/image/ + mkdir -p out/image + mv out/image-universal/* out/image/ + rmdir out/image-universal + # The runtime release flow (spec 28 §6): the tag IS the tebako + # product line (v, the trr convention — the resolver's + # {base}/v/ URL shape answers it). jruby has + # ONE flavor on ONE line — a tag ships everything here. + - name: Read the toolchain pins (recipe.yml is the SSOT) + run: ruby tools/pins.rb macos-arm64 jvm --env >> "$GITHUB_ENV" + - name: Create the release + upload the runtime pair assets + shell: bash + run: | + set -euo pipefail + tag="${{ github.ref_name }}" + gh release create "$tag" --repo tebako-packages/jruby \ + --title "$tag — tebako-runtime jruby (JRuby $PKG_VERSION, universal image on the java owner)" \ + --notes "tebako ruby:jruby runtime (spec 33 on_runtime): ONE universal env image + the per-triplet wrapper exe; manifest.json is the release index, SHA256SUMS.txt the trust anchor." \ + || gh release view "$tag" --repo tebako-packages/jruby > /dev/null + # The universal image + its sidecar (the image leg's artifact). + gh release upload --clobber "$tag" --repo tebako-packages/jruby \ + "out/image/$RUNTIME_IMAGE_STEM.tfs" "out/image/$RUNTIME_IMAGE_STEM.tfs.sha256" + # Per-triplet: the exe + sidecar + shard (never the sibling + # payload-*.yaml). + for shard in out/pair-*/tebako-runtime-*.manifest.json; do + dir="$(dirname "$shard")" + stem="$(basename "$shard" .manifest.json)" + gh release upload --clobber "$tag" --repo tebako-packages/jruby "$dir/$stem" "$dir/$stem.sha256" "$shard" + done + - name: Derive + upload the monoliths (manifest.json + SHA256SUMS.txt) + shell: bash + run: | + set -euo pipefail + tag="${{ github.ref_name }}" + ruby -rjson -e 'puts JSON.pretty_generate(ARGV.sort.map { |f| JSON.parse(File.read(f)) })' \ + out/pair-*/tebako-runtime-*.manifest.json > manifest.json + cat out/pair-*/tebako-runtime-*.sha256 "out/image/$RUNTIME_IMAGE_STEM.tfs.sha256" > SHA256SUMS.txt + gh release upload --clobber "$tag" --repo tebako-packages/jruby manifest.json SHA256SUMS.txt + # The registry mirrors resolution fields only (L3): the env image + # is the named artifact per platform — the SAME universal asset + # under every platform key (TODO.jruby/02 item 4's pinned + # consequence); kind: runtime carries NO entrypoints key. + - name: Render the registry entry + shell: bash + run: | + set -euo pipefail + tag="${{ github.ref_name }}" + img_sha="$(cut -d' ' -f1 "out/image/$RUNTIME_IMAGE_STEM.tfs.sha256")" + sed -e "s|@@VERSION@@|$PKG_VERSION|g" \ + -e "s|@@IMAGE_STEM@@|$RUNTIME_IMAGE_STEM|g" \ + -e "s|@@TAG@@|$tag|g" \ + -e "s|@@IMAGE_SHA256@@|$img_sha|g" \ + manifests/registry.yaml > tpkg-registry.yaml + # main is branch-protected (and the GitHub-Actions bypass is not + # available on this plan): the registry lands by bot PR + auto-merge, + # never by direct push. Idempotent across re-runs of the same tag. + - name: Publish the registry entry via pull request + run: | + set -euo pipefail + git config user.name "tebako-ci" + git config user.email "tebako@ribose.com" + git fetch origin main + if git diff --quiet origin/main -- tpkg-registry.yaml; then + echo "registry unchanged (or the entry already landed on main)" + exit 0 + fi + branch="publish/${{ github.ref_name }}-registry" + git checkout -b "$branch" + git add tpkg-registry.yaml + git commit -m "publish ${{ github.ref_name }}: registry entry" + # force-with-lease on the bot branch: a re-publish re-creates it + git push --force-with-lease origin "$branch" + if ! gh pr view "$branch" >/dev/null 2>&1; then + gh pr create --base main --head "$branch" \ + --title "publish ${{ github.ref_name }}: registry entry" \ + --body "Automated registry entry for ${{ github.ref_name }} (the build-payload publish job; main is protected, so the registry lands by PR)." + fi + gh pr merge --auto --squash "$branch" || gh pr merge --squash "$branch" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..89f9ac0 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +out/ diff --git a/manifests/layout.yaml b/manifests/layout.yaml new file mode 100644 index 0000000..7208316 --- /dev/null +++ b/manifests/layout.yaml @@ -0,0 +1,20 @@ +# Env-image layout declaration for the jruby runtime (spec 33 §5 — the +# depending runtime's env image declares NO interpreter: a STANDALONE boot +# of this pair fails closed by name; the runtime runs ON its java owner +# via the manifest's on_runtime composition). Grammar owner: +# docs/spec/schemas/layout.yaml in tamatebako/tebako (schema_minor 4); the +# writer here FLOWS the same owned values. Rendered by tools/build into +# /lib/tebako/layout.yaml inside the image. +schema_version: 1 +era: 2 +image_layout: 1 +# The mount root the image was built for — must equal the wrapper exe's +# compiled-in WRAPPER_RUNTIME_ROOT (/__tfs__ on POSIX — every leg of this +# feedstock is POSIX; crates/tebako-driver/src/wrapper.rs). A mismatch is +# exit 78, never a broken boot. +mount_root: "@@MOUNT_ROOT@@" +interpreter_api_version: "@@API_VERSION@@" +# No interpreter key (spec 33 §5), no preload_shim grant — the dep image +# never execs a process of its own; the OWNER pair carries the preload +# binding for the composed process (the java runtime's own layout). +mount_root_override: false diff --git a/manifests/registry.yaml b/manifests/registry.yaml new file mode 100644 index 0000000..eef182b --- /dev/null +++ b/manifests/registry.yaml @@ -0,0 +1,37 @@ +schema_version: 1 +# Registry template for the jruby RUNTIME entry — spec 33's +# runtime-on-runtime form, and the FIRST universal runtime image: the +# dist is bytecode + the ruby home with every platform's jffi stub +# (lib/jni/), so ONE asset serves all triplets. Every platform key below +# names the SAME universal artifact with the SAME digest — the registry +# grammar consequence TODO.jruby/02 item 4 pins: the triplet binding of +# a composed jruby comes from the java OWNER pair, never from this image. +# Rendered by the publish job (sed tokens from the built sidecars) and +# landed on main by bot PR (the CLI refuses kind: runtime — runtimes +# publish through the runtime release flow). kind: runtime entries carry +# NO entrypoints key (the registry validator: only apps and toolkits +# declare entrypoints). The on_runtime edge is NOT mirrored here (L3 +# mirrors resolution fields only): it lives in the release shard (the +# shim's plan-time read) and the in-image manifest (the authority). +# Platform matrix: temurin owner coverage is the constraint +# (tebako-packages/openjdk v2.5.0 ships macos-arm64 + linux-gnu-x86_64 + +# windows-ucrt64). This first release declares the two POSIX rows the +# acceptance gate covers (TODO.jruby/02); windows-ucrt64 is a named +# follow-up (the universal image already serves it — the row lands with +# its CI leg, never silently). +payloads: +- name: jruby + kind: runtime + versions: + - version: '@@VERSION@@' + implementation: jruby + platforms: + aarch64-macos: + artifact: @@IMAGE_STEM@@.tfs + sha256: '@@IMAGE_SHA256@@' + x86_64-linux-gnu: + artifact: @@IMAGE_STEM@@.tfs + sha256: '@@IMAGE_SHA256@@' + release: + ref: tfs:github:tebako-packages/jruby:@@TAG@@ + default: '@@VERSION@@' diff --git a/manifests/runtime.yaml b/manifests/runtime.yaml new file mode 100644 index 0000000..7c028d5 --- /dev/null +++ b/manifests/runtime.yaml @@ -0,0 +1,64 @@ +schema_version: 1 +# Runtime manifest template for jruby (spec 33 §2 — the runtime-on-runtime +# form): the env image is the jruby home (UNIVERSAL — the dist ships every +# platform's jffi stub under lib/jni/, so ONE image serves all triplets; +# the triplet binding comes from the composed java owner pair), composed +# ON a java owner at dispatch. The in-image on_runtime block is the +# AUTHORITY (the release shard mirrors it for the shim's plan-time read; +# the driver cross-checks). Filled from recipe.yml's flavors.jvm block by +# tools/build; every fact below is the 2026-09-07 probe's +# (TODO.jruby/02-probe-2026-09-07.md), never re-derived. +identity: + schema_version: 1 + kind: runtime + name: "jruby" + version: "@@VERSION@@" + producer: {tool: feedstock-build, tool_version: "1"} + created: "@@CREATED@@" + source: {src_sha256: "@@UPSTREAM_SHA256@@", commit: "@@UPSTREAM_COMMIT@@", builder: "local"} + digest: + tree_hash: "sha256:0000000000000000000000000000000000000000000000000000000000000000" # placeholder until the CAS lands (spec 03) + blob_sha256: "@@BLOB_SHA256@@" # zeroed inside the image (spec 03 §7) + signing: {state: unsigned} + encryption: {state: none} + # spec 33 §2's bridge law: compound {mount} argv tokens splice the + # materialized whole-tree home root, and the trigger reads the LITERAL + # key identity.annotations.java_home — the mechanism is runtime-neutral + # but the key name is not (tebako#548 tracks the neutral `home` key). + # Unannotated = named exit 65. The argv template's -Djruby.home token + # is LOAD-BEARING (probed 2026-09-07): `-jar` alone boots but RubyGems + # never loads ("`RubyGems' were not loaded" / LoadError on json); the + # explicit home wires the gem paths to the materialized tree. + annotations: {java_home: "/"} +provides: + # one-or-many on the wire (tpkg::RuntimeProvides' serde): ONE entry per + # platform this UNIVERSAL image covers — the model's own multi-platform + # form ("one entry per platform the image covers"). tools/build renders + # the list from the recipe's flavors.jvm.platforms coverage. + provides: +@@PROVIDES_YAML@@ + built_from: + src_sha256: "@@UPSTREAM_SHA256@@" + patch_set: "none" # a repack applies no patches; the field is required non-empty + # The spawn surface (spec 30 §2), rendered from the recipe's flavor + # entrypoints (the feedstock SSOT): TODO.jruby/02's named three + # (jruby, jgem, jirb). The entry DISPATCH never executes the bin/ + # scripts — the driver composes the owner java + argv_template; the + # declarations anchor the spawn-surface existence checks. jgem/jirb + # dispatch rides the composed wire's user args (the local gate's + # record), never a second launch path. + entrypoints: +@@ENTRYPOINTS_YAML@@ + # The owner edge + compose template (spec 33 §2). ANY java >= 21 (the + # jar's Build-Jdk-Spec) — temurin is the default pick; unlike + # truffleruby-jvm there is NO implementation constraint (jruby brings + # its own JIT; the graalvm compiler is not needed). The whole runtime + # is lib/jruby.jar (Main-Class: org.jruby.main.Main). + on_runtime: + mount: "@@ON_MOUNT@@" + argv_template: +@@ARGV_YAML@@ + env: {} + capabilities: {exec: true, read: true, runtime: true} +requires: + - {kind: runtime, engine: "@@ON_ENGINE@@", constraint: "@@ON_CONSTRAINT@@"} diff --git a/recipe.yml b/recipe.yml new file mode 100644 index 0000000..71afea5 --- /dev/null +++ b/recipe.yml @@ -0,0 +1,159 @@ +# Feedstock recipe for jruby +# spec 13 §9 + docs/conventions.md in tebako-packages/index +name: "jruby" +kind: runtime +summary: "JRuby — the tebako ruby:jruby RUNTIME (spec 33 runtime-on-runtime): a UNIVERSAL env image composed on the java owner pair (tebako-packages/openjdk) at dispatch" + +# ONE flavor: `jvm` — the only mode jruby has (there is no native jruby +# process; the JVM is always the process owner). The flavor shape rides +# the spec 28 §8 machinery unchanged (the mode is a NON-axis: the +# selector is ruby:jruby). +# +# The dist is UNIVERSAL (probe record TODO.jruby/02-probe-2026-09-07): +# jruby-dist is bytecode + the ruby home, and lib/jni/ ships every +# platform's jffi stub (~26 platforms incl. arch-unsuffixed Darwin) — the +# archive has NO per-platform variants. So the env image is built ONCE +# (the universal asset) and the triplet binding comes entirely from the +# composed java OWNER pair. The release still ships the per-triplet +# wrapper exe (the 2.5.0 launcher, byte-identical to tamatebako/tebako's +# own asset — it anchors the store entry's well-formedness and the +# spec 33 §5 standalone fail-closed smoke; it is never the composed +# process's owner) and a PER-TRIPLET release shard whose image.filename +# names the universal asset — tpkg::runtime_store::entry_asset_names +# flows that filename verbatim, so no tebako product change is needed +# (verified against v2.5.0 sources 2026-09-07). +flavors: + jvm: + implementation: "jruby" + # language_version ("4.0" — jruby 10.1.1.0 IS ruby 4.0.0, probed + # 2026-09-07: `java -jar lib/jruby.jar -v` prints + # "jruby 10.1.1.0 (4.0.0)") rides here as the feedstock SSOT; the + # shipped tpkg model has no EngineProvides.language_version field and + # mkimage re-serializes through the model — the key lands in the + # manifest when the model grows it (axis-polish batch item). + language_version: "4.0" + abi_line: "10.1" # the jruby line; jruby runs NO native extensions (see the shard's abi sentinel below) + interpreter_api_version: "10.1" # layout.yaml's declared line (no interpreter grant — spec 33 §5) + # The platform coverage this UNIVERSAL image declares (the L1 + # manifest's one-or-many provides list, the workflow's pair-leg + # matrix, and the registry's platform rows all mirror THIS list — the + # recipe is the SSOT). windows-ucrt64 is a named follow-up (the + # universal image already serves it; the row lands with its CI leg). + platforms: [aarch64-macos, x86_64-linux-gnu] + # The declared spawn surface (spec 30 §2) — TODO.jruby/02's named + # entrypoints. The entry DISPATCH composes the owner java + + # argv_template (below); the bin/ scripts are never exec'd (they are + # host shell — the composition's process is the owner's JVM). + entrypoints: [jruby, jgem, jirb] + on_runtime: + engine: "java" + # NO implementation key: the default temurin flavor is a legal + # owner (unlike truffleruby-jvm, which needs the graalvm compiler — + # jruby brings its own JIT). A graalvm owner is legal too (the + # spec 28 §8 any_of spelling lands with the axis-polish batch; the + # bare engine edge already means "any java implementation"). + constraint: ">= 21" # Build-Jdk-Spec: 21 in lib/jruby.jar's manifest (probed) + mount: "/__runners__/jruby" + # The compose template: the whole runtime is lib/jruby.jar + # (Main-Class: org.jruby.main.Main — probed). -Djruby.home is + # LOAD-BEARING (probed 2026-09-07): `-jar` alone boots but RubyGems + # and the default gems never load ("`RubyGems' were not loaded" / + # LoadError on `require "json"`); the explicit home token wires the + # gem paths to the materialized tree. All tokens space-free (the + # quoted-flow grammar tools/build parses). + argv_template: ["-Djruby.home={mount}", "-jar", "{mount}/lib/jruby.jar"] + upstream: + # JRuby 10.1.1.0 (2026-07-22), Maven Central dist. Upstream + # publishes .sha256 sidecars — the pins below ARE upstream's (the + # two-anchor rule: the fetch step cross-checks against the sidecar + # URL, not just the recipe). strip_components 1 (the archive nests + # jruby-/). ONE universal source — every leg images the same + # tree. + version: "10.1.1.0" + tag: "10.1.1.0" # the maven version (the release-index provenance) + sources: + - platform: "universal" + url: "https://repo1.maven.org/maven2/org/jruby/jruby-dist/10.1.1.0/jruby-dist-10.1.1.0-bin.tar.gz" + sha256: "1e08bff6a7f0134a4774fba37d8cf88b9dd4a7317bd49fca5af696a327450148" + strip_components: 1 + # The composed smoke's owner pair: the PUBLISHED temurin java runtime + # (tebako-packages/openjdk v2.5.0 — the 2.5.0 line is REQUIRED: + # tebako#552 — a pre-2.5.0 owner exe predates spec 33 §1's entry + # routing and misroutes the composed entry; the exe digests below + # equal tamatebako/tebako's own 2.5.0 launcher monolith entries — the + # pair's exe IS the launcher, byte-identical). owner_smoke sits AFTER + # upstream on purpose: tools/build's indentation-anchored reader takes + # the first 6-space `version:` in the flavor, which must be the + # upstream's (the truffleruby 2026-09-07 local gate caught + # owner_smoke.version shadowing it). + owner_smoke: + repo: "tebako-packages/openjdk" + release: "v2.5.0" + version: "21.0.12" + tebako: "2.5.0" + exe_sha256: + macos-arm64: "b4725da9e99425acbda3d189738f78773b7658d63c015cf03ef4e2f035e94d28" + linux-gnu-x86_64: "02272c8ece40c5cfadc9cb77330d68f120b8eddd8ec3b8d2360ef56450d1f245" + image_sha256: + macos-arm64: "d4e30fafb532ee3f9192a8bc711e2b05d463bc3b2eb2be2b90393a6910b61882" + linux-gnu-x86_64: "70fbf7a1aa7b32d0a208f451472ce72737cf535d8949e83eb42862e59c001c35" + +build: + system: repack # no compilation: unpack, prune, image + deps: [] + +# The runtime contract (spec 28 revised §8 + spec 29 + spec 33). +# tools/build renders manifests/runtime.yaml (tier-1, embedded at +# /__tpkg__/manifest.yaml) and manifests/layout.yaml (the pair contract, +# /lib/tebako/layout.yaml) from this block + the flavor block. +runtime: + engine: "ruby" + contract_version: 2 # the spec 17 driver contract the wrapper speaks + interpreter: "/bin/jruby" # the staged-tree existence check + the smoke's entry NAME (the layout grants NO interpreter — spec 33 §5) + smoke_args: "-v" # the standalone negative smoke's arg + # spec 29 §3: the owner reads the MATERIALIZED home (the argv template's + # {mount} is a host path per spec 33 §2's bridge law) — exec-cache + # materializes the tree whole (the java_home annotation is the + # trigger); a JVM needs no VFS once its home is materialized. + visibility: "exec-cache" + mount_root_override: false # closed default — nothing in the composed JVM follows TEBAKO_MOUNT_ROOT (spec 00 §9) + # The tebako product line the pair builds against. jruby has ONE line: + # 2.5.0 is the FIRST spec-33-capable launcher (the owner-exe finding — + # tebako#552); there is no older line to keep. wrapper_sha256 digests + # are the v2.5.0 release's monolith SHA256SUMS entries (fetched + # 2026-09-07). + wrapper_tebako: "2.5.0" + wrapper_sha256: + macos-arm64: "b4725da9e99425acbda3d189738f78773b7658d63c015cf03ef4e2f035e94d28" + linux-gnu-x86_64: "02272c8ece40c5cfadc9cb77330d68f120b8eddd8ec3b8d2360ef56450d1f245" + windows-ucrt64: "6a69fa069f4f502791fd165b7b7795b59ac6b62690d00631d075abc29d7e1bcd" + +# Factory tools used by tools/build: the tfs CLI (the in-process imager) +# and tebako-shim (the composed smoke's dispatcher) from the +# tamatebako/tebako release. The digests are the v2.5.0 monolith +# SHA256SUMS entries (fetched 2026-09-07); the fetch steps cross-check +# against the monolith. v2.5.0 is REQUIRED: tfs mkimage re-serializes the +# embedded manifest THROUGH the tpkg model, and only the 2.5.0 model +# round-trips on_runtime + the widened requires grammar (the 2.4.0 CLI +# would silently strip the owner edge). (link-unit rides the pin shape +# for the day a leg needs the preload grant; the jvm layout declares none +# — spec 33 §5.) +tools: + repo: "tamatebako/tebako" + release: "v2.5.0" + sha256: + tfs: + macos-arm64: "0e1f84c1259c3e290c68c329fa763a0bf9f6665efb321e24175013e249061b35" + linux-gnu-x86_64: "205b44d9eca7d327215ac0ae394fb74b310581836b56ee080999b873abe49787" + windows-ucrt64: "06f0d667ef3ecbf8ad717215872570901cfe31f9b6f68c7fca3fcfb975e6c32d" + tebako-shim: + macos-arm64: "ad6a88373abc721c7d7e5e6f482d3fd6af2e99e0117ddcfdf7529e77eb090139" + linux-gnu-x86_64: "991a66026944b3b5db88278a1f691ed91b85a0a04bd588b30831ed23312724da" + windows-ucrt64: "86727f043dd0cfac76ee074669a3a1fab5732f865fa70b3189f12f72f92c71d4" + +image: + format: dwarfs + # .tfs everywhere (the tebako fs image — DwarFS-T format; normal dwarfs + # readers cannot read it) + extension: + default: tfs diff --git a/tools/build b/tools/build new file mode 100755 index 0000000..c03aac8 --- /dev/null +++ b/tools/build @@ -0,0 +1,625 @@ +#!/usr/bin/env bash +# tools/build — the jruby RUNTIME feedstock builder, in two roles +# (docs/conventions.md in tebako-packages/index): +# +# ROLE=image build the UNIVERSAL env image ONCE (the dist is bytecode + +# the ruby home + every platform's jffi stub — one asset +# serves all triplets): fetch the pinned universal archive, +# sha256-verify (two-anchor: the recipe pin == upstream's +# published .sha256 sidecar), stage, dereference in-tree +# symlinks (the writer has no link node type), embed the +# tier-1 runtime manifest at /__tpkg__/manifest.yaml (blob +# digest zeroed, spec 03 §7; the one-or-many provides list +# declares every covered platform) and the layout +# declaration at /lib/tebako/layout.yaml (NO interpreter — +# spec 33 §5), image with tfs-cli's in-process dwarfs-t +# writer, read-back gates, sidecar + release-tier manifest. +# +# ROLE=pair per-triplet: pair the wrapper exe (the 2.5.0 +# tebako-runtime-launcher — byte-identical to +# tamatebako/tebako's own asset; it anchors the store +# entry's well-formedness and the standalone fail-closed +# smoke, it is NEVER the composed process's owner), write +# the per-triplet release shard (image.filename = the +# universal asset — tpkg::runtime_store::entry_asset_names +# flows it verbatim, no product change), then the smokes: +# the standalone NEGATIVE gate (exit 65, spec 33 §5) and +# the COMPOSED smoke (shim → this runtime → the published +# temurin owner; a probe payload prints RUBY_ENGINE and +# loads json through jruby's own default gem). +# +# Inputs (env; the workflow's matrix exports the per-leg values): +# ROLE image | pair (REQUIRED) +# FLAVOR the recipe's flavor key (default: jvm — the only mode) +# TRIPLET spec 03 triplet (pair legs; default aarch64-macos) +# ASSET_PLATFORM release-asset name form (pair legs; default macos-arm64) +# TAR archiver (default: tar) +# TFS_CLI tfs-cli binary, the in-process imager (default: tfs) +# WRAPPER_BIN the tebako-runtime-launcher exe (pair legs, REQUIRED) +# SHIM_BIN the tebako-shim exe (pair legs, REQUIRED — the smoke) +# IMAGE_TARBALL ROLE=pair: the image leg's artifact dir carrying +# .tfs + .sha256 (REQUIRED — the pair leg +# never rebuilds the image; one image, one digest) +# WORK build workspace (default: out/work-$ROLE[-$TRIPLET]) +# OUT artifact dir (default: out/$ROLE[-$TRIPLET]) +# +# Produces: ROLE=image → $OUT/.tfs + .sha256 + the +# release-tier payload manifest. ROLE=pair → $OUT/ (exe) + .sha256 +# + .manifest.json (the shard). +set -euo pipefail + +FEEDSTOCK="$(cd "$(dirname "$0")/.." && pwd)" +ROLE="${ROLE:-}" +FLAVOR="${FLAVOR:-jvm}" +TRIPLET="${TRIPLET:-aarch64-macos}" +ASSET_PLATFORM="${ASSET_PLATFORM:-macos-arm64}" +TAR="${TAR:-tar}" + +TFS_CLI="${TFS_CLI:-tfs}" +WRAPPER_BIN="${WRAPPER_BIN:-}" +SHIM_BIN="${SHIM_BIN:-}" +IMAGE_TARBALL="${IMAGE_TARBALL:-}" + +case "$ROLE" in + image) + WORK="${WORK:-$FEEDSTOCK/out/work-image}" + OUT="${OUT:-$FEEDSTOCK/out/image}" ;; + pair) + WORK="${WORK:-$FEEDSTOCK/out/work-pair-$TRIPLET}" + OUT="${OUT:-$FEEDSTOCK/out/pair-$TRIPLET}" ;; + *) echo "ROLE must be image or pair"; exit 64 ;; +esac + +step() { printf '\n== %s\n' "$*"; } + +# sha256sum exists on linux; macOS has only shasum. +sha256_of() { { sha256sum "$1" 2>/dev/null || shasum -a 256 "$1"; } | cut -d' ' -f1; } +sha256_check() { echo "$1 $2" | { sha256sum -c - 2>/dev/null || shasum -a 256 -c -; }; } +filesize() { stat -f%z "$1" 2>/dev/null || stat -c%s "$1"; } + +# The recipe carries a flavor dimension: flavors: holds the +# per-flavor scalars (4-space indent), its upstream: block the +# version/tag scalars (6-space) and the sources array (8/10-space). +# Parse without a YAML dependency (the recipe's shape is the feedstock's +# own convention, indentation-anchored). FLAVOR names the block; an +# unknown flavor reads empty and fails the presence checks below — a +# named error, never a guess (spec 00 §9). +_flavor_scope=' + /^flavors:/ { infl = 1; next } + /^[a-z_]+:/ { infl = 0 } + infl && index($0, " " flavor ":") == 1 { inf = 1; next } + infl && inf && /^ [a-z_]+:/ { inf = 0 } + !(infl && inf) { next } +' +recipe_flavor_value() { + # per-flavor scalar (implementation, language_version, abi_line, + # interpreter_api_version, entrypoints, platforms) — 4-space under the + # flavor key. + awk -v flavor="$FLAVOR" -v key="$1" "$_flavor_scope"' + $0 ~ ("^ " key ":") { + line = $0 + sub("^ " key ": *", "", line) + sub("^\"", "", line) + sub("\".*$", "", line) + sub(/[ \t]+#.*$/, "", line) + sub(/[ \t]+$/, "", line) + print line + exit + } + ' "$FEEDSTOCK/recipe.yml" +} +recipe_value() { + # the flavor's upstream scalar (version, tag) — 6-space; tolerates + # trailing comments — the value ends at the closing quote; anything + # after it is dropped + awk -v flavor="$FLAVOR" -v key="$1" "$_flavor_scope"' + $0 ~ ("^ " key ":") { + line = $0 + sub("^ " key ": *\"?", "", line) + sub("\".*$", "", line) + sub(/[ \t]+#.*$/, "", line) + sub(/[ \t]+$/, "", line) + print line + exit + } + ' "$FEEDSTOCK/recipe.yml" +} +recipe_source_value() { + # The ONE universal source (jruby ships no per-platform archives): the + # sources array's single entry is platform "universal". + awk -v flavor="$FLAVOR" -v key="$1" "$_flavor_scope"' + /^ - platform: "universal"/ { active = 1 } + active && $0 ~ ("^ " key ":") { + sub("^ " key ": *\"?", "") + sub("\"$", "") + print + exit + } + ' "$FEEDSTOCK/recipe.yml" +} +# The runtime block's scalars (indented two under the top-level +# `runtime:` key) — the SHARED contract keys. Quoted and unquoted values +# both parse; trailing comments are dropped either way. +recipe_runtime_value() { + awk -v key="$1" ' + /^runtime:/ { inrt = 1; next } + /^[a-z_]+:/ { inrt = 0 } + inrt && $0 ~ "^ " key ":" { + line = $0 + sub("^ " key ": *", "", line) + sub("^\"", "", line) + sub("\".*$", "", line) + sub(/[ \t]+#.*$/, "", line) + sub(/[ \t]+$/, "", line) + print line + exit + } + ' "$FEEDSTOCK/recipe.yml" +} +# The flavor's on_runtime sub-scalars (6-space under the 4-space +# on_runtime: key — spec 33 §2's owner edge) and the raw (unstripped) +# flavor scalar for the argv_template flow seq (its tokens are QUOTED — +# `{mount}` opens a flow mapping unquoted — so the generic value reader's +# quote-trimming would mangle it; the raw line is sed-parsed below). +recipe_onruntime_value() { + awk -v flavor="$FLAVOR" -v key="$1" "$_flavor_scope"' + /^ on_runtime:/ { inor = 1; next } + inor && /^ [a-z_]+:/ { inor = 0 } + inor && $0 ~ ("^ " key ":") { + line = $0 + sub("^ " key ": *", "", line) + sub("^\"", "", line) + sub("\".*$", "", line) + sub(/[ \t]+#.*$/, "", line) + sub(/[ \t]+$/, "", line) + print line + exit + } + ' "$FEEDSTOCK/recipe.yml" +} +recipe_flavor_raw() { + awk -v flavor="$FLAVOR" -v key="$1" "$_flavor_scope"' + $0 ~ ("^ " key ": *") { + line = $0 + sub("^ " key ": *", "", line) + sub(/[ \t]+#.*$/, "", line) + sub(/[ \t]+$/, "", line) + print line + exit + } + ' "$FEEDSTOCK/recipe.yml" +} + +VERSION="$(recipe_value version)" +TAG="$(recipe_value tag)" +URL="$(recipe_source_value url)" +SHA256="$(recipe_source_value sha256)" +STRIP="$(recipe_source_value strip_components)" +[ -n "$VERSION" ] && [ -n "$TAG" ] && [ -n "$URL" ] && [ -n "$SHA256" ] && [ -n "$STRIP" ] || { + echo "recipe.yml flavors.$FLAVOR upstream version/tag or the universal sources entry unreadable (unknown FLAVOR?)"; exit 1; } + +ENGINE="$(recipe_runtime_value engine)" +IMPLEMENTATION="$(recipe_flavor_value implementation)" +ABI_LINE="$(recipe_flavor_value abi_line)" +API_VERSION="$(recipe_flavor_value interpreter_api_version)" +INTERPRETER="$(recipe_runtime_value interpreter)" +WRAPPER_TEBAKO="$(recipe_flavor_value wrapper_tebako)" +[ -n "$WRAPPER_TEBAKO" ] || WRAPPER_TEBAKO="$(recipe_runtime_value wrapper_tebako)" +SMOKE_ARGS="$(recipe_runtime_value smoke_args)" +# The entrypoints + platform-coverage flow lists: [a, b, …] → a +# space-separated shell list. +ENTRYPOINTS="$(recipe_flavor_value entrypoints | sed -E 's/^\[//; s/\]$//; s/,/ /g')" +COVERAGE="$(recipe_flavor_value platforms | sed -E 's/^\[//; s/\]$//; s/,/ /g')" +# spec 33: the on_runtime edge defines this runtime (jruby has no other +# mode) — all four keys mandatory except implementation (jruby's edge +# has none by design: any java >= 21 owns it). +ON_ENGINE="$(recipe_onruntime_value engine)" +ON_CONSTRAINT="$(recipe_onruntime_value constraint)" +ON_MOUNT="$(recipe_onruntime_value mount)" +for v in ENGINE IMPLEMENTATION ABI_LINE API_VERSION INTERPRETER WRAPPER_TEBAKO SMOKE_ARGS ON_ENGINE ON_CONSTRAINT ON_MOUNT; do + eval "val=\$$v" + [ -n "$val" ] || { echo "recipe.yml runtime/flavors.$FLAVOR .${v} unreadable (or empty)"; exit 1; } +done +[ -n "$ENTRYPOINTS" ] || { echo "recipe.yml flavors.$FLAVOR.entrypoints unreadable (or empty)"; exit 1; } +[ -n "$COVERAGE" ] || { echo "recipe.yml flavors.$FLAVOR.platforms (the coverage list) unreadable (or empty)"; exit 1; } +INT_NAME="$(basename "$INTERPRETER")" + +# The compose template: one quoted, space-free token per element; strip +# the flow brackets, split on '", "' boundaries. +ARGV_TOKENS="$(recipe_flavor_raw argv_template | sed -E 's/^\[//; s/\][ \t]*$//; s/^"//; s/"$//; s/", *"/\n/g')" +[ -n "$ARGV_TOKENS" ] || { echo "recipe.yml flavors.$FLAVOR.argv_template unreadable (or empty)"; exit 1; } +ARGV_YAML="" +while IFS= read -r tok; do + ARGV_YAML="$ARGV_YAML - \"$tok\" +" +done </dev/null || mv "$ROOT" "$ROOT.stale.$$" + mkdir -p "$ROOT" + # The archive nests the home under one directory (jruby-/). The + # home's own layout IS the payload layout (/bin/jruby, /lib/jruby.jar, + # /lib/ruby, /lib/jni, …). + "$TAR" -xf "$ARCHIVE" -C "$ROOT" --strip-components="$STRIP" + [ -x "$ROOT$INTERPRETER" ] || { echo "no $INTERPRETER in the staged tree"; exit 1; } + [ -f "$ROOT/lib/jruby.jar" ] || { echo "no lib/jruby.jar in the staged tree — the whole runtime is that jar"; exit 1; } + + # The dwarfs-t writer has no symlink node type (verified 2026-09-06 on + # the graalvm tree: a symlink lands as a 0-byte regular-file node that + # stat cannot open — silent zeroing; the named ecosystem follow-up + # stands: the writer should store link nodes or refuse loudly). The + # stage dereferences every symlink whose terminal target is a real file + # INSIDE the staged tree; a dangling or tree-escaping link is a named + # error here, never a silent 0-byte node in the image. + step "dereference in-tree symlinks (the writer has no link node type)" + resolve_link() { + local p="$1" t d n=0 + while [ -L "$p" ]; do + n=$((n+1)); [ "$n" -gt 8 ] && return 1 + t="$(readlink "$p")" + case "$t" in /*) return 2 ;; esac + d="$(cd "$(dirname "$p")" && cd "$(dirname "$t")" 2>/dev/null && pwd -P)" || return 1 + p="$d/$(basename "$t")" + done + [ -f "$p" ] || return 1 + printf '%s' "$p" + } + deref_count=0 + while IFS= read -r link; do + rel="${link#"$ROOT"/}" + if resolved="$(resolve_link "$link")"; then + case "$resolved" in + "$ROOT"/*) : ;; + *) echo "symlink $rel resolves outside the payload tree: $resolved"; exit 1 ;; + esac + rm "$link" && cp "$resolved" "$link" + deref_count=$((deref_count+1)) + else + rc=$? + [ "$rc" = 2 ] && echo "symlink $rel has an ABSOLUTE target (escapes the VFS root): $(readlink "$link")" \ + || echo "symlink $rel dangles or loops" + exit 1 + fi + done < <(find "$ROOT" -type l) + echo "dereferenced: $deref_count" + + step "embed /__tpkg__/manifest.yaml (blob digest zeroed, spec 03 §7)" + mkdir -p "$ROOT/__tpkg__" + EP_YAML="" + for ep in $ENTRYPOINTS; do + EP_YAML="$EP_YAML - {name: \"$ep\", path: \"/bin/$ep\"} +" + done + sed -e "s|@@NAME@@|jruby|g" \ + -e "s|@@VERSION@@|$VERSION|g" \ + -e "s|@@CREATED@@|$(date -u +%Y-%m-%dT%H:%M:%SZ)|g" \ + -e "s|@@UPSTREAM_SHA256@@|$SHA256|g" \ + -e "s|@@UPSTREAM_COMMIT@@|$TAG|g" \ + -e "s|@@BLOB_SHA256@@|0000000000000000000000000000000000000000000000000000000000000000|g" \ + -e "s|@@ENGINE@@|$ENGINE|g" \ + -e "s|@@IMPLEMENTATION@@|$IMPLEMENTATION|g" \ + -e "s|@@ON_ENGINE@@|$ON_ENGINE|g" \ + -e "s|@@ON_CONSTRAINT@@|$ON_CONSTRAINT|g" \ + -e "s|@@ON_MOUNT@@|$ON_MOUNT|g" \ + "$FEEDSTOCK/manifests/runtime.yaml" | + EP_YAML="$EP_YAML" ARGV_YAML="$ARGV_YAML" PROVIDES_YAML="$PROVIDES_YAML" awk '{ + if (index($0, "@@ENTRYPOINTS_YAML@@")) printf "%s", ENVIRON["EP_YAML"]; + else if (index($0, "@@ARGV_YAML@@")) printf "%s", ENVIRON["ARGV_YAML"]; + else if (index($0, "@@PROVIDES_YAML@@")) printf "%s", ENVIRON["PROVIDES_YAML"]; + else print }' \ + > "$ROOT/__tpkg__/manifest.yaml" + + step "embed /lib/tebako/layout.yaml (the pair contract — NO interpreter, spec 33 §5)" + mkdir -p "$ROOT/lib/tebako" + sed -e "s|@@MOUNT_ROOT@@|$MOUNT_ROOT|g" \ + -e "s|@@API_VERSION@@|$API_VERSION|g" \ + "$FEEDSTOCK/manifests/layout.yaml" > "$ROOT/lib/tebako/layout.yaml" + + step "tfs mkimage (the in-process dwarfs-t writer)" + IMG="$OUT/$IMAGE_STEM.tfs" + rm -f "$IMG" + "$TFS_CLI" mkimage --format dwarfs "$ROOT" --output "$IMG" + sha=$(sha256_of "$IMG") + echo "image: $IMG" + echo "sha256: $sha" + printf '%s %s\n' "$sha" "$IMAGE_STEM.tfs" > "$IMG.sha256" + + # Read-back: a broken image fails THIS leg, never the publish step. + step "read-back: embedded manifest validates, layout + declared entrypoints present" + native_path() { if command -v cygpath >/dev/null 2>&1; then cygpath -m "$1"; else printf '%s' "$1"; fi; } + IMG_NATIVE="$(native_path "$IMG")" + MSYS2_ARG_CONV_EXCL='*' "$TFS_CLI" stat "$IMG_NATIVE" /__tpkg__/manifest.yaml >/dev/null || { + echo "read-back FAILED: no /__tpkg__/manifest.yaml in $IMG"; exit 1; } + MSYS2_ARG_CONV_EXCL='*' "$TFS_CLI" stat "$IMG_NATIVE" /lib/tebako/layout.yaml >/dev/null || { + echo "read-back FAILED: no /lib/tebako/layout.yaml in $IMG"; exit 1; } + echo "read-back: /lib/tebako/layout.yaml present" + INFO="$(MSYS2_ARG_CONV_EXCL='*' "$TFS_CLI" info --json "$IMG_NATIVE")" || { + echo "read-back FAILED: tfs info cannot read $IMG"; exit 1; } + case "$INFO" in + *'"manifest_error"'*) + printf '%s\n' "$INFO" | grep '"manifest_error"' >&2 + echo "read-back FAILED: embedded manifest does not validate" >&2; exit 1 ;; + esac + for ep in $ENTRYPOINTS; do + MSYS2_ARG_CONV_EXCL='*' "$TFS_CLI" stat "$IMG_NATIVE" "/bin/$ep" >/dev/null || { + echo "read-back FAILED: declared entrypoint /bin/$ep missing from $IMG"; exit 1; } + echo "read-back: /bin/$ep present" + done + + step "fill the sibling manifest (release tier: real blob sha)" + sed -e "s|@@BLOB_SHA256@@|$sha|g" \ + "$ROOT/__tpkg__/manifest.yaml" > "$OUT/payload-$FLAVOR-universal.yaml" + echo "manifest: $OUT/payload-$FLAVOR-universal.yaml" + echo "IMAGE-LEG-DONE sha256=$sha" + +else +# ==================================================================== pair + step "pair the wrapper exe ($STEM)" + [ -n "$WRAPPER_BIN" ] && [ -f "$WRAPPER_BIN" ] || { + echo "WRAPPER_BIN (the tebako-runtime-launcher exe) is required — the pair ships both artifacts"; exit 1; } + EXE_OUT="$OUT/$STEM" + cp "$WRAPPER_BIN" "$EXE_OUT" + chmod 755 "$EXE_OUT" + exe_sha=$(sha256_of "$EXE_OUT") + echo "wrapper: $EXE_OUT" + echo "sha256: $exe_sha" + printf '%s %s\n' "$exe_sha" "$STEM" > "$EXE_OUT.sha256" + + step "take the universal image (built once by the image leg)" + [ -n "$IMAGE_TARBALL" ] && [ -f "$IMAGE_TARBALL/$IMAGE_STEM.tfs" ] || { + echo "IMAGE_TARBALL must name the image leg's artifact dir carrying $IMAGE_STEM.tfs"; exit 1; } + IMG="$OUT/$IMAGE_STEM.tfs" + rm -f "$IMG" # a prior run left it 0444 + cp "$IMAGE_TARBALL/$IMAGE_STEM.tfs" "$IMG" + chmod 444 "$IMG" + # The sidecar is the digest channel (the image leg wrote it). + want_img_sha="$(cut -d' ' -f1 "$IMAGE_TARBALL/$IMAGE_STEM.tfs.sha256")" + sha256_check "$want_img_sha" "$IMG" + img_sha="$want_img_sha" + echo "image: $IMG (sha256 verified against the image leg's sidecar)" + + step "the release shard (.manifest.json — image.filename = the UNIVERSAL asset)" + ARCHIVE_NAME="$(basename "$URL")" + RELEASE_REF="${GITHUB_REF_NAME:-local}" + # The shard mirrors the on_runtime edge for the shim's plan-time read + # (spec 33 §2; the in-image block stays the authority — the driver + # cross-checks). jruby's edge carries NO implementation (any java >= + # 21). "abi": "none" is the cext sentinel: jruby runs NO native + # extensions, and a native-extension payload (whose runtime_requirement + # declares the ruby platform string as abi) must fail AT DISPATCH with + # the resolver's named abi-mismatch error — never match through the + # absent-field compat window and crash late (TODO.jruby/02's + # acceptance). The MRI line's real abi strings never equal "none". + cat > "$OUT/$STEM.manifest.json" <&1) + rc=$? + set -e + echo "$out" | head -3 + [ "$rc" = 65 ] || { echo "::error::standalone jvm boot wanted exit 65 (named fail-closed), got $rc"; exit 1; } + echo "standalone negative smoke OK (exit 65)" + + step "composed smoke (shim → this runtime → the published temurin owner)" + # The pins (pins.rb): OWNER_REPO/OWNER_RELEASE/OWNER_STEM/ + # OWNER_STORE_ID/OWNER_EXE_SHA256/OWNER_IMAGE_SHA256/DEP_STORE_ID and + # the TEBAKO_SHIM_ASSET/TEBAKO_SHIM_SHA256 tool pins. SHIM_BIN is the + # downloaded dispatcher (the workflow's fetch step). + for v in OWNER_REPO OWNER_RELEASE OWNER_STEM OWNER_STORE_ID OWNER_EXE_SHA256 OWNER_IMAGE_SHA256 DEP_STORE_ID; do + eval "val=\${$v:-}" + [ -n "$val" ] || { echo "::error::$v unset — run tools/pins.rb for this leg first (the composed smoke pins)"; exit 1; } + done + [ -n "$SHIM_BIN" ] && [ -f "$SHIM_BIN" ] || { echo "::error::SHIM_BIN (the tebako-shim exe) is required for the composed smoke"; exit 1; } + + STORE="$WORK/store" + rm -rf "$STORE" 2>/dev/null || mv "$STORE" "$STORE.stale.$$" + mkdir -p "$STORE/runtimes/$OWNER_STORE_ID" "$STORE/runtimes/$DEP_STORE_ID" \ + "$STORE/payloads/rbcsmoke" "$STORE/shims" + + # The owner entry: the PUBLISHED temurin pair (releases are the + # interface), sha256-verified against BOTH the recipe pins and the + # owner release's own SHA256SUMS (the two-anchor rule). + obase="https://github.com/$OWNER_REPO/releases/download/$OWNER_RELEASE" + curl -sL --fail -o "$WORK/owner.exe" "$obase/$OWNER_STEM" + curl -sL --fail -o "$WORK/owner.tfs" "$obase/$OWNER_STEM.tfs" + curl -sL --fail -o "$WORK/owner.manifest.json" "$obase/$OWNER_STEM.manifest.json" + curl -sL --fail -o "$WORK/owner-SHA256SUMS.txt" "$obase/SHA256SUMS.txt" + sha256_check "$OWNER_EXE_SHA256" "$WORK/owner.exe" + sha256_check "$OWNER_IMAGE_SHA256" "$WORK/owner.tfs" + for f in "$OWNER_STEM" "$OWNER_STEM.tfs"; do + want="$( [ "$f" = "$OWNER_STEM" ] && echo "$OWNER_EXE_SHA256" || echo "$OWNER_IMAGE_SHA256" )" + got="$(awk -v a="$f" '$2 == a {print $1}' "$WORK/owner-SHA256SUMS.txt")" + [ "$got" = "$want" ] || { echo "::error::owner release sums disagree with the recipe pin for $f"; exit 1; } + done + odir="$STORE/runtimes/$OWNER_STORE_ID" + cp "$WORK/owner.exe" "$odir/$OWNER_STEM" && chmod 755 "$odir/$OWNER_STEM" + cp "$WORK/owner.tfs" "$odir/$OWNER_STEM.tfs" && chmod 444 "$odir/$OWNER_STEM.tfs" + printf '%s %s\n' "$OWNER_EXE_SHA256" "$OWNER_STEM" > "$odir/sha256" + printf '%s %s\n' "$OWNER_IMAGE_SHA256" "$OWNER_STEM.tfs" > "$odir/$OWNER_STEM.tfs.sha256" + printf 'url=%s/%s\nsha256=%s\n' "$obase" "$OWNER_STEM" "$OWNER_EXE_SHA256" > "$odir/origin" + printf 'url=%s/%s.tfs\nsha256=%s\n' "$obase" "$OWNER_STEM" "$OWNER_IMAGE_SHA256" > "$odir/$OWNER_STEM.tfs.origin" + # The store's manifest.json is the release shard, array-wrapped. + ruby -rjson -e 'puts JSON.pretty_generate([JSON.parse(File.read(ARGV[0]))])' \ + "$WORK/owner.manifest.json" > "$odir/manifest.json" + + # The dep entry: the pair THIS leg just built (per-triplet exe + + # the UNIVERSAL image + this leg's shard). + ddir="$STORE/runtimes/$DEP_STORE_ID" + cp "$EXE_OUT" "$ddir/$STEM" && chmod 755 "$ddir/$STEM" + cp "$IMG" "$ddir/$IMAGE_STEM.tfs" && chmod 444 "$ddir/$IMAGE_STEM.tfs" + printf '%s %s\n' "$exe_sha" "$STEM" > "$ddir/sha256" + printf '%s %s\n' "$img_sha" "$IMAGE_STEM.tfs" > "$ddir/$IMAGE_STEM.tfs.sha256" + printf 'url=file://%s\nsha256=%s\n' "$EXE_OUT" "$exe_sha" > "$ddir/origin" + printf 'url=file://%s\nsha256=%s\n' "$IMG" "$img_sha" > "$ddir/$IMAGE_STEM.tfs.origin" + ruby -rjson -e 'puts JSON.pretty_generate([JSON.parse(File.read(ARGV[0]))])' \ + "$OUT/$STEM.manifest.json" > "$ddir/manifest.json" + + # The probe payload (kind app, runtime_requirement ruby:jruby) — the + # spec-33 dogfood's P-cell shape: prints RUBY_ENGINE and loads the json + # default gem (jruby's own java-backed json — no cext involved). + SMOKE_STAGE="$WORK/smoke-stage" + mkdir -p "$SMOKE_STAGE/app/bin" "$SMOKE_STAGE/__tpkg__" + cat > "$SMOKE_STAGE/app/bin/hello" <<'RUBY' +puts "hello from #{RUBY_ENGINE} #{RUBY_VERSION}" +require "json" +puts JSON.generate({ok: true, engine: RUBY_ENGINE}) +RUBY + chmod 755 "$SMOKE_STAGE/app/bin/hello" + cat > "$SMOKE_STAGE/__tpkg__/manifest.yaml" <= 10, < 11"} + platforms: universal + capabilities: {exec: true, read: true} +YAML + "$TFS_CLI" mkimage --format dwarfs "$SMOKE_STAGE" --output "$STORE/payloads/rbcsmoke/0.1.0.tfs" + chmod 444 "$STORE/payloads/rbcsmoke/0.1.0.tfs" + psha="$(sha256_of "$STORE/payloads/rbcsmoke/0.1.0.tfs")" + printf '%s %s\n' "$psha" "0.1.0.tfs" > "$STORE/payloads/rbcsmoke/0.1.0.tfs.sha256" + cp "$SMOKE_STAGE/__tpkg__/manifest.yaml" "$STORE/payloads/rbcsmoke/0.1.0.manifest.yaml" + cp "$SHIM_BIN" "$STORE/shims/rbcsmoke" && chmod 755 "$STORE/shims/rbcsmoke" + + out=$(env TEBAKO_HOME="$STORE" TEBAKO_OFFLINE=1 TEBAKO_RBCSMOKE_VERSION=0.1.0 \ + TMPDIR="$WORK/tmp" \ + "$STORE/shims/rbcsmoke" 2>&1) || { echo "::error::composed smoke failed: $out"; exit 1; } + echo "$out" | grep -v '^\[tebako\]' | head -3 + echo "$out" | grep -q '"engine":"jruby"' || { + echo "::error::composed smoke did not run on jruby: $out"; exit 1; } + echo "composed smoke OK (shim → jruby → temurin owner, json default gem loaded)" + + # The cext NEGATIVE cell (TODO.jruby/02's acceptance): a payload whose + # runtime_requirement declares a native-extension abi must fail AT + # DISPATCH with the resolver's named abi-mismatch error (the jruby + # shard's abi:"none" sentinel can never equal a real platform string) — + # never match and crash late. + step "cext negative smoke (native-abi payload → named dispatch error)" + cext_stage="$WORK/cext-stage" + mkdir -p "$cext_stage/app/bin" "$cext_stage/__tpkg__" + cat > "$cext_stage/app/bin/cextapp" <<'RUBY' +puts "must never print" +RUBY + chmod 755 "$cext_stage/app/bin/cextapp" + sed -e 's/name: rbcsmoke/name: cextapp/' \ + -e 's/path: \/app\/bin\/hello/path: \/app\/bin\/cextapp/' \ + -e 's/constraint: ">= 10, < 11"}/constraint: ">= 10, < 11", abi: arm64-darwin-23}/' \ + "$SMOKE_STAGE/__tpkg__/manifest.yaml" > "$cext_stage/__tpkg__/manifest.yaml" + mkdir -p "$STORE/payloads/cextapp" + "$TFS_CLI" mkimage --format dwarfs "$cext_stage" --output "$STORE/payloads/cextapp/0.1.0.tfs" + chmod 444 "$STORE/payloads/cextapp/0.1.0.tfs" + csha="$(sha256_of "$STORE/payloads/cextapp/0.1.0.tfs")" + printf '%s %s\n' "$csha" "0.1.0.tfs" > "$STORE/payloads/cextapp/0.1.0.tfs.sha256" + cp "$cext_stage/__tpkg__/manifest.yaml" "$STORE/payloads/cextapp/0.1.0.manifest.yaml" + cp "$SHIM_BIN" "$STORE/shims/cextapp" && chmod 755 "$STORE/shims/cextapp" + set +e + cout=$(env TEBAKO_HOME="$STORE" TEBAKO_OFFLINE=1 TEBAKO_CEXTAPP_VERSION=0.1.0 \ + TMPDIR="$WORK/tmp" \ + "$STORE/shims/cextapp" 2>&1) + crc=$? + set -e + echo "$cout" | head -5 + [ "$crc" != 0 ] || { echo "::error::cext payload booted on jruby — the abi sentinel failed"; exit 1; } + case "$cout" in + *must\ never\ print*) echo "::error::the cext payload EXECUTED on jruby"; exit 1 ;; + esac + echo "cext negative smoke OK (named dispatch failure, exit $crc — never a boot)" +fi diff --git a/tools/pins.rb b/tools/pins.rb new file mode 100755 index 0000000..5e516d4 --- /dev/null +++ b/tools/pins.rb @@ -0,0 +1,158 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# pins.rb — read recipe.yml's `tools:` block (the repo's toolchain pin +# SSOT) and emit KEY=VALUE lines for $GITHUB_ENV. The workflow carries NO +# version or digest literals — every value flows from the recipe. +# +# ruby tools/pins.rb [flavor] [--env] +# ruby tools/pins.rb --release-only [flavor] +# +# is the tebako release asset platform (macos-arm64, +# linux-gnu-x86_64). [flavor] is the recipe's flavor key — jruby has ONE +# flavor (`jvm`, the only mode; the default), kept in the spec 28 §8 +# shape so the machinery mirrors the sibling runtime feedstocks. +# --release-only emits just TEBAKO_RELEASE/PKG_NAME/PKG_VERSION. A tool +# listed in tools.sha256 without a pin for the requested platform is a +# named error, never a guess (spec 00 §9). +# +# Runtime-promotion additions (the kind: runtime pair): the wrapper exe +# pin flows from recipe.yml's runtime.wrapper_tebako (WRAPPER_RELEASE / +# WRAPPER_ASSET / RUNTIME_STEM_BASE) and the POSIX legs get PRELOAD_SHIM +# (the extracted link-unit tarball's libtfs_preload path). The sha256 +# map is data-driven: every tool key under tools.sha256 emits +# _ASSET/_SHA256 (link-unit names a .tar.gz, the CLIs name +# bare binaries). +# +# NEVER emit a bare TEBAKO_VERSION: in the sibling feedstocks tools/build +# uses that name for the RUNTIME release line with an env override, so a +# tools-version export silently clobbers the runtime pin (the 2026-08-27 +# metanorma collision: the press resolved runtime release v0.3.1, exit +# 124). The tools version lives inside the computed ASSET names. + +require "yaml" + +def die(msg) + warn "pins.rb: #{msg}" + exit 64 +end + +root = File.expand_path("..", __dir__) +recipe = YAML.load_file(File.join(root, "recipe.yml")) +tools = recipe.fetch("tools") +release = tools.fetch("release") +version = release.sub(/\Av/, "") +die "recipe.yml tools.sha256 missing" unless tools["sha256"].is_a?(Hash) + +# The flavor axis (spec 28 §8's shape): the flavor key names the recipe's +# flavors: block (jruby has exactly one — `jvm`, the default; the +# shape stays so the tooling mirrors the sibling feedstocks). Positional +# after the tool-platform (`pins.rb macos-arm64 jvm --env`), or the sole +# positional in --release-only mode (`pins.rb --release-only`). +# PKG_VERSION, RUNTIME_STEM_BASE and IMPLEMENTATION follow the selected +# flavor. +positional = ARGV - ["--env", "--release-only"] +flavor = if ARGV.include?("--release-only") + positional[0] || ENV["FLAVOR"] || "jvm" + else + positional[1] || ENV["FLAVOR"] || "jvm" + end +flavors = recipe.fetch("flavors") do + die "recipe.yml flavors block missing" +end +flavor_block = flavors[flavor] or + die "recipe.yml: unknown flavor '#{flavor}' (have: #{flavors.keys.join(', ')})" + +runtime = recipe.fetch("runtime") +# The wrapper pin is per-FLAVOR overridable (the jvm flavor requires the +# spec-33-aware launcher line; the shared runtime block stays the native +# flavor's): flavors..wrapper_tebako / .wrapper_sha256 win when +# present. A missing pin is a named error either way (spec 00 §9). +wrapper_tebako = flavor_block["wrapper_tebako"] || runtime.fetch("wrapper_tebako") +wrapper_shas = flavor_block["wrapper_sha256"] || runtime.fetch("wrapper_sha256") +pkg_version = flavor_block.dig("upstream", "version") || + die("recipe.yml flavors.#{flavor}.upstream.version missing") + +pairs = { + "TEBAKO_RELEASE" => release, + "PKG_NAME" => recipe.fetch("name"), + "PKG_VERSION" => pkg_version, + "FLAVOR" => flavor, + "IMPLEMENTATION" => flavor_block.fetch("implementation"), +} + +unless ARGV.include?("--release-only") + platform = ARGV[0] or die "usage: pins.rb [--env] | pins.rb --release-only" + exe = platform.start_with?("windows") ? ".exe" : "" + # Tools the recipe pins for POSIX legs only: a missing platform pin + # warns + skips; for every other tool a missing pin is a named error, + # never a guess. (This feedstock ships NO windows leg — upstream has no + # windows build — but the guard stays: a tool's POSIX-only scope is the + # recipe's data, not this script's assumption.) + posix_only = %w[link-unit] + tools.fetch("sha256").each do |tool, shas| + sha = shas[platform] + if sha.nil? + die "recipe.yml: no tools.sha256.#{tool}.#{platform} pin" unless posix_only.include?(tool) + warn "pins.rb: #{tool} has no #{platform} pin (POSIX-only tool — skipped)" + next + end + key = tool.upcase.tr("-", "_") + asset = if tool == "link-unit" + "#{tool}-#{version}-#{platform}.tar.gz" + else + "#{tool}-#{version}-#{platform}#{exe}" + end + pairs["#{key}_ASSET"] = asset + pairs["#{key}_SHA256"] = sha + end + # The wrapper exe (spec 29): the recipe's runtime.wrapper_tebako names + # the line (ships since tebako v2.1.0); runtime.wrapper_sha256 is its + # per-platform trust anchor — a missing pin is a named error, never a + # guess (the launcher ships as the runtime pair's entry point). + pairs["WRAPPER_RELEASE"] = "v#{wrapper_tebako}" + pairs["WRAPPER_ASSET"] = "tebako-runtime-launcher-#{wrapper_tebako}-#{platform}#{exe}" + pairs["WRAPPER_SHA256"] = wrapper_shas[platform] || + die("recipe.yml: no wrapper_sha256.#{platform} pin for flavor '#{flavor}'") + pairs["RUNTIME_STEM_BASE"] = "tebako-runtime-#{wrapper_tebako}-#{pkg_version}" + pairs["RUNTIME_STEM"] = "#{pairs['RUNTIME_STEM_BASE']}-#{platform}" + # The env image is UNIVERSAL (the dist ships every platform's jffi + # stub): ONE asset serves all triplets; each per-triplet shard's + # image.filename names THIS stem (entry_asset_names flows it verbatim — + # no product change). + pairs["RUNTIME_IMAGE_STEM"] = "#{pairs['RUNTIME_STEM_BASE']}-universal" + # The extracted preload shim (POSIX legs only). The tarball's internal + # top dir is VERSION-LESS (link-unit-/; the version lives in + # the asset name) — link-unit-stage.sh has tarred it that way since the + # script exists. + unless platform.start_with?("windows") + dl_ext = platform.include?("macos") ? "dylib" : "so" + pairs["PRELOAD_SHIM"] = ".packager/link-unit-#{platform}/libtfs_preload.#{dl_ext}" + end + # The composed smoke's owner pair (flavors with an on_runtime edge — + # spec 33): the workflow downloads the PUBLISHED owner runtime into a + # throwaway store. The recipe's flavors..owner_smoke block is + # the pin SSOT (repo/release/version/tebako line + exe/image sha256 + # per platform, cross-checked against that release's own SHA256SUMS at + # fetch). The store dir ids ride the release-asset platform spelling + # (java---), matching the store grammar. + if (owner = flavor_block["owner_smoke"]) + owner_version = owner.fetch("version") + owner_line = owner.fetch("tebako") + pairs["OWNER_REPO"] = owner.fetch("repo") + pairs["OWNER_RELEASE"] = owner.fetch("release") + pairs["OWNER_STEM"] = "tebako-runtime-#{owner_line}-#{owner_version}-#{platform}" + pairs["OWNER_STORE_ID"] = "java-#{owner_version}-#{owner_line}-#{platform}" + pairs["OWNER_EXE_SHA256"] = owner.fetch("exe_sha256")[platform] || + die("recipe.yml: no owner_smoke.exe_sha256.#{platform} pin") + pairs["OWNER_IMAGE_SHA256"] = owner.fetch("image_sha256")[platform] || + die("recipe.yml: no owner_smoke.image_sha256.#{platform} pin") + pairs["DEP_STORE_ID"] = "ruby-#{pkg_version}-#{wrapper_tebako}-#{platform}" + end +end + +if ARGV.include?("--env") || ARGV.include?("--release-only") + pairs.each { |k, v| puts "#{k}=#{v}" } +else + pairs.each { |k, v| puts "export #{k}=#{v}" } +end