Skip to content

Commit d620c63

Browse files
justin808claude
andcommitted
fix(release): address PR #3407 review feedback round 2 (7 items)
Hook (.claude/hooks/main-ci-status.sh): - write_cache_atomic prints from $1 instead of re-reading via cat (claude[bot], 2 inline duplicates + summary) - jq slurp now dedupes reruns by max_by(id), mirroring the Ruby gate in release.rake — keeps session-time and release-time CI views in sync when a check has been re-run (cursor bugbot) - Defensive ":=0" defaults for failed_count/in_progress_count guard the partial-output edge case (claude[bot]) rakelib/release.rake: - validate_main_ci_status! now flags partial-missing required checks with a new :missing_required_checks violation kind. Branch protection would refuse this merge state, so a release shouldn't ship through it. The all-missing case keeps :no_required_checks for clarity (chatgpt-codex-connector P1) - Comment in run_release_preflight_checks! explaining why the main-CI status check is wired separately in with_release_checkout (claude[bot] summary) react_on_rails/spec/react_on_rails/release_rake_helpers_spec.rb: - passing_run/failing_run/in_progress_run helpers now include "id" defaults to match real GitHub API responses (claude[bot]) - New test: aborts and lists missing required check names when some required checks are absent on a prerelease (chatgpt-codex-connector P1) - New test: warns instead of aborting when git fetch fails with allow_override, matching the dry-run symmetric case (claude[bot] summary) 63 examples, 0 failures. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ccb3a87 commit d620c63

3 files changed

Lines changed: 93 additions & 16 deletions

File tree

.claude/hooks/main-ci-status.sh

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,15 +39,18 @@ fail_open() {
3939
exit 0
4040
}
4141

42-
# Helper: atomically replace the cache file with the contents of $1, then cat
43-
# the new file to stdout. A direct `tee` would leave a partial file readable
42+
# Helper: atomically replace the cache file with the contents of $1, then
43+
# print $1 to stdout. A direct `tee` would leave a partial file readable
4444
# by a concurrent session-start if the script were interrupted mid-write.
45+
# We print from the parameter rather than re-reading the file, so a
46+
# concurrent delete between the `mv` and the print cannot trigger a
47+
# misleading "cache write failed" fail_open.
4548
write_cache_atomic() {
4649
local tmp
4750
tmp=$(mktemp "${CACHE_FILE}.XXXXXX") || return 1
4851
printf '%s' "$1" >"${tmp}" || { rm -f "${tmp}"; return 1; }
4952
mv -f "${tmp}" "${CACHE_FILE}" || { rm -f "${tmp}"; return 1; }
50-
cat "${CACHE_FILE}"
53+
printf '%s' "$1"
5154
}
5255

5356
command -v gh >/dev/null 2>&1 || fail_open "gh CLI not installed"
@@ -78,8 +81,16 @@ checks_jsonl=$(gh api \
7881
--jq '.check_runs[]' \
7982
2>/dev/null) || fail_open "gh api check-runs failed"
8083

