-
-
Notifications
You must be signed in to change notification settings - Fork 629
Release CI gate: evaluate last full-CI commit, not changelog/docs HEAD #4070
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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." | ||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. Suggest softening the wording:
Suggested change
|
||||||||||||||||
| 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 | ||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In a shallow clone,
Suggested change
|
||||||||||||||||
| ) | ||||||||||||||||
|
Comment on lines
+703
to
+705
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||||
|
|
||||||||||||||||
| # 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=<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: | ||||||||||||||||
|
|
||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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 | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The
Suggested change
|
||||||
| 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) } | ||||||
|
|
||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
commit_non_runtime_only?returnedfalse. When the loop terminates due toMAIN_CI_NONRUNTIME_WALK_LIMIT, the finalcurrentwas never passed tocommit_non_runtime_only?— it could itself be a non-runtime-only commit. The message would then misrepresent what CI ran on that commit.