diff --git a/rakelib/release.rake b/rakelib/release.rake index 567eee838b..72d90e25f8 100644 --- a/rakelib/release.rake +++ b/rakelib/release.rake @@ -547,12 +547,24 @@ def ci_status_override_enabled?(override_flag) release_truthy?(override_flag) || release_truthy?(ENV.fetch("RELEASE_CI_STATUS_OVERRIDE", nil)) end +# Escape hatch: force the CI gate to evaluate origin/main HEAD verbatim instead +# of walking back over non-runtime-only commits (see `main_ci_evaluation_sha`). +def ci_evaluate_head_only? + release_truthy?(ENV.fetch("RELEASE_CI_EVALUATE_HEAD", nil)) +end + # Statuses considered "incomplete" — anything not yet a finalized conclusion. CI_INCOMPLETE_STATUSES = %w[in_progress queued waiting requested pending].freeze # Conclusions considered acceptable. `skipped`/`neutral` are not failures (e.g. docs-only # paths-ignore skips, or workflows that intentionally short-circuit). CI_PASSING_CONCLUSIONS = %w[success skipped neutral].freeze +# Upper bound on how many consecutive non-runtime-only commits the CI gate will +# walk past when choosing which origin/main commit to evaluate. Bounds the git +# work and guards against an unbounded walk; beyond this we evaluate wherever we +# stopped. A real chain of docs/changelog commits is far shorter than this. +MAIN_CI_NONRUNTIME_WALK_LIMIT = 25 + # rubocop:disable Metrics/MethodLength def fetch_main_ci_checks(monorepo_root:, allow_override: false, dry_run: false) fetch_output, fetch_status = Open3.capture2e( @@ -576,7 +588,11 @@ def fetch_main_ci_checks(monorepo_root:, allow_override: false, dry_run: false) ) return nil end - sha = sha_output.strip + # Evaluate the most recent commit that actually ran the full suite. When HEAD + # is changelog/docs/comment-only (e.g. the pre-release `update-changelog` + # commit), CI path-skips the runtime suite there, so its checks tell us + # nothing about release health — walk back to the last runtime-bearing commit. + sha = main_ci_evaluation_sha(monorepo_root:, head_sha: sha_output.strip) repo_slug = github_repo_slug(monorepo_root) api_path = "repos/#{repo_slug}/commits/#{sha}/check-runs" @@ -634,6 +650,82 @@ def parse_gh_jsonl(output) end end +# Choose which origin/main commit the CI gate should evaluate. Starting at +# `head_sha`, walk back over commits that `script/ci-changes-detector` classifies +# as non-runtime-only (changelog/docs/source-comment changes — exactly the +# commits on which CI path-skips the runtime suite) and return the first commit +# that ran the full suite. The walk stops at HEAD when a commit isn't provably +# non-runtime-only, so the behavior degrades to the original "evaluate HEAD" gate. +def main_ci_evaluation_sha(monorepo_root:, head_sha:) + return head_sha if ci_evaluate_head_only? + + current = head_sha + skipped = [] + MAIN_CI_NONRUNTIME_WALK_LIMIT.times do + break unless commit_non_runtime_only?(monorepo_root:, sha: current) + + parent = git_parent_sha(monorepo_root:, sha: current) + break if parent.nil? + + skipped << current + current = parent + end + + log_main_ci_walkback(head_sha:, evaluated_sha: current, skipped:) unless skipped.empty? + current +end + +def log_main_ci_walkback(head_sha:, evaluated_sha:, skipped:) + puts "ℹ️ origin/main HEAD #{head_sha[0, 8]} is non-runtime-only " \ + "(changelog/docs/comments); CI path-skips the full suite on such commits." + puts " Skipped #{skipped.length} non-runtime commit(s): #{skipped.map { |s| s[0, 8] }.join(', ')}" + puts " Evaluating main CI on #{evaluated_sha[0, 8]} — the most recent commit that ran the full suite." + puts " (Set RELEASE_CI_EVALUATE_HEAD=true to force strict HEAD evaluation.)" +end + +# Whether `sha`'s changes are *provably* non-runtime-only per the canonical CI +# detector. Returns true only when the detector positively reports +# `non_runtime_only=true`; every other outcome (script missing, git/detector +# failure, unparseable output, or an explicit `false`) returns false. Conflating +# "unknown" with "runtime-bearing" is the safe direction for a release gate: the +# walk stops and the current commit is evaluated rather than skipped on a guess. +def commit_non_runtime_only?(monorepo_root:, sha:) + detector = File.join(monorepo_root, "script", "ci-changes-detector") + return false unless File.executable?(detector) + + Dir.mktmpdir("ror-ci-detector") do |dir| + output_file = File.join(dir, "github_output") + File.write(output_file, "") + # The detector writes `non_runtime_only=true|false` to $GITHUB_OUTPUT — the + # same machine interface CI consumes — so we reuse its path classification + # instead of re-deriving paths-ignore rules here. `^ ` diffs just + # that commit; a non-HEAD current ref means no uncommitted folding. + _stdout, status = Open3.capture2e( + { "GITHUB_OUTPUT" => output_file }, detector, "#{sha}^", sha, chdir: monorepo_root + ) + return false unless status.success? + + flag = File.read(output_file).lines.reverse.find { |line| line.start_with?("non_runtime_only=") } + return false if flag.nil? + + flag.split("=", 2).last.strip == "true" + end +rescue StandardError + false +end + +# First parent of `sha`, or nil at a root commit (or on any git failure) so the +# walk terminates cleanly. +def git_parent_sha(monorepo_root:, sha:) + output, status = Open3.capture2e( + "git", "-C", monorepo_root, "rev-parse", "--verify", "--quiet", "#{sha}^" + ) + return nil unless status.success? + + parent = output.strip + parent.empty? ? nil : parent +end + def fetch_main_commit_statuses(repo_slug:, sha:, allow_override:, dry_run:) api_path = "repos/#{repo_slug}/commits/#{sha}/statuses" @@ -1683,7 +1775,12 @@ This will update and release: 4th argument: Override release CI gates (true/false, default: false) Release CI policy: - Before releasing, the script checks CI status on origin/main HEAD. + Before releasing, the script checks CI status on origin/main. + - It evaluates the most recent commit that ran the full suite: if HEAD is + changelog/docs/comment-only (e.g. the pre-release `update-changelog` + commit), CI path-skips the runtime suite there, so the gate walks back to + the last runtime-bearing commit instead of waiting on meaningless checks. + Set RELEASE_CI_EVALUATE_HEAD=true to force strict HEAD evaluation. - Stable releases require every check run on the commit to have succeeded. - Pre-releases require only the GitHub-branch-protection-required checks to have succeeded. @@ -1701,6 +1798,7 @@ Environment variables: RUBYGEMS_OTP= # Provide RubyGems one-time password (reused for both gems) RELEASE_VERSION_POLICY_OVERRIDE=true # Override release version policy checks RELEASE_CI_STATUS_OVERRIDE=true # Override release CI gates + RELEASE_CI_EVALUATE_HEAD=true # Evaluate origin/main HEAD verbatim (skip the non-runtime walk-back) GEM_RELEASE_MAX_RETRIES= # Override max retry attempts (default: 3) Examples: diff --git a/react_on_rails/spec/react_on_rails/release_rake_helpers_spec.rb b/react_on_rails/spec/react_on_rails/release_rake_helpers_spec.rb index 9677738164..2e1bbbea29 100644 --- a/react_on_rails/spec/react_on_rails/release_rake_helpers_spec.rb +++ b/react_on_rails/spec/react_on_rails/release_rake_helpers_spec.rb @@ -1846,6 +1846,14 @@ def in_progress_run(name, id: next_check_run_id) let(:success_status) { instance_double(Process::Status, success?: true) } let(:failure_status) { instance_double(Process::Status, success?: false) } + # These tests focus on the fetch/parse behavior for a resolved SHA. The + # commit-selection walk-back is covered separately in + # `#main_ci_evaluation_sha`; stub it to the identity here so the gh check-runs + # path stays pinned to the SHA from `git rev-parse origin/main`. + before do + allow(self).to receive(:main_ci_evaluation_sha) { |**kwargs| kwargs[:head_sha] } + end + it "aborts if `git fetch origin main` fails" do allow(Open3).to receive(:capture2e) .with("git", "-C", monorepo_root, "fetch", "origin", "main", "--quiet") @@ -1981,6 +1989,187 @@ def in_progress_run(name, id: next_check_run_id) end end + describe "#main_ci_evaluation_sha" do + let(:monorepo_root) { "/tmp/repo" } + + before do + # Default to "no escape hatch"; the bypass test overrides this. + allow(self).to receive(:ci_evaluate_head_only?).and_return(false) + end + + it "returns HEAD unchanged when HEAD already ran the full suite" do + allow(self).to receive(:commit_non_runtime_only?) + .with(monorepo_root:, sha: "head").and_return(false) + + expect(self).not_to receive(:git_parent_sha) + expect(main_ci_evaluation_sha(monorepo_root:, head_sha: "head")).to eq("head") + end + + it "walks back one commit past a changelog/docs-only HEAD" do + allow(self).to receive(:commit_non_runtime_only?) + .with(monorepo_root:, sha: "head").and_return(true) + allow(self).to receive(:commit_non_runtime_only?) + .with(monorepo_root:, sha: "parent").and_return(false) + allow(self).to receive(:git_parent_sha) + .with(monorepo_root:, sha: "head").and_return("parent") + + result = nil + expect { result = main_ci_evaluation_sha(monorepo_root:, head_sha: "head") } + .to output(/Evaluating main CI on parent/).to_stdout + expect(result).to eq("parent") + end + + it "walks back over a chain of consecutive non-runtime-only commits" do + runtime = { "c0" => true, "c1" => true, "c2" => true, "c3" => false } + parents = { "c0" => "c1", "c1" => "c2", "c2" => "c3" } + allow(self).to receive(:commit_non_runtime_only?) { |**kwargs| runtime.fetch(kwargs[:sha]) } + allow(self).to receive(:git_parent_sha) { |**kwargs| parents[kwargs[:sha]] } + + result = nil + expect { result = main_ci_evaluation_sha(monorepo_root:, head_sha: "c0") } + .to output(/Skipped 3 non-runtime commit\(s\): c0, c1, c2/).to_stdout + expect(result).to eq("c3") + end + + it "evaluates HEAD verbatim when RELEASE_CI_EVALUATE_HEAD is set" do + allow(self).to receive(:ci_evaluate_head_only?).and_return(true) + + expect(self).not_to receive(:commit_non_runtime_only?) + expect(main_ci_evaluation_sha(monorepo_root:, head_sha: "head")).to eq("head") + end + + it "fail-safes to evaluating HEAD when the detector is unavailable" do + # No stub on commit_non_runtime_only?: with no detector script under this + # root the real predicate returns false, so the walk never starts. + expect(self).not_to receive(:git_parent_sha) + expect(main_ci_evaluation_sha(monorepo_root: "/nonexistent/repo", head_sha: "head")).to eq("head") + end + + it "stops at a root commit even when it is non-runtime-only" do + allow(self).to receive(:commit_non_runtime_only?) + .with(monorepo_root:, sha: "root").and_return(true) + allow(self).to receive(:git_parent_sha) + .with(monorepo_root:, sha: "root").and_return(nil) + + expect(main_ci_evaluation_sha(monorepo_root:, head_sha: "root")).to eq("root") + end + + it "stops after MAIN_CI_NONRUNTIME_WALK_LIMIT commits" do + allow(self).to receive(:commit_non_runtime_only?).and_return(true) + # Each commit reports a distinct parent, so only the cap terminates the walk. + allow(self).to receive(:git_parent_sha) { |**kwargs| "#{kwargs[:sha]}-p" } + + result = nil + expect { result = main_ci_evaluation_sha(monorepo_root:, head_sha: "c") } + .to output(/Skipped #{MAIN_CI_NONRUNTIME_WALK_LIMIT} non-runtime/o).to_stdout + expect(result).to eq("c#{'-p' * MAIN_CI_NONRUNTIME_WALK_LIMIT}") + end + end + + describe "#commit_non_runtime_only?" do + let(:success_status) { instance_double(Process::Status, success?: true) } + let(:failure_status) { instance_double(Process::Status, success?: false) } + + # Create a throwaway repo root with a (real, executable or not) detector + # script so `File.executable?` reflects reality; the detector itself is + # stubbed via Open3 so tests stay hermetic. + def with_detector(executable: true) + Dir.mktmpdir do |root| + Dir.mkdir(File.join(root, "script")) + detector = File.join(root, "script", "ci-changes-detector") + File.write(detector, "#!/bin/sh\n") + File.chmod(executable ? 0o755 : 0o644, detector) + yield root + end + end + + def stub_detector_output(content) + allow(Open3).to receive(:capture2e) do |env, *_cmd, **_opts| + File.write(env["GITHUB_OUTPUT"], content) + ["", success_status] + end + end + + it "returns false when the detector script is not executable" do + with_detector(executable: false) do |root| + expect(Open3).not_to receive(:capture2e) + expect(commit_non_runtime_only?(monorepo_root: root, sha: "abc")).to be false + end + end + + it "returns true when the detector reports non_runtime_only=true" do + with_detector do |root| + stub_detector_output("docs_only=true\nnon_runtime_only=true\n") + expect(commit_non_runtime_only?(monorepo_root: root, sha: "abc")).to be true + end + end + + it "returns false when the detector reports non_runtime_only=false" do + with_detector do |root| + stub_detector_output("non_runtime_only=false\n") + expect(commit_non_runtime_only?(monorepo_root: root, sha: "abc")).to be false + end + end + + it "passes the commit range and GITHUB_OUTPUT env to the detector" do + with_detector do |root| + detector = File.join(root, "script", "ci-changes-detector") + expect(Open3).to receive(:capture2e) do |env, *cmd, **opts| + expect(env).to include("GITHUB_OUTPUT") + expect(cmd).to eq([detector, "abc^", "abc"]) + expect(opts).to eq(chdir: root) + File.write(env["GITHUB_OUTPUT"], "non_runtime_only=true\n") + ["", success_status] + end + expect(commit_non_runtime_only?(monorepo_root: root, sha: "abc")).to be true + end + end + + it "returns false when the detector exits non-zero" do + with_detector do |root| + allow(Open3).to receive(:capture2e).and_return(["boom", failure_status]) + expect(commit_non_runtime_only?(monorepo_root: root, sha: "abc")).to be false + end + end + + it "returns false when the detector output omits the non_runtime_only flag" do + with_detector do |root| + stub_detector_output("docs_only=true\n") + expect(commit_non_runtime_only?(monorepo_root: root, sha: "abc")).to be false + end + end + end + + describe "#git_parent_sha" do + let(:monorepo_root) { "/tmp/repo" } + let(:success_status) { instance_double(Process::Status, success?: true) } + let(:failure_status) { instance_double(Process::Status, success?: false) } + + it "returns the first parent of a commit" do + allow(Open3).to receive(:capture2e) + .with("git", "-C", monorepo_root, "rev-parse", "--verify", "--quiet", "child^") + .and_return(["parentsha\n", success_status]) + + expect(git_parent_sha(monorepo_root:, sha: "child")).to eq("parentsha") + end + + it "returns nil at a root commit (git reports failure)" do + allow(Open3).to receive(:capture2e) + .with("git", "-C", monorepo_root, "rev-parse", "--verify", "--quiet", "root^") + .and_return(["", failure_status]) + + expect(git_parent_sha(monorepo_root:, sha: "root")).to be_nil + end + + it "returns nil when git succeeds but yields empty output" do + allow(Open3).to receive(:capture2e) + .with("git", "-C", monorepo_root, "rev-parse", "--verify", "--quiet", "weird^") + .and_return(["\n", success_status]) + + expect(git_parent_sha(monorepo_root:, sha: "weird")).to be_nil + end + end + describe "#required_check_names_for_main" do let(:monorepo_root) { "/tmp/repo" } let(:success_status) { instance_double(Process::Status, success?: true) }