81-
checks_json=$(echo "${checks_jsonl}" | jq -s '[.[] | {name, status, conclusion, html_url}]' 2>/dev/null) \
82-
|| fail_open "jq slurp failed"
84+
# Collapse multiple runs per check name to the most recent attempt (highest
85+
# check_run id). Without this, an old failed run that was later re-run and
86+
# passed would still be picked up by the `failed` filter below and show as
87+
# red here — while `validate_main_ci_status!` in `release.rake` (which does
88+
# the same dedup) would correctly report green. Keep the two in sync.
89+
checks_json=$(echo "${checks_jsonl}" | jq -s '
90+
[.[] | {id, name, status, conclusion, html_url}]
91+
| group_by(.name)
92+
| map(max_by(.id))
93+
' 2>/dev/null) || fail_open "jq slurp failed"
8394

8495
# Aggregate counts with jq. `success`, `skipped`, `neutral` are all "passing".
8596
# Anything completed with another conclusion is a failure. Anything not yet
@@ -105,6 +116,10 @@ total=$(echo "${summary}" | grep "^TOTAL=" | cut -d= -f2)
105116
passed=$(echo "${summary}" | grep "^PASSED=" | cut -d= -f2)
106117
failed_count=$(echo "${summary}" | grep "^FAILED_COUNT=" | cut -d= -f2)
107118
in_progress_count=$(echo "${summary}" | grep "^IN_PROGRESS_COUNT=" | cut -d= -f2)
119+
# Default to 0 when the parse step produced no line (partial-output edge case).
120+
# The `total=0` branch below already covers the all-empty case, but a defensive
121+
# default here keeps `[ "${failed_count}" -gt 0 ]` from silently failing.
122+
: "${failed_count:=0}" "${in_progress_count:=0}"
108123

109124
# Build the output as a single string, then atomically swap it into the cache
110125
# so a concurrent reader never sees a half-written file.

rakelib/release.rake

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,10 @@ def verify_gh_auth(monorepo_root:)
148148
end
149149

150150
def run_release_preflight_checks!(monorepo_root:, dry_run:)
151+
# The main-CI status check (`validate_main_ci_status!`) is intentionally
152+
# NOT in this function — it runs inside `with_release_checkout` (after
153+
# `git pull --rebase` but before tagging) so it still fires under
154+
# `dry_run: true` and surfaces the warning operators need to see.
151155
return if dry_run
152156

153157
puts "\n#{'=' * 80}"
@@ -385,6 +389,9 @@ def format_main_ci_status_violation(kind:, short_sha:, runs:) # rubocop:disable
385389
"CI may not have started yet, or the GitHub Checks API is unavailable."
386390
when :no_required_checks
387391
"❌ No required CI check runs found on origin/main (commit #{short_sha})."
392+
when :missing_required_checks
393+
"❌ Some required CI checks are missing on origin/main (commit #{short_sha}). " \
394+
"Branch protection would refuse this merge."
388395
else
389396
"❌ CI on origin/main is not healthy (commit #{short_sha})."
390397
end
@@ -457,14 +464,32 @@ def validate_main_ci_status!(monorepo_root:, is_prerelease:, allow_override:, dr
457464
required_names = is_prerelease ? required_check_names_for_main(monorepo_root: monorepo_root) : nil
458465
evaluated = required_names.nil? ? check_runs : check_runs.select { |run| required_names.include?(run["name"]) }
459466

460-
if evaluated.empty? && !required_names.nil?
461-
handle_main_ci_status_violation!(
462-
message: format_main_ci_status_violation(kind: :no_required_checks, short_sha: short_sha, runs: nil) +
463-
"\nRequired: #{required_names.join(', ')}",
464-
allow_override: allow_override,
465-
dry_run: dry_run
466-
)
467-
return
467+
# When branch protection lists required checks, treat any missing required
468+
# check as blocking. Branch protection would refuse the merge in this state,
469+
# so a release that ignored the gap would ship against a commit GitHub
470+
# itself considers unverified. `:no_required_checks` covers the all-missing
471+
# case (typically: CI hasn't started yet); `:missing_required_checks` covers
472+
# the partial case (some required workflows ran, others never registered —
473+
# usually a renamed/deleted workflow that branch protection still requires).
474+
unless required_names.nil?
475+
missing_names = required_names - evaluated.map { |run| run["name"] }
476+
if missing_names.length == required_names.length
477+
handle_main_ci_status_violation!(
478+
message: format_main_ci_status_violation(kind: :no_required_checks, short_sha: short_sha, runs: nil) +
479+
"\nRequired: #{required_names.join(', ')}",
480+
allow_override: allow_override,
481+
dry_run: dry_run
482+
)
483+
return
484+
elsif missing_names.any?
485+
handle_main_ci_status_violation!(
486+
message: format_main_ci_status_violation(kind: :missing_required_checks, short_sha: short_sha, runs: nil) +
487+
"\nRequired: #{required_names.join(', ')}\nMissing: #{missing_names.join(', ')}",
488+
allow_override: allow_override,
489+
dry_run: dry_run
490+
)
491+
return
492+
end
468493
end
469494

470495
# Report failures before in-progress runs. If both are present, the operator

react_on_rails/spec/react_on_rails/release_rake_helpers_spec.rb

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -499,26 +499,29 @@
499499
let(:sha) { "abc1234def5678abcdef" }
500500
let(:short_sha) { "abc1234d" }
501501

502-
def passing_run(name)
502+
def passing_run(name, id: rand(1_000_000))
503503
{
504+
"id" => id,
504505
"name" => name,
505506
"status" => "completed",
506507
"conclusion" => "success",
507508
"html_url" => "https://github.com/shakacode/react_on_rails/runs/#{name.gsub(/\W/, '_')}"
508509
}
509510
end
510511

511-
def failing_run(name, conclusion: "failure")
512+
def failing_run(name, conclusion: "failure", id: rand(1_000_000))
512513
{
514+
"id" => id,
513515
"name" => name,
514516
"status" => "completed",
515517
"conclusion" => conclusion,
516518
"html_url" => "https://github.com/shakacode/react_on_rails/runs/#{name.gsub(/\W/, '_')}"
517519
}
518520
end
519521

520-
def in_progress_run(name)
522+
def in_progress_run(name, id: rand(1_000_000))
521523
{
524+
"id" => id,
522525
"name" => name,
523526
"status" => "in_progress",
524527
"conclusion" => nil,
@@ -756,6 +759,28 @@ def in_progress_run(name)
756759
end.to raise_error(SystemExit, /No required CI check runs found.*DoesNotExist/m)
757760
end
758761
end
762+
763+
context "when some required checks are present but others are missing on a prerelease" do
764+
it "aborts and lists the missing required check names" do
765+
allow(self).to receive(:fetch_main_ci_checks)
766+
.with(monorepo_root: monorepo_root, allow_override: false, dry_run: false)
767+
.and_return(sha: sha, check_runs: [passing_run("Lint"), passing_run("Test")])
768+
allow(self).to receive(:required_check_names_for_main)
769+
.with(monorepo_root: monorepo_root).and_return(%w[Lint Test Build])
770+
771+
expect do
772+
validate_main_ci_status!(
773+
monorepo_root: monorepo_root,
774+
is_prerelease: true,
775+
allow_override: false,
776+
dry_run: false
777+
)
778+
end.to raise_error(
779+
SystemExit,
780+
/Some required CI checks are missing.*Missing:\s*Build/m
781+
)
782+
end
783+
end
759784
end
760785

761786
describe "#fetch_main_ci_checks" do
@@ -773,6 +798,18 @@ def in_progress_run(name)
773798
end.to raise_error(SystemExit, %r{Unable to fetch origin/main})
774799
end
775800

801+
it "warns instead of aborting when `git fetch` fails with allow_override" do
802+
allow(Open3).to receive(:capture2e)
803+
.with("git", "-C", monorepo_root, "fetch", "origin", "main", "--quiet")
804+
.and_return(["fetch failed: network down", failure_status])
805+
806+
result = nil
807+
expect do
808+
result = fetch_main_ci_checks(monorepo_root: monorepo_root, allow_override: true)
809+
end.to output(%r{CI STATUS OVERRIDE enabled.*Unable to fetch origin/main}m).to_stdout
810+
expect(result).to be_nil
811+
end
812+
776813
it "warns instead of aborting when `git fetch` fails in dry-run mode" do
777814
allow(Open3).to receive(:capture2e)
778815
.with("git", "-C", monorepo_root, "fetch", "origin", "main", "--quiet")

0 commit comments

Comments
 (0)