From 9d15a049bdb2427c19474bb06486563552bfd39d Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Sat, 6 Jun 2026 20:41:52 -1000 Subject: [PATCH 01/13] ci: harden release gate required check matching --- .claude/hooks/main-ci-status.sh | 29 +- rakelib/release.rake | 446 ++++++++---- .../release_rake_helpers_spec.rb | 659 ++++++++++++++---- 3 files changed, 875 insertions(+), 259 deletions(-) diff --git a/.claude/hooks/main-ci-status.sh b/.claude/hooks/main-ci-status.sh index 02842b0702..3c589e6e86 100755 --- a/.claude/hooks/main-ci-status.sh +++ b/.claude/hooks/main-ci-status.sh @@ -158,17 +158,17 @@ checks_jsonl=$(gh api \ # that each define a `detect-changes` job). Mirrors the Ruby dedup in # `validate_main_ci_status!` (rakelib/release.rake). Keep the two in sync. checks_json=$(echo "${checks_jsonl}" | jq -s ' - [.[] | {id, name, status, conclusion, html_url, suite_id: (.check_suite.id // .id)}] - | group_by([.suite_id, .name]) + [.[] | {id, name, status, conclusion, html_url, suite_id: (.check_suite.id // .id), app_id: (.app.id // null)}] + | group_by([.suite_id, .name, .app_id]) | map(max_by(.id)) ' 2>/dev/null) || fail_open "jq slurp failed" required_json=$(gh api \ "repos/${repo_slug}/branches/main/protection/required_status_checks" \ - --jq '(.contexts // []) + (.checks // [] | map(.context)) | unique' \ + --jq '{contexts: (.contexts // []), checks: (.checks // [] | map({context, app_id}))}' \ 2>/dev/null || echo "null") case "${required_json}" in - \[*\]) ;; + \{*\}) ;; *) required_json="null" ;; esac @@ -176,14 +176,31 @@ esac # Anything completed with another conclusion is a failure. Anything not yet # completed is in_progress. summary=$(echo "${checks_json}" | jq -r --argjson required_names "${required_json}" ' + def app_wildcard($app_id): $app_id == null or $app_id == -1; + def check_matches($required): + .name == $required.context and (app_wildcard($required.app_id) or .app_id == $required.app_id); + def required_check_label: + if app_wildcard(.app_id) then .context else "\(.context) (app_id: \(.app_id))" end; + . as $all - | ($all | map(.name)) as $observed_names | { total: length, passed: [.[] | select(.status == "completed" and (.conclusion | IN("success", "skipped", "neutral")))] | length, failed: [.[] | select(.status == "completed" and (.conclusion | IN("success", "skipped", "neutral") | not))], in_progress: [.[] | select(.status != "completed")], - missing_required: (if $required_names == null then [] else ($required_names - $observed_names) end) + missing_required: ( + if $required_names == null then [] + else + # NOTE: commit statuses are not fetched here; this hook is a fail-open + # display tool. The Ruby release gate owns legacy status enforcement. + (($required_names.contexts // []) + | map(select(. as $context | ($all | any(.name == $context) | not)))) + + + (($required_names.checks // []) + | map(select(. as $required | ($all | any(check_matches($required)) | not))) + | map(required_check_label)) + end + ) } | "TOTAL=\(.total)", "PASSED=\(.passed)", diff --git a/rakelib/release.rake b/rakelib/release.rake index 321adeca5f..d9e89b57f8 100644 --- a/rakelib/release.rake +++ b/rakelib/release.rake @@ -39,7 +39,7 @@ end def release_paths(monorepo_root) { - monorepo_root:, + monorepo_root: monorepo_root, gem_root: File.join(monorepo_root, "react_on_rails"), pro_gem_root: File.join(monorepo_root, "react_on_rails_pro"), dummy_app_dir: File.join(monorepo_root, "react_on_rails", "spec", "dummy"), @@ -81,7 +81,7 @@ def prompt_for_otp(service_name, allow_blank: false, hint: nil) abort "\n❌ No OTP provided. Aborting." end - normalize_otp_code(otp, service_name:) + normalize_otp_code(otp, service_name: service_name) end # Resolve the RubyGems OTP to reuse for BOTH gem pushes (react_on_rails and @@ -160,8 +160,8 @@ def github_repo_slug(monorepo_root) match[:repo] end -def capture_gh_output(*) - Open3.capture2e("gh", *) +def capture_gh_output(*args) + Open3.capture2e("gh", *args) rescue Errno::ENOENT abort "❌ GitHub CLI (`gh`) is not installed. Install it from https://cli.github.com/ and retry." end @@ -181,9 +181,9 @@ def stop_output_reader(output_reader) output_reader.kill if output_reader.alive? end -def capture_gh_output_with_timeout(*, timeout_seconds:) +def capture_gh_output_with_timeout(*args, timeout_seconds:) reader, writer = IO.pipe - pid = Process.spawn("gh", *, out: writer, err: writer) + pid = Process.spawn("gh", *args, out: writer, err: writer) writer.close output_reader = read_output_from_io(reader) status = nil @@ -315,7 +315,7 @@ def wait_for_shakaperf_release_gate_run!(repo_slug:, ref:, head_sha:, ignored_ru ignored_run_ids = ignored_run_ids.map(&:to_s) loop do - runs = fetch_shakaperf_release_gate_runs(repo_slug:, ref:) + runs = fetch_shakaperf_release_gate_runs(repo_slug: repo_slug, ref: ref) matching_run = runs.find do |run| run["headSha"] == head_sha && !ignored_run_ids.include?(run["databaseId"].to_s) && @@ -368,7 +368,7 @@ def run_shakaperf_release_gate!(monorepo_root:, ref:, head_sha:, allow_override: repo_slug = github_repo_slug(monorepo_root) puts "\nRunning ShakaPerf release gate on #{ref} at #{head_sha[0, 8]} before tagging and publishing..." - existing_run_ids = fetch_shakaperf_release_gate_runs(repo_slug:, ref:).map do |run| + existing_run_ids = fetch_shakaperf_release_gate_runs(repo_slug: repo_slug, ref: ref).map do |run| run["databaseId"].to_s end dispatch_started_at = shakaperf_release_gate_dispatch_started_at @@ -383,13 +383,13 @@ def run_shakaperf_release_gate!(monorepo_root:, ref:, head_sha:, allow_override: end run = wait_for_shakaperf_release_gate_run!( - repo_slug:, - ref:, - head_sha:, + repo_slug: repo_slug, + ref: ref, + head_sha: head_sha, ignored_run_ids: existing_run_ids, earliest_created_at: dispatch_started_at ) - watch_shakaperf_release_gate_run!(repo_slug:, run:) + watch_shakaperf_release_gate_run!(repo_slug: repo_slug, run: run) puts "✓ ShakaPerf release gate passed: #{run['url'] || "GitHub Actions run #{run.fetch('databaseId')}"}" end @@ -405,7 +405,7 @@ def run_release_preflight_checks!(monorepo_root:, dry_run:) puts "PRE-FLIGHT CHECKS" puts "=" * 80 verify_npm_auth - verify_gh_auth(monorepo_root:) + verify_gh_auth(monorepo_root: monorepo_root) end def current_gem_version(monorepo_root) @@ -559,8 +559,8 @@ def fetch_main_ci_checks(monorepo_root:, allow_override: false, dry_run: false) unless fetch_status.success? handle_main_ci_status_violation!( message: "❌ Unable to fetch origin/main for CI status check.\n\n#{fetch_output}", - allow_override:, - dry_run: + allow_override: allow_override, + dry_run: dry_run ) return nil end @@ -569,8 +569,8 @@ def fetch_main_ci_checks(monorepo_root:, allow_override: false, dry_run: false) unless sha_status.success? handle_main_ci_status_violation!( message: "❌ Unable to resolve origin/main HEAD.\n\n#{sha_output}", - allow_override:, - dry_run: + allow_override: allow_override, + dry_run: dry_run ) return nil end @@ -592,46 +592,137 @@ def fetch_main_ci_checks(monorepo_root:, allow_override: false, dry_run: false) rescue Errno::ENOENT handle_main_ci_status_violation!( message: "❌ GitHub CLI (`gh`) is not installed. Install it from https://cli.github.com/ and retry.", - allow_override:, - dry_run: + allow_override: allow_override, + dry_run: dry_run ) + # Only reached in override/dry-run mode; strict mode aborts above. return nil end unless status.success? handle_main_ci_status_violation!( message: "❌ Unable to query GitHub Checks API for #{sha}.\n\n#{output}", - allow_override:, - dry_run: + allow_override: allow_override, + dry_run: dry_run ) + # Only reached in override/dry-run mode; strict mode aborts above. return nil end begin - check_runs = output.lines.reject { |line| line.strip.empty? }.map do |line| - JSON.parse(line) - end + check_runs = parse_gh_jsonl(output) rescue JSON::ParserError => e handle_main_ci_status_violation!( message: "❌ Failed to parse check_runs response from gh: #{e.message}\n\nOutput:\n#{output}", - allow_override:, - dry_run: + allow_override: allow_override, + dry_run: dry_run ) return nil end - { sha:, repo_slug:, check_runs: } + { sha: sha, repo_slug: repo_slug, check_runs: check_runs } end # rubocop:enable Metrics/MethodLength +def parse_gh_jsonl(output) + output.lines.reject { |line| line.strip.empty? }.map do |line| + JSON.parse(line) + end +end + +def fetch_main_commit_statuses(repo_slug:, sha:, allow_override:, dry_run:) + api_path = "repos/#{repo_slug}/commits/#{sha}/statuses" + + begin + output, status = Open3.capture2e( + "gh", "api", "--paginate", "--jq", ".[]", api_path + ) + rescue Errno::ENOENT + handle_main_ci_status_violation!( + message: "❌ GitHub CLI (`gh`) is not installed. Install it from https://cli.github.com/ and retry.", + allow_override: allow_override, + dry_run: dry_run + ) + return nil + end + + unless status.success? + handle_main_ci_status_violation!( + message: "❌ Unable to query GitHub Statuses API for #{sha}.\n\n#{output}", + allow_override: allow_override, + dry_run: dry_run + ) + return nil + end + + begin + parse_gh_jsonl(output) + rescue JSON::ParserError => e + handle_main_ci_status_violation!( + message: "❌ Failed to parse statuses response from gh: #{e.message}\n\nOutput:\n#{output}", + allow_override: allow_override, + dry_run: dry_run + ) + # Only reached in override/dry-run mode; strict mode aborts above. + nil + end +end + +def normalize_status_as_check_run(status) + state = status["state"] + { + "id" => status["id"], + "name" => status["context"], + "status" => state == "pending" ? "pending" : "completed", + "conclusion" => normalize_status_conclusion(state), + "html_url" => status["target_url"] + } +end + +def normalize_status_conclusion(state) + case state + when "success" + "success" + when "pending" + nil + else + state || "error" # Undocumented states are treated as failures (fail-safe). + end +end + +def latest_commit_statuses(statuses) + statuses + .group_by { |status| status["context"] } + .map { |_context, context_statuses| context_statuses.max_by { |status| status["id"].to_i } } +end + +def normalize_required_check_entries(checks) + Array(checks).filter_map do |check| + context = check["context"].to_s + next if context.empty? + + { context: context, app_id: check["app_id"]&.to_i } + end.uniq +end + +def normalize_required_checks_payload(parsed) + return nil unless parsed.is_a?(Hash) + + contexts = Array(parsed["contexts"]).map(&:to_s).reject(&:empty?).uniq + checks = normalize_required_check_entries(parsed["checks"]) + + # No required names parseable is treated the same as "no branch protection + # visible" — fail-safe to evaluating every check run. + contexts.empty? && checks.empty? ? nil : { contexts: contexts, checks: checks } +end + def required_check_names_for_main(monorepo_root:, repo_slug: nil) repo_slug ||= github_repo_slug(monorepo_root) api_path = "repos/#{repo_slug}/branches/main/protection/required_status_checks" - # Combine the legacy `contexts` list (older protection rules) with the newer - # `checks[].context` list. Branch protection set up via the `checks` API - # leaves `contexts` as `[]`, so reading only `contexts` would yield an empty - # array and trip the `:no_required_checks` abort path even when CI is green. - jq_query = "(.contexts // []) + (.checks // [] | map(.context)) | unique" + # Keep legacy `contexts` separate from modern `checks` entries. Modern + # required checks can be pinned to a GitHub App via `app_id`; legacy contexts + # may be satisfied by either a Checks API run or a commit-status context. + jq_query = "{contexts: (.contexts // []), checks: (.checks // [] | map({context, app_id}))}" # Precondition: `fetch_main_ci_checks` already verified `gh` is installed # before `validate_main_ci_status!` calls this helper. The remaining failure # mode here is "branch protection unknown", which returns nil so the caller @@ -644,16 +735,101 @@ def required_check_names_for_main(monorepo_root:, repo_slug: nil) begin parsed = JSON.parse(output) - return nil unless parsed.is_a?(Array) - - # Empty array (no required names parseable) is treated the same as "no - # branch protection visible" — fail-safe to evaluating every check run. - parsed.empty? ? nil : parsed + normalize_required_checks_payload(parsed) rescue JSON::ParserError nil end end +def check_run_app_id(run) + app_id = run.dig("app", "id") + app_id&.to_i +end + +def required_check_app_wildcard?(app_id) + app_id.nil? || app_id == -1 +end + +def required_check_matches_run?(required_check, run) + required_check[:context] == run["name"] && + (required_check_app_wildcard?(required_check[:app_id]) || required_check[:app_id] == check_run_app_id(run)) +end + +def required_check_present?(required_check:, check_runs:) + check_runs.any? { |run| required_check_matches_run?(required_check, run) } +end + +def legacy_context_check_run_matches?(context:, run:, required_checks:) + return false unless run["name"] == context + + pinned_checks = required_checks[:checks].select do |required_check| + required_check[:context] == context && !required_check_app_wildcard?(required_check[:app_id]) + end + pinned_checks.empty? || pinned_checks.any? { |required_check| required_check_matches_run?(required_check, run) } +end + +def legacy_context_present?(context:, check_runs:, legacy_status_runs:, required_checks:) + matching_check_run = check_runs.any? do |run| + legacy_context_check_run_matches?(context: context, run: run, required_checks: required_checks) + end + + matching_check_run || legacy_status_runs.any? { |run| run["name"] == context } +end + +def required_check_label(required_check) + return required_check[:context] if required_check_app_wildcard?(required_check[:app_id]) + + "#{required_check[:context]} (app_id: #{required_check[:app_id]})" +end + +def required_check_labels(required_checks) + labels = required_checks[:contexts] + required_checks[:checks].map { |check| required_check_label(check) } + labels.tally.map { |label, count| count > 1 ? "#{label} (#{count} gates)" : label } +end + +def required_check_count(required_checks) + required_checks[:contexts].length + required_checks[:checks].length +end + +def missing_required_checks(required_checks:, check_runs:, legacy_status_runs:) + missing_modern = required_checks[:checks].reject do |required_check| + required_check_present?( + required_check: required_check, + check_runs: check_runs + ) + end + missing_legacy = required_checks[:contexts].reject do |context| + legacy_context_present?( + context: context, + check_runs: check_runs, + legacy_status_runs: legacy_status_runs, + required_checks: required_checks + ) + end + + # Keep the raw count separate from display labels: a legacy context and a + # modern wildcard check may share one label but remain distinct requirements. + { + count: missing_legacy.length + missing_modern.length, + labels: (missing_legacy + missing_modern.map { |check| required_check_label(check) }).uniq + } +end + +def legacy_status_runs_for_required_contexts(required_checks:, statuses:) + # Keep same-name statuses even when a check run exists; branch protection can + # require both a legacy status context and a modern check run to pass. + latest_commit_statuses(statuses) + .select { |status| required_checks[:contexts].include?(status["context"]) } + .map { |status| normalize_status_as_check_run(status) } +end + +def format_ci_status_run_line(run, kind:) + icon = kind == :in_progress ? "⏳" : "❌" + detail = kind == :in_progress ? (run["status"] || "in_progress") : (run["conclusion"] || "incomplete") + url = run["html_url"].to_s + url.empty? ? " #{icon} #{detail}: #{run['name']}" : " #{icon} #{detail}: #{run['name']}\n #{url}" +end + def format_main_ci_status_violation(kind:, short_sha:, runs:) # rubocop:disable Metrics/CyclomaticComplexity header = case kind when :in_progress @@ -675,11 +851,7 @@ def format_main_ci_status_violation(kind:, short_sha:, runs:) # rubocop:disable end return header if runs.nil? || runs.empty? - lines = runs.map do |run| - icon = kind == :in_progress ? "⏳" : "❌" - detail = kind == :in_progress ? (run["status"] || "in_progress") : (run["conclusion"] || "incomplete") - " #{icon} #{detail}: #{run['name']}\n #{run['html_url']}" - end + lines = runs.map { |run| format_ci_status_run_line(run, kind: kind) } "#{header}\n\n#{lines.join("\n")}" end @@ -710,7 +882,7 @@ end def validate_main_ci_status!(monorepo_root:, is_prerelease:, allow_override:, dry_run:) puts "\nChecking CI status on origin/main..." - data = fetch_main_ci_checks(monorepo_root:, allow_override:, dry_run:) + data = fetch_main_ci_checks(monorepo_root: monorepo_root, allow_override: allow_override, dry_run: dry_run) # `fetch_main_ci_checks` returns nil when it surfaced a violation through # `handle_main_ci_status_violation!` (dry-run or override path). In that case # the warning has already been printed and we should not continue. @@ -721,15 +893,6 @@ def validate_main_ci_status!(monorepo_root:, is_prerelease:, allow_override:, dr repo_slug = data[:repo_slug] check_runs = data[:check_runs] - if check_runs.empty? - handle_main_ci_status_violation!( - message: format_main_ci_status_violation(kind: :no_checks, short_sha:, runs: nil), - allow_override:, - dry_run: - ) - return - end - # Collapse multiple runs per (check_suite_id, name) to the most recent # attempt (highest check_run id). The key intentionally includes # check_suite_id so we only collapse *true* reruns (same workflow run, @@ -745,7 +908,7 @@ def validate_main_ci_status!(monorepo_root:, is_prerelease:, allow_override:, dr # another. The GitHub Actions Checks API always populates `check_suite`, # so this only matters for external check integrations. check_runs = check_runs - .group_by { |run| [run.dig("check_suite", "id") || run["id"], run["name"]] } + .group_by { |run| [run.dig("check_suite", "id") || run["id"], run["name"], check_run_app_id(run)] } .map { |_key, runs| runs.max_by { |run| run["id"].to_i } } # Always query branch-protection required checks (when configured) so the @@ -753,15 +916,67 @@ def validate_main_ci_status!(monorepo_root:, is_prerelease:, allow_override:, dr # `evaluated` then differs by mode: # - prerelease: only the required subset (narrower filter; non-required failures are advisory) # - stable: every check_run on the commit (broader filter; any failure blocks) - required_args = { monorepo_root: } + required_args = { monorepo_root: monorepo_root } required_args[:repo_slug] = repo_slug if repo_slug required_names = required_check_names_for_main(**required_args) + legacy_status_runs = [] + if required_names && required_names[:contexts].any? + statuses = fetch_main_commit_statuses( + repo_slug: repo_slug || github_repo_slug(monorepo_root), + sha: sha, + allow_override: allow_override, + dry_run: dry_run + ) + if statuses.nil? + # The fetch helper only returns nil after surfacing a dry-run/override + # warning; strict mode aborts before this point. + return unless allow_override || dry_run + + statuses = [] + end + + legacy_status_runs = legacy_status_runs_for_required_contexts( + required_checks: required_names, + statuses: statuses + ) + end + + if check_runs.empty? && legacy_status_runs.empty? + handle_main_ci_status_violation!( + message: format_main_ci_status_violation(kind: :no_checks, short_sha: short_sha, runs: nil), + allow_override: allow_override, + dry_run: dry_run + ) + return + end + evaluated = if is_prerelease && required_names - check_runs.select { |run| required_names.include?(run["name"]) } + check_runs.select do |run| + required_names[:contexts].any? do |context| + legacy_context_check_run_matches?(context: context, run: run, required_checks: required_names) + end || + required_names[:checks].any? { |required_check| required_check_matches_run?(required_check, run) } + end + legacy_status_runs else - check_runs + check_runs + legacy_status_runs end + # Report visible failures before missing/in-progress runs. If both are + # present, the operator needs to know about the failure right away; this also + # prevents same-label legacy/modern required checks from hiding a failed + # legacy status behind a "missing required" message. + failed = evaluated.select do |run| + run["status"] == "completed" && !CI_PASSING_CONCLUSIONS.include?(run["conclusion"]) + end + if failed.any? + handle_main_ci_status_violation!( + message: format_main_ci_status_violation(kind: :failed, short_sha: short_sha, runs: failed), + allow_override: allow_override, + dry_run: dry_run + ) + return + end + # When branch protection lists required checks, treat any missing required # check as blocking — for stable AND prerelease. Branch protection would # refuse the merge in this state, so a release that ignored the gap would @@ -771,49 +986,38 @@ def validate_main_ci_status!(monorepo_root:, is_prerelease:, allow_override:, dr # required workflows ran, others never registered — usually a renamed or # deleted workflow that branch protection still requires). unless required_names.nil? - observed_names = check_runs.map { |run| run["name"] } - missing_names = required_names - observed_names - if missing_names.length == required_names.length + required_labels = required_check_labels(required_names) + missing_required = missing_required_checks( + required_checks: required_names, + check_runs: check_runs, + legacy_status_runs: legacy_status_runs + ) + missing_names = missing_required[:labels] + if missing_required[:count] == required_check_count(required_names) handle_main_ci_status_violation!( - message: format_main_ci_status_violation(kind: :no_required_checks, short_sha:, runs: nil) + - "\nRequired: #{required_names.join(', ')}", - allow_override:, - dry_run: + message: format_main_ci_status_violation(kind: :no_required_checks, short_sha: short_sha, runs: nil) + + "\nRequired: #{required_labels.join(', ')}", + allow_override: allow_override, + dry_run: dry_run ) return elsif missing_names.any? handle_main_ci_status_violation!( - message: format_main_ci_status_violation(kind: :missing_required_checks, short_sha:, runs: nil) + - "\nRequired: #{required_names.join(', ')}\nMissing: #{missing_names.join(', ')}", - allow_override:, - dry_run: + message: format_main_ci_status_violation(kind: :missing_required_checks, short_sha: short_sha, runs: nil) + + "\nRequired: #{required_labels.join(', ')}\nMissing: #{missing_names.join(', ')}", + allow_override: allow_override, + dry_run: dry_run ) return end end - # Report failures before in-progress runs. If both are present, the operator - # needs to know about the failure right away — telling them to "wait or - # override" would just make them wait and re-run before seeing the real - # blocker. - failed = evaluated.select do |run| - run["status"] == "completed" && !CI_PASSING_CONCLUSIONS.include?(run["conclusion"]) - end - if failed.any? - handle_main_ci_status_violation!( - message: format_main_ci_status_violation(kind: :failed, short_sha:, runs: failed), - allow_override:, - dry_run: - ) - return - end - in_progress = evaluated.select { |run| CI_INCOMPLETE_STATUSES.include?(run["status"]) } if in_progress.any? handle_main_ci_status_violation!( - message: format_main_ci_status_violation(kind: :in_progress, short_sha:, runs: in_progress), - allow_override:, - dry_run: + message: format_main_ci_status_violation(kind: :in_progress, short_sha: short_sha, runs: in_progress), + allow_override: allow_override, + dry_run: dry_run ) return end @@ -828,9 +1032,9 @@ def validate_main_ci_status!(monorepo_root:, is_prerelease:, allow_override:, dr end if unknown.any? handle_main_ci_status_violation!( - message: format_main_ci_status_violation(kind: :unknown_status, short_sha:, runs: unknown), - allow_override:, - dry_run: + message: format_main_ci_status_violation(kind: :unknown_status, short_sha: short_sha, runs: unknown), + allow_override: allow_override, + dry_run: dry_run ) return end @@ -872,14 +1076,14 @@ end # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity def validate_release_version_policy!(monorepo_root:, target_gem_version:, allow_override:, fetch_tags: true) - tagged_versions = tagged_release_gem_versions(monorepo_root, fetch_tags:) + tagged_versions = tagged_release_gem_versions(monorepo_root, fetch_tags: fetch_tags) latest_tagged_version = tagged_versions.max_by { |version| Gem::Version.new(version) } if latest_tagged_version && Gem::Version.new(target_gem_version) <= Gem::Version.new(latest_tagged_version) handle_version_policy_violation!( message: "❌ Requested version #{target_gem_version} " \ "must be greater than latest tagged version #{latest_tagged_version}.", - allow_override: + allow_override: allow_override ) end @@ -903,12 +1107,12 @@ def validate_release_version_policy!(monorepo_root:, target_gem_version:, allow_ return unless latest_stable_version actual_bump_type = version_bump_type(previous_stable_gem_version: latest_stable_version, - target_gem_version:) + target_gem_version: target_gem_version) if actual_bump_type == :none handle_version_policy_violation!( message: "❌ Requested version #{target_gem_version} is not a major/minor/patch bump " \ "over latest stable #{latest_stable_version}.", - allow_override: + allow_override: allow_override ) return if allow_override end @@ -919,7 +1123,7 @@ def validate_release_version_policy!(monorepo_root:, target_gem_version:, allow_ end changelog_path = File.join(monorepo_root, "CHANGELOG.md") - changelog_section = extract_changelog_section(changelog_path:, version: target_gem_version) + changelog_section = extract_changelog_section(changelog_path: changelog_path, version: target_gem_version) unless changelog_section puts "ℹ️ VERSION POLICY: No changelog content found for #{target_gem_version}; " \ "skipping changelog bump-consistency check." @@ -936,7 +1140,7 @@ def validate_release_version_policy!(monorepo_root:, target_gem_version:, allow_ handle_version_policy_violation!( message: "❌ Version bump mismatch for #{target_gem_version}: CHANGELOG implies #{expected_bump_type}, " \ "but version bump is #{actual_bump_type} from #{latest_stable_version}.", - allow_override: + allow_override: allow_override ) end # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity @@ -960,7 +1164,7 @@ end def confirm_release!(version:, monorepo_root:) changelog_path = File.join(monorepo_root, "CHANGELOG.md") - has_changelog = extract_changelog_section(changelog_path:, version:) + has_changelog = extract_changelog_section(changelog_path: changelog_path, version: version) puts "" puts "################################################################################" @@ -988,7 +1192,7 @@ def changelog_dirty?(monorepo_root:) end def ensure_changelog_committed!(monorepo_root:) - return unless changelog_dirty?(monorepo_root:) + return unless changelog_dirty?(monorepo_root: monorepo_root) abort "❌ CHANGELOG.md has uncommitted changes. Commit or stash CHANGELOG.md before running sync_github_release." end @@ -1011,12 +1215,12 @@ end def prepare_github_release_context(monorepo_root:, gem_version:) prerelease = release_prerelease_version?(gem_version) changelog_path = File.join(monorepo_root, "CHANGELOG.md") - notes = extract_changelog_section(changelog_path:, version: gem_version) + notes = extract_changelog_section(changelog_path: changelog_path, version: gem_version) abort "❌ Could not find `### [#{gem_version}]` in CHANGELOG.md. Add that section and retry." unless notes { - notes:, - prerelease:, + notes: notes, + prerelease: prerelease, tag: "v#{gem_version}", title: "v#{gem_version}" } @@ -1024,7 +1228,7 @@ end # rubocop:disable Metrics/AbcSize def publish_or_update_github_release(monorepo_root:, release_context:, dry_run:) - ensure_git_tag_exists!(monorepo_root:, tag: release_context[:tag]) + ensure_git_tag_exists!(monorepo_root: monorepo_root, tag: release_context[:tag]) if dry_run puts "DRY RUN: Would create or update GitHub release #{release_context[:tag]}" \ @@ -1061,7 +1265,7 @@ end def sync_github_release_after_publish(monorepo_root:, gem_version:, dry_run:) changelog_path = File.join(monorepo_root, "CHANGELOG.md") - section = extract_changelog_section(changelog_path:, version: gem_version) + section = extract_changelog_section(changelog_path: changelog_path, version: gem_version) unless section puts "################################################################################" puts "Skipping GitHub release: no CHANGELOG.md section for #{gem_version}." @@ -1071,9 +1275,9 @@ def sync_github_release_after_publish(monorepo_root:, gem_version:, dry_run:) return end - verify_gh_auth(monorepo_root:) - release_context = prepare_github_release_context(monorepo_root:, gem_version:) - publish_or_update_github_release(monorepo_root:, release_context:, dry_run:) + verify_gh_auth(monorepo_root: monorepo_root) + release_context = prepare_github_release_context(monorepo_root: monorepo_root, gem_version: gem_version) + publish_or_update_github_release(monorepo_root: monorepo_root, release_context: release_context, dry_run: dry_run) end def with_release_checkout(monorepo_root:, dry_run:) @@ -1106,7 +1310,7 @@ def resolve_version_input(version_input, monorepo_root) stripped = version_input.to_s.strip return stripped unless stripped.empty? - changelog_version = extract_latest_changelog_version(monorepo_root:) + changelog_version = extract_latest_changelog_version(monorepo_root: monorepo_root) current_version = current_gem_version(monorepo_root) if changelog_version && Gem::Version.new(changelog_version) > Gem::Version.new(current_version) @@ -1290,7 +1494,7 @@ def fetch_npm_package_metadata_with_retries(package_ref, registry_url:, attempts last_status = nil attempts.times do |attempt| - output, status = fetch_npm_package_metadata(package_ref, registry_url:) + output, status = fetch_npm_package_metadata(package_ref, registry_url: registry_url) return [output, status] if status.success? last_output = output @@ -1326,9 +1530,9 @@ def verify_npm_package_published!( package_ref = "#{package_name}@#{expected_version}" output, status = fetch_npm_package_metadata_with_retries( package_ref, - registry_url:, - attempts:, - retry_delay_seconds: + registry_url: registry_url, + attempts: attempts, + retry_delay_seconds: retry_delay_seconds ) unless status.success? abort <<~ERROR @@ -1494,12 +1698,12 @@ task :release, %i[version dry_run override_version_policy override_ci_status] do # Configure output verbosity verbose(is_verbose) - run_release_preflight_checks!(monorepo_root:, dry_run: is_dry_run) + run_release_preflight_checks!(monorepo_root: monorepo_root, dry_run: is_dry_run) released_gem_version = nil released_npm_version = nil - with_release_checkout(monorepo_root:, dry_run: is_dry_run) do |release_root| + with_release_checkout(monorepo_root: monorepo_root, dry_run: is_dry_run) do |release_root| release_paths_hash = release_paths(release_root) sh_in_dir_for_release(release_root, "git pull --rebase") unless is_dry_run @@ -1509,7 +1713,7 @@ task :release, %i[version dry_run override_version_policy override_ci_status] do current_checkout_version = current_gem_version(release_root) resolved_target_gem_version = compute_target_gem_version( current_gem_version: current_checkout_version, - version_input: + version_input: version_input ) is_prerelease = release_prerelease_version?(resolved_target_gem_version) @@ -1529,7 +1733,7 @@ task :release, %i[version dry_run override_version_policy override_ci_status] do validate_main_ci_status!( monorepo_root: release_root, - is_prerelease:, + is_prerelease: is_prerelease, allow_override: allow_ci_status_override, dry_run: is_dry_run ) @@ -1736,10 +1940,10 @@ task :release, %i[version dry_run override_version_policy override_ci_status] do puts " - react_on_rails_pro/react_on_rails_pro.gemspec (uses ReactOnRails::VERSION)" puts "\nTo actually release, run: rake release[#{released_gem_version}]" else - sync_github_release_after_publish(monorepo_root:, gem_version: released_gem_version, dry_run: false) + sync_github_release_after_publish(monorepo_root: monorepo_root, gem_version: released_gem_version, dry_run: false) changelog_path = File.join(monorepo_root, "CHANGELOG.md") - has_changelog_section = extract_changelog_section(changelog_path:, version: released_gem_version) + has_changelog_section = extract_changelog_section(changelog_path: changelog_path, version: released_gem_version) puts "\n#{'=' * 80}" puts "RELEASE COMPLETE!" @@ -1797,17 +2001,17 @@ task :sync_github_release, %i[gem_version dry_run] do |_t, args| puts "ℹ️ sync_github_release reads local committed CHANGELOG.md; " \ "run `git pull --rebase` first for latest remote notes." if is_dry_run - if changelog_dirty?(monorepo_root:) + if changelog_dirty?(monorepo_root: monorepo_root) abort "❌ DRY RUN: CHANGELOG.md has uncommitted changes. " \ "Commit or stash CHANGELOG.md before running sync_github_release." end puts "DRY RUN: Validating CHANGELOG.md section exists for #{requested_gem_version}..." else - ensure_changelog_committed!(monorepo_root:) + ensure_changelog_committed!(monorepo_root: monorepo_root) end - verify_gh_auth(monorepo_root:) - release_context = prepare_github_release_context(monorepo_root:, gem_version: requested_gem_version) - publish_or_update_github_release(monorepo_root:, release_context:, dry_run: is_dry_run) + verify_gh_auth(monorepo_root: monorepo_root) + release_context = prepare_github_release_context(monorepo_root: monorepo_root, gem_version: requested_gem_version) + publish_or_update_github_release(monorepo_root: monorepo_root, release_context: release_context, dry_run: is_dry_run) end # rubocop:enable Metrics/BlockLength 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 8dea12b45a..cd106dd178 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 @@ -207,7 +207,7 @@ changelog_path = File.join(dir, "CHANGELOG.md") File.write(changelog_path, changelog) - section = extract_changelog_section(changelog_path:, version: "16.4.0") + section = extract_changelog_section(changelog_path: changelog_path, version: "16.4.0") expect(section).to include("Feature A") expect(section).not_to include("### [16.4.0] - 2026-03-08") expect(section).not_to include("Bug B") @@ -231,7 +231,7 @@ changelog_path = File.join(dir, "CHANGELOG.md") File.write(changelog_path, changelog) - section = extract_changelog_section(changelog_path:, version: "16.4.0") + section = extract_changelog_section(changelog_path: changelog_path, version: "16.4.0") expect(section).to be_nil end end @@ -319,7 +319,7 @@ it "dispatches the workflow on the release ref and watches the matching run" do allow(self).to receive(:fetch_shakaperf_release_gate_runs) - .with(repo_slug:, ref: "release-branch") + .with(repo_slug: repo_slug, ref: "release-branch") .and_return([{ "databaseId" => 999_999, "headSha" => head_sha }]) allow(self).to receive(:capture_gh_output) .with( @@ -330,9 +330,9 @@ .and_return(["", success_status]) allow(self).to receive(:wait_for_shakaperf_release_gate_run!) .with( - repo_slug:, + repo_slug: repo_slug, ref: "release-branch", - head_sha:, + head_sha: head_sha, ignored_run_ids: ["999999"], earliest_created_at: kind_of(Time) ) @@ -346,9 +346,9 @@ expect do run_shakaperf_release_gate!( - monorepo_root:, + monorepo_root: monorepo_root, ref: "release-branch", - head_sha:, + head_sha: head_sha, allow_override: false, dry_run: false ) @@ -361,9 +361,9 @@ ) expect(self).to have_received(:wait_for_shakaperf_release_gate_run!) .with( - repo_slug:, + repo_slug: repo_slug, ref: "release-branch", - head_sha:, + head_sha: head_sha, ignored_run_ids: ["999999"], earliest_created_at: kind_of(Time) ) @@ -379,9 +379,9 @@ expect do run_shakaperf_release_gate!( - monorepo_root:, + monorepo_root: monorepo_root, ref: "release-branch", - head_sha:, + head_sha: head_sha, allow_override: true, dry_run: false ) @@ -393,9 +393,9 @@ expect do run_shakaperf_release_gate!( - monorepo_root:, + monorepo_root: monorepo_root, ref: "release-branch", - head_sha:, + head_sha: head_sha, allow_override: false, dry_run: true ) @@ -404,7 +404,7 @@ it "aborts when workflow dispatch fails" do allow(self).to receive(:fetch_shakaperf_release_gate_runs) - .with(repo_slug:, ref: "release-branch") + .with(repo_slug: repo_slug, ref: "release-branch") .and_return([]) allow(self).to receive(:capture_gh_output) .with( @@ -416,9 +416,9 @@ expect do run_shakaperf_release_gate!( - monorepo_root:, + monorepo_root: monorepo_root, ref: "release-branch", - head_sha:, + head_sha: head_sha, allow_override: false, dry_run: false ) @@ -427,7 +427,7 @@ it "aborts when the matching gate run fails" do allow(self).to receive(:fetch_shakaperf_release_gate_runs) - .with(repo_slug:, ref: "release-branch") + .with(repo_slug: repo_slug, ref: "release-branch") .and_return([]) allow(self).to receive(:capture_gh_output) .with( @@ -438,9 +438,9 @@ .and_return(["", success_status]) allow(self).to receive(:wait_for_shakaperf_release_gate_run!) .with( - repo_slug:, + repo_slug: repo_slug, ref: "release-branch", - head_sha:, + head_sha: head_sha, ignored_run_ids: [], earliest_created_at: kind_of(Time) ) @@ -454,9 +454,9 @@ expect do run_shakaperf_release_gate!( - monorepo_root:, + monorepo_root: monorepo_root, ref: "release-branch", - head_sha:, + head_sha: head_sha, allow_override: false, dry_run: false ) @@ -465,7 +465,7 @@ it "aborts when watching the matching gate run times out" do allow(self).to receive(:fetch_shakaperf_release_gate_runs) - .with(repo_slug:, ref: "release-branch") + .with(repo_slug: repo_slug, ref: "release-branch") .and_return([]) allow(self).to receive(:capture_gh_output) .with( @@ -476,9 +476,9 @@ .and_return(["", success_status]) allow(self).to receive(:wait_for_shakaperf_release_gate_run!) .with( - repo_slug:, + repo_slug: repo_slug, ref: "release-branch", - head_sha:, + head_sha: head_sha, ignored_run_ids: [], earliest_created_at: kind_of(Time) ) @@ -492,9 +492,9 @@ expect do run_shakaperf_release_gate!( - monorepo_root:, + monorepo_root: monorepo_root, ref: "release-branch", - head_sha:, + head_sha: head_sha, allow_override: false, dry_run: false ) @@ -504,11 +504,11 @@ it "finds the workflow_dispatch run for the pushed head SHA" do matching_run = { "databaseId" => 2, "headSha" => head_sha } allow(self).to receive(:fetch_shakaperf_release_gate_runs) - .with(repo_slug:, ref: "release-branch") + .with(repo_slug: repo_slug, ref: "release-branch") .and_return([{ "databaseId" => 1, "headSha" => "old" }, matching_run]) expect( - wait_for_shakaperf_release_gate_run!(repo_slug:, ref: "release-branch", head_sha:) + wait_for_shakaperf_release_gate_run!(repo_slug: repo_slug, ref: "release-branch", head_sha: head_sha) ).to eq(matching_run) end @@ -516,14 +516,14 @@ stale_run = { "databaseId" => 1, "headSha" => head_sha } matching_run = { "databaseId" => 2, "headSha" => head_sha } allow(self).to receive(:fetch_shakaperf_release_gate_runs) - .with(repo_slug:, ref: "release-branch") + .with(repo_slug: repo_slug, ref: "release-branch") .and_return([stale_run, matching_run]) expect( wait_for_shakaperf_release_gate_run!( - repo_slug:, + repo_slug: repo_slug, ref: "release-branch", - head_sha:, + head_sha: head_sha, ignored_run_ids: [1] ) ).to eq(matching_run) @@ -533,14 +533,14 @@ stale_run = { "databaseId" => 1, "headSha" => head_sha, "createdAt" => "2026-06-05T01:00:00Z" } matching_run = { "databaseId" => 2, "headSha" => head_sha, "createdAt" => "2026-06-05T01:00:10Z" } allow(self).to receive(:fetch_shakaperf_release_gate_runs) - .with(repo_slug:, ref: "release-branch") + .with(repo_slug: repo_slug, ref: "release-branch") .and_return([stale_run, matching_run]) expect( wait_for_shakaperf_release_gate_run!( - repo_slug:, + repo_slug: repo_slug, ref: "release-branch", - head_sha:, + head_sha: head_sha, earliest_created_at: Time.iso8601("2026-06-05T01:00:05Z") ) ).to eq(matching_run) @@ -550,14 +550,14 @@ allow(Time).to receive(:now).and_return(Time.iso8601("2026-06-05T01:00:05.900Z")) newly_dispatched_run = { "databaseId" => 1, "headSha" => head_sha, "createdAt" => "2026-06-05T01:00:05Z" } allow(self).to receive(:fetch_shakaperf_release_gate_runs) - .with(repo_slug:, ref: "release-branch") + .with(repo_slug: repo_slug, ref: "release-branch") .and_return([newly_dispatched_run]) expect( wait_for_shakaperf_release_gate_run!( - repo_slug:, + repo_slug: repo_slug, ref: "release-branch", - head_sha:, + head_sha: head_sha, earliest_created_at: shakaperf_release_gate_dispatch_started_at ) ).to eq(newly_dispatched_run) @@ -565,15 +565,15 @@ it "aborts when no matching workflow_dispatch run appears before the deadline" do allow(self).to receive(:fetch_shakaperf_release_gate_runs) - .with(repo_slug:, ref: "release-branch") + .with(repo_slug: repo_slug, ref: "release-branch") .and_return([{ "databaseId" => 1, "headSha" => "other" }]) stub_const("SHAKAPERF_RELEASE_GATE_START_TIMEOUT_SECONDS", -1) expect do wait_for_shakaperf_release_gate_run!( - repo_slug:, + repo_slug: repo_slug, ref: "release-branch", - head_sha: + head_sha: head_sha ) end.to raise_error(SystemExit, /Timed out waiting for ShakaPerf release gate workflow to start/) end @@ -866,8 +866,15 @@ # to "no required checks configured" so tests that don't care about the # gate behave as before; tests that exercise the gate override this stub. before do - allow(self).to receive(:required_check_names_for_main) - .with(monorepo_root:).and_return(nil) + allow(self).to receive(:required_check_names_for_main).and_return(nil) + end + + def required_checks(contexts: [], checks: []) + { contexts: contexts, checks: checks } + end + + def required_check(context, app_id: nil) + { context: context, app_id: app_id } end def next_check_run_id @@ -878,24 +885,28 @@ def next_check_run_id # Distinct defaults keep same-named helper-generated runs from collapsing # in the nil-check_suite dedup path. Tests can still pin an id to model a # specific GitHub payload. - def passing_run(name, id: next_check_run_id) - { + def passing_run(name, id: next_check_run_id, app_id: nil) + run = { "id" => id, "name" => name, "status" => "completed", "conclusion" => "success", "html_url" => "https://github.com/shakacode/react_on_rails/runs/#{name.gsub(/\W/, '_')}" } + run["app"] = { "id" => app_id } if app_id + run end - def failing_run(name, conclusion: "failure", id: next_check_run_id) - { + def failing_run(name, conclusion: "failure", id: next_check_run_id, app_id: nil) + run = { "id" => id, "name" => name, "status" => "completed", "conclusion" => conclusion, "html_url" => "https://github.com/shakacode/react_on_rails/runs/#{name.gsub(/\W/, '_')}" } + run["app"] = { "id" => app_id } if app_id + run end def in_progress_run(name, id: next_check_run_id) @@ -911,12 +922,12 @@ def in_progress_run(name, id: next_check_run_id) context "when all check runs pass" do it "logs success and returns without raising" do allow(self).to receive(:fetch_main_ci_checks) - .with(monorepo_root:, allow_override: false, dry_run: false) - .and_return(sha:, check_runs: [passing_run("Lint"), passing_run("Test")]) + .with(monorepo_root: monorepo_root, allow_override: false, dry_run: false) + .and_return(sha: sha, check_runs: [passing_run("Lint"), passing_run("Test")]) expect do validate_main_ci_status!( - monorepo_root:, + monorepo_root: monorepo_root, is_prerelease: false, allow_override: false, dry_run: false @@ -928,12 +939,12 @@ def in_progress_run(name, id: next_check_run_id) context "when a check has failed on a stable release" do it "aborts with the failing check name and link" do allow(self).to receive(:fetch_main_ci_checks) - .with(monorepo_root:, allow_override: false, dry_run: false) - .and_return(sha:, check_runs: [passing_run("Lint"), failing_run("JS unit tests")]) + .with(monorepo_root: monorepo_root, allow_override: false, dry_run: false) + .and_return(sha: sha, check_runs: [passing_run("Lint"), failing_run("JS unit tests")]) expect do validate_main_ci_status!( - monorepo_root:, + monorepo_root: monorepo_root, is_prerelease: false, allow_override: false, dry_run: false @@ -945,14 +956,14 @@ def in_progress_run(name, id: next_check_run_id) context "when a non-required check fails on a prerelease" do it "passes because only required checks gate prereleases" do allow(self).to receive(:fetch_main_ci_checks) - .with(monorepo_root:, allow_override: false, dry_run: false) - .and_return(sha:, check_runs: [passing_run("Lint"), failing_run("Benchmark Workflow")]) + .with(monorepo_root: monorepo_root, allow_override: false, dry_run: false) + .and_return(sha: sha, check_runs: [passing_run("Lint"), failing_run("Benchmark Workflow")]) allow(self).to receive(:required_check_names_for_main) - .with(monorepo_root:).and_return(["Lint"]) + .with(monorepo_root: monorepo_root).and_return(required_checks(checks: [required_check("Lint")])) expect do validate_main_ci_status!( - monorepo_root:, + monorepo_root: monorepo_root, is_prerelease: true, allow_override: false, dry_run: false @@ -964,14 +975,14 @@ def in_progress_run(name, id: next_check_run_id) context "when a required check fails on a prerelease" do it "aborts because the required check gates prereleases" do allow(self).to receive(:fetch_main_ci_checks) - .with(monorepo_root:, allow_override: false, dry_run: false) - .and_return(sha:, check_runs: [failing_run("Lint"), passing_run("Benchmark Workflow")]) + .with(monorepo_root: monorepo_root, allow_override: false, dry_run: false) + .and_return(sha: sha, check_runs: [failing_run("Lint"), passing_run("Benchmark Workflow")]) allow(self).to receive(:required_check_names_for_main) - .with(monorepo_root:).and_return(["Lint"]) + .with(monorepo_root: monorepo_root).and_return(required_checks(checks: [required_check("Lint")])) expect do validate_main_ci_status!( - monorepo_root:, + monorepo_root: monorepo_root, is_prerelease: true, allow_override: false, dry_run: false @@ -983,12 +994,12 @@ def in_progress_run(name, id: next_check_run_id) context "when a check is still in progress" do it "aborts with the in-progress message and link" do allow(self).to receive(:fetch_main_ci_checks) - .with(monorepo_root:, allow_override: false, dry_run: false) - .and_return(sha:, check_runs: [passing_run("Lint"), in_progress_run("Slow test")]) + .with(monorepo_root: monorepo_root, allow_override: false, dry_run: false) + .and_return(sha: sha, check_runs: [passing_run("Lint"), in_progress_run("Slow test")]) expect do validate_main_ci_status!( - monorepo_root:, + monorepo_root: monorepo_root, is_prerelease: false, allow_override: false, dry_run: false @@ -1000,12 +1011,12 @@ def in_progress_run(name, id: next_check_run_id) context "when there are both failed and in-progress checks" do it "reports the failure first so the operator does not wait on an already-broken main" do allow(self).to receive(:fetch_main_ci_checks) - .with(monorepo_root:, allow_override: false, dry_run: false) - .and_return(sha:, check_runs: [failing_run("JS unit tests"), in_progress_run("Slow test")]) + .with(monorepo_root: monorepo_root, allow_override: false, dry_run: false) + .and_return(sha: sha, check_runs: [failing_run("JS unit tests"), in_progress_run("Slow test")]) expect do validate_main_ci_status!( - monorepo_root:, + monorepo_root: monorepo_root, is_prerelease: false, allow_override: false, dry_run: false @@ -1023,12 +1034,12 @@ def in_progress_run(name, id: next_check_run_id) old_failed = failing_run("Lint").merge("id" => 1, "check_suite" => { "id" => 100 }) new_passed = passing_run("Lint").merge("id" => 2, "check_suite" => { "id" => 100 }) allow(self).to receive(:fetch_main_ci_checks) - .with(monorepo_root:, allow_override: false, dry_run: false) - .and_return(sha:, check_runs: [old_failed, new_passed]) + .with(monorepo_root: monorepo_root, allow_override: false, dry_run: false) + .and_return(sha: sha, check_runs: [old_failed, new_passed]) expect do validate_main_ci_status!( - monorepo_root:, + monorepo_root: monorepo_root, is_prerelease: false, allow_override: false, dry_run: false @@ -1046,12 +1057,12 @@ def in_progress_run(name, id: next_check_run_id) "id" => 2, "check_suite" => { "id" => 200 } ) allow(self).to receive(:fetch_main_ci_checks) - .with(monorepo_root:, allow_override: false, dry_run: false) - .and_return(sha:, check_runs: [workflow_a_passing, workflow_b_failing]) + .with(monorepo_root: monorepo_root, allow_override: false, dry_run: false) + .and_return(sha: sha, check_runs: [workflow_a_passing, workflow_b_failing]) expect do validate_main_ci_status!( - monorepo_root:, + monorepo_root: monorepo_root, is_prerelease: false, allow_override: false, dry_run: false @@ -1063,12 +1074,12 @@ def in_progress_run(name, id: next_check_run_id) context "when same-named checks do not include check_suite data" do it "keeps helper-generated runs distinct by default" do allow(self).to receive(:fetch_main_ci_checks) - .with(monorepo_root:, allow_override: false, dry_run: false) - .and_return(sha:, check_runs: [passing_run("Lint"), failing_run("Lint")]) + .with(monorepo_root: monorepo_root, allow_override: false, dry_run: false) + .and_return(sha: sha, check_runs: [passing_run("Lint"), failing_run("Lint")]) expect do validate_main_ci_status!( - monorepo_root:, + monorepo_root: monorepo_root, is_prerelease: false, allow_override: false, dry_run: false @@ -1089,12 +1100,12 @@ def in_progress_run(name, id: next_check_run_id) "id" => 2, "check_suite" => { "id" => 200 } ) allow(self).to receive(:fetch_main_ci_checks) - .with(monorepo_root:, allow_override: false, dry_run: false) - .and_return(sha:, check_runs: [suite_a_old_failed, suite_a_new_passed, suite_b_passing]) + .with(monorepo_root: monorepo_root, allow_override: false, dry_run: false) + .and_return(sha: sha, check_runs: [suite_a_old_failed, suite_a_new_passed, suite_b_passing]) expect do validate_main_ci_status!( - monorepo_root:, + monorepo_root: monorepo_root, is_prerelease: false, allow_override: false, dry_run: false @@ -1106,12 +1117,12 @@ def in_progress_run(name, id: next_check_run_id) context "when there are zero check runs visible" do it "aborts with a 'no CI data' message" do allow(self).to receive(:fetch_main_ci_checks) - .with(monorepo_root:, allow_override: false, dry_run: false) - .and_return(sha:, check_runs: []) + .with(monorepo_root: monorepo_root, allow_override: false, dry_run: false) + .and_return(sha: sha, check_runs: []) expect do validate_main_ci_status!( - monorepo_root:, + monorepo_root: monorepo_root, is_prerelease: false, allow_override: false, dry_run: false @@ -1123,12 +1134,12 @@ def in_progress_run(name, id: next_check_run_id) context "when override is set on a failing main" do it "warns and returns instead of aborting" do allow(self).to receive(:fetch_main_ci_checks) - .with(monorepo_root:, allow_override: true, dry_run: false) - .and_return(sha:, check_runs: [failing_run("Lint")]) + .with(monorepo_root: monorepo_root, allow_override: true, dry_run: false) + .and_return(sha: sha, check_runs: [failing_run("Lint")]) expect do validate_main_ci_status!( - monorepo_root:, + monorepo_root: monorepo_root, is_prerelease: false, allow_override: true, dry_run: false @@ -1140,12 +1151,12 @@ def in_progress_run(name, id: next_check_run_id) context "when running in dry-run mode on a failing main" do it "warns and returns instead of aborting" do allow(self).to receive(:fetch_main_ci_checks) - .with(monorepo_root:, allow_override: false, dry_run: true) - .and_return(sha:, check_runs: [failing_run("Lint")]) + .with(monorepo_root: monorepo_root, allow_override: false, dry_run: true) + .and_return(sha: sha, check_runs: [failing_run("Lint")]) expect do validate_main_ci_status!( - monorepo_root:, + monorepo_root: monorepo_root, is_prerelease: false, allow_override: false, dry_run: true @@ -1157,12 +1168,12 @@ def in_progress_run(name, id: next_check_run_id) context "when fetch surfaces a violation via override/dry-run (returns nil)" do it "returns without raising and without trying to inspect the data" do allow(self).to receive(:fetch_main_ci_checks) - .with(monorepo_root:, allow_override: false, dry_run: true) + .with(monorepo_root: monorepo_root, allow_override: false, dry_run: true) .and_return(nil) expect do validate_main_ci_status!( - monorepo_root:, + monorepo_root: monorepo_root, is_prerelease: false, allow_override: false, dry_run: true @@ -1174,14 +1185,14 @@ def in_progress_run(name, id: next_check_run_id) context "when branch protection is not queryable on a prerelease" do it "falls back to evaluating all checks (fail-safe)" do allow(self).to receive(:fetch_main_ci_checks) - .with(monorepo_root:, allow_override: false, dry_run: false) - .and_return(sha:, check_runs: [passing_run("Lint"), failing_run("Optional Check")]) + .with(monorepo_root: monorepo_root, allow_override: false, dry_run: false) + .and_return(sha: sha, check_runs: [passing_run("Lint"), failing_run("Optional Check")]) allow(self).to receive(:required_check_names_for_main) - .with(monorepo_root:).and_return(nil) + .with(monorepo_root: monorepo_root).and_return(nil) expect do validate_main_ci_status!( - monorepo_root:, + monorepo_root: monorepo_root, is_prerelease: true, allow_override: false, dry_run: false @@ -1193,14 +1204,14 @@ def in_progress_run(name, id: next_check_run_id) context "when no check runs match the required names on a prerelease" do it "aborts with a 'no required check runs' message" do allow(self).to receive(:fetch_main_ci_checks) - .with(monorepo_root:, allow_override: false, dry_run: false) - .and_return(sha:, check_runs: [passing_run("Lint")]) + .with(monorepo_root: monorepo_root, allow_override: false, dry_run: false) + .and_return(sha: sha, check_runs: [passing_run("Lint")]) allow(self).to receive(:required_check_names_for_main) - .with(monorepo_root:).and_return(["DoesNotExist"]) + .with(monorepo_root: monorepo_root).and_return(required_checks(checks: [required_check("DoesNotExist")])) expect do validate_main_ci_status!( - monorepo_root:, + monorepo_root: monorepo_root, is_prerelease: true, allow_override: false, dry_run: false @@ -1212,14 +1223,15 @@ def in_progress_run(name, id: next_check_run_id) context "when some required checks are present but others are missing on a prerelease" do it "aborts and lists the missing required check names" do allow(self).to receive(:fetch_main_ci_checks) - .with(monorepo_root:, allow_override: false, dry_run: false) - .and_return(sha:, check_runs: [passing_run("Lint"), passing_run("Test")]) + .with(monorepo_root: monorepo_root, allow_override: false, dry_run: false) + .and_return(sha: sha, check_runs: [passing_run("Lint"), passing_run("Test")]) allow(self).to receive(:required_check_names_for_main) - .with(monorepo_root:).and_return(%w[Lint Test Build]) + .with(monorepo_root: monorepo_root) + .and_return(required_checks(checks: %w[Lint Test Build].map { |context| required_check(context) })) expect do validate_main_ci_status!( - monorepo_root:, + monorepo_root: monorepo_root, is_prerelease: true, allow_override: false, dry_run: false @@ -1234,14 +1246,15 @@ def in_progress_run(name, id: next_check_run_id) context "when some required checks are missing on a stable release" do it "aborts on stable too (branch protection would refuse the merge)" do allow(self).to receive(:fetch_main_ci_checks) - .with(monorepo_root:, allow_override: false, dry_run: false) - .and_return(sha:, check_runs: [passing_run("Lint"), passing_run("Test")]) + .with(monorepo_root: monorepo_root, allow_override: false, dry_run: false) + .and_return(sha: sha, check_runs: [passing_run("Lint"), passing_run("Test")]) allow(self).to receive(:required_check_names_for_main) - .with(monorepo_root:).and_return(%w[Lint Test Build]) + .with(monorepo_root: monorepo_root) + .and_return(required_checks(checks: %w[Lint Test Build].map { |context| required_check(context) })) expect do validate_main_ci_status!( - monorepo_root:, + monorepo_root: monorepo_root, is_prerelease: false, allow_override: false, dry_run: false @@ -1257,12 +1270,12 @@ def in_progress_run(name, id: next_check_run_id) it "treats the ambiguity as a failure rather than silently passing through" do weird_run = passing_run("Future Status").merge("status" => "scheduled", "conclusion" => nil) allow(self).to receive(:fetch_main_ci_checks) - .with(monorepo_root:, allow_override: false, dry_run: false) - .and_return(sha:, check_runs: [passing_run("Lint"), weird_run]) + .with(monorepo_root: monorepo_root, allow_override: false, dry_run: false) + .and_return(sha: sha, check_runs: [passing_run("Lint"), weird_run]) expect do validate_main_ci_status!( - monorepo_root:, + monorepo_root: monorepo_root, is_prerelease: false, allow_override: false, dry_run: false @@ -1273,12 +1286,12 @@ def in_progress_run(name, id: next_check_run_id) it "treats a nil status as a failure too" do weird_run = passing_run("Malformed").merge("status" => nil, "conclusion" => nil) allow(self).to receive(:fetch_main_ci_checks) - .with(monorepo_root:, allow_override: false, dry_run: false) - .and_return(sha:, check_runs: [passing_run("Lint"), weird_run]) + .with(monorepo_root: monorepo_root, allow_override: false, dry_run: false) + .and_return(sha: sha, check_runs: [passing_run("Lint"), weird_run]) expect do validate_main_ci_status!( - monorepo_root:, + monorepo_root: monorepo_root, is_prerelease: false, allow_override: false, dry_run: false @@ -1287,9 +1300,358 @@ def in_progress_run(name, id: next_check_run_id) end end + context "when a required check is pinned to a GitHub App" do + it "does not let a same-named check from another app satisfy the requirement" do + wrong_app_run = passing_run("Lint").merge("app" => { "id" => 999 }) + allow(self).to receive(:fetch_main_ci_checks) + .with(monorepo_root: monorepo_root, allow_override: false, dry_run: false) + .and_return(sha: sha, check_runs: [wrong_app_run]) + allow(self).to receive(:required_check_names_for_main) + .with(monorepo_root: monorepo_root) + .and_return(required_checks(checks: [required_check("Lint", app_id: 123)])) + + expect do + validate_main_ci_status!( + monorepo_root: monorepo_root, + is_prerelease: true, + allow_override: false, + dry_run: false + ) + end.to raise_error(SystemExit, /No required CI check runs found.*Lint \(app_id: 123\)/m) + end + + it "evaluates the same-named check from the required app" do + wrong_app_run = passing_run("Lint", app_id: 999) + required_app_run = failing_run("Lint", app_id: 123) + allow(self).to receive(:fetch_main_ci_checks) + .with(monorepo_root: monorepo_root, allow_override: false, dry_run: false) + .and_return(sha: sha, check_runs: [wrong_app_run, required_app_run]) + allow(self).to receive(:required_check_names_for_main) + .with(monorepo_root: monorepo_root) + .and_return(required_checks(checks: [required_check("Lint", app_id: 123)])) + + expect do + validate_main_ci_status!( + monorepo_root: monorepo_root, + is_prerelease: true, + allow_override: false, + dry_run: false + ) + end.to raise_error(SystemExit, %r{CI on origin/main is not healthy.*Lint}m) + end + + it "does not let a same-name check from a different app block a prerelease" do + wrong_app_run = failing_run("Lint", app_id: 999) + required_app_run = passing_run("Lint", app_id: 123) + allow(self).to receive(:fetch_main_ci_checks) + .with(monorepo_root: monorepo_root, allow_override: false, dry_run: false) + .and_return(sha: sha, repo_slug: "shakacode/react_on_rails", check_runs: [wrong_app_run, required_app_run]) + allow(self).to receive(:required_check_names_for_main) + .with(monorepo_root: monorepo_root, repo_slug: "shakacode/react_on_rails") + .and_return(required_checks(contexts: ["Lint"], checks: [required_check("Lint", app_id: 123)])) + allow(self).to receive(:fetch_main_commit_statuses) + .with(repo_slug: "shakacode/react_on_rails", sha: sha, allow_override: false, dry_run: false) + .and_return([ + { + "id" => 1, + "context" => "Lint", + "state" => "success", + "target_url" => "https://ci.example.com/lint" + } + ]) + + expect do + validate_main_ci_status!( + monorepo_root: monorepo_root, + is_prerelease: true, + allow_override: false, + dry_run: false + ) + end.to output(/Main CI is healthy on #{short_sha} \(2 required checks\)/).to_stdout + end + end + + context "when branch protection includes legacy status contexts" do + it "uses commit statuses to satisfy legacy contexts when no check runs are visible" do + allow(self).to receive(:fetch_main_ci_checks) + .with(monorepo_root: monorepo_root, allow_override: false, dry_run: false) + .and_return(sha: sha, repo_slug: "shakacode/react_on_rails", check_runs: []) + allow(self).to receive(:required_check_names_for_main) + .with(monorepo_root: monorepo_root, repo_slug: "shakacode/react_on_rails") + .and_return(required_checks(contexts: ["Travis"], checks: [])) + allow(self).to receive(:fetch_main_commit_statuses) + .with(repo_slug: "shakacode/react_on_rails", sha: sha, allow_override: false, dry_run: false) + .and_return([ + { + "id" => 1, + "context" => "Travis", + "state" => "success", + "target_url" => "https://ci.example.com/travis" + } + ]) + + expect do + validate_main_ci_status!( + monorepo_root: monorepo_root, + is_prerelease: true, + allow_override: false, + dry_run: false + ) + end.to output(/Main CI is healthy on #{short_sha} \(1 required check\)/).to_stdout + end + + it "blocks when the legacy commit status has failed" do + allow(self).to receive(:fetch_main_ci_checks) + .with(monorepo_root: monorepo_root, allow_override: false, dry_run: false) + .and_return(sha: sha, repo_slug: "shakacode/react_on_rails", check_runs: []) + allow(self).to receive(:required_check_names_for_main) + .with(monorepo_root: monorepo_root, repo_slug: "shakacode/react_on_rails") + .and_return(required_checks(contexts: ["Travis"], checks: [])) + allow(self).to receive(:fetch_main_commit_statuses) + .with(repo_slug: "shakacode/react_on_rails", sha: sha, allow_override: false, dry_run: false) + .and_return([ + { + "id" => 1, + "context" => "Travis", + "state" => "failure", + "target_url" => "https://ci.example.com/travis" + } + ]) + + expect do + validate_main_ci_status!( + monorepo_root: monorepo_root, + is_prerelease: true, + allow_override: false, + dry_run: false + ) + end.to raise_error(SystemExit, %r{CI on origin/main is not healthy.*Travis}m) + end + + it "blocks when a same-named legacy status fails even if a check run passes" do + allow(self).to receive(:fetch_main_ci_checks) + .with(monorepo_root: monorepo_root, allow_override: false, dry_run: false) + .and_return(sha: sha, repo_slug: "shakacode/react_on_rails", check_runs: [passing_run("Travis")]) + allow(self).to receive(:required_check_names_for_main) + .with(monorepo_root: monorepo_root, repo_slug: "shakacode/react_on_rails") + .and_return(required_checks(contexts: ["Travis"], checks: [])) + allow(self).to receive(:fetch_main_commit_statuses) + .with(repo_slug: "shakacode/react_on_rails", sha: sha, allow_override: false, dry_run: false) + .and_return([ + { + "id" => 1, + "context" => "Travis", + "state" => "failure", + "target_url" => "https://ci.example.com/travis" + } + ]) + + expect do + validate_main_ci_status!( + monorepo_root: monorepo_root, + is_prerelease: true, + allow_override: false, + dry_run: false + ) + end.to raise_error(SystemExit, %r{CI on origin/main is not healthy.*Travis}m) + end + + it "treats a pending legacy commit status as in progress" do + allow(self).to receive(:fetch_main_ci_checks) + .with(monorepo_root: monorepo_root, allow_override: false, dry_run: false) + .and_return(sha: sha, repo_slug: "shakacode/react_on_rails", check_runs: []) + allow(self).to receive(:required_check_names_for_main) + .with(monorepo_root: monorepo_root, repo_slug: "shakacode/react_on_rails") + .and_return(required_checks(contexts: ["Travis"], checks: [])) + allow(self).to receive(:fetch_main_commit_statuses) + .with(repo_slug: "shakacode/react_on_rails", sha: sha, allow_override: false, dry_run: false) + .and_return([ + { + "id" => 1, + "context" => "Travis", + "state" => "pending", + "target_url" => "https://ci.example.com/travis" + } + ]) + + expect do + validate_main_ci_status!( + monorepo_root: monorepo_root, + is_prerelease: true, + allow_override: false, + dry_run: false + ) + end.to raise_error(SystemExit, /CI is still in progress.*Travis/m) + end + + it "keeps evaluating fetched check runs when legacy status fetch is skipped in dry-run" do + allow(self).to receive(:fetch_main_ci_checks) + .with(monorepo_root: monorepo_root, allow_override: false, dry_run: true) + .and_return(sha: sha, repo_slug: "shakacode/react_on_rails", check_runs: [failing_run("Lint")]) + allow(self).to receive(:required_check_names_for_main) + .with(monorepo_root: monorepo_root, repo_slug: "shakacode/react_on_rails") + .and_return(required_checks(contexts: ["Travis"], checks: [])) + allow(self).to receive(:fetch_main_commit_statuses) + .with(repo_slug: "shakacode/react_on_rails", sha: sha, allow_override: false, dry_run: true) + .and_return(nil) + + expect do + validate_main_ci_status!( + monorepo_root: monorepo_root, + is_prerelease: false, + allow_override: false, + dry_run: true + ) + end.to output(%r{DRY RUN: .*CI on origin/main is not healthy.*DRY RUN:.*Lint}m).to_stdout + end + + it "does not print the commit-status API URL as a browser link" do + allow(self).to receive(:fetch_main_ci_checks) + .with(monorepo_root: monorepo_root, allow_override: false, dry_run: false) + .and_return(sha: sha, repo_slug: "shakacode/react_on_rails", check_runs: []) + allow(self).to receive(:required_check_names_for_main) + .with(monorepo_root: monorepo_root, repo_slug: "shakacode/react_on_rails") + .and_return(required_checks(contexts: ["Travis"], checks: [])) + allow(self).to receive(:fetch_main_commit_statuses) + .with(repo_slug: "shakacode/react_on_rails", sha: sha, allow_override: false, dry_run: false) + .and_return([ + { + "id" => 1, + "context" => "Travis", + "state" => "failure", + "url" => "https://api.github.com/repos/shakacode/react_on_rails/statuses/#{sha}" + } + ]) + + expect do + validate_main_ci_status!( + monorepo_root: monorepo_root, + is_prerelease: true, + allow_override: false, + dry_run: false + ) + end.to raise_error(SystemExit) { |error| expect(error.message).not_to include("api.github.com") } + end + + it "reports a failed legacy status before same-label missing modern checks" do + allow(self).to receive(:fetch_main_ci_checks) + .with(monorepo_root: monorepo_root, allow_override: false, dry_run: false) + .and_return(sha: sha, repo_slug: "shakacode/react_on_rails", check_runs: []) + allow(self).to receive(:required_check_names_for_main) + .with(monorepo_root: monorepo_root, repo_slug: "shakacode/react_on_rails") + .and_return(required_checks(contexts: ["Travis"], checks: [required_check("Travis")])) + allow(self).to receive(:fetch_main_commit_statuses) + .with(repo_slug: "shakacode/react_on_rails", sha: sha, allow_override: false, dry_run: false) + .and_return([ + { + "id" => 1, + "context" => "Travis", + "state" => "failure", + "target_url" => "https://ci.example.com/travis" + } + ]) + + expect do + validate_main_ci_status!( + monorepo_root: monorepo_root, + is_prerelease: true, + allow_override: false, + dry_run: false + ) + end.to raise_error(SystemExit, %r{CI on origin/main is not healthy.*Travis}m) + end + + it "blocks a stable release when a legacy commit status has failed" do + allow(self).to receive(:fetch_main_ci_checks) + .with(monorepo_root: monorepo_root, allow_override: false, dry_run: false) + .and_return(sha: sha, repo_slug: "shakacode/react_on_rails", check_runs: [passing_run("Lint")]) + allow(self).to receive(:required_check_names_for_main) + .with(monorepo_root: monorepo_root, repo_slug: "shakacode/react_on_rails") + .and_return(required_checks(contexts: ["Travis"], checks: [])) + allow(self).to receive(:fetch_main_commit_statuses) + .with(repo_slug: "shakacode/react_on_rails", sha: sha, allow_override: false, dry_run: false) + .and_return([ + { + "id" => 1, + "context" => "Travis", + "state" => "failure", + "target_url" => "https://ci.example.com/travis" + } + ]) + + expect do + validate_main_ci_status!( + monorepo_root: monorepo_root, + is_prerelease: false, + allow_override: false, + dry_run: false + ) + end.to raise_error(SystemExit, %r{CI on origin/main is not healthy.*Travis}m) + end + + it "reports same-label legacy success plus missing wildcard modern check as partially missing" do + allow(self).to receive(:fetch_main_ci_checks) + .with(monorepo_root: monorepo_root, allow_override: false, dry_run: false) + .and_return(sha: sha, repo_slug: "shakacode/react_on_rails", check_runs: []) + allow(self).to receive(:required_check_names_for_main) + .with(monorepo_root: monorepo_root, repo_slug: "shakacode/react_on_rails") + .and_return(required_checks(contexts: ["Travis"], checks: [required_check("Travis")])) + allow(self).to receive(:fetch_main_commit_statuses) + .with(repo_slug: "shakacode/react_on_rails", sha: sha, allow_override: false, dry_run: false) + .and_return([ + { + "id" => 1, + "context" => "Travis", + "state" => "success", + "target_url" => "https://ci.example.com/travis" + } + ]) + + expect do + validate_main_ci_status!( + monorepo_root: monorepo_root, + is_prerelease: true, + allow_override: false, + dry_run: false + ) + end.to raise_error(SystemExit) { |error| + expect(error.message).to match(/Some required CI checks are missing.*Missing:\s*Travis/m) + expect(error.message).to include("Required: Travis (2 gates)") + } + end + + it "does not let a legacy status satisfy an app-pinned modern check" do + allow(self).to receive(:fetch_main_ci_checks) + .with(monorepo_root: monorepo_root, allow_override: false, dry_run: false) + .and_return(sha: sha, repo_slug: "shakacode/react_on_rails", check_runs: []) + allow(self).to receive(:required_check_names_for_main) + .with(monorepo_root: monorepo_root, repo_slug: "shakacode/react_on_rails") + .and_return(required_checks(contexts: ["Travis"], checks: [required_check("Travis", app_id: 123)])) + allow(self).to receive(:fetch_main_commit_statuses) + .with(repo_slug: "shakacode/react_on_rails", sha: sha, allow_override: false, dry_run: false) + .and_return([ + { + "id" => 1, + "context" => "Travis", + "state" => "success", + "target_url" => "https://ci.example.com/travis" + } + ]) + + expect do + validate_main_ci_status!( + monorepo_root: monorepo_root, + is_prerelease: true, + allow_override: false, + dry_run: false + ) + end.to raise_error(SystemExit, /Some required CI checks are missing.*Missing:\s*Travis \(app_id: 123\)/m) + end + end + it "raises when asked to format an unknown CI violation kind" do expect do - format_main_ci_status_violation(kind: :typo, short_sha:, runs: []) + format_main_ci_status_violation(kind: :typo, short_sha: short_sha, runs: []) end.to raise_error(ArgumentError, /Unknown CI violation kind: :typo/) end end @@ -1305,7 +1667,7 @@ def in_progress_run(name, id: next_check_run_id) .and_return(["fetch failed: network down", failure_status]) expect do - fetch_main_ci_checks(monorepo_root:) + fetch_main_ci_checks(monorepo_root: monorepo_root) end.to raise_error(SystemExit, %r{Unable to fetch origin/main}) end @@ -1316,7 +1678,7 @@ def in_progress_run(name, id: next_check_run_id) result = nil expect do - result = fetch_main_ci_checks(monorepo_root:, allow_override: true) + result = fetch_main_ci_checks(monorepo_root: monorepo_root, allow_override: true) end.to output(%r{CI STATUS OVERRIDE enabled.*Unable to fetch origin/main}m).to_stdout expect(result).to be_nil end @@ -1328,7 +1690,7 @@ def in_progress_run(name, id: next_check_run_id) result = nil expect do - result = fetch_main_ci_checks(monorepo_root:, dry_run: true) + result = fetch_main_ci_checks(monorepo_root: monorepo_root, dry_run: true) end.to output(%r{DRY RUN.*Unable to fetch origin/main}m).to_stdout expect(result).to be_nil end @@ -1347,7 +1709,7 @@ def in_progress_run(name, id: next_check_run_id) .and_return(["HTTP 401: unauthorized", failure_status]) expect do - fetch_main_ci_checks(monorepo_root:) + fetch_main_ci_checks(monorepo_root: monorepo_root) end.to raise_error(SystemExit, /Unable to query GitHub Checks API.*HTTP 401/m) end @@ -1365,7 +1727,7 @@ def in_progress_run(name, id: next_check_run_id) .and_raise(Errno::ENOENT) expect do - fetch_main_ci_checks(monorepo_root:) + fetch_main_ci_checks(monorepo_root: monorepo_root) end.to raise_error(SystemExit, /GitHub CLI .* is not installed/) end @@ -1384,7 +1746,7 @@ def in_progress_run(name, id: next_check_run_id) result = nil expect do - result = fetch_main_ci_checks(monorepo_root:, dry_run: true) + result = fetch_main_ci_checks(monorepo_root: monorepo_root, dry_run: true) end.to output(/DRY RUN.*GitHub CLI .* is not installed/m).to_stdout expect(result).to be_nil end @@ -1404,7 +1766,7 @@ def in_progress_run(name, id: next_check_run_id) result = nil expect do - result = fetch_main_ci_checks(monorepo_root:, dry_run: true) + result = fetch_main_ci_checks(monorepo_root: monorepo_root, dry_run: true) end.to output(/DRY RUN.*Failed to parse check_runs response/m).to_stdout expect(result).to be_nil end @@ -1426,7 +1788,7 @@ def in_progress_run(name, id: next_check_run_id) "repos/shakacode/react_on_rails/commits/abc1234def/check-runs") .and_return([jsonl, success_status]) - result = fetch_main_ci_checks(monorepo_root:) + result = fetch_main_ci_checks(monorepo_root: monorepo_root) expect(result[:sha]).to eq("abc1234def") expect(result[:repo_slug]).to eq("shakacode/react_on_rails") expect(result[:check_runs].length).to eq(2) @@ -1438,7 +1800,7 @@ def in_progress_run(name, id: next_check_run_id) let(:monorepo_root) { "/tmp/repo" } let(:success_status) { instance_double(Process::Status, success?: true) } let(:failure_status) { instance_double(Process::Status, success?: false) } - let(:expected_jq) { "(.contexts // []) + (.checks // [] | map(.context)) | unique" } + let(:expected_jq) { "{contexts: (.contexts // []), checks: (.checks // [] | map({context, app_id}))}" } before do allow(self).to receive(:github_repo_slug).with(monorepo_root).and_return("shakacode/react_on_rails") @@ -1448,27 +1810,60 @@ def in_progress_run(name, id: next_check_run_id) allow(Open3).to receive(:capture2e) .with("gh", "api", "--jq", expected_jq, "repos/shakacode/react_on_rails/branches/main/protection/required_status_checks") - .and_return([%w[Lint Test].to_json, success_status]) + .and_return([{ contexts: %w[Lint Test], checks: [] }.to_json, success_status]) - expect(required_check_names_for_main(monorepo_root:)).to eq(%w[Lint Test]) + expect(required_check_names_for_main(monorepo_root: monorepo_root)).to eq( + contexts: %w[Lint Test], + checks: [] + ) end - it "returns modern required check contexts when branch protection uses checks" do + it "returns modern required check contexts and app IDs when branch protection uses checks" do allow(Open3).to receive(:capture2e) .with("gh", "api", "--jq", expected_jq, "repos/shakacode/react_on_rails/branches/main/protection/required_status_checks") - .and_return([%w[CodeQL Lint].to_json, success_status]) + .and_return([ + { + contexts: [], + checks: [ + { context: "CodeQL", app_id: 15_368 }, + { context: "Lint", app_id: nil } + ] + }.to_json, + success_status + ]) - expect(required_check_names_for_main(monorepo_root:)).to eq(%w[CodeQL Lint]) + expect(required_check_names_for_main(monorepo_root: monorepo_root)).to eq( + contexts: [], + checks: [ + { context: "CodeQL", app_id: 15_368 }, + { context: "Lint", app_id: nil } + ] + ) end - it "returns the deduplicated union when branch protection has contexts and checks" do + it "returns legacy contexts and modern checks separately when both are configured" do allow(Open3).to receive(:capture2e) .with("gh", "api", "--jq", expected_jq, "repos/shakacode/react_on_rails/branches/main/protection/required_status_checks") - .and_return([%w[CodeQL Lint Test].to_json, success_status]) + .and_return([ + { + contexts: %w[Lint Test], + checks: [ + { context: "CodeQL", app_id: -1 }, + { context: "Lint", app_id: nil } + ] + }.to_json, + success_status + ]) - expect(required_check_names_for_main(monorepo_root:)).to eq(%w[CodeQL Lint Test]) + expect(required_check_names_for_main(monorepo_root: monorepo_root)).to eq( + contexts: %w[Lint Test], + checks: [ + { context: "CodeQL", app_id: -1 }, + { context: "Lint", app_id: nil } + ] + ) end it "returns nil when the branch protection endpoint returns an error" do @@ -1477,7 +1872,7 @@ def in_progress_run(name, id: next_check_run_id) "repos/shakacode/react_on_rails/branches/main/protection/required_status_checks") .and_return(["HTTP 404: Branch not protected", failure_status]) - expect(required_check_names_for_main(monorepo_root:)).to be_nil + expect(required_check_names_for_main(monorepo_root: monorepo_root)).to be_nil end it "returns nil when the protection response yields an empty array (fail-safe)" do @@ -1488,9 +1883,9 @@ def in_progress_run(name, id: next_check_run_id) allow(Open3).to receive(:capture2e) .with("gh", "api", "--jq", expected_jq, "repos/shakacode/react_on_rails/branches/main/protection/required_status_checks") - .and_return(["[]", success_status]) + .and_return([{ contexts: [], checks: [] }.to_json, success_status]) - expect(required_check_names_for_main(monorepo_root:)).to be_nil + expect(required_check_names_for_main(monorepo_root: monorepo_root)).to be_nil end end end From 1570d617eaf7d8215b1a6062d7955300d294a002 Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Sun, 7 Jun 2026 09:51:42 -1000 Subject: [PATCH 02/13] Address release gate review follow-ups --- .claude/hooks/main-ci-status.sh | 5 ++-- rakelib/release.rake | 17 +++++++----- .../release_rake_helpers_spec.rb | 26 +++++++++++++++++++ 3 files changed, 39 insertions(+), 9 deletions(-) diff --git a/.claude/hooks/main-ci-status.sh b/.claude/hooks/main-ci-status.sh index 3c589e6e86..6503564728 100755 --- a/.claude/hooks/main-ci-status.sh +++ b/.claude/hooks/main-ci-status.sh @@ -191,8 +191,9 @@ summary=$(echo "${checks_json}" | jq -r --argjson required_names "${required_jso missing_required: ( if $required_names == null then [] else - # NOTE: commit statuses are not fetched here; this hook is a fail-open - # display tool. The Ruby release gate owns legacy status enforcement. + # NOTE: legacy contexts are intentionally app-agnostic here. Commit + # statuses are not fetched here; this hook is a fail-open display + # tool. The Ruby release gate owns app-pinned legacy enforcement. (($required_names.contexts // []) | map(select(. as $context | ($all | any(.name == $context) | not)))) + diff --git a/rakelib/release.rake b/rakelib/release.rake index d9e89b57f8..58129f2364 100644 --- a/rakelib/release.rake +++ b/rakelib/release.rake @@ -686,7 +686,8 @@ def normalize_status_conclusion(state) when "pending" nil else - state || "error" # Undocumented states are treated as failures (fail-safe). + # GitHub documents error/failure/pending/success; anything else should block. + state || "error" end end @@ -782,9 +783,13 @@ def required_check_label(required_check) "#{required_check[:context]} (app_id: #{required_check[:app_id]})" end +def format_required_check_labels(labels) + labels.tally.map { |label, count| count > 1 ? "#{label} (#{count} gates)" : label } +end + def required_check_labels(required_checks) labels = required_checks[:contexts] + required_checks[:checks].map { |check| required_check_label(check) } - labels.tally.map { |label, count| count > 1 ? "#{label} (#{count} gates)" : label } + format_required_check_labels(labels) end def required_check_count(required_checks) @@ -811,7 +816,7 @@ def missing_required_checks(required_checks:, check_runs:, legacy_status_runs:) # modern wildcard check may share one label but remain distinct requirements. { count: missing_legacy.length + missing_modern.length, - labels: (missing_legacy + missing_modern.map { |check| required_check_label(check) }).uniq + labels: format_required_check_labels(missing_legacy + missing_modern.map { |check| required_check_label(check) }) } end @@ -928,10 +933,8 @@ def validate_main_ci_status!(monorepo_root:, is_prerelease:, allow_override:, dr dry_run: dry_run ) if statuses.nil? - # The fetch helper only returns nil after surfacing a dry-run/override - # warning; strict mode aborts before this point. - return unless allow_override || dry_run - + # Only dry-run/override mode reaches this point; strict mode aborts inside + # the fetch helper after surfacing the violation. statuses = [] end 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 cd106dd178..aa2beaa2d3 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 @@ -1620,6 +1620,32 @@ def in_progress_run(name, id: next_check_run_id) } end + it "reports duplicate same-label requirements in the missing list" do + allow(self).to receive(:fetch_main_ci_checks) + .with(monorepo_root: monorepo_root, allow_override: false, dry_run: false) + .and_return(sha: sha, repo_slug: "shakacode/react_on_rails", check_runs: [passing_run("Build")]) + allow(self).to receive(:required_check_names_for_main) + .with(monorepo_root: monorepo_root, repo_slug: "shakacode/react_on_rails") + .and_return( + required_checks(contexts: ["Travis"], checks: [required_check("Travis"), required_check("Build")]) + ) + allow(self).to receive(:fetch_main_commit_statuses) + .with(repo_slug: "shakacode/react_on_rails", sha: sha, allow_override: false, dry_run: false) + .and_return([]) + + expect do + validate_main_ci_status!( + monorepo_root: monorepo_root, + is_prerelease: true, + allow_override: false, + dry_run: false + ) + end.to raise_error(SystemExit) { |error| + expect(error.message).to include("Required: Travis (2 gates), Build") + expect(error.message).to include("Missing: Travis (2 gates)") + } + end + it "does not let a legacy status satisfy an app-pinned modern check" do allow(self).to receive(:fetch_main_ci_checks) .with(monorepo_root: monorepo_root, allow_override: false, dry_run: false) From dcdba14e76bf740504885d2ff06fea0e558571dd Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Sun, 7 Jun 2026 09:57:09 -1000 Subject: [PATCH 03/13] Address release gate review thread follow-ups --- rakelib/release.rake | 5 ++++- .../release_rake_helpers_spec.rb | 21 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/rakelib/release.rake b/rakelib/release.rake index 58129f2364..819386d720 100644 --- a/rakelib/release.rake +++ b/rakelib/release.rake @@ -763,6 +763,7 @@ end def legacy_context_check_run_matches?(context:, run:, required_checks:) return false unless run["name"] == context + # Same-name modern checks with pinned apps constrain the legacy-status rail too. pinned_checks = required_checks[:checks].select do |required_check| required_check[:context] == context && !required_check_app_wildcard?(required_check[:app_id]) end @@ -933,7 +934,9 @@ def validate_main_ci_status!(monorepo_root:, is_prerelease:, allow_override:, dr dry_run: dry_run ) if statuses.nil? - # Only dry-run/override mode reaches this point; strict mode aborts inside + raise "BUG: fetch_main_commit_statuses returned nil in strict mode" unless allow_override || dry_run + + # Only dry-run/override mode reaches the fallback; strict mode aborts inside # the fetch helper after surfacing the violation. statuses = [] end 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 aa2beaa2d3..e349c7076f 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 @@ -1505,6 +1505,27 @@ def in_progress_run(name, id: next_check_run_id) end.to output(%r{DRY RUN: .*CI on origin/main is not healthy.*DRY RUN:.*Lint}m).to_stdout end + it "raises if strict legacy status fetch unexpectedly returns nil" do + allow(self).to receive(:fetch_main_ci_checks) + .with(monorepo_root: monorepo_root, allow_override: false, dry_run: false) + .and_return(sha: sha, repo_slug: "shakacode/react_on_rails", check_runs: [passing_run("Lint")]) + allow(self).to receive(:required_check_names_for_main) + .with(monorepo_root: monorepo_root, repo_slug: "shakacode/react_on_rails") + .and_return(required_checks(contexts: ["Travis"], checks: [])) + allow(self).to receive(:fetch_main_commit_statuses) + .with(repo_slug: "shakacode/react_on_rails", sha: sha, allow_override: false, dry_run: false) + .and_return(nil) + + expect do + validate_main_ci_status!( + monorepo_root: monorepo_root, + is_prerelease: true, + allow_override: false, + dry_run: false + ) + end.to raise_error(RuntimeError, /fetch_main_commit_statuses returned nil in strict mode/) + end + it "does not print the commit-status API URL as a browser link" do allow(self).to receive(:fetch_main_ci_checks) .with(monorepo_root: monorepo_root, allow_override: false, dry_run: false) From a2ff66158698c8ba20a363fe3bb5874d86dd6084 Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Sun, 7 Jun 2026 10:02:34 -1000 Subject: [PATCH 04/13] Handle mirrored required status checks --- rakelib/release.rake | 38 ++++++++---- .../release_rake_helpers_spec.rb | 60 +++++++------------ 2 files changed, 49 insertions(+), 49 deletions(-) diff --git a/rakelib/release.rake b/rakelib/release.rake index 819386d720..53f8fb30c0 100644 --- a/rakelib/release.rake +++ b/rakelib/release.rake @@ -709,8 +709,9 @@ end def normalize_required_checks_payload(parsed) return nil unless parsed.is_a?(Hash) - contexts = Array(parsed["contexts"]).map(&:to_s).reject(&:empty?).uniq checks = normalize_required_check_entries(parsed["checks"]) + check_contexts = checks.map { |check| check[:context] } + contexts = Array(parsed["contexts"]).map(&:to_s).reject(&:empty?).uniq - check_contexts # No required names parseable is treated the same as "no branch protection # visible" — fail-safe to evaluating every check run. @@ -756,8 +757,10 @@ def required_check_matches_run?(required_check, run) (required_check_app_wildcard?(required_check[:app_id]) || required_check[:app_id] == check_run_app_id(run)) end -def required_check_present?(required_check:, check_runs:) - check_runs.any? { |run| required_check_matches_run?(required_check, run) } +def required_check_present?(required_check:, check_runs:, legacy_status_runs:) + check_runs.any? { |run| required_check_matches_run?(required_check, run) } || + (required_check_app_wildcard?(required_check[:app_id]) && + legacy_status_runs.any? { |run| run["name"] == required_check[:context] }) end def legacy_context_check_run_matches?(context:, run:, required_checks:) @@ -801,7 +804,8 @@ def missing_required_checks(required_checks:, check_runs:, legacy_status_runs:) missing_modern = required_checks[:checks].reject do |required_check| required_check_present?( required_check: required_check, - check_runs: check_runs + check_runs: check_runs, + legacy_status_runs: legacy_status_runs ) end missing_legacy = required_checks[:contexts].reject do |context| @@ -813,19 +817,32 @@ def missing_required_checks(required_checks:, check_runs:, legacy_status_runs:) ) end - # Keep the raw count separate from display labels: a legacy context and a - # modern wildcard check may share one label but remain distinct requirements. + # Keep the raw count separate from display labels for deliberately duplicated + # names; mirrored branch-protection contexts are removed during normalization. { count: missing_legacy.length + missing_modern.length, labels: format_required_check_labels(missing_legacy + missing_modern.map { |check| required_check_label(check) }) } end +def legacy_status_contexts_for_required_checks(required_checks) + wildcard_check_contexts = required_checks[:checks] + .select { |check| required_check_app_wildcard?(check[:app_id]) } + .map { |check| check[:context] } + + ( + required_checks[:contexts] + + wildcard_check_contexts + ).uniq +end + def legacy_status_runs_for_required_contexts(required_checks:, statuses:) - # Keep same-name statuses even when a check run exists; branch protection can - # require both a legacy status context and a modern check run to pass. + status_contexts = legacy_status_contexts_for_required_checks(required_checks) + + # App-wildcard required checks can be satisfied by either Checks API runs or + # legacy commit statuses. App-pinned checks still require a matching check run. latest_commit_statuses(statuses) - .select { |status| required_checks[:contexts].include?(status["context"]) } + .select { |status| status_contexts.include?(status["context"]) } .map { |status| normalize_status_as_check_run(status) } end @@ -925,8 +942,9 @@ def validate_main_ci_status!(monorepo_root:, is_prerelease:, allow_override:, dr required_args = { monorepo_root: monorepo_root } required_args[:repo_slug] = repo_slug if repo_slug required_names = required_check_names_for_main(**required_args) + required_status_contexts = required_names ? legacy_status_contexts_for_required_checks(required_names) : [] legacy_status_runs = [] - if required_names && required_names[:contexts].any? + if required_status_contexts.any? statuses = fetch_main_commit_statuses( repo_slug: repo_slug || github_repo_slug(monorepo_root), sha: sha, 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 e349c7076f..226b12318e 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 @@ -866,7 +866,11 @@ # to "no required checks configured" so tests that don't care about the # gate behave as before; tests that exercise the gate override this stub. before do - allow(self).to receive(:required_check_names_for_main).and_return(nil) + allow(self).to receive_messages( + fetch_main_commit_statuses: [], + github_repo_slug: "shakacode/react_on_rails", + required_check_names_for_main: nil + ) end def required_checks(contexts: [], checks: []) @@ -1348,17 +1352,7 @@ def in_progress_run(name, id: next_check_run_id) .and_return(sha: sha, repo_slug: "shakacode/react_on_rails", check_runs: [wrong_app_run, required_app_run]) allow(self).to receive(:required_check_names_for_main) .with(monorepo_root: monorepo_root, repo_slug: "shakacode/react_on_rails") - .and_return(required_checks(contexts: ["Lint"], checks: [required_check("Lint", app_id: 123)])) - allow(self).to receive(:fetch_main_commit_statuses) - .with(repo_slug: "shakacode/react_on_rails", sha: sha, allow_override: false, dry_run: false) - .and_return([ - { - "id" => 1, - "context" => "Lint", - "state" => "success", - "target_url" => "https://ci.example.com/lint" - } - ]) + .and_return(required_checks(checks: [required_check("Lint", app_id: 123)])) expect do validate_main_ci_status!( @@ -1367,7 +1361,7 @@ def in_progress_run(name, id: next_check_run_id) allow_override: false, dry_run: false ) - end.to output(/Main CI is healthy on #{short_sha} \(2 required checks\)/).to_stdout + end.to output(/Main CI is healthy on #{short_sha} \(1 required check\)/).to_stdout end end @@ -1554,13 +1548,13 @@ def in_progress_run(name, id: next_check_run_id) end.to raise_error(SystemExit) { |error| expect(error.message).not_to include("api.github.com") } end - it "reports a failed legacy status before same-label missing modern checks" do + it "reports a failed legacy status for a wildcard required check" do allow(self).to receive(:fetch_main_ci_checks) .with(monorepo_root: monorepo_root, allow_override: false, dry_run: false) .and_return(sha: sha, repo_slug: "shakacode/react_on_rails", check_runs: []) allow(self).to receive(:required_check_names_for_main) .with(monorepo_root: monorepo_root, repo_slug: "shakacode/react_on_rails") - .and_return(required_checks(contexts: ["Travis"], checks: [required_check("Travis")])) + .and_return(required_checks(checks: [required_check("Travis")])) allow(self).to receive(:fetch_main_commit_statuses) .with(repo_slug: "shakacode/react_on_rails", sha: sha, allow_override: false, dry_run: false) .and_return([ @@ -1610,13 +1604,13 @@ def in_progress_run(name, id: next_check_run_id) end.to raise_error(SystemExit, %r{CI on origin/main is not healthy.*Travis}m) end - it "reports same-label legacy success plus missing wildcard modern check as partially missing" do + it "uses a legacy status to satisfy a wildcard required check" do allow(self).to receive(:fetch_main_ci_checks) .with(monorepo_root: monorepo_root, allow_override: false, dry_run: false) .and_return(sha: sha, repo_slug: "shakacode/react_on_rails", check_runs: []) allow(self).to receive(:required_check_names_for_main) .with(monorepo_root: monorepo_root, repo_slug: "shakacode/react_on_rails") - .and_return(required_checks(contexts: ["Travis"], checks: [required_check("Travis")])) + .and_return(required_checks(checks: [required_check("Travis")])) allow(self).to receive(:fetch_main_commit_statuses) .with(repo_slug: "shakacode/react_on_rails", sha: sha, allow_override: false, dry_run: false) .and_return([ @@ -1635,20 +1629,17 @@ def in_progress_run(name, id: next_check_run_id) allow_override: false, dry_run: false ) - end.to raise_error(SystemExit) { |error| - expect(error.message).to match(/Some required CI checks are missing.*Missing:\s*Travis/m) - expect(error.message).to include("Required: Travis (2 gates)") - } + end.to output(/Main CI is healthy on #{short_sha} \(1 required check\)/).to_stdout end - it "reports duplicate same-label requirements in the missing list" do + it "reports a missing mirrored wildcard check once" do allow(self).to receive(:fetch_main_ci_checks) .with(monorepo_root: monorepo_root, allow_override: false, dry_run: false) .and_return(sha: sha, repo_slug: "shakacode/react_on_rails", check_runs: [passing_run("Build")]) allow(self).to receive(:required_check_names_for_main) .with(monorepo_root: monorepo_root, repo_slug: "shakacode/react_on_rails") .and_return( - required_checks(contexts: ["Travis"], checks: [required_check("Travis"), required_check("Build")]) + required_checks(checks: [required_check("Travis"), required_check("Build")]) ) allow(self).to receive(:fetch_main_commit_statuses) .with(repo_slug: "shakacode/react_on_rails", sha: sha, allow_override: false, dry_run: false) @@ -1662,28 +1653,19 @@ def in_progress_run(name, id: next_check_run_id) dry_run: false ) end.to raise_error(SystemExit) { |error| - expect(error.message).to include("Required: Travis (2 gates), Build") - expect(error.message).to include("Missing: Travis (2 gates)") + expect(error.message).to include("Required: Travis, Build") + expect(error.message).to include("Missing: Travis") + expect(error.message).not_to include("2 gates") } end it "does not let a legacy status satisfy an app-pinned modern check" do allow(self).to receive(:fetch_main_ci_checks) .with(monorepo_root: monorepo_root, allow_override: false, dry_run: false) - .and_return(sha: sha, repo_slug: "shakacode/react_on_rails", check_runs: []) + .and_return(sha: sha, repo_slug: "shakacode/react_on_rails", check_runs: [passing_run("Other")]) allow(self).to receive(:required_check_names_for_main) .with(monorepo_root: monorepo_root, repo_slug: "shakacode/react_on_rails") - .and_return(required_checks(contexts: ["Travis"], checks: [required_check("Travis", app_id: 123)])) - allow(self).to receive(:fetch_main_commit_statuses) - .with(repo_slug: "shakacode/react_on_rails", sha: sha, allow_override: false, dry_run: false) - .and_return([ - { - "id" => 1, - "context" => "Travis", - "state" => "success", - "target_url" => "https://ci.example.com/travis" - } - ]) + .and_return(required_checks(checks: [required_check("Travis", app_id: 123)])) expect do validate_main_ci_status!( @@ -1692,7 +1674,7 @@ def in_progress_run(name, id: next_check_run_id) allow_override: false, dry_run: false ) - end.to raise_error(SystemExit, /Some required CI checks are missing.*Missing:\s*Travis \(app_id: 123\)/m) + end.to raise_error(SystemExit, /No required CI check runs found.*Required: Travis \(app_id: 123\)/m) end end @@ -1905,7 +1887,7 @@ def in_progress_run(name, id: next_check_run_id) ]) expect(required_check_names_for_main(monorepo_root: monorepo_root)).to eq( - contexts: %w[Lint Test], + contexts: %w[Test], checks: [ { context: "CodeQL", app_id: -1 }, { context: "Lint", app_id: nil } From b97c287cbee2012ccb2d813b649301858b4faef0 Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Sun, 7 Jun 2026 10:04:13 -1000 Subject: [PATCH 05/13] Address release gate review cleanup --- rakelib/release.rake | 2 ++ react_on_rails/spec/react_on_rails/release_rake_helpers_spec.rb | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/rakelib/release.rake b/rakelib/release.rake index 53f8fb30c0..3ca054d0e7 100644 --- a/rakelib/release.rake +++ b/rakelib/release.rake @@ -590,6 +590,8 @@ def fetch_main_ci_checks(monorepo_root:, allow_override: false, dry_run: false) "gh", "api", "--paginate", "--jq", ".check_runs[]", api_path ) rescue Errno::ENOENT + # validate_main_ci_status! normally checks `gh` first, but keep this helper + # defensive for direct calls and focused tests. handle_main_ci_status_violation!( message: "❌ GitHub CLI (`gh`) is not installed. Install it from https://cli.github.com/ and retry.", allow_override: allow_override, 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 226b12318e..a040abf629 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 @@ -1306,7 +1306,7 @@ def in_progress_run(name, id: next_check_run_id) context "when a required check is pinned to a GitHub App" do it "does not let a same-named check from another app satisfy the requirement" do - wrong_app_run = passing_run("Lint").merge("app" => { "id" => 999 }) + wrong_app_run = passing_run("Lint", app_id: 999) allow(self).to receive(:fetch_main_ci_checks) .with(monorepo_root: monorepo_root, allow_override: false, dry_run: false) .and_return(sha: sha, check_runs: [wrong_app_run]) From f30942e16f930f0434c2b27742a7333b2dd14def Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Sun, 7 Jun 2026 10:19:36 -1000 Subject: [PATCH 06/13] Address release gate status review follow-ups --- rakelib/release.rake | 34 +++++++++---------- .../release_rake_helpers_spec.rb | 30 +++++++++++++++- 2 files changed, 45 insertions(+), 19 deletions(-) diff --git a/rakelib/release.rake b/rakelib/release.rake index 3ca054d0e7..e6ce17c992 100644 --- a/rakelib/release.rake +++ b/rakelib/release.rake @@ -672,11 +672,12 @@ end def normalize_status_as_check_run(status) state = status["state"] + conclusion = normalize_status_conclusion(state) { "id" => status["id"], "name" => status["context"], - "status" => state == "pending" ? "pending" : "completed", - "conclusion" => normalize_status_conclusion(state), + "status" => conclusion.nil? ? "pending" : "completed", + "conclusion" => conclusion, "html_url" => status["target_url"] } end @@ -687,15 +688,19 @@ def normalize_status_conclusion(state) "success" when "pending" nil + when "failure", "error" + state else - # GitHub documents error/failure/pending/success; anything else should block. - state || "error" + # GitHub documents error/failure/pending/success; unknown values should block. + "error" end end def latest_commit_statuses(statuses) statuses .group_by { |status| status["context"] } + # GitHub status ids are monotonically increasing for a commit/context, so + # the highest id is the most recent status returned by the paginated API. .map { |_context, context_statuses| context_statuses.max_by { |status| status["id"].to_i } } end @@ -765,19 +770,13 @@ def required_check_present?(required_check:, check_runs:, legacy_status_runs:) legacy_status_runs.any? { |run| run["name"] == required_check[:context] }) end -def legacy_context_check_run_matches?(context:, run:, required_checks:) - return false unless run["name"] == context - - # Same-name modern checks with pinned apps constrain the legacy-status rail too. - pinned_checks = required_checks[:checks].select do |required_check| - required_check[:context] == context && !required_check_app_wildcard?(required_check[:app_id]) - end - pinned_checks.empty? || pinned_checks.any? { |required_check| required_check_matches_run?(required_check, run) } +def legacy_context_check_run_matches?(context:, run:) + run["name"] == context end -def legacy_context_present?(context:, check_runs:, legacy_status_runs:, required_checks:) +def legacy_context_present?(context:, check_runs:, legacy_status_runs:) matching_check_run = check_runs.any? do |run| - legacy_context_check_run_matches?(context: context, run: run, required_checks: required_checks) + legacy_context_check_run_matches?(context: context, run: run) end matching_check_run || legacy_status_runs.any? { |run| run["name"] == context } @@ -814,8 +813,7 @@ def missing_required_checks(required_checks:, check_runs:, legacy_status_runs:) legacy_context_present?( context: context, check_runs: check_runs, - legacy_status_runs: legacy_status_runs, - required_checks: required_checks + legacy_status_runs: legacy_status_runs ) end @@ -954,7 +952,7 @@ def validate_main_ci_status!(monorepo_root:, is_prerelease:, allow_override:, dr dry_run: dry_run ) if statuses.nil? - raise "BUG: fetch_main_commit_statuses returned nil in strict mode" unless allow_override || dry_run + raise "Unexpected nil response from fetch_main_commit_statuses in strict mode" unless allow_override || dry_run # Only dry-run/override mode reaches the fallback; strict mode aborts inside # the fetch helper after surfacing the violation. @@ -979,7 +977,7 @@ def validate_main_ci_status!(monorepo_root:, is_prerelease:, allow_override:, dr evaluated = if is_prerelease && required_names check_runs.select do |run| required_names[:contexts].any? do |context| - legacy_context_check_run_matches?(context: context, run: run, required_checks: required_names) + legacy_context_check_run_matches?(context: context, run: run) end || required_names[:checks].any? { |required_check| required_check_matches_run?(required_check, run) } end + legacy_status_runs 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 a040abf629..7b34dcfba4 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 @@ -1478,6 +1478,34 @@ def in_progress_run(name, id: next_check_run_id) end.to raise_error(SystemExit, /CI is still in progress.*Travis/m) end + it "treats an unknown legacy commit status state as failed" do + allow(self).to receive(:fetch_main_ci_checks) + .with(monorepo_root: monorepo_root, allow_override: false, dry_run: false) + .and_return(sha: sha, repo_slug: "shakacode/react_on_rails", check_runs: []) + allow(self).to receive(:required_check_names_for_main) + .with(monorepo_root: monorepo_root, repo_slug: "shakacode/react_on_rails") + .and_return(required_checks(contexts: ["Travis"], checks: [])) + allow(self).to receive(:fetch_main_commit_statuses) + .with(repo_slug: "shakacode/react_on_rails", sha: sha, allow_override: false, dry_run: false) + .and_return([ + { + "id" => 1, + "context" => "Travis", + "state" => "unexpected", + "target_url" => "https://ci.example.com/travis" + } + ]) + + expect do + validate_main_ci_status!( + monorepo_root: monorepo_root, + is_prerelease: true, + allow_override: false, + dry_run: false + ) + end.to raise_error(SystemExit, %r{CI on origin/main is not healthy.*Travis}m) + end + it "keeps evaluating fetched check runs when legacy status fetch is skipped in dry-run" do allow(self).to receive(:fetch_main_ci_checks) .with(monorepo_root: monorepo_root, allow_override: false, dry_run: true) @@ -1517,7 +1545,7 @@ def in_progress_run(name, id: next_check_run_id) allow_override: false, dry_run: false ) - end.to raise_error(RuntimeError, /fetch_main_commit_statuses returned nil in strict mode/) + end.to raise_error(RuntimeError, /Unexpected nil response from fetch_main_commit_statuses in strict mode/) end it "does not print the commit-status API URL as a browser link" do From c91e1dfdf89c7fb944eb0ea21809c270058d1598 Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Sun, 7 Jun 2026 10:27:36 -1000 Subject: [PATCH 07/13] Rebase release gate on shorthand style cleanup --- rakelib/release.rake | 198 ++++----- .../release_rake_helpers_spec.rb | 394 +++++++++--------- 2 files changed, 296 insertions(+), 296 deletions(-) diff --git a/rakelib/release.rake b/rakelib/release.rake index e6ce17c992..ada1d1a516 100644 --- a/rakelib/release.rake +++ b/rakelib/release.rake @@ -39,7 +39,7 @@ end def release_paths(monorepo_root) { - monorepo_root: monorepo_root, + monorepo_root:, gem_root: File.join(monorepo_root, "react_on_rails"), pro_gem_root: File.join(monorepo_root, "react_on_rails_pro"), dummy_app_dir: File.join(monorepo_root, "react_on_rails", "spec", "dummy"), @@ -81,7 +81,7 @@ def prompt_for_otp(service_name, allow_blank: false, hint: nil) abort "\n❌ No OTP provided. Aborting." end - normalize_otp_code(otp, service_name: service_name) + normalize_otp_code(otp, service_name:) end # Resolve the RubyGems OTP to reuse for BOTH gem pushes (react_on_rails and @@ -160,8 +160,8 @@ def github_repo_slug(monorepo_root) match[:repo] end -def capture_gh_output(*args) - Open3.capture2e("gh", *args) +def capture_gh_output(*) + Open3.capture2e("gh", *) rescue Errno::ENOENT abort "❌ GitHub CLI (`gh`) is not installed. Install it from https://cli.github.com/ and retry." end @@ -181,9 +181,9 @@ def stop_output_reader(output_reader) output_reader.kill if output_reader.alive? end -def capture_gh_output_with_timeout(*args, timeout_seconds:) +def capture_gh_output_with_timeout(*, timeout_seconds:) reader, writer = IO.pipe - pid = Process.spawn("gh", *args, out: writer, err: writer) + pid = Process.spawn("gh", *, out: writer, err: writer) writer.close output_reader = read_output_from_io(reader) status = nil @@ -315,7 +315,7 @@ def wait_for_shakaperf_release_gate_run!(repo_slug:, ref:, head_sha:, ignored_ru ignored_run_ids = ignored_run_ids.map(&:to_s) loop do - runs = fetch_shakaperf_release_gate_runs(repo_slug: repo_slug, ref: ref) + runs = fetch_shakaperf_release_gate_runs(repo_slug:, ref:) matching_run = runs.find do |run| run["headSha"] == head_sha && !ignored_run_ids.include?(run["databaseId"].to_s) && @@ -368,7 +368,7 @@ def run_shakaperf_release_gate!(monorepo_root:, ref:, head_sha:, allow_override: repo_slug = github_repo_slug(monorepo_root) puts "\nRunning ShakaPerf release gate on #{ref} at #{head_sha[0, 8]} before tagging and publishing..." - existing_run_ids = fetch_shakaperf_release_gate_runs(repo_slug: repo_slug, ref: ref).map do |run| + existing_run_ids = fetch_shakaperf_release_gate_runs(repo_slug:, ref:).map do |run| run["databaseId"].to_s end dispatch_started_at = shakaperf_release_gate_dispatch_started_at @@ -383,13 +383,13 @@ def run_shakaperf_release_gate!(monorepo_root:, ref:, head_sha:, allow_override: end run = wait_for_shakaperf_release_gate_run!( - repo_slug: repo_slug, - ref: ref, - head_sha: head_sha, + repo_slug:, + ref:, + head_sha:, ignored_run_ids: existing_run_ids, earliest_created_at: dispatch_started_at ) - watch_shakaperf_release_gate_run!(repo_slug: repo_slug, run: run) + watch_shakaperf_release_gate_run!(repo_slug:, run:) puts "✓ ShakaPerf release gate passed: #{run['url'] || "GitHub Actions run #{run.fetch('databaseId')}"}" end @@ -405,7 +405,7 @@ def run_release_preflight_checks!(monorepo_root:, dry_run:) puts "PRE-FLIGHT CHECKS" puts "=" * 80 verify_npm_auth - verify_gh_auth(monorepo_root: monorepo_root) + verify_gh_auth(monorepo_root:) end def current_gem_version(monorepo_root) @@ -559,8 +559,8 @@ def fetch_main_ci_checks(monorepo_root:, allow_override: false, dry_run: false) unless fetch_status.success? handle_main_ci_status_violation!( message: "❌ Unable to fetch origin/main for CI status check.\n\n#{fetch_output}", - allow_override: allow_override, - dry_run: dry_run + allow_override:, + dry_run: ) return nil end @@ -569,8 +569,8 @@ def fetch_main_ci_checks(monorepo_root:, allow_override: false, dry_run: false) unless sha_status.success? handle_main_ci_status_violation!( message: "❌ Unable to resolve origin/main HEAD.\n\n#{sha_output}", - allow_override: allow_override, - dry_run: dry_run + allow_override:, + dry_run: ) return nil end @@ -594,8 +594,8 @@ def fetch_main_ci_checks(monorepo_root:, allow_override: false, dry_run: false) # defensive for direct calls and focused tests. handle_main_ci_status_violation!( message: "❌ GitHub CLI (`gh`) is not installed. Install it from https://cli.github.com/ and retry.", - allow_override: allow_override, - dry_run: dry_run + allow_override:, + dry_run: ) # Only reached in override/dry-run mode; strict mode aborts above. return nil @@ -604,8 +604,8 @@ def fetch_main_ci_checks(monorepo_root:, allow_override: false, dry_run: false) unless status.success? handle_main_ci_status_violation!( message: "❌ Unable to query GitHub Checks API for #{sha}.\n\n#{output}", - allow_override: allow_override, - dry_run: dry_run + allow_override:, + dry_run: ) # Only reached in override/dry-run mode; strict mode aborts above. return nil @@ -616,13 +616,13 @@ def fetch_main_ci_checks(monorepo_root:, allow_override: false, dry_run: false) rescue JSON::ParserError => e handle_main_ci_status_violation!( message: "❌ Failed to parse check_runs response from gh: #{e.message}\n\nOutput:\n#{output}", - allow_override: allow_override, - dry_run: dry_run + allow_override:, + dry_run: ) return nil end - { sha: sha, repo_slug: repo_slug, check_runs: check_runs } + { sha:, repo_slug:, check_runs: } end # rubocop:enable Metrics/MethodLength @@ -642,8 +642,8 @@ def fetch_main_commit_statuses(repo_slug:, sha:, allow_override:, dry_run:) rescue Errno::ENOENT handle_main_ci_status_violation!( message: "❌ GitHub CLI (`gh`) is not installed. Install it from https://cli.github.com/ and retry.", - allow_override: allow_override, - dry_run: dry_run + allow_override:, + dry_run: ) return nil end @@ -651,8 +651,8 @@ def fetch_main_commit_statuses(repo_slug:, sha:, allow_override:, dry_run:) unless status.success? handle_main_ci_status_violation!( message: "❌ Unable to query GitHub Statuses API for #{sha}.\n\n#{output}", - allow_override: allow_override, - dry_run: dry_run + allow_override:, + dry_run: ) return nil end @@ -662,8 +662,8 @@ def fetch_main_commit_statuses(repo_slug:, sha:, allow_override:, dry_run:) rescue JSON::ParserError => e handle_main_ci_status_violation!( message: "❌ Failed to parse statuses response from gh: #{e.message}\n\nOutput:\n#{output}", - allow_override: allow_override, - dry_run: dry_run + allow_override:, + dry_run: ) # Only reached in override/dry-run mode; strict mode aborts above. nil @@ -709,7 +709,7 @@ def normalize_required_check_entries(checks) context = check["context"].to_s next if context.empty? - { context: context, app_id: check["app_id"]&.to_i } + { context:, app_id: check["app_id"]&.to_i } end.uniq end @@ -722,7 +722,7 @@ def normalize_required_checks_payload(parsed) # No required names parseable is treated the same as "no branch protection # visible" — fail-safe to evaluating every check run. - contexts.empty? && checks.empty? ? nil : { contexts: contexts, checks: checks } + contexts.empty? && checks.empty? ? nil : { contexts:, checks: } end def required_check_names_for_main(monorepo_root:, repo_slug: nil) @@ -776,7 +776,7 @@ end def legacy_context_present?(context:, check_runs:, legacy_status_runs:) matching_check_run = check_runs.any? do |run| - legacy_context_check_run_matches?(context: context, run: run) + legacy_context_check_run_matches?(context:, run:) end matching_check_run || legacy_status_runs.any? { |run| run["name"] == context } @@ -804,16 +804,16 @@ end def missing_required_checks(required_checks:, check_runs:, legacy_status_runs:) missing_modern = required_checks[:checks].reject do |required_check| required_check_present?( - required_check: required_check, - check_runs: check_runs, - legacy_status_runs: legacy_status_runs + required_check:, + check_runs:, + legacy_status_runs: ) end missing_legacy = required_checks[:contexts].reject do |context| legacy_context_present?( - context: context, - check_runs: check_runs, - legacy_status_runs: legacy_status_runs + context:, + check_runs:, + legacy_status_runs: ) end @@ -874,7 +874,7 @@ def format_main_ci_status_violation(kind:, short_sha:, runs:) # rubocop:disable end return header if runs.nil? || runs.empty? - lines = runs.map { |run| format_ci_status_run_line(run, kind: kind) } + lines = runs.map { |run| format_ci_status_run_line(run, kind:) } "#{header}\n\n#{lines.join("\n")}" end @@ -905,7 +905,7 @@ end def validate_main_ci_status!(monorepo_root:, is_prerelease:, allow_override:, dry_run:) puts "\nChecking CI status on origin/main..." - data = fetch_main_ci_checks(monorepo_root: monorepo_root, allow_override: allow_override, dry_run: dry_run) + data = fetch_main_ci_checks(monorepo_root:, allow_override:, dry_run:) # `fetch_main_ci_checks` returns nil when it surfaced a violation through # `handle_main_ci_status_violation!` (dry-run or override path). In that case # the warning has already been printed and we should not continue. @@ -939,7 +939,7 @@ def validate_main_ci_status!(monorepo_root:, is_prerelease:, allow_override:, dr # `evaluated` then differs by mode: # - prerelease: only the required subset (narrower filter; non-required failures are advisory) # - stable: every check_run on the commit (broader filter; any failure blocks) - required_args = { monorepo_root: monorepo_root } + required_args = { monorepo_root: } required_args[:repo_slug] = repo_slug if repo_slug required_names = required_check_names_for_main(**required_args) required_status_contexts = required_names ? legacy_status_contexts_for_required_checks(required_names) : [] @@ -947,9 +947,9 @@ def validate_main_ci_status!(monorepo_root:, is_prerelease:, allow_override:, dr if required_status_contexts.any? statuses = fetch_main_commit_statuses( repo_slug: repo_slug || github_repo_slug(monorepo_root), - sha: sha, - allow_override: allow_override, - dry_run: dry_run + sha:, + allow_override:, + dry_run: ) if statuses.nil? raise "Unexpected nil response from fetch_main_commit_statuses in strict mode" unless allow_override || dry_run @@ -961,15 +961,15 @@ def validate_main_ci_status!(monorepo_root:, is_prerelease:, allow_override:, dr legacy_status_runs = legacy_status_runs_for_required_contexts( required_checks: required_names, - statuses: statuses + statuses: ) end if check_runs.empty? && legacy_status_runs.empty? handle_main_ci_status_violation!( - message: format_main_ci_status_violation(kind: :no_checks, short_sha: short_sha, runs: nil), - allow_override: allow_override, - dry_run: dry_run + message: format_main_ci_status_violation(kind: :no_checks, short_sha:, runs: nil), + allow_override:, + dry_run: ) return end @@ -977,7 +977,7 @@ def validate_main_ci_status!(monorepo_root:, is_prerelease:, allow_override:, dr evaluated = if is_prerelease && required_names check_runs.select do |run| required_names[:contexts].any? do |context| - legacy_context_check_run_matches?(context: context, run: run) + legacy_context_check_run_matches?(context:, run:) end || required_names[:checks].any? { |required_check| required_check_matches_run?(required_check, run) } end + legacy_status_runs @@ -994,9 +994,9 @@ def validate_main_ci_status!(monorepo_root:, is_prerelease:, allow_override:, dr end if failed.any? handle_main_ci_status_violation!( - message: format_main_ci_status_violation(kind: :failed, short_sha: short_sha, runs: failed), - allow_override: allow_override, - dry_run: dry_run + message: format_main_ci_status_violation(kind: :failed, short_sha:, runs: failed), + allow_override:, + dry_run: ) return end @@ -1013,24 +1013,24 @@ def validate_main_ci_status!(monorepo_root:, is_prerelease:, allow_override:, dr required_labels = required_check_labels(required_names) missing_required = missing_required_checks( required_checks: required_names, - check_runs: check_runs, - legacy_status_runs: legacy_status_runs + check_runs:, + legacy_status_runs: ) missing_names = missing_required[:labels] if missing_required[:count] == required_check_count(required_names) handle_main_ci_status_violation!( - message: format_main_ci_status_violation(kind: :no_required_checks, short_sha: short_sha, runs: nil) + + message: format_main_ci_status_violation(kind: :no_required_checks, short_sha:, runs: nil) + "\nRequired: #{required_labels.join(', ')}", - allow_override: allow_override, - dry_run: dry_run + allow_override:, + dry_run: ) return elsif missing_names.any? handle_main_ci_status_violation!( - message: format_main_ci_status_violation(kind: :missing_required_checks, short_sha: short_sha, runs: nil) + + message: format_main_ci_status_violation(kind: :missing_required_checks, short_sha:, runs: nil) + "\nRequired: #{required_labels.join(', ')}\nMissing: #{missing_names.join(', ')}", - allow_override: allow_override, - dry_run: dry_run + allow_override:, + dry_run: ) return end @@ -1039,9 +1039,9 @@ def validate_main_ci_status!(monorepo_root:, is_prerelease:, allow_override:, dr in_progress = evaluated.select { |run| CI_INCOMPLETE_STATUSES.include?(run["status"]) } if in_progress.any? handle_main_ci_status_violation!( - message: format_main_ci_status_violation(kind: :in_progress, short_sha: short_sha, runs: in_progress), - allow_override: allow_override, - dry_run: dry_run + message: format_main_ci_status_violation(kind: :in_progress, short_sha:, runs: in_progress), + allow_override:, + dry_run: ) return end @@ -1056,9 +1056,9 @@ def validate_main_ci_status!(monorepo_root:, is_prerelease:, allow_override:, dr end if unknown.any? handle_main_ci_status_violation!( - message: format_main_ci_status_violation(kind: :unknown_status, short_sha: short_sha, runs: unknown), - allow_override: allow_override, - dry_run: dry_run + message: format_main_ci_status_violation(kind: :unknown_status, short_sha:, runs: unknown), + allow_override:, + dry_run: ) return end @@ -1100,14 +1100,14 @@ end # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity def validate_release_version_policy!(monorepo_root:, target_gem_version:, allow_override:, fetch_tags: true) - tagged_versions = tagged_release_gem_versions(monorepo_root, fetch_tags: fetch_tags) + tagged_versions = tagged_release_gem_versions(monorepo_root, fetch_tags:) latest_tagged_version = tagged_versions.max_by { |version| Gem::Version.new(version) } if latest_tagged_version && Gem::Version.new(target_gem_version) <= Gem::Version.new(latest_tagged_version) handle_version_policy_violation!( message: "❌ Requested version #{target_gem_version} " \ "must be greater than latest tagged version #{latest_tagged_version}.", - allow_override: allow_override + allow_override: ) end @@ -1131,12 +1131,12 @@ def validate_release_version_policy!(monorepo_root:, target_gem_version:, allow_ return unless latest_stable_version actual_bump_type = version_bump_type(previous_stable_gem_version: latest_stable_version, - target_gem_version: target_gem_version) + target_gem_version:) if actual_bump_type == :none handle_version_policy_violation!( message: "❌ Requested version #{target_gem_version} is not a major/minor/patch bump " \ "over latest stable #{latest_stable_version}.", - allow_override: allow_override + allow_override: ) return if allow_override end @@ -1147,7 +1147,7 @@ def validate_release_version_policy!(monorepo_root:, target_gem_version:, allow_ end changelog_path = File.join(monorepo_root, "CHANGELOG.md") - changelog_section = extract_changelog_section(changelog_path: changelog_path, version: target_gem_version) + changelog_section = extract_changelog_section(changelog_path:, version: target_gem_version) unless changelog_section puts "ℹ️ VERSION POLICY: No changelog content found for #{target_gem_version}; " \ "skipping changelog bump-consistency check." @@ -1164,7 +1164,7 @@ def validate_release_version_policy!(monorepo_root:, target_gem_version:, allow_ handle_version_policy_violation!( message: "❌ Version bump mismatch for #{target_gem_version}: CHANGELOG implies #{expected_bump_type}, " \ "but version bump is #{actual_bump_type} from #{latest_stable_version}.", - allow_override: allow_override + allow_override: ) end # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity @@ -1188,7 +1188,7 @@ end def confirm_release!(version:, monorepo_root:) changelog_path = File.join(monorepo_root, "CHANGELOG.md") - has_changelog = extract_changelog_section(changelog_path: changelog_path, version: version) + has_changelog = extract_changelog_section(changelog_path:, version:) puts "" puts "################################################################################" @@ -1216,7 +1216,7 @@ def changelog_dirty?(monorepo_root:) end def ensure_changelog_committed!(monorepo_root:) - return unless changelog_dirty?(monorepo_root: monorepo_root) + return unless changelog_dirty?(monorepo_root:) abort "❌ CHANGELOG.md has uncommitted changes. Commit or stash CHANGELOG.md before running sync_github_release." end @@ -1239,12 +1239,12 @@ end def prepare_github_release_context(monorepo_root:, gem_version:) prerelease = release_prerelease_version?(gem_version) changelog_path = File.join(monorepo_root, "CHANGELOG.md") - notes = extract_changelog_section(changelog_path: changelog_path, version: gem_version) + notes = extract_changelog_section(changelog_path:, version: gem_version) abort "❌ Could not find `### [#{gem_version}]` in CHANGELOG.md. Add that section and retry." unless notes { - notes: notes, - prerelease: prerelease, + notes:, + prerelease:, tag: "v#{gem_version}", title: "v#{gem_version}" } @@ -1252,7 +1252,7 @@ end # rubocop:disable Metrics/AbcSize def publish_or_update_github_release(monorepo_root:, release_context:, dry_run:) - ensure_git_tag_exists!(monorepo_root: monorepo_root, tag: release_context[:tag]) + ensure_git_tag_exists!(monorepo_root:, tag: release_context[:tag]) if dry_run puts "DRY RUN: Would create or update GitHub release #{release_context[:tag]}" \ @@ -1289,7 +1289,7 @@ end def sync_github_release_after_publish(monorepo_root:, gem_version:, dry_run:) changelog_path = File.join(monorepo_root, "CHANGELOG.md") - section = extract_changelog_section(changelog_path: changelog_path, version: gem_version) + section = extract_changelog_section(changelog_path:, version: gem_version) unless section puts "################################################################################" puts "Skipping GitHub release: no CHANGELOG.md section for #{gem_version}." @@ -1299,9 +1299,9 @@ def sync_github_release_after_publish(monorepo_root:, gem_version:, dry_run:) return end - verify_gh_auth(monorepo_root: monorepo_root) - release_context = prepare_github_release_context(monorepo_root: monorepo_root, gem_version: gem_version) - publish_or_update_github_release(monorepo_root: monorepo_root, release_context: release_context, dry_run: dry_run) + verify_gh_auth(monorepo_root:) + release_context = prepare_github_release_context(monorepo_root:, gem_version:) + publish_or_update_github_release(monorepo_root:, release_context:, dry_run:) end def with_release_checkout(monorepo_root:, dry_run:) @@ -1334,7 +1334,7 @@ def resolve_version_input(version_input, monorepo_root) stripped = version_input.to_s.strip return stripped unless stripped.empty? - changelog_version = extract_latest_changelog_version(monorepo_root: monorepo_root) + changelog_version = extract_latest_changelog_version(monorepo_root:) current_version = current_gem_version(monorepo_root) if changelog_version && Gem::Version.new(changelog_version) > Gem::Version.new(current_version) @@ -1518,7 +1518,7 @@ def fetch_npm_package_metadata_with_retries(package_ref, registry_url:, attempts last_status = nil attempts.times do |attempt| - output, status = fetch_npm_package_metadata(package_ref, registry_url: registry_url) + output, status = fetch_npm_package_metadata(package_ref, registry_url:) return [output, status] if status.success? last_output = output @@ -1554,9 +1554,9 @@ def verify_npm_package_published!( package_ref = "#{package_name}@#{expected_version}" output, status = fetch_npm_package_metadata_with_retries( package_ref, - registry_url: registry_url, - attempts: attempts, - retry_delay_seconds: retry_delay_seconds + registry_url:, + attempts:, + retry_delay_seconds: ) unless status.success? abort <<~ERROR @@ -1722,12 +1722,12 @@ task :release, %i[version dry_run override_version_policy override_ci_status] do # Configure output verbosity verbose(is_verbose) - run_release_preflight_checks!(monorepo_root: monorepo_root, dry_run: is_dry_run) + run_release_preflight_checks!(monorepo_root:, dry_run: is_dry_run) released_gem_version = nil released_npm_version = nil - with_release_checkout(monorepo_root: monorepo_root, dry_run: is_dry_run) do |release_root| + with_release_checkout(monorepo_root:, dry_run: is_dry_run) do |release_root| release_paths_hash = release_paths(release_root) sh_in_dir_for_release(release_root, "git pull --rebase") unless is_dry_run @@ -1737,7 +1737,7 @@ task :release, %i[version dry_run override_version_policy override_ci_status] do current_checkout_version = current_gem_version(release_root) resolved_target_gem_version = compute_target_gem_version( current_gem_version: current_checkout_version, - version_input: version_input + version_input: ) is_prerelease = release_prerelease_version?(resolved_target_gem_version) @@ -1757,7 +1757,7 @@ task :release, %i[version dry_run override_version_policy override_ci_status] do validate_main_ci_status!( monorepo_root: release_root, - is_prerelease: is_prerelease, + is_prerelease:, allow_override: allow_ci_status_override, dry_run: is_dry_run ) @@ -1964,10 +1964,10 @@ task :release, %i[version dry_run override_version_policy override_ci_status] do puts " - react_on_rails_pro/react_on_rails_pro.gemspec (uses ReactOnRails::VERSION)" puts "\nTo actually release, run: rake release[#{released_gem_version}]" else - sync_github_release_after_publish(monorepo_root: monorepo_root, gem_version: released_gem_version, dry_run: false) + sync_github_release_after_publish(monorepo_root:, gem_version: released_gem_version, dry_run: false) changelog_path = File.join(monorepo_root, "CHANGELOG.md") - has_changelog_section = extract_changelog_section(changelog_path: changelog_path, version: released_gem_version) + has_changelog_section = extract_changelog_section(changelog_path:, version: released_gem_version) puts "\n#{'=' * 80}" puts "RELEASE COMPLETE!" @@ -2025,17 +2025,17 @@ task :sync_github_release, %i[gem_version dry_run] do |_t, args| puts "ℹ️ sync_github_release reads local committed CHANGELOG.md; " \ "run `git pull --rebase` first for latest remote notes." if is_dry_run - if changelog_dirty?(monorepo_root: monorepo_root) + if changelog_dirty?(monorepo_root:) abort "❌ DRY RUN: CHANGELOG.md has uncommitted changes. " \ "Commit or stash CHANGELOG.md before running sync_github_release." end puts "DRY RUN: Validating CHANGELOG.md section exists for #{requested_gem_version}..." else - ensure_changelog_committed!(monorepo_root: monorepo_root) + ensure_changelog_committed!(monorepo_root:) end - verify_gh_auth(monorepo_root: monorepo_root) - release_context = prepare_github_release_context(monorepo_root: monorepo_root, gem_version: requested_gem_version) - publish_or_update_github_release(monorepo_root: monorepo_root, release_context: release_context, dry_run: is_dry_run) + verify_gh_auth(monorepo_root:) + release_context = prepare_github_release_context(monorepo_root:, gem_version: requested_gem_version) + publish_or_update_github_release(monorepo_root:, release_context:, dry_run: is_dry_run) end # rubocop:enable Metrics/BlockLength 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 7b34dcfba4..40b12adfeb 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 @@ -207,7 +207,7 @@ changelog_path = File.join(dir, "CHANGELOG.md") File.write(changelog_path, changelog) - section = extract_changelog_section(changelog_path: changelog_path, version: "16.4.0") + section = extract_changelog_section(changelog_path:, version: "16.4.0") expect(section).to include("Feature A") expect(section).not_to include("### [16.4.0] - 2026-03-08") expect(section).not_to include("Bug B") @@ -231,7 +231,7 @@ changelog_path = File.join(dir, "CHANGELOG.md") File.write(changelog_path, changelog) - section = extract_changelog_section(changelog_path: changelog_path, version: "16.4.0") + section = extract_changelog_section(changelog_path:, version: "16.4.0") expect(section).to be_nil end end @@ -319,7 +319,7 @@ it "dispatches the workflow on the release ref and watches the matching run" do allow(self).to receive(:fetch_shakaperf_release_gate_runs) - .with(repo_slug: repo_slug, ref: "release-branch") + .with(repo_slug:, ref: "release-branch") .and_return([{ "databaseId" => 999_999, "headSha" => head_sha }]) allow(self).to receive(:capture_gh_output) .with( @@ -330,9 +330,9 @@ .and_return(["", success_status]) allow(self).to receive(:wait_for_shakaperf_release_gate_run!) .with( - repo_slug: repo_slug, + repo_slug:, ref: "release-branch", - head_sha: head_sha, + head_sha:, ignored_run_ids: ["999999"], earliest_created_at: kind_of(Time) ) @@ -346,9 +346,9 @@ expect do run_shakaperf_release_gate!( - monorepo_root: monorepo_root, + monorepo_root:, ref: "release-branch", - head_sha: head_sha, + head_sha:, allow_override: false, dry_run: false ) @@ -361,9 +361,9 @@ ) expect(self).to have_received(:wait_for_shakaperf_release_gate_run!) .with( - repo_slug: repo_slug, + repo_slug:, ref: "release-branch", - head_sha: head_sha, + head_sha:, ignored_run_ids: ["999999"], earliest_created_at: kind_of(Time) ) @@ -379,9 +379,9 @@ expect do run_shakaperf_release_gate!( - monorepo_root: monorepo_root, + monorepo_root:, ref: "release-branch", - head_sha: head_sha, + head_sha:, allow_override: true, dry_run: false ) @@ -393,9 +393,9 @@ expect do run_shakaperf_release_gate!( - monorepo_root: monorepo_root, + monorepo_root:, ref: "release-branch", - head_sha: head_sha, + head_sha:, allow_override: false, dry_run: true ) @@ -404,7 +404,7 @@ it "aborts when workflow dispatch fails" do allow(self).to receive(:fetch_shakaperf_release_gate_runs) - .with(repo_slug: repo_slug, ref: "release-branch") + .with(repo_slug:, ref: "release-branch") .and_return([]) allow(self).to receive(:capture_gh_output) .with( @@ -416,9 +416,9 @@ expect do run_shakaperf_release_gate!( - monorepo_root: monorepo_root, + monorepo_root:, ref: "release-branch", - head_sha: head_sha, + head_sha:, allow_override: false, dry_run: false ) @@ -427,7 +427,7 @@ it "aborts when the matching gate run fails" do allow(self).to receive(:fetch_shakaperf_release_gate_runs) - .with(repo_slug: repo_slug, ref: "release-branch") + .with(repo_slug:, ref: "release-branch") .and_return([]) allow(self).to receive(:capture_gh_output) .with( @@ -438,9 +438,9 @@ .and_return(["", success_status]) allow(self).to receive(:wait_for_shakaperf_release_gate_run!) .with( - repo_slug: repo_slug, + repo_slug:, ref: "release-branch", - head_sha: head_sha, + head_sha:, ignored_run_ids: [], earliest_created_at: kind_of(Time) ) @@ -454,9 +454,9 @@ expect do run_shakaperf_release_gate!( - monorepo_root: monorepo_root, + monorepo_root:, ref: "release-branch", - head_sha: head_sha, + head_sha:, allow_override: false, dry_run: false ) @@ -465,7 +465,7 @@ it "aborts when watching the matching gate run times out" do allow(self).to receive(:fetch_shakaperf_release_gate_runs) - .with(repo_slug: repo_slug, ref: "release-branch") + .with(repo_slug:, ref: "release-branch") .and_return([]) allow(self).to receive(:capture_gh_output) .with( @@ -476,9 +476,9 @@ .and_return(["", success_status]) allow(self).to receive(:wait_for_shakaperf_release_gate_run!) .with( - repo_slug: repo_slug, + repo_slug:, ref: "release-branch", - head_sha: head_sha, + head_sha:, ignored_run_ids: [], earliest_created_at: kind_of(Time) ) @@ -492,9 +492,9 @@ expect do run_shakaperf_release_gate!( - monorepo_root: monorepo_root, + monorepo_root:, ref: "release-branch", - head_sha: head_sha, + head_sha:, allow_override: false, dry_run: false ) @@ -504,11 +504,11 @@ it "finds the workflow_dispatch run for the pushed head SHA" do matching_run = { "databaseId" => 2, "headSha" => head_sha } allow(self).to receive(:fetch_shakaperf_release_gate_runs) - .with(repo_slug: repo_slug, ref: "release-branch") + .with(repo_slug:, ref: "release-branch") .and_return([{ "databaseId" => 1, "headSha" => "old" }, matching_run]) expect( - wait_for_shakaperf_release_gate_run!(repo_slug: repo_slug, ref: "release-branch", head_sha: head_sha) + wait_for_shakaperf_release_gate_run!(repo_slug:, ref: "release-branch", head_sha:) ).to eq(matching_run) end @@ -516,14 +516,14 @@ stale_run = { "databaseId" => 1, "headSha" => head_sha } matching_run = { "databaseId" => 2, "headSha" => head_sha } allow(self).to receive(:fetch_shakaperf_release_gate_runs) - .with(repo_slug: repo_slug, ref: "release-branch") + .with(repo_slug:, ref: "release-branch") .and_return([stale_run, matching_run]) expect( wait_for_shakaperf_release_gate_run!( - repo_slug: repo_slug, + repo_slug:, ref: "release-branch", - head_sha: head_sha, + head_sha:, ignored_run_ids: [1] ) ).to eq(matching_run) @@ -533,14 +533,14 @@ stale_run = { "databaseId" => 1, "headSha" => head_sha, "createdAt" => "2026-06-05T01:00:00Z" } matching_run = { "databaseId" => 2, "headSha" => head_sha, "createdAt" => "2026-06-05T01:00:10Z" } allow(self).to receive(:fetch_shakaperf_release_gate_runs) - .with(repo_slug: repo_slug, ref: "release-branch") + .with(repo_slug:, ref: "release-branch") .and_return([stale_run, matching_run]) expect( wait_for_shakaperf_release_gate_run!( - repo_slug: repo_slug, + repo_slug:, ref: "release-branch", - head_sha: head_sha, + head_sha:, earliest_created_at: Time.iso8601("2026-06-05T01:00:05Z") ) ).to eq(matching_run) @@ -550,14 +550,14 @@ allow(Time).to receive(:now).and_return(Time.iso8601("2026-06-05T01:00:05.900Z")) newly_dispatched_run = { "databaseId" => 1, "headSha" => head_sha, "createdAt" => "2026-06-05T01:00:05Z" } allow(self).to receive(:fetch_shakaperf_release_gate_runs) - .with(repo_slug: repo_slug, ref: "release-branch") + .with(repo_slug:, ref: "release-branch") .and_return([newly_dispatched_run]) expect( wait_for_shakaperf_release_gate_run!( - repo_slug: repo_slug, + repo_slug:, ref: "release-branch", - head_sha: head_sha, + head_sha:, earliest_created_at: shakaperf_release_gate_dispatch_started_at ) ).to eq(newly_dispatched_run) @@ -565,15 +565,15 @@ it "aborts when no matching workflow_dispatch run appears before the deadline" do allow(self).to receive(:fetch_shakaperf_release_gate_runs) - .with(repo_slug: repo_slug, ref: "release-branch") + .with(repo_slug:, ref: "release-branch") .and_return([{ "databaseId" => 1, "headSha" => "other" }]) stub_const("SHAKAPERF_RELEASE_GATE_START_TIMEOUT_SECONDS", -1) expect do wait_for_shakaperf_release_gate_run!( - repo_slug: repo_slug, + repo_slug:, ref: "release-branch", - head_sha: head_sha + head_sha: ) end.to raise_error(SystemExit, /Timed out waiting for ShakaPerf release gate workflow to start/) end @@ -874,11 +874,11 @@ end def required_checks(contexts: [], checks: []) - { contexts: contexts, checks: checks } + { contexts:, checks: } end def required_check(context, app_id: nil) - { context: context, app_id: app_id } + { context:, app_id: } end def next_check_run_id @@ -926,12 +926,12 @@ def in_progress_run(name, id: next_check_run_id) context "when all check runs pass" do it "logs success and returns without raising" do allow(self).to receive(:fetch_main_ci_checks) - .with(monorepo_root: monorepo_root, allow_override: false, dry_run: false) - .and_return(sha: sha, check_runs: [passing_run("Lint"), passing_run("Test")]) + .with(monorepo_root:, allow_override: false, dry_run: false) + .and_return(sha:, check_runs: [passing_run("Lint"), passing_run("Test")]) expect do validate_main_ci_status!( - monorepo_root: monorepo_root, + monorepo_root:, is_prerelease: false, allow_override: false, dry_run: false @@ -943,12 +943,12 @@ def in_progress_run(name, id: next_check_run_id) context "when a check has failed on a stable release" do it "aborts with the failing check name and link" do allow(self).to receive(:fetch_main_ci_checks) - .with(monorepo_root: monorepo_root, allow_override: false, dry_run: false) - .and_return(sha: sha, check_runs: [passing_run("Lint"), failing_run("JS unit tests")]) + .with(monorepo_root:, allow_override: false, dry_run: false) + .and_return(sha:, check_runs: [passing_run("Lint"), failing_run("JS unit tests")]) expect do validate_main_ci_status!( - monorepo_root: monorepo_root, + monorepo_root:, is_prerelease: false, allow_override: false, dry_run: false @@ -960,14 +960,14 @@ def in_progress_run(name, id: next_check_run_id) context "when a non-required check fails on a prerelease" do it "passes because only required checks gate prereleases" do allow(self).to receive(:fetch_main_ci_checks) - .with(monorepo_root: monorepo_root, allow_override: false, dry_run: false) - .and_return(sha: sha, check_runs: [passing_run("Lint"), failing_run("Benchmark Workflow")]) + .with(monorepo_root:, allow_override: false, dry_run: false) + .and_return(sha:, check_runs: [passing_run("Lint"), failing_run("Benchmark Workflow")]) allow(self).to receive(:required_check_names_for_main) - .with(monorepo_root: monorepo_root).and_return(required_checks(checks: [required_check("Lint")])) + .with(monorepo_root:).and_return(required_checks(checks: [required_check("Lint")])) expect do validate_main_ci_status!( - monorepo_root: monorepo_root, + monorepo_root:, is_prerelease: true, allow_override: false, dry_run: false @@ -979,14 +979,14 @@ def in_progress_run(name, id: next_check_run_id) context "when a required check fails on a prerelease" do it "aborts because the required check gates prereleases" do allow(self).to receive(:fetch_main_ci_checks) - .with(monorepo_root: monorepo_root, allow_override: false, dry_run: false) - .and_return(sha: sha, check_runs: [failing_run("Lint"), passing_run("Benchmark Workflow")]) + .with(monorepo_root:, allow_override: false, dry_run: false) + .and_return(sha:, check_runs: [failing_run("Lint"), passing_run("Benchmark Workflow")]) allow(self).to receive(:required_check_names_for_main) - .with(monorepo_root: monorepo_root).and_return(required_checks(checks: [required_check("Lint")])) + .with(monorepo_root:).and_return(required_checks(checks: [required_check("Lint")])) expect do validate_main_ci_status!( - monorepo_root: monorepo_root, + monorepo_root:, is_prerelease: true, allow_override: false, dry_run: false @@ -998,12 +998,12 @@ def in_progress_run(name, id: next_check_run_id) context "when a check is still in progress" do it "aborts with the in-progress message and link" do allow(self).to receive(:fetch_main_ci_checks) - .with(monorepo_root: monorepo_root, allow_override: false, dry_run: false) - .and_return(sha: sha, check_runs: [passing_run("Lint"), in_progress_run("Slow test")]) + .with(monorepo_root:, allow_override: false, dry_run: false) + .and_return(sha:, check_runs: [passing_run("Lint"), in_progress_run("Slow test")]) expect do validate_main_ci_status!( - monorepo_root: monorepo_root, + monorepo_root:, is_prerelease: false, allow_override: false, dry_run: false @@ -1015,12 +1015,12 @@ def in_progress_run(name, id: next_check_run_id) context "when there are both failed and in-progress checks" do it "reports the failure first so the operator does not wait on an already-broken main" do allow(self).to receive(:fetch_main_ci_checks) - .with(monorepo_root: monorepo_root, allow_override: false, dry_run: false) - .and_return(sha: sha, check_runs: [failing_run("JS unit tests"), in_progress_run("Slow test")]) + .with(monorepo_root:, allow_override: false, dry_run: false) + .and_return(sha:, check_runs: [failing_run("JS unit tests"), in_progress_run("Slow test")]) expect do validate_main_ci_status!( - monorepo_root: monorepo_root, + monorepo_root:, is_prerelease: false, allow_override: false, dry_run: false @@ -1038,12 +1038,12 @@ def in_progress_run(name, id: next_check_run_id) old_failed = failing_run("Lint").merge("id" => 1, "check_suite" => { "id" => 100 }) new_passed = passing_run("Lint").merge("id" => 2, "check_suite" => { "id" => 100 }) allow(self).to receive(:fetch_main_ci_checks) - .with(monorepo_root: monorepo_root, allow_override: false, dry_run: false) - .and_return(sha: sha, check_runs: [old_failed, new_passed]) + .with(monorepo_root:, allow_override: false, dry_run: false) + .and_return(sha:, check_runs: [old_failed, new_passed]) expect do validate_main_ci_status!( - monorepo_root: monorepo_root, + monorepo_root:, is_prerelease: false, allow_override: false, dry_run: false @@ -1061,12 +1061,12 @@ def in_progress_run(name, id: next_check_run_id) "id" => 2, "check_suite" => { "id" => 200 } ) allow(self).to receive(:fetch_main_ci_checks) - .with(monorepo_root: monorepo_root, allow_override: false, dry_run: false) - .and_return(sha: sha, check_runs: [workflow_a_passing, workflow_b_failing]) + .with(monorepo_root:, allow_override: false, dry_run: false) + .and_return(sha:, check_runs: [workflow_a_passing, workflow_b_failing]) expect do validate_main_ci_status!( - monorepo_root: monorepo_root, + monorepo_root:, is_prerelease: false, allow_override: false, dry_run: false @@ -1078,12 +1078,12 @@ def in_progress_run(name, id: next_check_run_id) context "when same-named checks do not include check_suite data" do it "keeps helper-generated runs distinct by default" do allow(self).to receive(:fetch_main_ci_checks) - .with(monorepo_root: monorepo_root, allow_override: false, dry_run: false) - .and_return(sha: sha, check_runs: [passing_run("Lint"), failing_run("Lint")]) + .with(monorepo_root:, allow_override: false, dry_run: false) + .and_return(sha:, check_runs: [passing_run("Lint"), failing_run("Lint")]) expect do validate_main_ci_status!( - monorepo_root: monorepo_root, + monorepo_root:, is_prerelease: false, allow_override: false, dry_run: false @@ -1104,12 +1104,12 @@ def in_progress_run(name, id: next_check_run_id) "id" => 2, "check_suite" => { "id" => 200 } ) allow(self).to receive(:fetch_main_ci_checks) - .with(monorepo_root: monorepo_root, allow_override: false, dry_run: false) - .and_return(sha: sha, check_runs: [suite_a_old_failed, suite_a_new_passed, suite_b_passing]) + .with(monorepo_root:, allow_override: false, dry_run: false) + .and_return(sha:, check_runs: [suite_a_old_failed, suite_a_new_passed, suite_b_passing]) expect do validate_main_ci_status!( - monorepo_root: monorepo_root, + monorepo_root:, is_prerelease: false, allow_override: false, dry_run: false @@ -1121,12 +1121,12 @@ def in_progress_run(name, id: next_check_run_id) context "when there are zero check runs visible" do it "aborts with a 'no CI data' message" do allow(self).to receive(:fetch_main_ci_checks) - .with(monorepo_root: monorepo_root, allow_override: false, dry_run: false) - .and_return(sha: sha, check_runs: []) + .with(monorepo_root:, allow_override: false, dry_run: false) + .and_return(sha:, check_runs: []) expect do validate_main_ci_status!( - monorepo_root: monorepo_root, + monorepo_root:, is_prerelease: false, allow_override: false, dry_run: false @@ -1138,12 +1138,12 @@ def in_progress_run(name, id: next_check_run_id) context "when override is set on a failing main" do it "warns and returns instead of aborting" do allow(self).to receive(:fetch_main_ci_checks) - .with(monorepo_root: monorepo_root, allow_override: true, dry_run: false) - .and_return(sha: sha, check_runs: [failing_run("Lint")]) + .with(monorepo_root:, allow_override: true, dry_run: false) + .and_return(sha:, check_runs: [failing_run("Lint")]) expect do validate_main_ci_status!( - monorepo_root: monorepo_root, + monorepo_root:, is_prerelease: false, allow_override: true, dry_run: false @@ -1155,12 +1155,12 @@ def in_progress_run(name, id: next_check_run_id) context "when running in dry-run mode on a failing main" do it "warns and returns instead of aborting" do allow(self).to receive(:fetch_main_ci_checks) - .with(monorepo_root: monorepo_root, allow_override: false, dry_run: true) - .and_return(sha: sha, check_runs: [failing_run("Lint")]) + .with(monorepo_root:, allow_override: false, dry_run: true) + .and_return(sha:, check_runs: [failing_run("Lint")]) expect do validate_main_ci_status!( - monorepo_root: monorepo_root, + monorepo_root:, is_prerelease: false, allow_override: false, dry_run: true @@ -1172,12 +1172,12 @@ def in_progress_run(name, id: next_check_run_id) context "when fetch surfaces a violation via override/dry-run (returns nil)" do it "returns without raising and without trying to inspect the data" do allow(self).to receive(:fetch_main_ci_checks) - .with(monorepo_root: monorepo_root, allow_override: false, dry_run: true) + .with(monorepo_root:, allow_override: false, dry_run: true) .and_return(nil) expect do validate_main_ci_status!( - monorepo_root: monorepo_root, + monorepo_root:, is_prerelease: false, allow_override: false, dry_run: true @@ -1189,14 +1189,14 @@ def in_progress_run(name, id: next_check_run_id) context "when branch protection is not queryable on a prerelease" do it "falls back to evaluating all checks (fail-safe)" do allow(self).to receive(:fetch_main_ci_checks) - .with(monorepo_root: monorepo_root, allow_override: false, dry_run: false) - .and_return(sha: sha, check_runs: [passing_run("Lint"), failing_run("Optional Check")]) + .with(monorepo_root:, allow_override: false, dry_run: false) + .and_return(sha:, check_runs: [passing_run("Lint"), failing_run("Optional Check")]) allow(self).to receive(:required_check_names_for_main) - .with(monorepo_root: monorepo_root).and_return(nil) + .with(monorepo_root:).and_return(nil) expect do validate_main_ci_status!( - monorepo_root: monorepo_root, + monorepo_root:, is_prerelease: true, allow_override: false, dry_run: false @@ -1208,14 +1208,14 @@ def in_progress_run(name, id: next_check_run_id) context "when no check runs match the required names on a prerelease" do it "aborts with a 'no required check runs' message" do allow(self).to receive(:fetch_main_ci_checks) - .with(monorepo_root: monorepo_root, allow_override: false, dry_run: false) - .and_return(sha: sha, check_runs: [passing_run("Lint")]) + .with(monorepo_root:, allow_override: false, dry_run: false) + .and_return(sha:, check_runs: [passing_run("Lint")]) allow(self).to receive(:required_check_names_for_main) - .with(monorepo_root: monorepo_root).and_return(required_checks(checks: [required_check("DoesNotExist")])) + .with(monorepo_root:).and_return(required_checks(checks: [required_check("DoesNotExist")])) expect do validate_main_ci_status!( - monorepo_root: monorepo_root, + monorepo_root:, is_prerelease: true, allow_override: false, dry_run: false @@ -1227,15 +1227,15 @@ def in_progress_run(name, id: next_check_run_id) context "when some required checks are present but others are missing on a prerelease" do it "aborts and lists the missing required check names" do allow(self).to receive(:fetch_main_ci_checks) - .with(monorepo_root: monorepo_root, allow_override: false, dry_run: false) - .and_return(sha: sha, check_runs: [passing_run("Lint"), passing_run("Test")]) + .with(monorepo_root:, allow_override: false, dry_run: false) + .and_return(sha:, check_runs: [passing_run("Lint"), passing_run("Test")]) allow(self).to receive(:required_check_names_for_main) - .with(monorepo_root: monorepo_root) + .with(monorepo_root:) .and_return(required_checks(checks: %w[Lint Test Build].map { |context| required_check(context) })) expect do validate_main_ci_status!( - monorepo_root: monorepo_root, + monorepo_root:, is_prerelease: true, allow_override: false, dry_run: false @@ -1250,15 +1250,15 @@ def in_progress_run(name, id: next_check_run_id) context "when some required checks are missing on a stable release" do it "aborts on stable too (branch protection would refuse the merge)" do allow(self).to receive(:fetch_main_ci_checks) - .with(monorepo_root: monorepo_root, allow_override: false, dry_run: false) - .and_return(sha: sha, check_runs: [passing_run("Lint"), passing_run("Test")]) + .with(monorepo_root:, allow_override: false, dry_run: false) + .and_return(sha:, check_runs: [passing_run("Lint"), passing_run("Test")]) allow(self).to receive(:required_check_names_for_main) - .with(monorepo_root: monorepo_root) + .with(monorepo_root:) .and_return(required_checks(checks: %w[Lint Test Build].map { |context| required_check(context) })) expect do validate_main_ci_status!( - monorepo_root: monorepo_root, + monorepo_root:, is_prerelease: false, allow_override: false, dry_run: false @@ -1274,12 +1274,12 @@ def in_progress_run(name, id: next_check_run_id) it "treats the ambiguity as a failure rather than silently passing through" do weird_run = passing_run("Future Status").merge("status" => "scheduled", "conclusion" => nil) allow(self).to receive(:fetch_main_ci_checks) - .with(monorepo_root: monorepo_root, allow_override: false, dry_run: false) - .and_return(sha: sha, check_runs: [passing_run("Lint"), weird_run]) + .with(monorepo_root:, allow_override: false, dry_run: false) + .and_return(sha:, check_runs: [passing_run("Lint"), weird_run]) expect do validate_main_ci_status!( - monorepo_root: monorepo_root, + monorepo_root:, is_prerelease: false, allow_override: false, dry_run: false @@ -1290,12 +1290,12 @@ def in_progress_run(name, id: next_check_run_id) it "treats a nil status as a failure too" do weird_run = passing_run("Malformed").merge("status" => nil, "conclusion" => nil) allow(self).to receive(:fetch_main_ci_checks) - .with(monorepo_root: monorepo_root, allow_override: false, dry_run: false) - .and_return(sha: sha, check_runs: [passing_run("Lint"), weird_run]) + .with(monorepo_root:, allow_override: false, dry_run: false) + .and_return(sha:, check_runs: [passing_run("Lint"), weird_run]) expect do validate_main_ci_status!( - monorepo_root: monorepo_root, + monorepo_root:, is_prerelease: false, allow_override: false, dry_run: false @@ -1308,15 +1308,15 @@ def in_progress_run(name, id: next_check_run_id) it "does not let a same-named check from another app satisfy the requirement" do wrong_app_run = passing_run("Lint", app_id: 999) allow(self).to receive(:fetch_main_ci_checks) - .with(monorepo_root: monorepo_root, allow_override: false, dry_run: false) - .and_return(sha: sha, check_runs: [wrong_app_run]) + .with(monorepo_root:, allow_override: false, dry_run: false) + .and_return(sha:, check_runs: [wrong_app_run]) allow(self).to receive(:required_check_names_for_main) - .with(monorepo_root: monorepo_root) + .with(monorepo_root:) .and_return(required_checks(checks: [required_check("Lint", app_id: 123)])) expect do validate_main_ci_status!( - monorepo_root: monorepo_root, + monorepo_root:, is_prerelease: true, allow_override: false, dry_run: false @@ -1328,15 +1328,15 @@ def in_progress_run(name, id: next_check_run_id) wrong_app_run = passing_run("Lint", app_id: 999) required_app_run = failing_run("Lint", app_id: 123) allow(self).to receive(:fetch_main_ci_checks) - .with(monorepo_root: monorepo_root, allow_override: false, dry_run: false) - .and_return(sha: sha, check_runs: [wrong_app_run, required_app_run]) + .with(monorepo_root:, allow_override: false, dry_run: false) + .and_return(sha:, check_runs: [wrong_app_run, required_app_run]) allow(self).to receive(:required_check_names_for_main) - .with(monorepo_root: monorepo_root) + .with(monorepo_root:) .and_return(required_checks(checks: [required_check("Lint", app_id: 123)])) expect do validate_main_ci_status!( - monorepo_root: monorepo_root, + monorepo_root:, is_prerelease: true, allow_override: false, dry_run: false @@ -1348,15 +1348,15 @@ def in_progress_run(name, id: next_check_run_id) wrong_app_run = failing_run("Lint", app_id: 999) required_app_run = passing_run("Lint", app_id: 123) allow(self).to receive(:fetch_main_ci_checks) - .with(monorepo_root: monorepo_root, allow_override: false, dry_run: false) - .and_return(sha: sha, repo_slug: "shakacode/react_on_rails", check_runs: [wrong_app_run, required_app_run]) + .with(monorepo_root:, allow_override: false, dry_run: false) + .and_return(sha:, repo_slug: "shakacode/react_on_rails", check_runs: [wrong_app_run, required_app_run]) allow(self).to receive(:required_check_names_for_main) - .with(monorepo_root: monorepo_root, repo_slug: "shakacode/react_on_rails") + .with(monorepo_root:, repo_slug: "shakacode/react_on_rails") .and_return(required_checks(checks: [required_check("Lint", app_id: 123)])) expect do validate_main_ci_status!( - monorepo_root: monorepo_root, + monorepo_root:, is_prerelease: true, allow_override: false, dry_run: false @@ -1368,13 +1368,13 @@ def in_progress_run(name, id: next_check_run_id) context "when branch protection includes legacy status contexts" do it "uses commit statuses to satisfy legacy contexts when no check runs are visible" do allow(self).to receive(:fetch_main_ci_checks) - .with(monorepo_root: monorepo_root, allow_override: false, dry_run: false) - .and_return(sha: sha, repo_slug: "shakacode/react_on_rails", check_runs: []) + .with(monorepo_root:, allow_override: false, dry_run: false) + .and_return(sha:, repo_slug: "shakacode/react_on_rails", check_runs: []) allow(self).to receive(:required_check_names_for_main) - .with(monorepo_root: monorepo_root, repo_slug: "shakacode/react_on_rails") + .with(monorepo_root:, repo_slug: "shakacode/react_on_rails") .and_return(required_checks(contexts: ["Travis"], checks: [])) allow(self).to receive(:fetch_main_commit_statuses) - .with(repo_slug: "shakacode/react_on_rails", sha: sha, allow_override: false, dry_run: false) + .with(repo_slug: "shakacode/react_on_rails", sha:, allow_override: false, dry_run: false) .and_return([ { "id" => 1, @@ -1386,7 +1386,7 @@ def in_progress_run(name, id: next_check_run_id) expect do validate_main_ci_status!( - monorepo_root: monorepo_root, + monorepo_root:, is_prerelease: true, allow_override: false, dry_run: false @@ -1396,13 +1396,13 @@ def in_progress_run(name, id: next_check_run_id) it "blocks when the legacy commit status has failed" do allow(self).to receive(:fetch_main_ci_checks) - .with(monorepo_root: monorepo_root, allow_override: false, dry_run: false) - .and_return(sha: sha, repo_slug: "shakacode/react_on_rails", check_runs: []) + .with(monorepo_root:, allow_override: false, dry_run: false) + .and_return(sha:, repo_slug: "shakacode/react_on_rails", check_runs: []) allow(self).to receive(:required_check_names_for_main) - .with(monorepo_root: monorepo_root, repo_slug: "shakacode/react_on_rails") + .with(monorepo_root:, repo_slug: "shakacode/react_on_rails") .and_return(required_checks(contexts: ["Travis"], checks: [])) allow(self).to receive(:fetch_main_commit_statuses) - .with(repo_slug: "shakacode/react_on_rails", sha: sha, allow_override: false, dry_run: false) + .with(repo_slug: "shakacode/react_on_rails", sha:, allow_override: false, dry_run: false) .and_return([ { "id" => 1, @@ -1414,7 +1414,7 @@ def in_progress_run(name, id: next_check_run_id) expect do validate_main_ci_status!( - monorepo_root: monorepo_root, + monorepo_root:, is_prerelease: true, allow_override: false, dry_run: false @@ -1424,13 +1424,13 @@ def in_progress_run(name, id: next_check_run_id) it "blocks when a same-named legacy status fails even if a check run passes" do allow(self).to receive(:fetch_main_ci_checks) - .with(monorepo_root: monorepo_root, allow_override: false, dry_run: false) - .and_return(sha: sha, repo_slug: "shakacode/react_on_rails", check_runs: [passing_run("Travis")]) + .with(monorepo_root:, allow_override: false, dry_run: false) + .and_return(sha:, repo_slug: "shakacode/react_on_rails", check_runs: [passing_run("Travis")]) allow(self).to receive(:required_check_names_for_main) - .with(monorepo_root: monorepo_root, repo_slug: "shakacode/react_on_rails") + .with(monorepo_root:, repo_slug: "shakacode/react_on_rails") .and_return(required_checks(contexts: ["Travis"], checks: [])) allow(self).to receive(:fetch_main_commit_statuses) - .with(repo_slug: "shakacode/react_on_rails", sha: sha, allow_override: false, dry_run: false) + .with(repo_slug: "shakacode/react_on_rails", sha:, allow_override: false, dry_run: false) .and_return([ { "id" => 1, @@ -1442,7 +1442,7 @@ def in_progress_run(name, id: next_check_run_id) expect do validate_main_ci_status!( - monorepo_root: monorepo_root, + monorepo_root:, is_prerelease: true, allow_override: false, dry_run: false @@ -1452,13 +1452,13 @@ def in_progress_run(name, id: next_check_run_id) it "treats a pending legacy commit status as in progress" do allow(self).to receive(:fetch_main_ci_checks) - .with(monorepo_root: monorepo_root, allow_override: false, dry_run: false) - .and_return(sha: sha, repo_slug: "shakacode/react_on_rails", check_runs: []) + .with(monorepo_root:, allow_override: false, dry_run: false) + .and_return(sha:, repo_slug: "shakacode/react_on_rails", check_runs: []) allow(self).to receive(:required_check_names_for_main) - .with(monorepo_root: monorepo_root, repo_slug: "shakacode/react_on_rails") + .with(monorepo_root:, repo_slug: "shakacode/react_on_rails") .and_return(required_checks(contexts: ["Travis"], checks: [])) allow(self).to receive(:fetch_main_commit_statuses) - .with(repo_slug: "shakacode/react_on_rails", sha: sha, allow_override: false, dry_run: false) + .with(repo_slug: "shakacode/react_on_rails", sha:, allow_override: false, dry_run: false) .and_return([ { "id" => 1, @@ -1470,7 +1470,7 @@ def in_progress_run(name, id: next_check_run_id) expect do validate_main_ci_status!( - monorepo_root: monorepo_root, + monorepo_root:, is_prerelease: true, allow_override: false, dry_run: false @@ -1480,13 +1480,13 @@ def in_progress_run(name, id: next_check_run_id) it "treats an unknown legacy commit status state as failed" do allow(self).to receive(:fetch_main_ci_checks) - .with(monorepo_root: monorepo_root, allow_override: false, dry_run: false) - .and_return(sha: sha, repo_slug: "shakacode/react_on_rails", check_runs: []) + .with(monorepo_root:, allow_override: false, dry_run: false) + .and_return(sha:, repo_slug: "shakacode/react_on_rails", check_runs: []) allow(self).to receive(:required_check_names_for_main) - .with(monorepo_root: monorepo_root, repo_slug: "shakacode/react_on_rails") + .with(monorepo_root:, repo_slug: "shakacode/react_on_rails") .and_return(required_checks(contexts: ["Travis"], checks: [])) allow(self).to receive(:fetch_main_commit_statuses) - .with(repo_slug: "shakacode/react_on_rails", sha: sha, allow_override: false, dry_run: false) + .with(repo_slug: "shakacode/react_on_rails", sha:, allow_override: false, dry_run: false) .and_return([ { "id" => 1, @@ -1498,7 +1498,7 @@ def in_progress_run(name, id: next_check_run_id) expect do validate_main_ci_status!( - monorepo_root: monorepo_root, + monorepo_root:, is_prerelease: true, allow_override: false, dry_run: false @@ -1508,18 +1508,18 @@ def in_progress_run(name, id: next_check_run_id) it "keeps evaluating fetched check runs when legacy status fetch is skipped in dry-run" do allow(self).to receive(:fetch_main_ci_checks) - .with(monorepo_root: monorepo_root, allow_override: false, dry_run: true) - .and_return(sha: sha, repo_slug: "shakacode/react_on_rails", check_runs: [failing_run("Lint")]) + .with(monorepo_root:, allow_override: false, dry_run: true) + .and_return(sha:, repo_slug: "shakacode/react_on_rails", check_runs: [failing_run("Lint")]) allow(self).to receive(:required_check_names_for_main) - .with(monorepo_root: monorepo_root, repo_slug: "shakacode/react_on_rails") + .with(monorepo_root:, repo_slug: "shakacode/react_on_rails") .and_return(required_checks(contexts: ["Travis"], checks: [])) allow(self).to receive(:fetch_main_commit_statuses) - .with(repo_slug: "shakacode/react_on_rails", sha: sha, allow_override: false, dry_run: true) + .with(repo_slug: "shakacode/react_on_rails", sha:, allow_override: false, dry_run: true) .and_return(nil) expect do validate_main_ci_status!( - monorepo_root: monorepo_root, + monorepo_root:, is_prerelease: false, allow_override: false, dry_run: true @@ -1529,18 +1529,18 @@ def in_progress_run(name, id: next_check_run_id) it "raises if strict legacy status fetch unexpectedly returns nil" do allow(self).to receive(:fetch_main_ci_checks) - .with(monorepo_root: monorepo_root, allow_override: false, dry_run: false) - .and_return(sha: sha, repo_slug: "shakacode/react_on_rails", check_runs: [passing_run("Lint")]) + .with(monorepo_root:, allow_override: false, dry_run: false) + .and_return(sha:, repo_slug: "shakacode/react_on_rails", check_runs: [passing_run("Lint")]) allow(self).to receive(:required_check_names_for_main) - .with(monorepo_root: monorepo_root, repo_slug: "shakacode/react_on_rails") + .with(monorepo_root:, repo_slug: "shakacode/react_on_rails") .and_return(required_checks(contexts: ["Travis"], checks: [])) allow(self).to receive(:fetch_main_commit_statuses) - .with(repo_slug: "shakacode/react_on_rails", sha: sha, allow_override: false, dry_run: false) + .with(repo_slug: "shakacode/react_on_rails", sha:, allow_override: false, dry_run: false) .and_return(nil) expect do validate_main_ci_status!( - monorepo_root: monorepo_root, + monorepo_root:, is_prerelease: true, allow_override: false, dry_run: false @@ -1550,13 +1550,13 @@ def in_progress_run(name, id: next_check_run_id) it "does not print the commit-status API URL as a browser link" do allow(self).to receive(:fetch_main_ci_checks) - .with(monorepo_root: monorepo_root, allow_override: false, dry_run: false) - .and_return(sha: sha, repo_slug: "shakacode/react_on_rails", check_runs: []) + .with(monorepo_root:, allow_override: false, dry_run: false) + .and_return(sha:, repo_slug: "shakacode/react_on_rails", check_runs: []) allow(self).to receive(:required_check_names_for_main) - .with(monorepo_root: monorepo_root, repo_slug: "shakacode/react_on_rails") + .with(monorepo_root:, repo_slug: "shakacode/react_on_rails") .and_return(required_checks(contexts: ["Travis"], checks: [])) allow(self).to receive(:fetch_main_commit_statuses) - .with(repo_slug: "shakacode/react_on_rails", sha: sha, allow_override: false, dry_run: false) + .with(repo_slug: "shakacode/react_on_rails", sha:, allow_override: false, dry_run: false) .and_return([ { "id" => 1, @@ -1568,7 +1568,7 @@ def in_progress_run(name, id: next_check_run_id) expect do validate_main_ci_status!( - monorepo_root: monorepo_root, + monorepo_root:, is_prerelease: true, allow_override: false, dry_run: false @@ -1578,13 +1578,13 @@ def in_progress_run(name, id: next_check_run_id) it "reports a failed legacy status for a wildcard required check" do allow(self).to receive(:fetch_main_ci_checks) - .with(monorepo_root: monorepo_root, allow_override: false, dry_run: false) - .and_return(sha: sha, repo_slug: "shakacode/react_on_rails", check_runs: []) + .with(monorepo_root:, allow_override: false, dry_run: false) + .and_return(sha:, repo_slug: "shakacode/react_on_rails", check_runs: []) allow(self).to receive(:required_check_names_for_main) - .with(monorepo_root: monorepo_root, repo_slug: "shakacode/react_on_rails") + .with(monorepo_root:, repo_slug: "shakacode/react_on_rails") .and_return(required_checks(checks: [required_check("Travis")])) allow(self).to receive(:fetch_main_commit_statuses) - .with(repo_slug: "shakacode/react_on_rails", sha: sha, allow_override: false, dry_run: false) + .with(repo_slug: "shakacode/react_on_rails", sha:, allow_override: false, dry_run: false) .and_return([ { "id" => 1, @@ -1596,7 +1596,7 @@ def in_progress_run(name, id: next_check_run_id) expect do validate_main_ci_status!( - monorepo_root: monorepo_root, + monorepo_root:, is_prerelease: true, allow_override: false, dry_run: false @@ -1606,13 +1606,13 @@ def in_progress_run(name, id: next_check_run_id) it "blocks a stable release when a legacy commit status has failed" do allow(self).to receive(:fetch_main_ci_checks) - .with(monorepo_root: monorepo_root, allow_override: false, dry_run: false) - .and_return(sha: sha, repo_slug: "shakacode/react_on_rails", check_runs: [passing_run("Lint")]) + .with(monorepo_root:, allow_override: false, dry_run: false) + .and_return(sha:, repo_slug: "shakacode/react_on_rails", check_runs: [passing_run("Lint")]) allow(self).to receive(:required_check_names_for_main) - .with(monorepo_root: monorepo_root, repo_slug: "shakacode/react_on_rails") + .with(monorepo_root:, repo_slug: "shakacode/react_on_rails") .and_return(required_checks(contexts: ["Travis"], checks: [])) allow(self).to receive(:fetch_main_commit_statuses) - .with(repo_slug: "shakacode/react_on_rails", sha: sha, allow_override: false, dry_run: false) + .with(repo_slug: "shakacode/react_on_rails", sha:, allow_override: false, dry_run: false) .and_return([ { "id" => 1, @@ -1624,7 +1624,7 @@ def in_progress_run(name, id: next_check_run_id) expect do validate_main_ci_status!( - monorepo_root: monorepo_root, + monorepo_root:, is_prerelease: false, allow_override: false, dry_run: false @@ -1634,13 +1634,13 @@ def in_progress_run(name, id: next_check_run_id) it "uses a legacy status to satisfy a wildcard required check" do allow(self).to receive(:fetch_main_ci_checks) - .with(monorepo_root: monorepo_root, allow_override: false, dry_run: false) - .and_return(sha: sha, repo_slug: "shakacode/react_on_rails", check_runs: []) + .with(monorepo_root:, allow_override: false, dry_run: false) + .and_return(sha:, repo_slug: "shakacode/react_on_rails", check_runs: []) allow(self).to receive(:required_check_names_for_main) - .with(monorepo_root: monorepo_root, repo_slug: "shakacode/react_on_rails") + .with(monorepo_root:, repo_slug: "shakacode/react_on_rails") .and_return(required_checks(checks: [required_check("Travis")])) allow(self).to receive(:fetch_main_commit_statuses) - .with(repo_slug: "shakacode/react_on_rails", sha: sha, allow_override: false, dry_run: false) + .with(repo_slug: "shakacode/react_on_rails", sha:, allow_override: false, dry_run: false) .and_return([ { "id" => 1, @@ -1652,7 +1652,7 @@ def in_progress_run(name, id: next_check_run_id) expect do validate_main_ci_status!( - monorepo_root: monorepo_root, + monorepo_root:, is_prerelease: true, allow_override: false, dry_run: false @@ -1662,20 +1662,20 @@ def in_progress_run(name, id: next_check_run_id) it "reports a missing mirrored wildcard check once" do allow(self).to receive(:fetch_main_ci_checks) - .with(monorepo_root: monorepo_root, allow_override: false, dry_run: false) - .and_return(sha: sha, repo_slug: "shakacode/react_on_rails", check_runs: [passing_run("Build")]) + .with(monorepo_root:, allow_override: false, dry_run: false) + .and_return(sha:, repo_slug: "shakacode/react_on_rails", check_runs: [passing_run("Build")]) allow(self).to receive(:required_check_names_for_main) - .with(monorepo_root: monorepo_root, repo_slug: "shakacode/react_on_rails") + .with(monorepo_root:, repo_slug: "shakacode/react_on_rails") .and_return( required_checks(checks: [required_check("Travis"), required_check("Build")]) ) allow(self).to receive(:fetch_main_commit_statuses) - .with(repo_slug: "shakacode/react_on_rails", sha: sha, allow_override: false, dry_run: false) + .with(repo_slug: "shakacode/react_on_rails", sha:, allow_override: false, dry_run: false) .and_return([]) expect do validate_main_ci_status!( - monorepo_root: monorepo_root, + monorepo_root:, is_prerelease: true, allow_override: false, dry_run: false @@ -1689,15 +1689,15 @@ def in_progress_run(name, id: next_check_run_id) it "does not let a legacy status satisfy an app-pinned modern check" do allow(self).to receive(:fetch_main_ci_checks) - .with(monorepo_root: monorepo_root, allow_override: false, dry_run: false) - .and_return(sha: sha, repo_slug: "shakacode/react_on_rails", check_runs: [passing_run("Other")]) + .with(monorepo_root:, allow_override: false, dry_run: false) + .and_return(sha:, repo_slug: "shakacode/react_on_rails", check_runs: [passing_run("Other")]) allow(self).to receive(:required_check_names_for_main) - .with(monorepo_root: monorepo_root, repo_slug: "shakacode/react_on_rails") + .with(monorepo_root:, repo_slug: "shakacode/react_on_rails") .and_return(required_checks(checks: [required_check("Travis", app_id: 123)])) expect do validate_main_ci_status!( - monorepo_root: monorepo_root, + monorepo_root:, is_prerelease: true, allow_override: false, dry_run: false @@ -1708,7 +1708,7 @@ def in_progress_run(name, id: next_check_run_id) it "raises when asked to format an unknown CI violation kind" do expect do - format_main_ci_status_violation(kind: :typo, short_sha: short_sha, runs: []) + format_main_ci_status_violation(kind: :typo, short_sha:, runs: []) end.to raise_error(ArgumentError, /Unknown CI violation kind: :typo/) end end @@ -1724,7 +1724,7 @@ def in_progress_run(name, id: next_check_run_id) .and_return(["fetch failed: network down", failure_status]) expect do - fetch_main_ci_checks(monorepo_root: monorepo_root) + fetch_main_ci_checks(monorepo_root:) end.to raise_error(SystemExit, %r{Unable to fetch origin/main}) end @@ -1735,7 +1735,7 @@ def in_progress_run(name, id: next_check_run_id) result = nil expect do - result = fetch_main_ci_checks(monorepo_root: monorepo_root, allow_override: true) + result = fetch_main_ci_checks(monorepo_root:, allow_override: true) end.to output(%r{CI STATUS OVERRIDE enabled.*Unable to fetch origin/main}m).to_stdout expect(result).to be_nil end @@ -1747,7 +1747,7 @@ def in_progress_run(name, id: next_check_run_id) result = nil expect do - result = fetch_main_ci_checks(monorepo_root: monorepo_root, dry_run: true) + result = fetch_main_ci_checks(monorepo_root:, dry_run: true) end.to output(%r{DRY RUN.*Unable to fetch origin/main}m).to_stdout expect(result).to be_nil end @@ -1766,7 +1766,7 @@ def in_progress_run(name, id: next_check_run_id) .and_return(["HTTP 401: unauthorized", failure_status]) expect do - fetch_main_ci_checks(monorepo_root: monorepo_root) + fetch_main_ci_checks(monorepo_root:) end.to raise_error(SystemExit, /Unable to query GitHub Checks API.*HTTP 401/m) end @@ -1784,7 +1784,7 @@ def in_progress_run(name, id: next_check_run_id) .and_raise(Errno::ENOENT) expect do - fetch_main_ci_checks(monorepo_root: monorepo_root) + fetch_main_ci_checks(monorepo_root:) end.to raise_error(SystemExit, /GitHub CLI .* is not installed/) end @@ -1803,7 +1803,7 @@ def in_progress_run(name, id: next_check_run_id) result = nil expect do - result = fetch_main_ci_checks(monorepo_root: monorepo_root, dry_run: true) + result = fetch_main_ci_checks(monorepo_root:, dry_run: true) end.to output(/DRY RUN.*GitHub CLI .* is not installed/m).to_stdout expect(result).to be_nil end @@ -1823,7 +1823,7 @@ def in_progress_run(name, id: next_check_run_id) result = nil expect do - result = fetch_main_ci_checks(monorepo_root: monorepo_root, dry_run: true) + result = fetch_main_ci_checks(monorepo_root:, dry_run: true) end.to output(/DRY RUN.*Failed to parse check_runs response/m).to_stdout expect(result).to be_nil end @@ -1845,7 +1845,7 @@ def in_progress_run(name, id: next_check_run_id) "repos/shakacode/react_on_rails/commits/abc1234def/check-runs") .and_return([jsonl, success_status]) - result = fetch_main_ci_checks(monorepo_root: monorepo_root) + result = fetch_main_ci_checks(monorepo_root:) expect(result[:sha]).to eq("abc1234def") expect(result[:repo_slug]).to eq("shakacode/react_on_rails") expect(result[:check_runs].length).to eq(2) @@ -1869,7 +1869,7 @@ def in_progress_run(name, id: next_check_run_id) "repos/shakacode/react_on_rails/branches/main/protection/required_status_checks") .and_return([{ contexts: %w[Lint Test], checks: [] }.to_json, success_status]) - expect(required_check_names_for_main(monorepo_root: monorepo_root)).to eq( + expect(required_check_names_for_main(monorepo_root:)).to eq( contexts: %w[Lint Test], checks: [] ) @@ -1890,7 +1890,7 @@ def in_progress_run(name, id: next_check_run_id) success_status ]) - expect(required_check_names_for_main(monorepo_root: monorepo_root)).to eq( + expect(required_check_names_for_main(monorepo_root:)).to eq( contexts: [], checks: [ { context: "CodeQL", app_id: 15_368 }, @@ -1914,7 +1914,7 @@ def in_progress_run(name, id: next_check_run_id) success_status ]) - expect(required_check_names_for_main(monorepo_root: monorepo_root)).to eq( + expect(required_check_names_for_main(monorepo_root:)).to eq( contexts: %w[Test], checks: [ { context: "CodeQL", app_id: -1 }, @@ -1929,7 +1929,7 @@ def in_progress_run(name, id: next_check_run_id) "repos/shakacode/react_on_rails/branches/main/protection/required_status_checks") .and_return(["HTTP 404: Branch not protected", failure_status]) - expect(required_check_names_for_main(monorepo_root: monorepo_root)).to be_nil + expect(required_check_names_for_main(monorepo_root:)).to be_nil end it "returns nil when the protection response yields an empty array (fail-safe)" do @@ -1942,7 +1942,7 @@ def in_progress_run(name, id: next_check_run_id) "repos/shakacode/react_on_rails/branches/main/protection/required_status_checks") .and_return([{ contexts: [], checks: [] }.to_json, success_status]) - expect(required_check_names_for_main(monorepo_root: monorepo_root)).to be_nil + expect(required_check_names_for_main(monorepo_root:)).to be_nil end end end From 7810d704e24e4cd7869b8fcf285419620f6ad277 Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Sun, 7 Jun 2026 10:29:10 -1000 Subject: [PATCH 08/13] Address release gate mirror review comments --- rakelib/release.rake | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/rakelib/release.rake b/rakelib/release.rake index ada1d1a516..9adc9ad39b 100644 --- a/rakelib/release.rake +++ b/rakelib/release.rake @@ -718,6 +718,9 @@ def normalize_required_checks_payload(parsed) checks = normalize_required_check_entries(parsed["checks"]) check_contexts = checks.map { |check| check[:context] } + # GitHub mirrors required status-check names into both `contexts` and `checks`. + # Keep the modern `checks` entry when names overlap so one required gate is + # evaluated once, with its app pin preserved. contexts = Array(parsed["contexts"]).map(&:to_s).reject(&:empty?).uniq - check_contexts # No required names parseable is treated the same as "no branch protection @@ -779,7 +782,7 @@ def legacy_context_present?(context:, check_runs:, legacy_status_runs:) legacy_context_check_run_matches?(context:, run:) end - matching_check_run || legacy_status_runs.any? { |run| run["name"] == context } + matching_check_run || legacy_status_runs.any? { |run| legacy_context_check_run_matches?(context:, run:) } end def required_check_label(required_check) From fad5f88b7b143a926e92c91bf51f440ab62c3268 Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Sun, 7 Jun 2026 10:42:21 -1000 Subject: [PATCH 09/13] Address release gate CI review follow-ups --- .claude/hooks/main-ci-status.sh | 5 +- rakelib/release.rake | 30 +++++--- .../release_rake_helpers_spec.rb | 68 ++++++++++++++++++- 3 files changed, 89 insertions(+), 14 deletions(-) diff --git a/.claude/hooks/main-ci-status.sh b/.claude/hooks/main-ci-status.sh index 6503564728..81a06e6bd6 100755 --- a/.claude/hooks/main-ci-status.sh +++ b/.claude/hooks/main-ci-status.sh @@ -181,6 +181,7 @@ summary=$(echo "${checks_json}" | jq -r --argjson required_names "${required_jso .name == $required.context and (app_wildcard($required.app_id) or .app_id == $required.app_id); def required_check_label: if app_wildcard(.app_id) then .context else "\(.context) (app_id: \(.app_id))" end; + def modern_contexts: ($required_names.checks // []) | map(.context); . as $all | { @@ -194,7 +195,9 @@ summary=$(echo "${checks_json}" | jq -r --argjson required_names "${required_jso # NOTE: legacy contexts are intentionally app-agnostic here. Commit # statuses are not fetched here; this hook is a fail-open display # tool. The Ruby release gate owns app-pinned legacy enforcement. - (($required_names.contexts // []) + # GitHub mirrors modern required checks into legacy `contexts`; remove + # those mirror names here so the display count matches the release gate. + ((($required_names.contexts // []) - modern_contexts) | map(select(. as $context | ($all | any(.name == $context) | not)))) + (($required_names.checks // []) diff --git a/rakelib/release.rake b/rakelib/release.rake index 9adc9ad39b..b0e7b80684 100644 --- a/rakelib/release.rake +++ b/rakelib/release.rake @@ -699,9 +699,9 @@ end def latest_commit_statuses(statuses) statuses .group_by { |status| status["context"] } - # GitHub status ids are monotonically increasing for a commit/context, so - # the highest id is the most recent status returned by the paginated API. - .map { |_context, context_statuses| context_statuses.max_by { |status| status["id"].to_i } } + .map do |_context, context_statuses| + context_statuses.max_by { |status| [status["id"].to_i, status["created_at"].to_s] } + end end def normalize_required_check_entries(checks) @@ -773,16 +773,16 @@ def required_check_present?(required_check:, check_runs:, legacy_status_runs:) legacy_status_runs.any? { |run| run["name"] == required_check[:context] }) end -def legacy_context_check_run_matches?(context:, run:) +def context_name_matches?(context:, run:) run["name"] == context end def legacy_context_present?(context:, check_runs:, legacy_status_runs:) matching_check_run = check_runs.any? do |run| - legacy_context_check_run_matches?(context:, run:) + context_name_matches?(context:, run:) end - matching_check_run || legacy_status_runs.any? { |run| legacy_context_check_run_matches?(context:, run:) } + matching_check_run || legacy_status_runs.any? { |run| context_name_matches?(context:, run:) } end def required_check_label(required_check) @@ -853,7 +853,7 @@ def format_ci_status_run_line(run, kind:) icon = kind == :in_progress ? "⏳" : "❌" detail = kind == :in_progress ? (run["status"] || "in_progress") : (run["conclusion"] || "incomplete") url = run["html_url"].to_s - url.empty? ? " #{icon} #{detail}: #{run['name']}" : " #{icon} #{detail}: #{run['name']}\n #{url}" + url.strip.empty? ? " #{icon} #{detail}: #{run['name']}" : " #{icon} #{detail}: #{run['name']}\n #{url}" end def format_main_ci_status_violation(kind:, short_sha:, runs:) # rubocop:disable Metrics/CyclomaticComplexity @@ -955,7 +955,14 @@ def validate_main_ci_status!(monorepo_root:, is_prerelease:, allow_override:, dr dry_run: ) if statuses.nil? - raise "Unexpected nil response from fetch_main_commit_statuses in strict mode" unless allow_override || dry_run + unless allow_override || dry_run + handle_main_ci_status_violation!( + message: "❌ Internal error: legacy status fetch returned nil unexpectedly in strict mode.", + allow_override:, + dry_run: + ) + return + end # Only dry-run/override mode reaches the fallback; strict mode aborts inside # the fetch helper after surfacing the violation. @@ -980,7 +987,7 @@ def validate_main_ci_status!(monorepo_root:, is_prerelease:, allow_override:, dr evaluated = if is_prerelease && required_names check_runs.select do |run| required_names[:contexts].any? do |context| - legacy_context_check_run_matches?(context:, run:) + context_name_matches?(context:, run:) end || required_names[:checks].any? { |required_check| required_check_matches_run?(required_check, run) } end + legacy_status_runs @@ -1072,8 +1079,9 @@ def validate_main_ci_status!(monorepo_root:, is_prerelease:, allow_override:, dr # non-required runs and labelling them "required" would misrepresent the # gate. qualifier = is_prerelease && required_names ? "required " : "" - noun = evaluated.length == 1 ? "check" : "checks" - puts "✓ Main CI is healthy on #{short_sha} (#{evaluated.length} #{qualifier}#{noun})" + healthy_count = is_prerelease && required_names ? required_check_count(required_names) : evaluated.length + noun = healthy_count == 1 ? "check" : "checks" + puts "✓ Main CI is healthy on #{short_sha} (#{healthy_count} #{qualifier}#{noun})" end # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity 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 40b12adfeb..fa0da2eb6f 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 @@ -1527,7 +1527,7 @@ def in_progress_run(name, id: next_check_run_id) end.to output(%r{DRY RUN: .*CI on origin/main is not healthy.*DRY RUN:.*Lint}m).to_stdout end - it "raises if strict legacy status fetch unexpectedly returns nil" do + it "aborts if strict legacy status fetch unexpectedly returns nil" do allow(self).to receive(:fetch_main_ci_checks) .with(monorepo_root:, allow_override: false, dry_run: false) .and_return(sha:, repo_slug: "shakacode/react_on_rails", check_runs: [passing_run("Lint")]) @@ -1545,7 +1545,7 @@ def in_progress_run(name, id: next_check_run_id) allow_override: false, dry_run: false ) - end.to raise_error(RuntimeError, /Unexpected nil response from fetch_main_commit_statuses in strict mode/) + end.to raise_error(SystemExit, /Internal error: legacy status fetch returned nil unexpectedly in strict mode/) end it "does not print the commit-status API URL as a browser link" do @@ -1660,6 +1660,70 @@ def in_progress_run(name, id: next_check_run_id) end.to output(/Main CI is healthy on #{short_sha} \(1 required check\)/).to_stdout end + it "counts a mirrored wildcard required check once when both APIs report it" do + allow(self).to receive(:fetch_main_ci_checks) + .with(monorepo_root:, allow_override: false, dry_run: false) + .and_return(sha:, repo_slug: "shakacode/react_on_rails", check_runs: [passing_run("Travis")]) + allow(self).to receive(:required_check_names_for_main) + .with(monorepo_root:, repo_slug: "shakacode/react_on_rails") + .and_return(required_checks(checks: [required_check("Travis")])) + allow(self).to receive(:fetch_main_commit_statuses) + .with(repo_slug: "shakacode/react_on_rails", sha:, allow_override: false, dry_run: false) + .and_return([ + { + "id" => 1, + "context" => "Travis", + "state" => "success", + "target_url" => "https://ci.example.com/travis" + } + ]) + + expect do + validate_main_ci_status!( + monorepo_root:, + is_prerelease: true, + allow_override: false, + dry_run: false + ) + end.to output(/Main CI is healthy on #{short_sha} \(1 required check\)/).to_stdout + end + + it "uses created_at as a tiebreaker for same-context legacy statuses" do + allow(self).to receive(:fetch_main_ci_checks) + .with(monorepo_root:, allow_override: false, dry_run: false) + .and_return(sha:, repo_slug: "shakacode/react_on_rails", check_runs: []) + allow(self).to receive(:required_check_names_for_main) + .with(monorepo_root:, repo_slug: "shakacode/react_on_rails") + .and_return(required_checks(contexts: ["Travis"], checks: [])) + allow(self).to receive(:fetch_main_commit_statuses) + .with(repo_slug: "shakacode/react_on_rails", sha:, allow_override: false, dry_run: false) + .and_return([ + { + "id" => 1, + "context" => "Travis", + "state" => "failure", + "created_at" => "2026-06-07T20:00:00Z", + "target_url" => "https://ci.example.com/travis" + }, + { + "id" => 1, + "context" => "Travis", + "state" => "success", + "created_at" => "2026-06-07T20:00:01Z", + "target_url" => "https://ci.example.com/travis" + } + ]) + + expect do + validate_main_ci_status!( + monorepo_root:, + is_prerelease: true, + allow_override: false, + dry_run: false + ) + end.to output(/Main CI is healthy on #{short_sha} \(1 required check\)/).to_stdout + end + it "reports a missing mirrored wildcard check once" do allow(self).to receive(:fetch_main_ci_checks) .with(monorepo_root:, allow_override: false, dry_run: false) From 3a3c4d19cbfcd8f0cc8f020f310007c44f3be2c4 Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Sun, 7 Jun 2026 10:46:14 -1000 Subject: [PATCH 10/13] Document legacy status pending invariant --- rakelib/release.rake | 1 + 1 file changed, 1 insertion(+) diff --git a/rakelib/release.rake b/rakelib/release.rake index b0e7b80684..54713817a0 100644 --- a/rakelib/release.rake +++ b/rakelib/release.rake @@ -676,6 +676,7 @@ def normalize_status_as_check_run(status) { "id" => status["id"], "name" => status["context"], + # `pending` must stay in CI_INCOMPLETE_STATUSES so commit statuses still block as in-progress. "status" => conclusion.nil? ? "pending" : "completed", "conclusion" => conclusion, "html_url" => status["target_url"] From 058bcddb7e511593fcebb9789d46868736f9c2d1 Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Sun, 7 Jun 2026 10:48:33 -1000 Subject: [PATCH 11/13] Avoid green CI status after legacy status fetch warning --- rakelib/release.rake | 6 +++ .../release_rake_helpers_spec.rb | 38 ++++++++++++++++++- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/rakelib/release.rake b/rakelib/release.rake index 54713817a0..86ad2dc4ba 100644 --- a/rakelib/release.rake +++ b/rakelib/release.rake @@ -948,6 +948,7 @@ def validate_main_ci_status!(monorepo_root:, is_prerelease:, allow_override:, dr required_names = required_check_names_for_main(**required_args) required_status_contexts = required_names ? legacy_status_contexts_for_required_checks(required_names) : [] legacy_status_runs = [] + legacy_status_fetch_unknown = false if required_status_contexts.any? statuses = fetch_main_commit_statuses( repo_slug: repo_slug || github_repo_slug(monorepo_root), @@ -967,6 +968,7 @@ def validate_main_ci_status!(monorepo_root:, is_prerelease:, allow_override:, dr # Only dry-run/override mode reaches the fallback; strict mode aborts inside # the fetch helper after surfacing the violation. + legacy_status_fetch_unknown = true statuses = [] end @@ -1074,6 +1076,10 @@ def validate_main_ci_status!(monorepo_root:, is_prerelease:, allow_override:, dr return end + # The fetch helper already warned in dry-run/override mode. Do not print a + # green status when required legacy status data was unavailable. + return if legacy_status_fetch_unknown + # Only label the count "required" when `evaluated` was actually filtered to # the required subset (prerelease + branch protection visible). On stable # releases we keep evaluating every check_run, so the count includes 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 fa0da2eb6f..c2e39f65ed 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 @@ -2,9 +2,20 @@ require_relative "simplecov_helper" require_relative "spec_helper" +require "stringio" require "tmpdir" RSpec.describe "release.rake helper methods" do + def capture_stdout + original_stdout = $stdout + output = StringIO.new + $stdout = output + yield + output.string + ensure + $stdout = original_stdout + end + before do next if Object.instance_variable_defined?(:@release_rake_helpers_loaded) @@ -1527,7 +1538,32 @@ def in_progress_run(name, id: next_check_run_id) end.to output(%r{DRY RUN: .*CI on origin/main is not healthy.*DRY RUN:.*Lint}m).to_stdout end - it "aborts if strict legacy status fetch unexpectedly returns nil" do + it "does not print green when required legacy status data is unavailable in dry-run" do + allow(self).to receive(:fetch_main_ci_checks) + .with(monorepo_root:, allow_override: false, dry_run: true) + .and_return(sha:, repo_slug: "shakacode/react_on_rails", check_runs: [passing_run("Travis")]) + allow(self).to receive(:required_check_names_for_main) + .with(monorepo_root:, repo_slug: "shakacode/react_on_rails") + .and_return(required_checks(contexts: ["Travis"], checks: [])) + allow(self).to receive(:fetch_main_commit_statuses) do + puts "⚠️ DRY RUN: Required legacy status fetch failed." + nil + end + + output = capture_stdout do + validate_main_ci_status!( + monorepo_root:, + is_prerelease: true, + allow_override: false, + dry_run: true + ) + end + + expect(output).to include("DRY RUN: Required legacy status fetch failed.") + expect(output).not_to include("Main CI is healthy") + end + + it "raises if strict legacy status fetch unexpectedly returns nil" do allow(self).to receive(:fetch_main_ci_checks) .with(monorepo_root:, allow_override: false, dry_run: false) .and_return(sha:, repo_slug: "shakacode/react_on_rails", check_runs: [passing_run("Lint")]) From e501b99d829d31168456567bd95b43bdebc94bb0 Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Sun, 7 Jun 2026 10:57:37 -1000 Subject: [PATCH 12/13] Fix mirrored legacy status success count --- rakelib/release.rake | 11 ++++---- .../release_rake_helpers_spec.rb | 28 +++++++++++++++++++ 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/rakelib/release.rake b/rakelib/release.rake index 86ad2dc4ba..1011a5a56d 100644 --- a/rakelib/release.rake +++ b/rakelib/release.rake @@ -701,6 +701,7 @@ def latest_commit_statuses(statuses) statuses .group_by { |status| status["context"] } .map do |_context, context_statuses| + # GitHub emits ISO 8601 UTC `created_at` values, which sort chronologically as strings. context_statuses.max_by { |status| [status["id"].to_i, status["created_at"].to_s] } end end @@ -1080,13 +1081,11 @@ def validate_main_ci_status!(monorepo_root:, is_prerelease:, allow_override:, dr # green status when required legacy status data was unavailable. return if legacy_status_fetch_unknown - # Only label the count "required" when `evaluated` was actually filtered to - # the required subset (prerelease + branch protection visible). On stable - # releases we keep evaluating every check_run, so the count includes - # non-required runs and labelling them "required" would misrepresent the - # gate. + # Stable releases still evaluated every visible run above for failures, but + # when branch protection is visible the success count should report required + # gates so mirrored Checks/Statuses API entries are not counted twice. qualifier = is_prerelease && required_names ? "required " : "" - healthy_count = is_prerelease && required_names ? required_check_count(required_names) : evaluated.length + healthy_count = required_names ? required_check_count(required_names) : evaluated.length noun = healthy_count == 1 ? "check" : "checks" puts "✓ Main CI is healthy on #{short_sha} (#{healthy_count} #{qualifier}#{noun})" end 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 c2e39f65ed..fee065f72d 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 @@ -1724,6 +1724,34 @@ def in_progress_run(name, id: next_check_run_id) end.to output(/Main CI is healthy on #{short_sha} \(1 required check\)/).to_stdout end + it "counts a mirrored wildcard required check once on stable releases" do + allow(self).to receive(:fetch_main_ci_checks) + .with(monorepo_root:, allow_override: false, dry_run: false) + .and_return(sha:, repo_slug: "shakacode/react_on_rails", check_runs: [passing_run("Travis")]) + allow(self).to receive(:required_check_names_for_main) + .with(monorepo_root:, repo_slug: "shakacode/react_on_rails") + .and_return(required_checks(checks: [required_check("Travis")])) + allow(self).to receive(:fetch_main_commit_statuses) + .with(repo_slug: "shakacode/react_on_rails", sha:, allow_override: false, dry_run: false) + .and_return([ + { + "id" => 1, + "context" => "Travis", + "state" => "success", + "target_url" => "https://ci.example.com/travis" + } + ]) + + expect do + validate_main_ci_status!( + monorepo_root:, + is_prerelease: false, + allow_override: false, + dry_run: false + ) + end.to output(/Main CI is healthy on #{short_sha} \(1 check\)/).to_stdout + end + it "uses created_at as a tiebreaker for same-context legacy statuses" do allow(self).to receive(:fetch_main_ci_checks) .with(monorepo_root:, allow_override: false, dry_run: false) From 9cff463d4852d053a226e7281b3e3a44d7a84cda Mon Sep 17 00:00:00 2001 From: Justin Gordon Date: Sun, 7 Jun 2026 11:01:09 -1000 Subject: [PATCH 13/13] Polish release gate status normalization --- rakelib/release.rake | 18 +++++++----------- .../release_rake_helpers_spec.rb | 4 ++-- 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/rakelib/release.rake b/rakelib/release.rake index 1011a5a56d..b5b2ae092c 100644 --- a/rakelib/release.rake +++ b/rakelib/release.rake @@ -702,7 +702,7 @@ def latest_commit_statuses(statuses) .group_by { |status| status["context"] } .map do |_context, context_statuses| # GitHub emits ISO 8601 UTC `created_at` values, which sort chronologically as strings. - context_statuses.max_by { |status| [status["id"].to_i, status["created_at"].to_s] } + context_statuses.max_by { |status| [status["created_at"].to_s, status["id"].to_i] } end end @@ -757,6 +757,7 @@ end def check_run_app_id(run) app_id = run.dig("app", "id") + # nil is the branch-protection wildcard; GitHub check-run app IDs are integers. app_id&.to_i end @@ -775,16 +776,12 @@ def required_check_present?(required_check:, check_runs:, legacy_status_runs:) legacy_status_runs.any? { |run| run["name"] == required_check[:context] }) end -def context_name_matches?(context:, run:) - run["name"] == context -end - def legacy_context_present?(context:, check_runs:, legacy_status_runs:) matching_check_run = check_runs.any? do |run| - context_name_matches?(context:, run:) + run["name"] == context end - matching_check_run || legacy_status_runs.any? { |run| context_name_matches?(context:, run:) } + matching_check_run || legacy_status_runs.any? { |run| run["name"] == context } end def required_check_label(required_check) @@ -846,8 +843,9 @@ def legacy_status_runs_for_required_contexts(required_checks:, statuses:) # App-wildcard required checks can be satisfied by either Checks API runs or # legacy commit statuses. App-pinned checks still require a matching check run. - latest_commit_statuses(statuses) + statuses .select { |status| status_contexts.include?(status["context"]) } + .then { |relevant_statuses| latest_commit_statuses(relevant_statuses) } .map { |status| normalize_status_as_check_run(status) } end @@ -990,9 +988,7 @@ def validate_main_ci_status!(monorepo_root:, is_prerelease:, allow_override:, dr evaluated = if is_prerelease && required_names check_runs.select do |run| - required_names[:contexts].any? do |context| - context_name_matches?(context:, run:) - end || + required_names[:contexts].include?(run["name"]) || required_names[:checks].any? { |required_check| required_check_matches_run?(required_check, run) } end + legacy_status_runs else 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 fee065f72d..9677738164 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 @@ -1752,7 +1752,7 @@ def in_progress_run(name, id: next_check_run_id) end.to output(/Main CI is healthy on #{short_sha} \(1 check\)/).to_stdout end - it "uses created_at as a tiebreaker for same-context legacy statuses" do + it "uses the newest same-context legacy status" do allow(self).to receive(:fetch_main_ci_checks) .with(monorepo_root:, allow_override: false, dry_run: false) .and_return(sha:, repo_slug: "shakacode/react_on_rails", check_runs: []) @@ -1770,7 +1770,7 @@ def in_progress_run(name, id: next_check_run_id) "target_url" => "https://ci.example.com/travis" }, { - "id" => 1, + "id" => 2, "context" => "Travis", "state" => "success", "created_at" => "2026-06-07T20:00:01Z",