diff --git a/.claude/hooks/main-ci-status.sh b/.claude/hooks/main-ci-status.sh index 02842b0702..81a06e6bd6 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,35 @@ 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; + def modern_contexts: ($required_names.checks // []) | map(.context); + . 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: 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. + # 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 // []) + | 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..b5b2ae092c 100644 --- a/rakelib/release.rake +++ b/rakelib/release.rake @@ -590,11 +590,14 @@ 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:, dry_run: ) + # Only reached in override/dry-run mode; strict mode aborts above. return nil end @@ -604,13 +607,12 @@ def fetch_main_ci_checks(monorepo_root:, allow_override: false, dry_run: false) allow_override:, 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}", @@ -624,14 +626,117 @@ def fetch_main_ci_checks(monorepo_root:, allow_override: false, dry_run: false) 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:, + 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:, + 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:, + 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"] + conclusion = normalize_status_conclusion(state) + { + "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"] + } +end + +def normalize_status_conclusion(state) + case state + when "success" + "success" + when "pending" + nil + when "failure", "error" + state + else + # GitHub documents error/failure/pending/success; unknown values should block. + "error" + end +end + +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["created_at"].to_s, status["id"].to_i] } + end +end + +def normalize_required_check_entries(checks) + Array(checks).filter_map do |check| + context = check["context"].to_s + next if context.empty? + + { context:, app_id: check["app_id"]&.to_i } + end.uniq +end + +def normalize_required_checks_payload(parsed) + return nil unless parsed.is_a?(Hash) + + 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 + # visible" — fail-safe to evaluating every check run. + contexts.empty? && checks.empty? ? nil : { contexts:, 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 +749,113 @@ 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") + # nil is the branch-protection wildcard; GitHub check-run app IDs are integers. + 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:, 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_present?(context:, check_runs:, legacy_status_runs:) + matching_check_run = check_runs.any? do |run| + run["name"] == context + 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 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) } + format_required_check_labels(labels) +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:, + check_runs:, + legacy_status_runs: + ) + end + missing_legacy = required_checks[:contexts].reject do |context| + legacy_context_present?( + context:, + check_runs:, + legacy_status_runs: + ) + end + + # 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:) + 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. + 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 + +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.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 header = case kind when :in_progress @@ -675,11 +877,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:) } "#{header}\n\n#{lines.join("\n")}" end @@ -721,15 +919,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 +934,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 @@ -756,12 +945,72 @@ def validate_main_ci_status!(monorepo_root:, is_prerelease:, allow_override:, dr 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) : [] + 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), + sha:, + allow_override:, + dry_run: + ) + if statuses.nil? + 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. + legacy_status_fetch_unknown = true + statuses = [] + end + + legacy_status_runs = legacy_status_runs_for_required_contexts( + required_checks: required_names, + 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:, runs: nil), + allow_override:, + 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].include?(run["name"]) || + 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:, runs: failed), + allow_override:, + 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,12 +1020,17 @@ 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:, + 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(', ')}", + "\nRequired: #{required_labels.join(', ')}", allow_override:, dry_run: ) @@ -784,7 +1038,7 @@ def validate_main_ci_status!(monorepo_root:, is_prerelease:, allow_override:, dr 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(', ')}", + "\nRequired: #{required_labels.join(', ')}\nMissing: #{missing_names.join(', ')}", allow_override:, dry_run: ) @@ -792,22 +1046,6 @@ def validate_main_ci_status!(monorepo_root:, is_prerelease:, allow_override:, dr 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!( @@ -835,14 +1073,17 @@ def validate_main_ci_status!(monorepo_root:, is_prerelease:, allow_override:, dr return end - # 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. + # 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 + + # 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 " : "" - noun = evaluated.length == 1 ? "check" : "checks" - puts "✓ Main CI is healthy on #{short_sha} (#{evaluated.length} #{qualifier}#{noun})" + 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 # 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 8dea12b45a..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 @@ -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) @@ -866,8 +877,19 @@ # 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_messages( + fetch_main_commit_statuses: [], + github_repo_slug: "shakacode/react_on_rails", + required_check_names_for_main: nil + ) + end + + def required_checks(contexts: [], checks: []) + { contexts:, checks: } + end + + def required_check(context, app_id: nil) + { context:, app_id: } end def next_check_run_id @@ -878,24 +900,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) @@ -948,7 +974,7 @@ def in_progress_run(name, id: next_check_run_id) .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:).and_return(["Lint"]) + .with(monorepo_root:).and_return(required_checks(checks: [required_check("Lint")])) expect do validate_main_ci_status!( @@ -967,7 +993,7 @@ def in_progress_run(name, id: next_check_run_id) .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:).and_return(["Lint"]) + .with(monorepo_root:).and_return(required_checks(checks: [required_check("Lint")])) expect do validate_main_ci_status!( @@ -1196,7 +1222,7 @@ def in_progress_run(name, id: next_check_run_id) .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:).and_return(["DoesNotExist"]) + .with(monorepo_root:).and_return(required_checks(checks: [required_check("DoesNotExist")])) expect do validate_main_ci_status!( @@ -1215,7 +1241,8 @@ def in_progress_run(name, id: next_check_run_id) .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:).and_return(%w[Lint Test Build]) + .with(monorepo_root:) + .and_return(required_checks(checks: %w[Lint Test Build].map { |context| required_check(context) })) expect do validate_main_ci_status!( @@ -1237,7 +1264,8 @@ def in_progress_run(name, id: next_check_run_id) .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:).and_return(%w[Lint Test Build]) + .with(monorepo_root:) + .and_return(required_checks(checks: %w[Lint Test Build].map { |context| required_check(context) })) expect do validate_main_ci_status!( @@ -1287,6 +1315,525 @@ 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", app_id: 999) + allow(self).to receive(:fetch_main_ci_checks) + .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:) + .and_return(required_checks(checks: [required_check("Lint", app_id: 123)])) + + expect do + validate_main_ci_status!( + 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:, 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:) + .and_return(required_checks(checks: [required_check("Lint", app_id: 123)])) + + expect do + validate_main_ci_status!( + 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:, 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:, 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:, + 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 + 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:, 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" => "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 "blocks when the legacy commit status has failed" 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", + "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 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:, 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(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", + "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 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:, 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" => "pending", + "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 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:, 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" => "unexpected", + "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 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:, 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:, 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: true) + .and_return(nil) + + expect do + validate_main_ci_status!( + 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 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")]) + 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(nil) + + expect do + validate_main_ci_status!( + monorepo_root:, + is_prerelease: true, + allow_override: false, + dry_run: false + ) + 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 + 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", + "url" => "https://api.github.com/repos/shakacode/react_on_rails/statuses/#{sha}" + } + ]) + + expect do + validate_main_ci_status!( + 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 for a wildcard required check" 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(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" => "failure", + "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 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:, 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:, 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", + "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 raise_error(SystemExit, %r{CI on origin/main is not healthy.*Travis}m) + end + + it "uses a legacy status to satisfy a wildcard required check" 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(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 "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 "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 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: []) + 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" => 2, + "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) + .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:, 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:, allow_override: false, dry_run: false) + .and_return([]) + + expect do + validate_main_ci_status!( + monorepo_root:, + is_prerelease: true, + allow_override: false, + dry_run: false + ) + end.to raise_error(SystemExit) { |error| + 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:, 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:, 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:, + is_prerelease: true, + allow_override: false, + dry_run: false + ) + end.to raise_error(SystemExit, /No required CI check runs found.*Required: 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: []) @@ -1438,7 +1985,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 +1995,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:)).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:)).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:)).to eq( + contexts: %w[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 @@ -1488,7 +2068,7 @@ 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 end