Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 100 additions & 2 deletions rakelib/release.rake
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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"
Expand Down Expand Up @@ -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."

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The log message claims the evaluated SHA is "the most recent commit that ran the full suite," but this is only guaranteed when the walk stopped because commit_non_runtime_only? returned false. When the loop terminates due to MAIN_CI_NONRUNTIME_WALK_LIMIT, the final current was never passed to commit_non_runtime_only? — it could itself be a non-runtime-only commit. The message would then misrepresent what CI ran on that commit.

Suggested change
puts " Evaluating main CI on #{evaluated_sha[0, 8]} — the most recent commit that ran the full suite."
walk_limit_reached = skipped.length >= MAIN_CI_NONRUNTIME_WALK_LIMIT
suffix = walk_limit_reached ? " (walk limit reached; full-suite status unconfirmed)." : " — the most recent commit that ran the full suite."
puts " Evaluating main CI on #{evaluated_sha[0, 8]}#{suffix}"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The phrase "the most recent commit that ran the full suite" overclaims what we actually know. main_ci_evaluation_sha returns the first commit that isn't provably non-runtime-only — which is the correct, safe stopping point — but that commit's CI could still have been skipped for an unrelated reason, or we might be at the walk-limit boundary without having verified it.

Suggest softening the wording:

Suggested change
puts " Evaluating main CI on #{evaluated_sha[0, 8]} — the most recent commit that ran the full suite."
puts " Evaluating main CI on #{evaluated_sha[0, 8]} — the most recent commit not classified as changelog/docs-only."

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. `<sha>^ <sha>` 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In a shallow clone, sha^ may not be fetched, causing git_diff_base_resolve to exit non-zero (the detector uses set -euo pipefail). This returns false — the safe direction — but it silently prevents any walk-back. Worth a brief comment so the failure mode is obvious to a future reader:

Suggested change
{ "GITHUB_OUTPUT" => output_file }, detector, "#{sha}^", sha, chdir: monorepo_root
# NOTE: `sha^` requires the parent commit to be present in the local clone.
# In a shallow clone where the parent isn't fetched, the detector exits non-zero
# and this method returns false (fail-safe: treat as runtime-bearing, stop walk).
_stdout, status = Open3.capture2e(
{ "GITHUB_OUTPUT" => output_file }, detector, "#{sha}^", sha, chdir: monorepo_root
)

)
Comment on lines +703 to +705

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Match CI's push diff before skipping HEAD

When origin/main is updated by a push containing multiple commits, the workflows classify changes from github.event.before to the pushed HEAD (for example .github/workflows/gem-tests.yml:74-75 and .github/workflows/lint-js-and-ruby.yml:67-68), not from HEAD^ to HEAD. If that push contains a runtime commit followed by a changelog/docs-only commit, full CI actually runs on HEAD, but this per-commit detector invocation labels only the last commit as non_runtime_only and walks back to the parent. The release gate can then ignore the real HEAD check run (or block on a parent that never had a main-push run), so the selected commit no longer matches the commit whose CI GitHub just evaluated.

Useful? React with 👍 / 👎.

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
Comment on lines +713 to +715

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The bare rescue StandardError at the end of commit_non_runtime_only? silently discards every unexpected failure (temp-dir creation errors, unexpected IO errors, etc.) and returns false. While the fail-safe direction is correct, a suppressed exception can make real operational problems (e.g. a permissions issue on the detector script, a broken git install) invisible during a release. Logging the exception message preserves the safe fallback while providing a diagnostic signal.

Suggested change
rescue StandardError
false
end
rescue StandardError => e
warn "⚠️ commit_non_runtime_only? raised #{e.class}: #{e.message}; treating commit as runtime-bearing."
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"

Expand Down Expand Up @@ -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.
Expand All @@ -1701,6 +1798,7 @@ Environment variables:
RUBYGEMS_OTP=<code> # 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=<n> # Override max retry attempts (default: 3)

Examples:
Expand Down
189 changes: 189 additions & 0 deletions react_on_rails/spec/react_on_rails/release_rake_helpers_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The /o flag ("interpolate once") is a no-op in modern Ruby when the interpolated value is a constant — the regex is already compiled once at parse time. This is harmless but can confuse readers who expect it to mean something.

Suggested change
.to output(/Skipped #{MAIN_CI_NONRUNTIME_WALK_LIMIT} non-runtime/o).to_stdout
.to output(/Skipped #{MAIN_CI_NONRUNTIME_WALK_LIMIT} non-runtime/).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) }
Expand Down
Loading