From e2b06bced3e969d04e63fd336cbce4757b4cf64f Mon Sep 17 00:00:00 2001 From: "Peter H. Boling" Date: Wed, 4 Nov 2026 14:04:51 +0000 Subject: [PATCH 1/8] kettle-jem: harden macOS runners in the current.yml workflow template Adds a first, macOS-only step (before checkout, since checkout runs git) that removes the eMudhra-related roots from /etc/ssl/cert.pem, generated only when macos is a declared platform so no dead step is emitted otherwise. The script is packaged under lib/kettle/jem/ci and inlined into the workflow; it matches by subject, logs removed fingerprints and verifies the result. --- CHANGELOG.md | 2 + gems/kettle-jem/lib/kettle/jem.rb | 33 ++++++++ .../kettle/jem/ci/harden_macos_ca_bundle.sh | 75 +++++++++++++++++++ .../.github/workflows/current.yml.example | 2 +- .../appraisals_and_gemfile_template_spec.rb | 4 +- .../spec/kettle/jem_platforms_config_spec.rb | 64 +++++++++++++++- 6 files changed, 175 insertions(+), 5 deletions(-) create mode 100755 gems/kettle-jem/lib/kettle/jem/ci/harden_macos_ca_bundle.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ac0c501b..345b85b06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,8 @@ Please file a bug if you notice a violation of semantic versioning. ### Added +- kettle-jem: the `current.yml` workflow template now removes the eMudhra-related roots that Apple still ships in `/etc/ssl/cert.pem` as its first step (before checkout) on macOS runners, when `macos` is a declared platform. The inlined script, `lib/kettle/jem/ci/harden_macos_ca_bundle.sh`, matches by certificate subject, logs each removed fingerprint and fails if any remains; it is not emitted for destinations without macOS lanes (galadran/we-have-a-problem.com#157) + - ast-crispr-yaml-psych: `Selectors.mapping_entry` and `Selectors.sequence_item` can be used as `Insert` destinations (insert after the matched owner) (#105) ### Changed diff --git a/gems/kettle-jem/lib/kettle/jem.rb b/gems/kettle-jem/lib/kettle/jem.rb index ebac8fbde..3f505ee62 100644 --- a/gems/kettle-jem/lib/kettle/jem.rb +++ b/gems/kettle-jem/lib/kettle/jem.rb @@ -14985,6 +14985,7 @@ def template_tokens(facts, funding) "KJ|CI:EXEC_CMD" => facts.dig(:ci, :exec_cmd).to_s, "KJ|CI:OS_MATRIX_ENTRIES" => github_actions_current_os_matrix_entries(facts), "KJ|CI:OS_TEST_STEPS" => github_actions_current_os_test_steps(facts), + "KJ|CI:MACOS_HARDENING_STEP" => github_actions_macos_hardening_step(facts), "KJ|README:CURRENT_MRI_PLATFORMS" => readme_current_mri_platforms(facts), "KJ|RAKE:TOP_LEVEL_DOCS_TASK" => rake_top_level_docs_task_token(facts).rstrip, "KJ|GITHUB_ACTIONS:COVERAGE_UPLOAD_STEPS" => github_actions_coverage_steps(disabled_integrations: facts.dig(:integrations, :disabled)) @@ -20960,6 +20961,38 @@ def github_actions_current_os_matrix_entries(facts) end.join("\n") end + MACOS_CA_HARDENING_SCRIPT = File.expand_path("jem/ci/harden_macos_ca_bundle.sh", __dir__) + private_constant :MACOS_CA_HARDENING_SCRIPT + + # The first step of current.yml's test job when macos is a declared platform: + # removes the eMudhra-related roots Apple still ships in /etc/ssl/cert.pem + # (https://github.com/galadran/we-have-a-problem.com/issues/157). The script is + # inlined, not fetched or run from the repo, because it has to run before the + # first download, and actions/checkout runs git. Like the Windows guard, the + # step exists only when its platform is declared, so no dead `if:` is emitted + # for linux-only or linux+windows destinations. Returns the step plus a blank + # line, or "" so the token vanishes without leaving a stray line behind. The + # script is a copy of structuredmerge/structuredmerge + # workspace-scripts/harden_macos_ca_bundle.sh (tested there against a bundle + # containing an eMudhra root). + def github_actions_macos_hardening_step(facts) + platforms = facts.dig(:ci, :platforms) + platforms = DEFAULT_PLATFORMS if platforms.nil? || platforms.empty? + return "" unless platforms.include?("macos") + + header = [ + " - name: Remove eMudhra roots from the macOS CA bundle", + " if: runner.os == 'macOS'", + " shell: bash", + " run: |" + ] + script = File.read(MACOS_CA_HARDENING_SCRIPT).rstrip.lines(chomp: true).map do |line| + line.empty? ? "" : " #{line}" + end + + "#{(header + script).join("\n")}\n\n" + end + # The current.yml test steps, generated from the same platforms facts. The # Windows-only guard and step exist only when windows is declared, so no # tautological `if:` survives in a linux+macos destination (spec slice diff --git a/gems/kettle-jem/lib/kettle/jem/ci/harden_macos_ca_bundle.sh b/gems/kettle-jem/lib/kettle/jem/ci/harden_macos_ca_bundle.sh new file mode 100755 index 000000000..611970054 --- /dev/null +++ b/gems/kettle-jem/lib/kettle/jem/ci/harden_macos_ca_bundle.sh @@ -0,0 +1,75 @@ +#!/bin/sh +# Remove the eMudhra-related roots from the macOS system CA bundle. +# +# Apple's /etc/ssl/cert.pem still carries roots from the eMudhra incident, and the +# developer tools Apple ships (git, curl, python3) read that file, so a network +# attacker could tamper with downloads made during a release build. GitHub's macOS +# runners are ephemeral, so removing the roots at job start needs no re-application +# after OS updates. Reference: https://github.com/galadran/we-have-a-problem.com/issues/157 +# +# It must run BEFORE the first step that downloads anything (actions/checkout runs +# git), so the workflows inline this exact file. A contract test keeps them identical. +# +# Matching is by certificate subject so no fingerprints need to be guessed; every +# removed certificate is printed with its SHA-256 fingerprint for the audit log, and +# the script fails if any matching certificate is still present afterwards. +# +# Environment (for tests): CA_BUNDLE (default /etc/ssl/cert.pem), SUDO (default sudo). +set -eu + +bundle="${CA_BUNDLE:-/etc/ssl/cert.pem}" +sudo_cmd="${SUDO-sudo}" +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT + +matches() { + case "$1" in + *"AAA Certificate Services"* | *"emSign ECC Root CA - G3"* | *[eE][mM]udhra*) return 0 ;; + *) return 1 ;; + esac +} + +total="$(grep -c -- '-----BEGIN CERTIFICATE-----' "$bundle")" +[ "$total" -ge 100 ] || { echo "refusing to edit $bundle: only $total certificates" >&2; exit 1; } + +awk -v dir="$work" ' + /-----BEGIN CERTIFICATE-----/ { if (out != "") close(out); n++; out = dir "/c" n ".pem" } + out != "" { print > out } +' "$bundle" + +: > "$work/clean.pem" +removed=0 +i=1 +while [ "$i" -le "$total" ]; do + file="$work/c$i.pem" + subject="$(openssl x509 -in "$file" -noout -subject 2>/dev/null || true)" + if [ -n "$subject" ] && matches "$subject"; then + fingerprint="$(openssl x509 -in "$file" -noout -fingerprint -sha256)" + echo "removing: $subject ($fingerprint)" + removed=$((removed + 1)) + else + cat "$file" >> "$work/clean.pem" + fi + i=$((i + 1)) +done + +if [ "$removed" -gt 0 ]; then + $sudo_cmd tee "$bundle" < "$work/clean.pem" > /dev/null +fi + +# Verify what is on disk now, not what we intended to write. +after="$(grep -c -- '-----BEGIN CERTIFICATE-----' "$bundle")" +[ "$after" -eq $((total - removed)) ] || { echo "certificate count mismatch: $after != $total - $removed" >&2; exit 1; } +rm -f "$work"/c*.pem +awk -v dir="$work" ' + /-----BEGIN CERTIFICATE-----/ { if (out != "") close(out); n++; out = dir "/v" n ".pem" } + out != "" { print > out } +' "$bundle" +for file in "$work"/v*.pem; do + subject="$(openssl x509 -in "$file" -noout -subject 2>/dev/null || true)" + if [ -n "$subject" ] && matches "$subject"; then + echo "still trusted after cleanup: $subject" >&2 + exit 1 + fi +done +echo "CA bundle $bundle: $total certificates, removed $removed, $after remain." diff --git a/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/current.yml.example b/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/current.yml.example index 25cc40d17..7f4a6fce1 100644 --- a/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/current.yml.example +++ b/gems/kettle-jem/lib/kettle/jem/templates/.github/workflows/current.yml.example @@ -41,7 +41,7 @@ jobs: {KJ|CI:OS_MATRIX_ENTRIES} steps: - - name: Checkout +{KJ|CI:MACOS_HARDENING_STEP} - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false diff --git a/gems/kettle-jem/spec/integration/appraisals_and_gemfile_template_spec.rb b/gems/kettle-jem/spec/integration/appraisals_and_gemfile_template_spec.rb index 614ba98cc..2926a28df 100644 --- a/gems/kettle-jem/spec/integration/appraisals_and_gemfile_template_spec.rb +++ b/gems/kettle-jem/spec/integration/appraisals_and_gemfile_template_spec.rb @@ -1072,9 +1072,11 @@ { "KJ|CI:EXEC_CMD" => "bundle exec kettle-test", "KJ|CI:OS_MATRIX_ENTRIES" => described_class.send(:github_actions_current_os_matrix_entries, {ci: {exec_cmd: "bundle exec kettle-test", platforms: %w[linux macos windows]}}), - "KJ|CI:OS_TEST_STEPS" => described_class.send(:github_actions_current_os_test_steps, {ci: {exec_cmd: "bundle exec kettle-test", platforms: %w[linux macos windows]}}) + "KJ|CI:OS_TEST_STEPS" => described_class.send(:github_actions_current_os_test_steps, {ci: {exec_cmd: "bundle exec kettle-test", platforms: %w[linux macos windows]}}), + "KJ|CI:MACOS_HARDENING_STEP" => described_class.send(:github_actions_macos_hardening_step, {ci: {exec_cmd: "bundle exec kettle-test", platforms: %w[linux macos windows]}}) } ) + expect(resolved_current_workflow.index("Remove eMudhra roots from the macOS CA bundle")).to be < resolved_current_workflow.index("- name: Checkout") expect(resolved_current_workflow).to include("appraisal: \"current\"\n os: ubuntu-latest") expect(resolved_current_workflow).to include("appraisal: \"current\"\n os: macos-latest") expect(resolved_current_workflow).to include("appraisal: \"current\"\n os: windows-latest") diff --git a/gems/kettle-jem/spec/kettle/jem_platforms_config_spec.rb b/gems/kettle-jem/spec/kettle/jem_platforms_config_spec.rb index 8affcb621..5ec8f7dea 100644 --- a/gems/kettle-jem/spec/kettle/jem_platforms_config_spec.rb +++ b/gems/kettle-jem/spec/kettle/jem_platforms_config_spec.rb @@ -195,6 +195,59 @@ def ci_facts(platforms, exec_cmd: "bundle exec kettle-test") end end + describe "github_actions_macos_hardening_step" do + let(:script) { File.read(File.expand_path("../../lib/kettle/jem/ci/harden_macos_ca_bundle.sh", __dir__)).rstrip } + + def step_for(platforms) + described_class.send(:github_actions_macos_hardening_step, ci_facts(platforms)) + end + + it "is empty when macos is not declared, so no dead step is emitted" do + expect(step_for(%w[linux])).to eq("") + expect(step_for(%w[linux windows])).to eq("") + end + + it "emits a macOS-only inline step when macos is declared, including by default" do + step = step_for(%w[linux macos]) + + expect(step).to start_with(" - name: Remove eMudhra roots from the macOS CA bundle\n") + expect(step).to include(" if: runner.os == 'macOS'\n") + expect(step).to end_with("\n\n") + expect(described_class.send(:github_actions_macos_hardening_step, {ci: {}})).to eq(step) + end + + it "inlines the packaged script verbatim and parses as a workflow step" do + steps = YAML.safe_load(step_for(%w[linux macos windows])) + + expect(steps.length).to eq(1) + expect(steps.first).to include("if" => "runner.os == 'macOS'", "shell" => "bash") + expect(steps.first.fetch("run").rstrip).to eq(script) + expect(steps.first).not_to have_key("uses") + end + + it "is wired in before checkout in the current.yml template" do + template = File.read(File.expand_path("../../lib/kettle/jem/templates/.github/workflows/current.yml.example", __dir__)) + + expect(template).to match(/steps:\n\{KJ\|CI:MACOS_HARDENING_STEP\} - name: Checkout\n/) + end + + it "produces a current.yml whose first step hardens macOS before checkout" do + template = File.read(File.expand_path("../../lib/kettle/jem/templates/.github/workflows/current.yml.example", __dir__)) + rendered = template.sub("{KJ|CI:MACOS_HARDENING_STEP}", step_for(%w[linux macos])) + rendered = rendered.sub("{KJ|CI:OS_MATRIX_ENTRIES}", "") + names = rendered.scan(/^ - name: (.+)$/).flatten + + expect(names.first(2)).to eq(["Remove eMudhra roots from the macOS CA bundle", "Checkout"]) + end + + it "leaves the template unchanged for destinations without macos" do + template = File.read(File.expand_path("../../lib/kettle/jem/templates/.github/workflows/current.yml.example", __dir__)) + rendered = template.sub("{KJ|CI:MACOS_HARDENING_STEP}", step_for(%w[linux])) + + expect(rendered).to include(" steps:\n - name: Checkout\n") + end + end + describe "readme_current_mri_platforms" do def summary_facts(platforms:) {ci: {platforms: platforms}} @@ -215,7 +268,7 @@ def summary_facts(platforms:) end describe "packaged current.yml template" do - it "resolves both tokens through the real pipeline into valid YAML" do + it "resolves its tokens through the real pipeline into valid YAML" do facts = ci_facts(%w[linux macos windows]) resolved = described_class.send( :resolve_template_tokens, @@ -223,12 +276,17 @@ def summary_facts(platforms:) { "KJ|CI:EXEC_CMD" => "bundle exec kettle-test", "KJ|CI:OS_MATRIX_ENTRIES" => described_class.send(:github_actions_current_os_matrix_entries, facts), - "KJ|CI:OS_TEST_STEPS" => described_class.send(:github_actions_current_os_test_steps, facts) + "KJ|CI:OS_TEST_STEPS" => described_class.send(:github_actions_current_os_test_steps, facts), + "KJ|CI:MACOS_HARDENING_STEP" => described_class.send(:github_actions_macos_hardening_step, facts) } ) expect(resolved).not_to include("{KJ|") - expect(YAML.safe_load(resolved.gsub(/\{\{.*?}}/, "x"), aliases: true).dig("jobs", "test", "strategy", "matrix", "include").length).to eq(3) + workflow = YAML.safe_load(resolved.gsub(/\{\{.*?}}/, "x"), aliases: true) + expect(workflow.dig("jobs", "test", "strategy", "matrix", "include").length).to eq(3) + steps = workflow.dig("jobs", "test", "steps") + expect(steps.first(2).map { |step| step["name"] }).to eq(["Remove eMudhra roots from the macOS CA bundle", "Checkout"]) + expect(steps.first["if"]).to eq("runner.os == 'macOS'") end end end From 641498efa7aac915f86de5a1d0404ca0cce85c7b Mon Sep 17 00:00:00 2001 From: "Peter H. Boling" Date: Wed, 4 Nov 2026 18:55:29 +0000 Subject: [PATCH 2/8] CI: resolve the unpublished core dep in benchmark and TSLP lanes; keep self-gem deletion Two CI fixes, verified locally against a built 0.2.1 core gem: - benchmark-dev and tslp-cold-start failed in Setup Ruby: tree_haver's runtime dependency structuredmerge-core ~> 0.2 no longer resolves from any registry (the kernel is pre-publication by design), so "tree_haver cannot be used" aborted appraisal install. Both lanes now checkout the kernel at main, compile its Ruby binding, and set STRUCTUREDMERGE_CORE_DEV, which is the documented selection switch (TYPED_CORE_CI.md) that wires core through nomono from packages/ruby. - structural_merge_template_spec failed: gemfile_path_source_override_names exempted every path-declared gem in a template from dependency pruning, including the package's own gem, so 'gem "example", path: "."' survived and delete_dependency_declarations stopped reporting it. The exemption now never covers the package's own gem, in both the aggregate and the modular template policy (introduced by 35f690046/4eb138300 on Oct 31). Local evidence: 773-example kettle-jem suite passes except the three documented /code/fixtures-environmental specs; appraisals_and_gemfile_template_spec 76/76; the failing structural spec passes; .github/tests 9/9. --- .github/workflows/current.yml | 42 +++++++++++++++++++++++++++++++ gems/kettle-jem/lib/kettle/jem.rb | 10 +++++--- 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/.github/workflows/current.yml b/.github/workflows/current.yml index a4acbf0a9..63acf9103 100644 --- a/.github/workflows/current.yml +++ b/.github/workflows/current.yml @@ -546,11 +546,30 @@ jobs: BUNDLE_GEMFILE: ${{ github.workspace }}/gems/ast-merge-git/Appraisal.root.gemfile K_JEM_TEMPLATING: true STRUCTUREDMERGE_DEV: ${{ github.workspace }}/gems + STRUCTUREDMERGE_CORE_DEV: ${{ github.workspace }}/kernel steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 + - name: Checkout kernel for the local core override + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: structuredmerge/structuredmerge + ref: main + path: kernel + persist-credentials: false + - name: Compile the kernel Ruby binding + working-directory: kernel/packages/ruby + shell: bash + run: | + set -euo pipefail + ulimit -c 0 + export CARGO_BUILD_JOBS="1" CARGO_INCREMENTAL="0" + export CARGO_TARGET_DIR="${GITHUB_WORKSPACE}/kernel/tmp/benchmark-core-target" + mkdir -p ../../tmp + bundle install --jobs 4 --retry 8 + bundle exec rake compile - name: Clone benchmark corpus shell: bash run: | @@ -570,6 +589,7 @@ jobs: BUNDLE_GEMFILE: ${{ github.workspace }}/gems/ast-merge-git/Appraisal.root.gemfile K_JEM_TEMPLATING: true STRUCTUREDMERGE_DEV: ${{ github.workspace }}/gems + STRUCTUREDMERGE_CORE_DEV: ${{ github.workspace }}/kernel with: ruby-version: ruby bundler: latest @@ -584,6 +604,7 @@ jobs: BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} K_JEM_TEMPLATING: true STRUCTUREDMERGE_DEV: ${{ github.workspace }}/gems + STRUCTUREDMERGE_CORE_DEV: ${{ github.workspace }}/kernel run: | set -euo pipefail @@ -703,15 +724,35 @@ jobs: BUNDLE_GEMFILE: ${{ github.workspace }}/gems/tree_haver/Appraisal.root.gemfile K_JEM_TEMPLATING: true STRUCTUREDMERGE_DEV: ${{ github.workspace }}/gems + STRUCTUREDMERGE_CORE_DEV: ${{ github.workspace }}/kernel steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Checkout kernel for the local core override + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: structuredmerge/structuredmerge + ref: main + path: kernel + persist-credentials: false + - name: Compile the kernel Ruby binding + working-directory: kernel/packages/ruby + shell: bash + run: | + set -euo pipefail + ulimit -c 0 + export CARGO_BUILD_JOBS="1" CARGO_INCREMENTAL="0" + export CARGO_TARGET_DIR="${GITHUB_WORKSPACE}/kernel/tmp/tslp-core-target" + mkdir -p ../../tmp + bundle install --jobs 4 --retry 8 + bundle exec rake compile - name: Setup Ruby uses: appraisal-rb/setup-ruby-flash@6f8ad36ba7488db591541483afb5464d01e8861b # v2.14 env: BUNDLE_GEMFILE: ${{ github.workspace }}/gems/tree_haver/Appraisal.root.gemfile K_JEM_TEMPLATING: true STRUCTUREDMERGE_DEV: ${{ github.workspace }}/gems + STRUCTUREDMERGE_CORE_DEV: ${{ github.workspace }}/kernel with: ruby-version: ruby bundler: latest @@ -726,6 +767,7 @@ jobs: K_JEM_TEMPLATING: true K_SOUP_COV_DO: false STRUCTUREDMERGE_DEV: ${{ github.workspace }}/gems + STRUCTUREDMERGE_CORE_DEV: ${{ github.workspace }}/kernel run: | set -euo pipefail export TREE_SITTER_LANGUAGE_PACK_CACHE_DIR diff --git a/gems/kettle-jem/lib/kettle/jem.rb b/gems/kettle-jem/lib/kettle/jem.rb index 3f505ee62..56cf45e6c 100644 --- a/gems/kettle-jem/lib/kettle/jem.rb +++ b/gems/kettle-jem/lib/kettle/jem.rb @@ -7825,8 +7825,10 @@ def finalize_gemfile_template_source(recipe, content, destination_content, facts end removable = [package_name, *(runtime_dependencies - named_fragment_dependencies)] # A modular Gemfile may repeat a runtime gem only to select its local - # source. Keep explicit path overrides while pruning ordinary duplicates. - removable -= gemfile_path_source_override_names(template_content) + # source. Keep explicit path overrides while pruning ordinary + # duplicates. The package's own gem is never exempt: a self-reference + # is a prohibited declaration, not a local source override. + removable -= (gemfile_path_source_override_names(template_content) - [package_name]) output = remove_gemfile_dependency_blocks(content, removable) output = remove_gemfile_percent_w_entries(output, removable) end @@ -9483,7 +9485,9 @@ def merge_gemfile_template_policy(content, facts:, template_content: nil, preser # A Gemfile path declaration can override the source of a runtime gemspec # dependency. Keep those declarations so local development can resolve an # unpublished dependency without changing the runtime dependency graph. - removable_gems -= gemfile_path_source_override_names(template_content.to_s) + # The package's own gem is never exempt: a self-reference is a prohibited + # declaration, not a local source override. + removable_gems -= (gemfile_path_source_override_names(template_content.to_s) - [package_name]) removable_gems << "version_gem" unless version_gem_enabled?(facts) pruned = remove_gemfile_dependency_blocks(content, removable_gems) pruned = remove_gemfile_percent_w_entries(pruned, [package_name]) unless preserve_self_word_entries From 56eeb98a015e52e18696ab3e729f24bd352d048c Mon Sep 17 00:00:00 2001 From: "Peter H. Boling" Date: Wed, 4 Nov 2026 19:01:00 +0000 Subject: [PATCH 3/8] CI: compile the kernel binding after Ruby setup, not before bundle is only on PATH once setup-ruby-flash runs; the compile step before it aborted with exit 127 on both lanes. --- .github/workflows/current.yml | 44 +++++++++++++++++------------------ 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/.github/workflows/current.yml b/.github/workflows/current.yml index 63acf9103..f208079aa 100644 --- a/.github/workflows/current.yml +++ b/.github/workflows/current.yml @@ -559,17 +559,6 @@ jobs: ref: main path: kernel persist-credentials: false - - name: Compile the kernel Ruby binding - working-directory: kernel/packages/ruby - shell: bash - run: | - set -euo pipefail - ulimit -c 0 - export CARGO_BUILD_JOBS="1" CARGO_INCREMENTAL="0" - export CARGO_TARGET_DIR="${GITHUB_WORKSPACE}/kernel/tmp/benchmark-core-target" - mkdir -p ../../tmp - bundle install --jobs 4 --retry 8 - bundle exec rake compile - name: Clone benchmark corpus shell: bash run: | @@ -598,6 +587,17 @@ jobs: working-directory: gems/ast-merge-git appraisal-name: benchmark-dev appraisal-root-gemfile: Appraisal.root.gemfile + - name: Compile the kernel Ruby binding + working-directory: kernel/packages/ruby + shell: bash + run: | + set -euo pipefail + ulimit -c 0 + export CARGO_BUILD_JOBS="1" CARGO_INCREMENTAL="0" + export CARGO_TARGET_DIR="${GITHUB_WORKSPACE}/kernel/tmp/benchmark-core-target" + mkdir -p ../../tmp + bundle install --jobs 4 --retry 8 + bundle exec rake compile - name: Run affected-capability benchmark working-directory: gems/ast-merge-git env: @@ -735,17 +735,6 @@ jobs: ref: main path: kernel persist-credentials: false - - name: Compile the kernel Ruby binding - working-directory: kernel/packages/ruby - shell: bash - run: | - set -euo pipefail - ulimit -c 0 - export CARGO_BUILD_JOBS="1" CARGO_INCREMENTAL="0" - export CARGO_TARGET_DIR="${GITHUB_WORKSPACE}/kernel/tmp/tslp-core-target" - mkdir -p ../../tmp - bundle install --jobs 4 --retry 8 - bundle exec rake compile - name: Setup Ruby uses: appraisal-rb/setup-ruby-flash@6f8ad36ba7488db591541483afb5464d01e8861b # v2.14 env: @@ -761,6 +750,17 @@ jobs: working-directory: gems/tree_haver appraisal-name: tslp-cold-start appraisal-root-gemfile: Appraisal.root.gemfile + - name: Compile the kernel Ruby binding + working-directory: kernel/packages/ruby + shell: bash + run: | + set -euo pipefail + ulimit -c 0 + export CARGO_BUILD_JOBS="1" CARGO_INCREMENTAL="0" + export CARGO_TARGET_DIR="${GITHUB_WORKSPACE}/kernel/tmp/tslp-core-target" + mkdir -p ../../tmp + bundle install --jobs 4 --retry 8 + bundle exec rake compile - name: Run cold-start TSLP checks working-directory: gems/tree_haver env: From 58dbade85088a705292df0df0685de8758502b3c Mon Sep 17 00:00:00 2001 From: "Peter H. Boling" Date: Wed, 4 Nov 2026 19:48:03 +0000 Subject: [PATCH 4/8] CI: advance the fixtures pin so gem suites can resolve their contracts The ast-merge, bash-merge, go-merge, json-merge, rust-merge and typescript-merge suites reference fixture files and contract updates that landed in structuredmerge-fixtures after the pinned revision (ad5fd0b6): 16 files were missing outright and source_line_disposition.json was 51/23 lines behind the shared per-line rubric the suites assert. Advance the pin to fixtures main (9c6e4bb0). --- .structuredmerge/fixtures.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.structuredmerge/fixtures.lock b/.structuredmerge/fixtures.lock index 020469cdf..52004bd67 100644 --- a/.structuredmerge/fixtures.lock +++ b/.structuredmerge/fixtures.lock @@ -1,3 +1,3 @@ repository: https://github.com/structuredmerge/structuredmerge-fixtures.git branch: main -revision: ad5fd0b66b084e63576e6b006a1addf5bad8fcf5 +revision: 9c6e4bb0625f28207d1f40e5d103936eed56154a From f29f63a27493eb04f3c4a20856afafe67771ee09 Mon Sep 17 00:00:00 2001 From: "Peter H. Boling" Date: Wed, 4 Nov 2026 20:48:29 +0000 Subject: [PATCH 5/8] CI: wire the pre-publication core into the appraisal root baselines The previous fix compiled the kernel binding and set STRUCTUREDMERGE_CORE_DEV, but only the appraisal gemfiles (which eval templating_local.gemfile) honor that switch. Setup Ruby resolves Appraisal.root.gemfile first, and tree_haver's runtime dependency on structuredmerge-core ~> 0.2 still raced the registry. Add the same path wiring to both root baselines so the root install resolves core from the kernel checkout's packages/ruby. --- gems/ast-merge-git/Appraisal.root.gemfile | 15 +++++++++++++++ gems/tree_haver/Appraisal.root.gemfile | 15 +++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/gems/ast-merge-git/Appraisal.root.gemfile b/gems/ast-merge-git/Appraisal.root.gemfile index f65feba32..3a0386209 100644 --- a/gems/ast-merge-git/Appraisal.root.gemfile +++ b/gems/ast-merge-git/Appraisal.root.gemfile @@ -25,3 +25,18 @@ if !structuredmerge_dev_root.empty? && !%w[false 0 no off].include?(structuredme end end end + +# structuredmerge-core lives in a subdirectory of its own repository. The +# lockstep tree_haver depends on it at runtime, and it is pre-publication: when +# STRUCTUREDMERGE_CORE_DEV points at a kernel checkout, wire the binding from +# packages/ruby so this baseline resolves. Mirrors the switch in +# gemfiles/modular/templating_local.gemfile; registry-only resolution (toggle +# unset) remains the default for local development and post-publication runs. +structuredmerge_core_dev = ENV.fetch('STRUCTUREDMERGE_CORE_DEV', '') +if !structuredmerge_core_dev.empty? && !%w[false 0 no off].include?(structuredmerge_core_dev.downcase) + core_gemspec = File.join(structuredmerge_core_dev, 'packages/ruby/structuredmerge_core.gemspec') + if File.exist?(core_gemspec) + instance_variable_get(:@dependencies).reject! { |dependency| dependency.name == 'structuredmerge-core' } + gem 'structuredmerge-core', path: File.join(structuredmerge_core_dev, 'packages/ruby') + end +end diff --git a/gems/tree_haver/Appraisal.root.gemfile b/gems/tree_haver/Appraisal.root.gemfile index 75e03f7a1..4678db8e9 100644 --- a/gems/tree_haver/Appraisal.root.gemfile +++ b/gems/tree_haver/Appraisal.root.gemfile @@ -6,3 +6,18 @@ source 'https://gem.coop' gemspec gem 'nomono', '~> 1.1', '>= 1.1.18' + +# structuredmerge-core lives in a subdirectory of its own repository. tree_haver +# (this gem) depends on it at runtime, and it is pre-publication: when +# STRUCTUREDMERGE_CORE_DEV points at a kernel checkout, wire the binding from +# packages/ruby so this baseline resolves. Mirrors the switch in +# gemfiles/modular/templating_local.gemfile; registry-only resolution (toggle +# unset) remains the default for local development and post-publication runs. +structuredmerge_core_dev = ENV.fetch('STRUCTUREDMERGE_CORE_DEV', '') +if !structuredmerge_core_dev.empty? && !%w[false 0 no off].include?(structuredmerge_core_dev.downcase) + core_gemspec = File.join(structuredmerge_core_dev, 'packages/ruby/structuredmerge_core.gemspec') + if File.exist?(core_gemspec) + instance_variable_get(:@dependencies).reject! { |dependency| dependency.name == 'structuredmerge-core' } + gem 'structuredmerge-core', path: File.join(structuredmerge_core_dev, 'packages/ruby') + end +end From 2b591fb806900c3872b482fda91d3f81d34e6abe Mon Sep 17 00:00:00 2001 From: "Peter H. Boling" Date: Wed, 4 Nov 2026 20:55:22 +0000 Subject: [PATCH 6/8] CI: use the sanctioned templating eval in the appraisal root baselines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit hand-rolled a STRUCTUREDMERGE_CORE_DEV path block into both Appraisal.root.gemfiles — reimplementing what kettle-jem already templates. ast-crispr-toml's root gemfile is the established shape (70d776c25, ef07e5718, dbd43d7f3): a generator_only/K_JEM_TEMPLATING gate evals gemfiles/modular/templating.gemfile, and a frozen plain-Bundler copy does the same when STRUCTUREDMERGE_DEV is engaged, because Appraisal's root resolution runs without generator_only. templating.gemfile then routes STRUCTUREDMERGE_CORE_DEV through templating_local's nomono subdirs wiring. Bring tree_haver's and ast-merge-git's root baselines to that shape instead of duplicating the wiring inline. Both now resolve structuredmerge-core from the kernel checkout under CI's env, and both keep registry-only resolution when the toggles are unset. Verified locally: toggles on, `bundle lock` resolves core 0.2.1 from the kernel path in both gems; toggles off, both still resolve. --- gems/ast-merge-git/Appraisal.root.gemfile | 37 +++++++++++++++------- gems/tree_haver/Appraisal.root.gemfile | 38 ++++++++++++++++------- 2 files changed, 51 insertions(+), 24 deletions(-) diff --git a/gems/ast-merge-git/Appraisal.root.gemfile b/gems/ast-merge-git/Appraisal.root.gemfile index 3a0386209..807167db7 100644 --- a/gems/ast-merge-git/Appraisal.root.gemfile +++ b/gems/ast-merge-git/Appraisal.root.gemfile @@ -1,5 +1,11 @@ # frozen_string_literal: true +# kettle-jem:freeze +# To retain chunks of comments & code during kettle-jem templating: +# Wrap custom sections with freeze markers (e.g., as above and below this comment chunk). +# kettle-jem will then preserve content between those markers across template runs. +# kettle-jem:unfreeze + source 'https://gem.coop' # Shared baseline for the Appraisal2 CI lanes in this gem. @@ -26,17 +32,24 @@ if !structuredmerge_dev_root.empty? && !%w[false 0 no off].include?(structuredme end end -# structuredmerge-core lives in a subdirectory of its own repository. The -# lockstep tree_haver depends on it at runtime, and it is pre-publication: when -# STRUCTUREDMERGE_CORE_DEV points at a kernel checkout, wire the binding from -# packages/ruby so this baseline resolves. Mirrors the switch in -# gemfiles/modular/templating_local.gemfile; registry-only resolution (toggle -# unset) remains the default for local development and post-publication runs. -structuredmerge_core_dev = ENV.fetch('STRUCTUREDMERGE_CORE_DEV', '') -if !structuredmerge_core_dev.empty? && !%w[false 0 no off].include?(structuredmerge_core_dev.downcase) - core_gemspec = File.join(structuredmerge_core_dev, 'packages/ruby/structuredmerge_core.gemspec') - if File.exist?(core_gemspec) - instance_variable_get(:@dependencies).reject! { |dependency| dependency.name == 'structuredmerge-core' } - gem 'structuredmerge-core', path: File.join(structuredmerge_core_dev, 'packages/ruby') +if respond_to?(:generator_only) + generator_only do + if ENV.fetch('K_JEM_TEMPLATING', 'false').casecmp('true').zero? + eval_gemfile 'gemfiles/modular/templating.gemfile' + end + end +end +# kettle-jem:freeze +# The lockstep tree_haver depends on structuredmerge-core ~> 0.2, which no +# registry carries before the kernel publishes. Appraisal's root resolution +# runs under plain Bundler, where generator_only is unavailable, so wire core +# from the local kernel checkout through the modular templating gemfile +# whenever the family dev env is engaged. Without that env the modular file +# retains registry mode, and published gems resolve normally. +unless respond_to?(:generator_only) + if ENV.fetch('K_JEM_TEMPLATING', 'false').casecmp('true').zero? || + !ENV.fetch('STRUCTUREDMERGE_DEV', '').strip.empty? + eval_gemfile 'gemfiles/modular/templating.gemfile' end end +# kettle-jem:unfreeze diff --git a/gems/tree_haver/Appraisal.root.gemfile b/gems/tree_haver/Appraisal.root.gemfile index 4678db8e9..33df9e3b5 100644 --- a/gems/tree_haver/Appraisal.root.gemfile +++ b/gems/tree_haver/Appraisal.root.gemfile @@ -1,5 +1,11 @@ # frozen_string_literal: true +# kettle-jem:freeze +# To retain chunks of comments & code during kettle-jem templating: +# Wrap custom sections with freeze markers (e.g., as above and below this comment chunk). +# kettle-jem will then preserve content between those markers across template runs. +# kettle-jem:unfreeze + source 'https://gem.coop' # Shared baseline for the Appraisal2 CI lanes in this gem. @@ -7,17 +13,25 @@ gemspec gem 'nomono', '~> 1.1', '>= 1.1.18' -# structuredmerge-core lives in a subdirectory of its own repository. tree_haver -# (this gem) depends on it at runtime, and it is pre-publication: when -# STRUCTUREDMERGE_CORE_DEV points at a kernel checkout, wire the binding from -# packages/ruby so this baseline resolves. Mirrors the switch in -# gemfiles/modular/templating_local.gemfile; registry-only resolution (toggle -# unset) remains the default for local development and post-publication runs. -structuredmerge_core_dev = ENV.fetch('STRUCTUREDMERGE_CORE_DEV', '') -if !structuredmerge_core_dev.empty? && !%w[false 0 no off].include?(structuredmerge_core_dev.downcase) - core_gemspec = File.join(structuredmerge_core_dev, 'packages/ruby/structuredmerge_core.gemspec') - if File.exist?(core_gemspec) - instance_variable_get(:@dependencies).reject! { |dependency| dependency.name == 'structuredmerge-core' } - gem 'structuredmerge-core', path: File.join(structuredmerge_core_dev, 'packages/ruby') +if respond_to?(:generator_only) + generator_only do + if ENV.fetch('K_JEM_TEMPLATING', 'false').casecmp('true').zero? + eval_gemfile 'gemfiles/modular/templating.gemfile' + end + end +end +# kettle-jem:freeze +# The gemspec's runtime dependency on structuredmerge-core ~> 0.2 cannot be +# resolved from any registry before the kernel publishes. Appraisal's root +# resolution runs under plain Bundler, where generator_only is unavailable, so +# wire core (and any lockstep siblings) from local checkouts through the +# modular templating gemfile whenever the family dev env is engaged. Without +# that env the modular file retains registry mode, and published gems resolve +# normally. +unless respond_to?(:generator_only) + if ENV.fetch('K_JEM_TEMPLATING', 'false').casecmp('true').zero? || + !ENV.fetch('STRUCTUREDMERGE_DEV', '').strip.empty? + eval_gemfile 'gemfiles/modular/templating.gemfile' end end +# kettle-jem:unfreeze From c4ce8445f3b093ed035b6f0a42a99e8ba61e0043 Mon Sep 17 00:00:00 2001 From: "Peter H. Boling" Date: Wed, 4 Nov 2026 21:10:33 +0000 Subject: [PATCH 7/8] CI: pre-install nomono for the appraisal lanes' root evaluation The templating eval now activates templating_local.gemfile in the root baseline, and its bootstrap `Kernel.send(:gem, 'nomono', ...)` requires nomono >= 1.1.18 while the runner's rv GEM_PATH only carries 1.1.14. The gem-suite and deps lanes already solve this through setup-ruby-flash's `pre-bundle-gems: nomono --source ...`; add the same to the TSLP and benchmark lanes (mirrors 1810f878c's "install nomono before Gemfile evaluation" for the contract tests). --- .github/workflows/current.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/current.yml b/.github/workflows/current.yml index f208079aa..b61725b22 100644 --- a/.github/workflows/current.yml +++ b/.github/workflows/current.yml @@ -587,6 +587,8 @@ jobs: working-directory: gems/ast-merge-git appraisal-name: benchmark-dev appraisal-root-gemfile: Appraisal.root.gemfile + pre-bundle-gems: | + nomono --source https://gem.coop --source https://rubygems.org - name: Compile the kernel Ruby binding working-directory: kernel/packages/ruby shell: bash @@ -750,6 +752,8 @@ jobs: working-directory: gems/tree_haver appraisal-name: tslp-cold-start appraisal-root-gemfile: Appraisal.root.gemfile + pre-bundle-gems: | + nomono --source https://gem.coop --source https://rubygems.org - name: Compile the kernel Ruby binding working-directory: kernel/packages/ruby shell: bash From 4045557304838237d0ca3cae9a5f345e2de346f7 Mon Sep 17 00:00:00 2001 From: "Peter H. Boling" Date: Wed, 4 Nov 2026 22:01:11 +0000 Subject: [PATCH 8/8] CI: point the kernel compile step at the kernel's own Gemfile The job-level BUNDLE_GEMFILE (the appraisal root baseline) leaked into the compile step, so its bundle install built the consumer bundle instead of rake-compiler: 'Please install rake-compiler'. Pin the step's BUNDLE_GEMFILE to kernel/packages/ruby/Gemfile, mirroring how the typed-core job compiles. --- .github/workflows/current.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/current.yml b/.github/workflows/current.yml index b61725b22..c8ff39279 100644 --- a/.github/workflows/current.yml +++ b/.github/workflows/current.yml @@ -592,6 +592,8 @@ jobs: - name: Compile the kernel Ruby binding working-directory: kernel/packages/ruby shell: bash + env: + BUNDLE_GEMFILE: ${{ github.workspace }}/kernel/packages/ruby/Gemfile run: | set -euo pipefail ulimit -c 0 @@ -757,6 +759,8 @@ jobs: - name: Compile the kernel Ruby binding working-directory: kernel/packages/ruby shell: bash + env: + BUNDLE_GEMFILE: ${{ github.workspace }}/kernel/packages/ruby/Gemfile run: | set -euo pipefail ulimit -c 0