diff --git a/.github/workflows/current.yml b/.github/workflows/current.yml index a4acbf0a9..c8ff39279 100644 --- a/.github/workflows/current.yml +++ b/.github/workflows/current.yml @@ -546,11 +546,19 @@ 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: Clone benchmark corpus shell: bash run: | @@ -570,6 +578,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 @@ -578,12 +587,28 @@ 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 + env: + BUNDLE_GEMFILE: ${{ github.workspace }}/kernel/packages/ruby/Gemfile + 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: 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 +728,24 @@ 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: 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 @@ -720,12 +754,28 @@ 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 + env: + BUNDLE_GEMFILE: ${{ github.workspace }}/kernel/packages/ruby/Gemfile + 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: 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/.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 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/ast-merge-git/Appraisal.root.gemfile b/gems/ast-merge-git/Appraisal.root.gemfile index f65feba32..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. @@ -25,3 +31,25 @@ if !structuredmerge_dev_root.empty? && !%w[false 0 no off].include?(structuredme end end end + +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/kettle-jem/lib/kettle/jem.rb b/gems/kettle-jem/lib/kettle/jem.rb index ebac8fbde..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 @@ -14985,6 +14989,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 +20965,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 diff --git a/gems/tree_haver/Appraisal.root.gemfile b/gems/tree_haver/Appraisal.root.gemfile index 75e03f7a1..33df9e3b5 100644 --- a/gems/tree_haver/Appraisal.root.gemfile +++ b/gems/tree_haver/Appraisal.root.gemfile @@ -1,8 +1,37 @@ # 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. gemspec gem 'nomono', '~> 1.1', '>= 1.1.18' + +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