From 8eab3953f6faad0a9e8cc58cf2dd5f8330ccbc00 Mon Sep 17 00:00:00 2001 From: River Date: Wed, 16 Sep 2026 16:11:38 +0000 Subject: [PATCH 1/6] Pass gem filenames to `gem push` as argv, not a shell string `CLI#release` globbed `.gem` files and interpolated each one into a single command string, so `Open3.capture2e` handed the whole thing to a shell. Those filenames come from artifacts the unprivileged compile job uploads (`pkg/*.gem`, so any file the build produces, not just the ones `Gem::PackageTask` names), and the release job downloads them with their basenames intact, after RubyGems credentials are configured. A basename such as `hello$(touch injected).gem` therefore executed in the privileged publishing job. The `extname == ".gem"` check does not prevent that, it only skips non-gem files. Passing the filename as its own argv element means no shell is involved and the name can never be parsed as syntax. The regression test runs the real release path against a fake `gem` on PATH rather than stubbing the call, so it fails on the old code both ways: the planted command runs, and `gem` receives `pkg/hello.gem` instead of the literal basename. Reported as https://vault.shopify.io/issues/78390 (CWE-78). Co-authored-by: Aaron Patterson --- CHANGELOG.md | 7 +++++++ lib/cibuildgem/cli.rb | 2 +- test/cli_test.rb | 44 ++++++++++++++++++++++++++++++++++++------- 3 files changed, 45 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 667aae1a8..86529c370 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Security + +- `cibuildgem release` no longer runs `gem push` through a shell. The `.gem` filenames it iterates over come from + artifacts built in the unprivileged compile job, and interpolating them into a command string let shell syntax in a + basename execute in the release job, after RubyGems credentials are configured. The filename is now passed as a + single argv element. + ## [0.3.0] - 2026-03-27 ### Added diff --git a/lib/cibuildgem/cli.rb b/lib/cibuildgem/cli.rb index e76fb0b3e..37af84924 100644 --- a/lib/cibuildgem/cli.rb +++ b/lib/cibuildgem/cli.rb @@ -113,7 +113,7 @@ def release pathname = Pathname(file) next if pathname.directory? || pathname.extname != ".gem" - out, status = Open3.capture2e("gem push #{file}") + out, status = Open3.capture2e("gem", "push", file) next if status.success? if out =~ /Repushing of gem versions is not allowed/ diff --git a/test/cli_test.rb b/test/cli_test.rb index 7a1feebbb..54518b337 100644 --- a/test/cli_test.rb +++ b/test/cli_test.rb @@ -3,6 +3,7 @@ require "test_helper" require "English" require "open3" +require "tmpdir" module Cibuildgem class CLITest < Minitest::Test @@ -137,8 +138,8 @@ def test_release_succeeds status = Struct.new(:success?) gem_pushed = [] - callable = proc do |gem_name| - gem_pushed << gem_name + callable = proc do |*command| + gem_pushed << command ["", status.new(true)] end @@ -147,7 +148,7 @@ def test_release_succeeds CLI.start(["release", "--glob", "tmp/*"]) end - assert_equal(["gem push tmp/bar.gem", "gem push tmp/foo.gem"], gem_pushed.sort) + assert_equal([["gem", "push", "tmp/bar.gem"], ["gem", "push", "tmp/foo.gem"]], gem_pushed.sort) ensure FileUtils.rm_rf("tmp/foo.gem") FileUtils.rm_rf("tmp/bar.gem") @@ -161,10 +162,10 @@ def test_release_when_gem_was_already_pushed status = Struct.new(:success?) gem_pushed = [] - callable = proc do |gem_name| - gem_pushed << gem_name + callable = proc do |*command| + gem_pushed << command - if gem_name == "gem push tmp/bar.gem" + if command == ["gem", "push", "tmp/bar.gem"] ["Repushing of gem versions is not allowed", status.new(false)] else ["", status.new(true)] @@ -179,7 +180,7 @@ def test_release_when_gem_was_already_pushed assert_equal("Gem tmp/bar.gem already exists on RubyGems.org, skipping...\n", out) end - assert_equal(["gem push tmp/bar.gem", "gem push tmp/foo.gem"], gem_pushed.sort) + assert_equal([["gem", "push", "tmp/bar.gem"], ["gem", "push", "tmp/foo.gem"]], gem_pushed.sort) ensure FileUtils.rm_rf("tmp/foo.gem") FileUtils.rm_rf("tmp/bar.gem") @@ -207,6 +208,35 @@ def test_release_fails FileUtils.rm_rf("tmp/some_file") end + def test_release_does_not_let_the_shell_evaluate_a_gem_filename + original_path = ENV["PATH"] + + Dir.mktmpdir do |dir| + argv_log = File.join(dir, "argv") + fake_bin = File.join(dir, "bin") + FileUtils.mkdir_p(fake_bin) + File.write(File.join(fake_bin, "gem"), <<~SH) + #!/bin/sh + printf '%s\\n' "$@" > #{argv_log} + SH + FileUtils.chmod(0o755, File.join(fake_bin, "gem")) + ENV["PATH"] = [fake_bin, original_path].join(File::PATH_SEPARATOR) + + Dir.chdir(dir) do + FileUtils.mkdir_p("pkg") + FileUtils.touch("pkg/hello$(touch injected).gem") + + CLI.start(["release", "--glob", "pkg/*"]) + + refute(File.exist?("injected"), "the filename was evaluated by a shell") + end + + assert_equal(["push", "pkg/hello$(touch injected).gem"], File.readlines(argv_log, chomp: true)) + end + ensure + ENV["PATH"] = original_path + end + def test_print_ruby_cc_version out, _ = capture_subprocess_io do Dir.chdir("test/fixtures/dummy_gem") do From d26623732eb9378393a7451171277d5c1e2a1953 Mon Sep 17 00:00:00 2001 From: River Date: Wed, 16 Sep 2026 16:31:18 +0000 Subject: [PATCH 2/6] Run the rake subprocess as argv instead of a command string `run_rake_tasks!` joined the ruby binary, the rake executable, the task names, the rakelib dir and the patch path into one string and handed it to `system`. None of those are attacker-controlled the way the release job's artifact filenames were, so this is hardening rather than a fix: whether a shell gets involved currently depends on whether any of those interpolated paths happens to contain a shell metacharacter, which is a property of where the gem is installed rather than of this code. Passing an argv list removes the question. It also means an install path containing a space now works, which the string form silently broke: Ruby splits a metacharacter-free command string on whitespace itself. Co-authored-by: Aaron Patterson --- lib/cibuildgem/cli.rb | 10 ++++++++-- test/cli_test.rb | 20 ++++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/lib/cibuildgem/cli.rb b/lib/cibuildgem/cli.rb index 37af84924..9c22b9c05 100644 --- a/lib/cibuildgem/cli.rb +++ b/lib/cibuildgem/cli.rb @@ -140,7 +140,6 @@ def print_normalized_platform private def run_rake_tasks!(*tasks) - all_tasks = tasks.join(" ") rakelibdir = [File.expand_path("tasks", __dir__), "rakelib"].join(File::PATH_SEPARATOR) rake_compiler_path = Gem.loaded_specs["rake-compiler"].full_require_paths rake_specs = Gem.loaded_specs["rake"] @@ -152,7 +151,14 @@ def run_rake_tasks!(*tasks) system( { "RUBYLIB" => load_paths }, - "bundle exec #{RbConfig.ruby} #{rake_executable} #{all_tasks} -R#{rakelibdir} -r #{patch}", + "bundle", + "exec", + RbConfig.ruby, + rake_executable, + *tasks.map(&:to_s), + "-R#{rakelibdir}", + "-r", + patch, exception: true, ) end diff --git a/test/cli_test.rb b/test/cli_test.rb index 54518b337..6c96dc9d6 100644 --- a/test/cli_test.rb +++ b/test/cli_test.rb @@ -345,6 +345,26 @@ def test_keep_the_extension_task_config_defined_by_the_gem end end + def test_run_rake_tasks_builds_an_argv_list_rather_than_a_command_string + cli = CLI.new + command = nil + recorder = ->(*args, **_kwargs) do + command = args + + true + end + + cli.stub(:system, recorder) do + cli.send(:run_rake_tasks!, "cibuildgem:setup", :compile) + end + + _env, *argv = command + + assert_equal(["bundle", "exec"], argv.first(2)) + assert_includes(argv, "cibuildgem:setup") + assert_includes(argv, "compile") + end + private def raise_instead_of_exit(&block) From 0e2761fe2d71365e3b5f69c53d1586aabc609b1b Mon Sep 17 00:00:00 2001 From: River Date: Wed, 16 Sep 2026 16:33:35 +0000 Subject: [PATCH 3/6] Refuse to publish a .gem whose basename is not a plain gem name The argv change stops a crafted artifact filename reaching a shell, but it leaves the release job publishing whatever `.gem` files the compile job happened to produce. The compile job runs without publishing credentials precisely because its output is less trusted, and the upload pattern is `pkg/*.gem`, so the set is not limited to what `Gem::PackageTask` names. `release` now checks each basename against /\A[A-Za-z0-9._-]+\.gem\z/ and raises otherwise. RubyGems already restricts a gem's name, version and platform to those characters, so every filename `cibuildgem package` can produce is accepted, and this cannot start rejecting a legitimate release. This is deliberately not the other remediation the report suggested, narrowing the compile job's upload to the package task's expected outputs. Reconstructing `-[-].gem` in the CLI can drift from what rake-compiler actually writes, and with `if-no-files-found: error` any drift breaks the release; the README also invites consumers to enhance the packaging tasks, so a gem that legitimately produces extra packages would have them silently dropped. Checking the shape of the name has neither failure mode and covers any sink, not just this one. The end-to-end test from the previous commit becomes the refusal case, since a crafted name no longer reaches `gem push` at all. A second case covers a real platform gem name going through untouched, so the argv contract stays proven against an executed subprocess rather than only against the stubbed `Open3` calls. Co-authored-by: Aaron Patterson --- CHANGELOG.md | 4 +++ lib/cibuildgem/cli.rb | 19 +++++++++++++ test/cli_test.rb | 65 +++++++++++++++++++++++++++++-------------- 3 files changed, 67 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 86529c370..6df8d5410 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 artifacts built in the unprivileged compile job, and interpolating them into a command string let shell syntax in a basename execute in the release job, after RubyGems credentials are configured. The filename is now passed as a single argv element. +- `cibuildgem release` now refuses a `.gem` whose basename holds anything other than letters, digits, dots, dashes + and underscores, rather than publishing it. RubyGems restricts a gem's name, version and platform to those + characters, so every filename `cibuildgem package` produces is accepted and one that is not was planted by + something else running in the compile job. ## [0.3.0] - 2026-03-27 diff --git a/lib/cibuildgem/cli.rb b/lib/cibuildgem/cli.rb index 9c22b9c05..a44d3a5d7 100644 --- a/lib/cibuildgem/cli.rb +++ b/lib/cibuildgem/cli.rb @@ -11,6 +11,10 @@ class CLI < Thor source_root(File.expand_path("templates", __dir__)) + # RubyGems already restricts a gem's name, version and platform to these characters, so every filename + # `cibuildgem package` can legitimately produce matches. Anything else was not produced by the package task. + RELEASABLE_GEM_FILENAME = /\A[A-Za-z0-9._-]+\.gem\z/ + class << self def exit_on_failure? true @@ -113,6 +117,8 @@ def release pathname = Pathname(file) next if pathname.directory? || pathname.extname != ".gem" + verify_releasable_filename!(pathname) + out, status = Open3.capture2e("gem", "push", file) next if status.success? @@ -139,6 +145,19 @@ def print_normalized_platform private + def verify_releasable_filename!(pathname) + basename = pathname.basename.to_s + return if RELEASABLE_GEM_FILENAME.match?(basename) + + raise(<<~MSG) + Refusing to publish #{basename.inspect}. + + A gem filename may only contain letters, digits, dots, dashes and underscores, so this file was not + produced by `cibuildgem package`. Artifacts reach the release job from the compile job, which runs + without publishing credentials, so an unexpected filename is treated as tampering rather than a gem. + MSG + end + def run_rake_tasks!(*tasks) rakelibdir = [File.expand_path("tasks", __dir__), "rakelib"].join(File::PATH_SEPARATOR) rake_compiler_path = Gem.loaded_specs["rake-compiler"].full_require_paths diff --git a/test/cli_test.rb b/test/cli_test.rb index 6c96dc9d6..c2d9e690a 100644 --- a/test/cli_test.rb +++ b/test/cli_test.rb @@ -208,33 +208,28 @@ def test_release_fails FileUtils.rm_rf("tmp/some_file") end - def test_release_does_not_let_the_shell_evaluate_a_gem_filename - original_path = ENV["PATH"] + def test_release_refuses_a_filename_that_is_not_a_plain_gem_name + with_fake_gem_executable do |argv_log| + FileUtils.touch("pkg/hello$(touch injected).gem") - Dir.mktmpdir do |dir| - argv_log = File.join(dir, "argv") - fake_bin = File.join(dir, "bin") - FileUtils.mkdir_p(fake_bin) - File.write(File.join(fake_bin, "gem"), <<~SH) - #!/bin/sh - printf '%s\\n' "$@" > #{argv_log} - SH - FileUtils.chmod(0o755, File.join(fake_bin, "gem")) - ENV["PATH"] = [fake_bin, original_path].join(File::PATH_SEPARATOR) + error = assert_raises(RuntimeError) do + CLI.start(["release", "--glob", "pkg/*"]) + end - Dir.chdir(dir) do - FileUtils.mkdir_p("pkg") - FileUtils.touch("pkg/hello$(touch injected).gem") + assert_match("hello$(touch injected).gem", error.message) + refute(File.exist?("injected"), "the filename was evaluated by a shell") + refute(File.exist?(argv_log), "the file was handed to `gem push` instead of being refused") + end + end - CLI.start(["release", "--glob", "pkg/*"]) + def test_release_pushes_a_plain_gem_name_without_a_shell + with_fake_gem_executable do |argv_log| + FileUtils.touch("pkg/hello_world-1.2.3-x86_64-linux.gem") - refute(File.exist?("injected"), "the filename was evaluated by a shell") - end + CLI.start(["release", "--glob", "pkg/*"]) - assert_equal(["push", "pkg/hello$(touch injected).gem"], File.readlines(argv_log, chomp: true)) + assert_equal(["push", "pkg/hello_world-1.2.3-x86_64-linux.gem"], File.readlines(argv_log, chomp: true)) end - ensure - ENV["PATH"] = original_path end def test_print_ruby_cc_version @@ -367,6 +362,34 @@ def test_run_rake_tasks_builds_an_argv_list_rather_than_a_command_string private + # Runs the block in a scratch directory holding an empty `pkg/`, with a `gem` on PATH that records its + # arguments in the file whose path is yielded. Nothing is stubbed, so the release path resolves and + # executes a real subprocess the way it does on a release runner. + def with_fake_gem_executable(&block) + original_path = ENV["PATH"] + + Dir.mktmpdir do |dir| + argv_log = File.join(dir, "argv") + fake_bin = File.join(dir, "bin") + + FileUtils.mkdir_p(fake_bin) + File.write(File.join(fake_bin, "gem"), <<~SH) + #!/bin/sh + printf '%s\\n' "$@" > #{argv_log} + SH + FileUtils.chmod(0o755, File.join(fake_bin, "gem")) + ENV["PATH"] = [fake_bin, original_path].join(File::PATH_SEPARATOR) + + Dir.chdir(dir) do + FileUtils.mkdir_p("pkg") + + block.call(argv_log) + end + end + ensure + ENV["PATH"] = original_path + end + def raise_instead_of_exit(&block) Kernel.stub(:exit, ->(_) { raise }) do assert_raises(StandardError) do From 569ddbed3270e47f6da8a295fa650520430bdb4a Mon Sep 17 00:00:00 2001 From: River Date: Wed, 16 Sep 2026 16:32:37 +0000 Subject: [PATCH 4/6] Stop interpolating working-directory into github-script source The "Setup Rake Compiler" step placed `inputs.working-directory` inside a single-quoted JavaScript string literal in the `actions/github-script` body. Workflow expression substitution does not escape quotes, backslashes or newlines, so a `working-directory` value containing a single quote closes the literal and everything after it is evaluated as JavaScript by the runner (CWE-94, code injection). Callers passing a fixed path such as `.` are unaffected; a caller deriving the path from event data or workflow inputs turns a path selector into arbitrary code execution inside the compile job. Pass the value through the step environment and read it from `process.env` instead, so the script source is static and the input stays data. Add a regression test asserting that no github-script step in the action interpolates a workflow expression into its script body. Co-authored-by: Aaron Patterson --- .github/actions/cibuildgem/action.yml | 7 ++++- test/action_test.rb | 40 +++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 test/action_test.rb diff --git a/.github/actions/cibuildgem/action.yml b/.github/actions/cibuildgem/action.yml index ceaf5e7f7..fd52548c6 100644 --- a/.github/actions/cibuildgem/action.yml +++ b/.github/actions/cibuildgem/action.yml @@ -32,11 +32,16 @@ runs: - name: "Setup Rake Compiler" if: "${{ inputs.step == 'compile' }}" uses: actions/github-script@v8 + env: + # Passed through the environment rather than interpolated into the script + # below: a value containing a quote would otherwise close the string + # literal and the remainder would be evaluated as JavaScript. + WORKING_DIRECTORY: "${{ inputs.working-directory }}" with: script: | const url = new URL(`file:///${process.env.GITHUB_ACTION_PATH}/dist/index.js`); const { run } = await import(url.href) - await run('${{ inputs.working-directory }}') + await run(process.env.WORKING_DIRECTORY) - name: Package if: "${{ inputs.step == 'compile' }}" working-directory: ${{ inputs.working-directory }} diff --git a/test/action_test.rb b/test/action_test.rb new file mode 100644 index 000000000..a9bd82c1c --- /dev/null +++ b/test/action_test.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require "test_helper" +require "yaml" + +module Cibuildgem + class ActionTest < Minitest::Test + ACTION_PATH = File.expand_path("../.github/actions/cibuildgem/action.yml", __dir__) + + def test_github_script_steps_do_not_interpolate_workflow_expressions + github_script_steps.each do |step| + script = step.dig("with", "script") + + refute_match( + /\$\{\{/, + script, + "Step #{step["name"].inspect} interpolates a workflow expression into evaluated " \ + "JavaScript. Pass the value through `env:` and read it from `process.env` instead.", + ) + end + end + + def test_setup_rake_compiler_reads_the_working_directory_from_the_environment + step = github_script_steps.fetch(0) + + assert_equal("${{ inputs.working-directory }}", step.dig("env", "WORKING_DIRECTORY")) + assert_includes(step.dig("with", "script"), "run(process.env.WORKING_DIRECTORY)") + end + + private + + def github_script_steps + steps = YAML.safe_load_file(ACTION_PATH).dig("runs", "steps") + matching = steps.select { |step| step["uses"].to_s.start_with?("actions/github-script") } + + refute_empty(matching) + matching + end + end +end From 9af1b06fd709e708c13f190c3cf1d7c44e1f6c03 Mon Sep 17 00:00:00 2001 From: River Date: Wed, 16 Sep 2026 16:33:05 +0000 Subject: [PATCH 5/6] Pass the action's `version` input as data, not as shell code The composite action built its install command with `format('gem install cibuildgem -v {0}', inputs.version)` and emitted the result as a `shell: bash` run body. Workflow expression substitution only concatenates text, so a workflow that wires `version` to a value it does not control (an event payload field, a `workflow_call` input) turns a version selector into arbitrary commands running in the job, with its token and secrets (CWE-78). A hard-coded version is unaffected, so is omitting the input, and invocations inside this repository take the `rake install` branch. Pass the input through the step environment and reference it as a quoted shell variable, so the script source is static and the value stays data. It reaches `gem install` as a single argument, and RubyGems rejects it with "Illformed requirement" when it is not a version, so nothing else has to validate it. Two regression tests cover it. The first asserts the step reads the version from the environment and runs a literal command. The second renders the `version` branch of the step's `case()` expression and runs it against a fake `gem` on PATH with a hostile value, asserting that nothing was executed and that `gem` received the value as a single argument. Both fail on the old shape, the second by actually creating the file the planted command touches. Reported as https://vault.shopify.io/issues/78388 (CWE-78). Co-authored-by: Aaron Patterson --- .github/actions/cibuildgem/action.yml | 4 +- CHANGELOG.md | 4 ++ test/action_test.rb | 64 ++++++++++++++++++++++++++- 3 files changed, 70 insertions(+), 2 deletions(-) diff --git a/.github/actions/cibuildgem/action.yml b/.github/actions/cibuildgem/action.yml index fd52548c6..29afc5d25 100644 --- a/.github/actions/cibuildgem/action.yml +++ b/.github/actions/cibuildgem/action.yml @@ -22,10 +22,12 @@ runs: steps: - name: Install cibuildgem shell: bash + env: + CIBUILDGEM_VERSION: "${{ inputs.version }}" run: |- ${{ case( github.repository == 'Shopify/cibuildgem', 'rake install', - inputs.version != null, format('gem install cibuildgem -v {0}', inputs.version), + inputs.version != null, 'gem install cibuildgem -v "$CIBUILDGEM_VERSION"', 'gem install cibuildgem' ) }} working-directory: "${{ github.action_path }}" diff --git a/CHANGELOG.md b/CHANGELOG.md index 6df8d5410..7a5b2069d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 and underscores, rather than publishing it. RubyGems restricts a gem's name, version and platform to those characters, so every filename `cibuildgem package` produces is accepted and one that is not was planted by something else running in the compile job. +- The action's `version` input is now passed to `gem install` through the step environment instead of being + interpolated into the shell script. A workflow that wired the input to a value it did not control turned a version + selector into arbitrary commands running in the job; the value now reaches `gem install` as a single argument and + RubyGems rejects it if it is not a version. ## [0.3.0] - 2026-03-27 diff --git a/test/action_test.rb b/test/action_test.rb index a9bd82c1c..6c4df7cd2 100644 --- a/test/action_test.rb +++ b/test/action_test.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true require "test_helper" +require "fileutils" +require "tmpdir" require "yaml" module Cibuildgem @@ -27,10 +29,70 @@ def test_setup_rake_compiler_reads_the_working_directory_from_the_environment assert_includes(step.dig("with", "script"), "run(process.env.WORKING_DIRECTORY)") end + def test_install_step_reads_the_cibuildgem_version_from_the_environment + assert_equal("${{ inputs.version }}", install_step.dig("env", "CIBUILDGEM_VERSION")) + assert_equal(%(gem install cibuildgem -v "$CIBUILDGEM_VERSION"), version_branch_script("1.2.3")) + end + + def test_the_version_input_reaches_gem_install_as_a_single_argument + hostile_version = nil + argv = nil + + Dir.mktmpdir do |dir| + marker = File.join(dir, "injected") + argv_log = File.join(dir, "argv") + hostile_version = "$(touch #{marker})" + + system( + { "PATH" => "#{fake_gem_bin(dir, argv_log)}:#{ENV["PATH"]}", "CIBUILDGEM_VERSION" => hostile_version }, + "bash", "-c", version_branch_script(hostile_version), + out: File::NULL, err: File::NULL, + ) + refute_path_exists(marker, "the version input was evaluated as shell syntax instead of passed as data") + + argv = File.readlines(argv_log, chomp: true) + end + + assert_equal(["install", "cibuildgem", "-v", hostile_version], argv) + end + private + def fake_gem_bin(dir, argv_log) + bin = File.join(dir, "bin") + FileUtils.mkdir_p(bin) + File.write(File.join(bin, "gem"), <<~SH) + #!/usr/bin/env bash + printf '%s\\n' "$@" > #{argv_log} + SH + FileUtils.chmod(0o755, File.join(bin, "gem")) + + bin + end + + def steps + YAML.safe_load_file(ACTION_PATH).dig("runs", "steps") + end + + def install_step + steps.fetch(0).tap { |step| assert_equal("Install cibuildgem", step["name"]) } + end + + # The `version` branch of the install step's `case()` expression, rendered as the shell script the runner + # would produce for `version`. Understands a literal command, which is the safe shape, and a `format()` + # call interpolating the input, which is the shape these tests exist to reject. + def version_branch_script(version) + branch = install_step.fetch("run")[/inputs\.version != null,\s*(.+?),\s*'[^']*'\s*\)/m, 1] + refute_nil(branch, "no `version` branch found in the install step") + + case branch + when /\A'(.*)'\z/m then Regexp.last_match(1) + when /\Aformat\('(.*)',\s*inputs\.version\)\z/m then Regexp.last_match(1).sub("{0}", version) + else flunk("cannot tell what the install step runs when `version` is set: #{branch}") + end + end + def github_script_steps - steps = YAML.safe_load_file(ACTION_PATH).dig("runs", "steps") matching = steps.select { |step| step["uses"].to_s.start_with?("actions/github-script") } refute_empty(matching) From 0b0e7c3b5988bbf4773e995cee9e8d13005bb430 Mon Sep 17 00:00:00 2001 From: Edouard CHIN Date: Fri, 18 Sep 2026 14:57:57 +0200 Subject: [PATCH 6/6] Fix RuboCop offenses in action_test.rb Split the multi-line system() call so each argument is on its own line (Layout/MultilineMethodArgumentLineBreaks) and keep the trailing comma consistent with each item on its own line (Style/TrailingCommaInArguments). --- test/action_test.rb | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/test/action_test.rb b/test/action_test.rb index 6c4df7cd2..287180840 100644 --- a/test/action_test.rb +++ b/test/action_test.rb @@ -45,8 +45,11 @@ def test_the_version_input_reaches_gem_install_as_a_single_argument system( { "PATH" => "#{fake_gem_bin(dir, argv_log)}:#{ENV["PATH"]}", "CIBUILDGEM_VERSION" => hostile_version }, - "bash", "-c", version_branch_script(hostile_version), - out: File::NULL, err: File::NULL, + "bash", + "-c", + version_branch_script(hostile_version), + out: File::NULL, + err: File::NULL, ) refute_path_exists(marker, "the version input was evaluated as shell syntax instead of passed as data")