Fix ci-required base ref for PR merge-ref checkouts - #4819
Conversation
WalkthroughThe required CI workflow now resolves event-specific diff bases, validates pull request merge-parent structure, logs the files used for classification, and runs a synthetic-repository test harness covering the updated behavior. ChangesCI diff-base resolution
Estimated code review effort: 3 (Moderate) | ~25 minutes Possibly related issues
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Greptile SummaryFixes required-CI change classification for pull-request merge checkouts.
Confidence Score: 3/5The stale merge-ref path should be fixed before merging because it can classify an older PR revision and skip required hosted CI for current changes. A mismatch between the merge commit’s second parent and the event’s current head is detected but only warned about, after which suite routing is still computed from the stale checkout. Files Needing Attention: .github/workflows/ci-required.yml Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[Select default base from event payload] --> B{Explicit workflow_dispatch base?}
B -->|Yes| C[Use workflow_dispatch input]
B -->|No| D{pull_request event?}
D -->|No| E[Use event payload base]
D -->|Yes| F{HEAD has exactly two parents?}
F -->|Yes| G[Use HEAD first parent]
G --> H{Second parent matches PR head?}
H -->|No| I[Emit stale merge-ref warning]
H -->|Yes| J[Run change detector]
I --> J
F -->|No| K[Warn and retain event payload base]
K --> J
C --> J
E --> J
J --> L[Print first 50 classified files]
Reviews (1): Last reviewed commit: "Fix ci-required base ref for PR merge-re..." | Re-trigger Greptile |
|
+ci-force-full Batch coordinator ( This PR changes the base ref that feeds Per The detector classifies this PR |
Force-Full Hosted CI RequestedTriggered 9 workflow(s) for View progress in the Actions tab. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f0b725f8d3
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Review: Fix ci-required base ref for PR merge-ref checkoutsOverviewSingle-file change to
The PR body is unusually thorough (root-cause repro on a full clone, a 4-branch test matrix, extensive test-suite validation, and an explicit decision log), and the fix is narrowly scoped — no changes to Code quality
Issue found (see inline comment)
Other observations (not blocking)
SecurityNo new injection surface: all event-derived values flow through Overall: solid, well-justified fix with strong test coverage described in the PR body. One real (if edge-case) robustness bug on the new diagnostic line worth fixing before merge. |
actions/checkout resolves a pull_request event to refs/pull/<n>/merge, so HEAD is the PR head merged into the CURRENT base tip. The changed-files detector was fed github.event.pull_request.base.sha instead, which only refreshes when the PR is opened or synchronized. On a branch that has not been pushed for a while the two drift apart, and diffing from the stale SHA folds every base-branch commit in between into the changed-file set. That is how PR #4739, whose entire diff was one markdown file under internal/, got classified as a generator change and was told to spend a hosted CI run it did not need. Take the base from the merge commit's first parent, which is the exact commit the merge was computed against. Guard it on HEAD having exactly two parents, so a single-parent HEAD (a PR conflicting with its base, where GitHub produces no merge ref) never takes HEAD^1, which there would be the PR's own previous commit. bundle-size.yml derives its size baseline from the same first parent for the same reason. Fetch depth is not the cause and is unchanged: the first parent is a parent of HEAD, so it can never fall outside the shallow window, and the deepen loop in script/lib/git-diff-base already handles genuinely unreachable bases. Also print the resolved base and the file list the classification was computed from, so a widened diff is obvious at a glance. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
react_on_rails/spec/react_on_rails/ruby_version_support_spec.rb:116 asserts
that ten workflows contain the base-ref expression
github.event.pull_request.base.sha || github.event.merge_group.base_sha ||
github.event.before || 'origin/main'
as a literal substring of the raw file, so merge queue runs are guaranteed to
diff from merge_group.base_sha. Moving the expression into a folded block
scalar broke that match.
The folded scalar was also worse on its own merits. Continuation lines indented
past the first line are not folded, so YAML preserved the newlines: the parsed
value was "${{\n github.event...\n}}" rather than a single-line expression.
That relies on GitHub evaluating a newline-containing expression on the
merge_group path, which cannot be verified locally and would misclassify every
merge queue entry if it did not hold.
Restoring the single line satisfies the guard without modifying it and removes
the unverifiable assumption. The merge_group path was never altered
behaviorally: base_ref is initialized from this expression, and the merge-ref
override is gated on GITHUB_EVENT_NAME = pull_request.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
f0b725f to
021e584
Compare
Review summaryWhat this PR does: Fixes Correctness: The core logic checks out. I traced the branches against
Main concern — test coverage: left as an inline comment. This PR adds meaningful branching logic (array parsing off Security: No concerns — all interpolated values are either GitHub-provided SHAs or output of local Performance: None — no extra network calls ( Scope: Appropriately narrow — only |
Fall back to the event base when the merge ref is stale. Mind the direction of the error. A stale event base can only ADD base-branch commits to the diff, which over-selects suites and wastes runner minutes. Classifying from a stale merge ref's first parent instead OMITS changes present in the current head, which can flip run_generators to false and let this required gate pass without the hosted run those changes needed. Narrowing is the unsafe direction, so once the second parent is proven not to be the current PR head, stop trusting that base and use the event base. The warning stays. This differs from the single-parent branch, which still warns and continues: there no better base is available, whereas here one is. Consume the changed-file list fully before truncating. Actions runs run: steps under `bash --noprofile --norc -eo pipefail`. Piping git diff into `head -50` lets head close the read end once satisfied; git then takes SIGPIPE and exits 141, pipefail promotes that to the pipeline status, and set -e fails the whole required gate. It would fire on large diffs, meaning the widened-diff case the output exists to expose. The list is now captured first and truncated with sed, which reads its input to the end. Add script/ci-required-diff-base-test.bash. CI-selection logic in this repo pairs with a companion test (ci-changes-detector, git-diff-base, ci-required-hosted-gate); this branching had none. The harness extracts the step body from the committed workflow with a YAML parser and runs it against a synthetic repository under the same shell flags Actions uses, covering the current merge ref, the stale merge ref fallback, a single-parent HEAD, workflow_dispatch input, merge_group, and a 3,000-file diff. Both fixes above were confirmed to fail this harness when reverted: 141 for the pipe, and the wrong base for the stale merge ref. Extracting the step into script/ is deferred to #4824. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b2d695aca0
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
ReviewWent through the diff-base logic in Correctness: The core fix is sound. Security: Moving the Testing: One nit posted inline: the new diagnostic No other correctness, security, or performance issues found. The documented residual gap (stale merge-ref fallback path not yet exercised on hosted CI) is already called out in the PR description and tracked via #4820, so I'm not re-flagging it here. |
Falling back to the event base was not enough. The detector diffs the chosen base against HEAD, and when the merge ref is stale HEAD's TREE does not contain the current head's changes at all. Every base is then diffed against a tree that is already missing them, so widening the base cannot recover a file that is not in HEAD. run_generators could still come back false and let this required gate pass without the hosted run those changes needed. The remaining options were to fetch and classify the reported head, which means a network fetch plus classifying a tree that no longer matches the rest of the job, or to refuse to classify. A required gate should refuse, so this now exits 1 with an actionable message: re-run the job so GitHub recomputes refs/pull/<n>/merge, or push again. This stays different from the single-parent branch, which still warns and continues. There HEAD is the real PR head, so a wide base still yields a complete diff. Here it does not. The test harness now asserts the non-zero exit, the error text, and that no base reaches the detector once the tree is known to be wrong. It also asserts directly that the newer head's file is absent from the widest possible diff against the stale HEAD, which is the reason no base could have worked. Reverting the guard makes the harness fail with "expected exit 1, got 0". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review: Fix ci-required base ref for PR merge-ref checkoutsOverviewThis PR fixes a real bug: The investigation and write-up are unusually rigorous (reproduced on a real runner, verified the fix fails when reverted, checked shellcheck/actionlint/YAML parsing). The one substantive concern below is that the shipped code has drifted from what the write-up says it does. Main finding: description/code mismatch on the stale-merge-ref path (also left as an inline comment)
The PR's own "Codex Decision Log" item 6 describes different behavior: falling back to
Other observations
|
bash `set -e` does not fire on a failed command substitution in an assignment, so a failing git diff would have left changed_files empty and printed "none", which reads as "nothing changed". That is the same under-selection direction the rest of this step guards against, arrived at by a different route. The assignment is new (it came in with the SIGPIPE fix), so the guard belongs with it. script/ci-changes-detector guards its own diff the same way; this matches that pattern rather than inventing another. The guard is not covered by an executable case in the harness. Exercising it needs a git that fails only on `diff --name-only`, and injecting one via PATH does not reliably survive into the child shell across developer environments, so a case for it would pass or fail for reasons unrelated to the guard. Rather than commit a test that is green for the wrong reason, the gap is recorded in the harness and tracked with the extraction work in #4824, where this logic moves into script/ and becomes directly callable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ReviewFocused on Code quality / correctnessThe bash logic itself is solid:
Main finding: PR description doesn't match the shipped behavior for the stale-merge-ref case (left as an inline comment on the relevant block)The code hard-fails ( This reads like leftover text from an earlier iteration (item 6 mentions being "reversed in review" once already) that wasn't updated after the final change to fail-closed. It's worth reconciling before merge: fail-closed is a materially different — and more disruptive — behavior for a required gate (it can hard-block a PR on a transient GitHub race rather than just widening the diff and warning), and reviewers should be evaluating the behavior that's actually shipping, not the one described. Minor
Nothing else here raises correctness, security, or performance concerns — the shell-injection avoidance, pipefail/SIGPIPE fix, and fail-closed guard for under-selection are all good calls. The main ask is to fix the description/behavior mismatch above. |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (2)
.github/workflows/ci-required.yml (2)
206-234: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick winSilent skip when
git merge-basefails.If
git merge-base "$base_ref" HEADfails, the whole diagnostic block is skipped with no::warning::/::error::, so the log just ends after the "Diff base: ..." line with no explanation for the missing file listing. Given how carefully every other failure path in this step is surfaced, consider emitting a warning here too for consistency.♻️ Suggested diagnostic on merge-base failure
- if diff_base="$(git merge-base "$base_ref" HEAD 2>/dev/null)"; then + if diff_base="$(git merge-base "$base_ref" HEAD 2>/dev/null)"; then ... + else + echo "::warning::Could not compute merge-base of ${base_ref} and HEAD; skipping changed-file listing." fi🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In @.github/workflows/ci-required.yml around lines 206 - 234, Add an explicit failure branch for the git merge-base command surrounding changed_files in the diagnostic block, emitting a clear ::warning:: or ::error:: that the changed-file listing was skipped because merge-base failed. Preserve the existing successful-path diff handling and git diff failure guard.
129-235: 🧹 Nitpick | 🔵 TrivialReminder: this is a semantic workflow change.
This reworks the diff-base logic that drives suite selection for every PR. As per coding guidelines, for semantic workflow changes you should "inspect secrets, permissions, triggers, and third-party actions; run
actionlint,yamllint .github/, andscript/ci-changes-detector origin/main, and post aWorkflow Change Audit:summary before merge."🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In @.github/workflows/ci-required.yml around lines 129 - 235, Before merging the semantic diff-base changes in the PR workflow, inspect its secrets, permissions, triggers, and third-party actions; run actionlint, yamllint .github/, and script/ci-changes-detector origin/main; then post a “Workflow Change Audit:” summary containing the findings and validation results.Source: Coding guidelines
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In @.github/workflows/ci-required.yml:
- Around line 216-225: Update the comment immediately above the changed_files
assignment in the workflow to accurately describe Bash set -e behavior for this
plain assignment. Remove the claim that a failed git diff would be silently
suppressed, and retain the rationale that the explicit guard provides a clear
::error:: message before exiting; preserve the guard and its behavior unchanged.
---
Nitpick comments:
In @.github/workflows/ci-required.yml:
- Around line 206-234: Add an explicit failure branch for the git merge-base
command surrounding changed_files in the diagnostic block, emitting a clear
::warning:: or ::error:: that the changed-file listing was skipped because
merge-base failed. Preserve the existing successful-path diff handling and git
diff failure guard.
- Around line 129-235: Before merging the semantic diff-base changes in the PR
workflow, inspect its secrets, permissions, triggers, and third-party actions;
run actionlint, yamllint .github/, and script/ci-changes-detector origin/main;
then post a “Workflow Change Audit:” summary containing the findings and
validation results.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: c2511d33-5ef8-4bcd-b59a-b8f4bb952444
📒 Files selected for processing (2)
.github/workflows/ci-required.ymlscript/ci-required-diff-base-test.bash
The comment claimed `set -e` does not fire on a failed command substitution in
an assignment. That is wrong for a plain assignment. The masking applies to
declaration assignments:
bash -c 'set -e; x=$(false); echo REACHED'
-> exit 1, REACHED never printed
bash -c 'set -e; f(){ local x=$(false); echo REACHED; }; f'
-> prints REACHED, exit 0
So the step would abort on a failed git diff with or without the guard, and the
empty-list-reads-as-nothing-changed scenario the comment described could not
happen.
The guard stays, for the reason it actually earns: it turns a bare non-zero exit
plus git's stderr into a message naming which refs the diff was between. Only
the comment changes; behavior is untouched, and every line in this diff is a
comment.
script/ci-changes-detector carries the same inaccurate claim, which is where
this one came from. Deliberately not corrected here, since it would pull an
unrelated file into this PR; noted on #4824 so the extraction fixes both.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review summarySolid, well-reasoned fix for the actual root cause identified in #4756 (stale Main finding — the fix is scoped to one workflow, but the bug is duplicated in ~12 others. Security note (positive): the old code interpolated Design tradeoff worth surfacing: the new fail-closed path (exit 1 when the merge commit's second parent doesn't match No correctness issues found in the parent-parsing logic itself ( |
…ills * origin/main: Fix ci-required base ref for PR merge-ref checkouts (#4819) Honor response charset and reject non-2xx HTTP-served SSR bundles (#4817) [Pro] Redact RSC render-error metadata on the fetched (client-navigation) payload path (#4821) Forward-port the 17.0.1 changelog section to main (#4814)
…t-policy * origin/main: (33 commits) Fix ci-required base ref for PR merge-ref checkouts (#4819) Honor response charset and reject non-2xx HTTP-served SSR bundles (#4817) [Pro] Redact RSC render-error metadata on the fetched (client-navigation) payload path (#4821) Forward-port the 17.0.1 changelog section to main (#4814) Handle selector metacharacters in renderComponent DOM IDs (#4808) [Pro] Prevent caching RSC renders with errors (#4804) Agents: trust Copilot review identities (#4807) Agents: bind fleet closeout to generated pack (#4805) Docs: ADR 0002 — Skills-in-package over MCP for agent-native DX (#4735) Scope GitHub release commands to the origin repository (#4803) Forward-port OSS npm license metadata fix (#4794) Add golden-output gate for the serverWebpackConfig generator template (#4790) Cover the rspack CSS SSR generator fixes and de-duplicate the loader path (#4788) Configure agent workflow repo policy (#4785) Forward-port gh include mixed framing from #4684 (#4784) Release: enforce one-change forward-port closeout (#4783) Forward-port multi-URL rolling-deploy seeding to main (#4782) Docs: clarify React 18 streaming without RSC (#4780) Docs: forward-port v17 upgrade and generator gate guidance (#4781) Record the final React on Rails 17.0.0 changelog (#4742) ... # Conflicts: # AGENTS.md # internal/contributor-info/release-train-runbook.md
…out-vm-pool * origin/main: Docs: move agent coordination to the HTTP backend (#4764) Detect unnoticed changes across generated webpack/Rspack configs (#4839) Fix durable ShakaPerf release evidence reuse (#4833) Docs: add missing content — release notes, upgrade guide, config, API references (#4843) (#4844) Fix incorrect docs: helper names, defaults, requirements, runtime refs (#4836) Scroll-priority streaming: candidate architecture evaluation (#4835) (#4841) Fix generated server config lint cleanup (#4840) Document serialized release backport policy (#4592) Package version-matched agent skills and docs (#4809) Fix ci-required base ref for PR merge-ref checkouts (#4819) Honor response charset and reject non-2xx HTTP-served SSR bundles (#4817) [Pro] Redact RSC render-error metadata on the fetched (client-navigation) payload path (#4821) Forward-port the 17.0.1 changelog section to main (#4814) # Conflicts: # CHANGELOG.md
…ential-broker * origin/main: (75 commits) Silence routine startup diagnostics for Rails commands (#4849) Docs: move agent coordination to the HTTP backend (#4764) Detect unnoticed changes across generated webpack/Rspack configs (#4839) Fix durable ShakaPerf release evidence reuse (#4833) Docs: add missing content — release notes, upgrade guide, config, API references (#4843) (#4844) Fix incorrect docs: helper names, defaults, requirements, runtime refs (#4836) Scroll-priority streaming: candidate architecture evaluation (#4835) (#4841) Fix generated server config lint cleanup (#4840) Document serialized release backport policy (#4592) Package version-matched agent skills and docs (#4809) Fix ci-required base ref for PR merge-ref checkouts (#4819) Honor response charset and reject non-2xx HTTP-served SSR bundles (#4817) [Pro] Redact RSC render-error metadata on the fetched (client-navigation) payload path (#4821) Forward-port the 17.0.1 changelog section to main (#4814) Handle selector metacharacters in renderComponent DOM IDs (#4808) [Pro] Prevent caching RSC renders with errors (#4804) Agents: trust Copilot review identities (#4807) Agents: bind fleet closeout to generated pack (#4805) Docs: ADR 0002 — Skills-in-package over MCP for agent-native DX (#4735) Scope GitHub release commands to the origin repository (#4803) ...
Fixes #4756
Correcting the record first
#4756 is titled and framed around a "shallow merge-base fallback":
fetch-depth: 50too small, merge base unresolvable, fallback widens the diff. Fetch depth was never the cause. I reproduced the bug on a full, unshallow clone, which by itself rules depth out.Three pieces of evidence from the run the issue cites:
refs/remotes/pull/4739/merge=ed86829364509ac3e4e3782e52f44300d11e0f17. SoHEADwas GitHub's PR merge commit, not the PR head. Its parents are613c6c2a(base side) andbf4711c1(PR head side).github.event.pull_request.base.sha=18bdc4f8, dated2026-07-18T08:06:09Z. The run was at23:33Z, so the base SHA was about 15 hours behind the merge commit's own base parent.merge-base(18bdc4f8, HEAD) = 18bdc4f8is the correct answer for those inputs. The inputs were wrong, not the merge-base resolution.Deepening shallow historylines. The deepen loop never ran.script/lib/git-diff-basealready deepens 50 → 100 → ... →--unshallowand hard-fails with an explicit diagnostic when a base is genuinely unreachable, so the "deepen or fail closed" half of the request is already implemented upstream.The clincher: the post-rebase passing run used base
613c6c2a, which is exactlyHEAD^1of the failing run's merge commit. Had the failing run usedHEAD^1, it would have classified documentation-only with no rebase and no hosted CI.Root cause
For
pull_requesteventsactions/checkoutresolvesrefs/pull/<n>/merge, soHEADis the PR head merged into the current base tip.github.event.pull_request.base.shais a different commit: it only refreshes when the PR is opened or synchronized. On a branch that has not been pushed for a while the two drift apart, and diffing from the stale SHA folds every base-branch commit in between into the changed-file set. A one-file docs-only PR then looks like it touched generators.The fix
Take the base from the merge commit's first parent, which is the exact commit the merge was computed against, guarded on
HEADhaving exactly two parents..github/workflows/bundle-size.yml:82-99already derives its size baseline from the same first parent, with the same documented reasoning. This follows that precedent rather than inventing a mechanism.fetch-depthstays at 50. The first parent is a parent ofHEAD, so it can never fall outside the shallow window, and widening the fetch would slow every run for no benefit.Reproduction
A docs-only branch, a simulated
refs/pull/N/merge(first parent = main tip, second parent = PR head), and a stalebase.shastanding in for the 15-hour lag. Run on a full clone (git rev-parse --is-shallow-repository=false).BEFORE: base = stale
pull_request.base.shaThat
run_generators=trueis what failsrequired-pr-gateand tells the author to spend a hosted CI run.AFTER: same stale
base.shain the environment, fix appliedThis is not a hand-written recreation. The step's
run:body was extracted straight out of the committed.github/workflows/ci-required.ymlwith a YAML parser and executed, so what is tested is exactly what ships.Branch matrix
This is now a committed test rather than a one-off:
script/ci-required-diff-base-test.bash, wired into this workflow's ownRun CI gate testsstep. It extracts the step body from the committed YAML with a YAML parser and runs it against a synthetic repository underbash --noprofile --norc -eo pipefail, the exact shell Actions uses.Two cases carry most of the weight. A single-parent
HEADmust never takeHEAD^1, because there it is the PR's own previous commit rather than the base branch. And a stale merge ref must not use its first parent at all, because that base describes an older head and would narrow the diff (decision log item 6).The harness is a real guard, not a rubber stamp. Reverting each fix makes it fail:
141 is the SIGPIPE exit the reviewers predicted.
The stale-merge-ref case also asserts directly that the newer head's file is absent from the widest possible diff against the stale
HEAD. That assertion is the evidence for decision log item 6: it is why no choice of base could have fixed this, and why the step refuses instead.The fix is not total, by design
There is one fallback path that lands on
EVENT_BASE_REF— the stalegithub.event.pull_request.base.sha. On that path the original over-selection can still occur, so a stale docs-only PR in that state still gets a wide classification.That is deliberate. A stale event base can only widen the diff, which wastes runner minutes; it cannot let an unverified change through. The second abnormal state does not fall back at all — it fails the gate, because widening the base cannot repair a
HEADthat is missing the current head's changes. Neither state is silent: the fallback path emits a::warning::annotation plus the printed changed-file list, and the fail-closed path emits::error::and exits non-zero.HEAD's tree is missing the current head's changes, so no base can produce a trustworthy classification. Decision log item 6.Scope
Two files:
.github/workflows/ci-required.ymland the newscript/ci-required-diff-base-test.bash.Nine other hosted workflows feed the detector the same stale
base.shaand have the same latent misrouting — tracked in #4818. Moving this step's logic out of YAML intoscript/is tracked in #4824. Neither is widened into this PR.Post-merge exercise
Required by
.github/read-me.mdfor semantic.github/workflows/**changes: #4820.The stale-base path cannot be exercised from this branch, because a freshly pushed PR always has a current
base.sha, so this PR's own run only covers the case where old and new logic agree. #4820 specifies the throwaway stale docs-only PR, the expected step-log evidence, and the cleanup.Changelog
No changelog entry applies.
CHANGELOG.mdis user-visible changes only per the repo seam in.agents/agent-workflow.yml. This is internal CI plumbing with no effect on the published gem or npm package.Labels
Labels: ci-toolingbecause the change is confined to CI workflow suite-routing logic and ships no runtime code.Codex Decision Log
Warn instead of hard-fail when
HEADis not a two-parent merge commit.bundle-size.ymlhard-fails with::error::in the equivalent situation; this deliberately diverges. GitHub does not produce a merge ref for a PR that conflicts with its base, so a single-parentHEADon apull_requestevent is a real, reachable state rather than a can't-happen.ci-requiredis the required gate on every PR, so hard-failing there would block the gate on every conflicted PR, which is strictly worse than the misclassification it guards against, and a conflicted PR cannot merge anyway.bundle-size.ymlcan afford::error::precisely because it is advisory and this gate is not. Please do not "fix" this back to a hard fail without that asymmetry in mind.fetch-depthleft at 50. The issue suggested deepening. The first parent is a parent ofHEADand therefore always in the clone, so deepening buys nothing and would slow every PR.script/ci-changes-detectoruntouched. The issue suggested changing the detector. The detector's merge-base resolution is correct; only its input was wrong. Fixing the input keeps the blast radius to one file.Stale merge ref warns but still uses the first parent.Reversed in review — this was wrong. See item 6.internal/planning/CONTEXT.mdnot modified, although it was in scope for this lane. That file is a product-strategy glossary (Language / Relationships / Example dialogue / Flagged ambiguities) and documents no CI decisions, so adding one would have meant inventing a new section style in a tightly scoped document. CI routing behavior is documented in.github/read-me.mdand decisions ininternal/adr/, neither of which was in scope. The decision record lives in this PR body and in the inline comments on the changed step.A stale merge ref fails closed. This is the subtle one, and it took two corrections to get right — read the direction of the error, and then read which half of the diff it applies to.
First correction (from Greptile): my original code only warned and kept classifying from the stale merge ref's first parent. Compare which way each base can be wrong:
run_generatorscan come back false and this required gate can pass without the hosted run those changes needed. Unsafe direction.So I switched to falling back to
EVENT_BASE_REF. That was still wrong, and this is the part a future reader will most likely get wrong too, because I did and so did the reviewer who proposed it.Second correction (from Codex): widening the base does not fix under-selection, because the base is only half of the diff. The detector diffs the chosen base against
HEAD. When the merge ref is stale,HEAD's tree does not contain the current head's changes at all. Changing the base changes what we diff from; it does nothing about what we diff to. A file added in the newer head stays invisible no matter how wide the base is. There is no base that repairs a tree which is already missing the changes.That leaves two honest options: fetch and classify the reported head (a network fetch, and then the classified tree no longer matches what the rest of the job checked out), or refuse to classify. A required gate should refuse. The step now exits 1 with a message saying to re-run so GitHub recomputes
refs/pull/<n>/merge, or push again — the condition is a race against that recomputation, so a re-run clears it.A third option, emitting a maximal conservative classification instead of failing, was rejected on evidence rather than taste. It does not avoid a red gate, and it makes the failure misleading:
That is the exact wrong-remedy message ci-required: shallow merge-base fallback misclassifies stale docs-only PRs as generator changes #4756 exists to eliminate, and it would route someone to spend a full hosted run over a transient merge-ref race. Same availability cost, worse diagnosis.
Note the asymmetry with item 1, which still stands: on a single-parent HEAD we warn and continue, because
HEADis the real PR head there and a wide base still yields a complete diff. Positive proof that the checkout is wrong is what separates the two cases.The changed-file list is captured before truncation, and truncated with
sedrather thanhead. Two reviewers independently found thatgit diff --name-only | head -50breaks under the Actions shell (bash --noprofile --norc -eo pipefail):headcloses the read end,gittakes SIGPIPE and exits 141,pipefailpromotes it,set -efails the gate. It would fire on large diffs — the widened-diff case this output exists to expose — so a cosmetic diagnostic could have taken down the required gate exactly when it had something to show.sed -n '1,50p'reads its input to the end, so nothing closes early.mapfilewith an array slice reads better but needs bash 4+, and this repo's*-test.bashharnesses run on macOS where/bin/bashis 3.2.The changed-file assignment is guarded — for the diagnostic, not for control flow. I first justified this guard by claiming
set -edoes not fire on a failed command substitution in an assignment. That was wrong, and CodeRabbit caught it. The masking applies to declaration assignments, not plain ones:So the step aborts on a failed
git diffwith or without the guard, and the "empty list reads as nothing changed" scenario I described could not occur. The guard stays because it names which refs the diff was between instead of dying with a bare non-zero exit and git's stderr — a real improvement, just not the one I claimed. Corrected in e0c835a; every line of that commit's diff is a comment.script/ci-changes-detectorcarries the same inaccurate claim, which is where I copied it from. Deliberately not corrected here (it would pull an unrelated file into a two-file PR); recorded on CI: extract ci-required diff-base selection from workflow YAML into script/ #4824 so the extraction fixes both, since the bad reasoning has already propagated once.Two plausible shell claims in this PR were wrong, and CI could not have caught either. Worth stating plainly for whoever maintains this next. The first: that widening the diff base fixes under-selection — false, because the base is only half the diff and
HEAD's tree was the broken half (item 6). The second: theset -eclaim above. Both survived a fully green hosted matrix, because neither is observable from a passing test — the code did the right thing for a misstated reason, or the failure mode was unreachable. Both were caught by review. The committed harness in item 10 raises the floor for behavior, but reasoning stated in comments still needs a reader.Test harness committed; extracting the step into
script/deferred. Review correctly noted that every other piece of CI-selection logic here pairs with a companion test, and this branching had none.script/ci-required-diff-base-test.bashnow covers it, and is wired into this workflow's ownRun CI gate testsstep. The structural half — moving the logic out of YAML intoscript/— is deliberately not done here: refactoring a required gate is a larger change than the bug fix that surfaced it. Filed as CI: extract ci-required diff-base selection from workflow YAML into script/ #4824, proposed alongside CI: nine hosted workflows feed the changed-files detector a stale pull_request.base.sha #4818 since that issue would otherwise paste the same shape into nine more workflows.Confidence note
bash script/ci-required-diff-base-test.bash:6 diff-base tests passed. Also confirmed to fail when each fix is reverted (output above), so it is a real guard.RUN_GENERATORS=true SHOULD_RUN_HOSTED_CI=false bash script/ci-required-hosted-gateexits 1 with the generator message. This is the evidence behind rejecting the maximal-classification alternative in decision log item 6.bash -n script/ci-required-diff-base-test.bash: syntax OK.shellcheck -S warning: clean.bundle exec rspec spec/react_on_rails/ruby_version_support_spec.rb:7 examples, 0 failures, including the merge-queue base-SHA guard this PR previously broke.SHELLCHECK_OPTS="-S warning" actionlintrepo-wide, exit 0 (also run against the committed file alone, exit 0). This is the same invocation.github/workflows/actionlint.ymluses.ruby -e "... YAML.safe_load_file ..."over all workflows:all workflow YAML parsed OK. This is the gate's own "Validate workflow YAML" step.node .github/workflows/hosted-ci-safety.test.cjs:hosted CI workflow safety tests passed. This test asserts againstci-required.ymldirectly.ruby bin/lint-mirrored-blocks: 2MIRROR OF:markers across 1 pair, 10MIRROR VALUES OF:markers across 5 pairs, exit 0.bash script/ci-required-hosted-gate-test.bash: 7 tests passed.ruby script/ci_required_merge_group_gate_test.rb: 11 tests passed.ruby script/pr_merge_ledger_test.rb: 254 runs, 0 failures.ruby .agents/bin/agent_workflow_drift_manifest_test_test.rb: 11 runs, 0 failures.ruby .agents/skills/pr-batch/bin/pr-ci-readiness-test.rb: 66 runs, 0 failures.node --test .github/workflows/ci-commands.test.cjs: 49 pass, 0 fail.Reproduction and all four selection branches, run against the step body extracted from the committed YAML (output above).
Observed on a real GitHub-hosted runner.
pull_requestworkflows run from the PR branch, so this PR's ownci-requiredrun already executed the new step. From run 30455776751:The two-parent detection, the first-parent selection, and the new changed-file listing all work on
ubuntu-latestwith the runner's real shallow checkout.Hosted
actionlintandzizmorchecks both pass on this PR.bundle exec rubocopwas not run. Nothing in the diff is Ruby.base.shawas current and old and new logic agree on it. The hosted run above proves the new code path executes correctly, not that it repairs a stale branch. Follow-up: Exercise GitHub Actions changes from PR #4819 #4820 specifies the throwaway stale docs-only PR needed to close that gap post-merge.github.event.pull_request.head.shais ever empty on a realpull_requestevent is assumed, not observed; the code treats an empty value as "skip the staleness check" rather than failing.|| { ::error::; exit 1; }guard on the changed-file assignment (item 8) has no executable test. Lower stakes than when I first wrote this, sinceset -eaborts there regardless and the guard only improves the message. Exercising it needs agitthat fails only ondiff --name-only, and injecting one viaPATHdid not reliably reach the child shell in my environment, so any case I wrote would have passed or failed for reasons unrelated to the guard. I removed it rather than ship a test that is green for the wrong reason; the gap is recorded in the harness and tracked with CI: extract ci-required diff-base selection from workflow YAML into script/ #4824, where the logic moves intoscript/and the guard becomes directly callable. The equivalent guard inscript/ci-changes-detectoris likewise enforced by comment rather than by test.HEADon this repo. That state is asserted from GitHub's documented merge-ref behavior.Detector classification for this PR
script/ci-changes-detector origin/mainon this branch reportsCI infrastructure (full test suite, no benchmarks)and setsrun_generators=true, soci-requiredwill fail until hosted CI is requested. That is expected for any.github/workflows/**change and is not a symptom of this fix.Guard-spec failure: diagnosis (coordinator)
The full hosted matrix caught a real failure on the previous head — recorded here because the
authoring agent's session terminated on an API error before it could write this up. The diagnosis
below is the coordinator's own, verified against the committed YAML.
Failure:
react_on_rails/spec/react_on_rails/ruby_version_support_spec.rb:116,"uses the merge queue base SHA for merge_group change detection", on bothrspec-package-tests (3.3, minimum, unit)and(4.0, latest, unit). Job:https://github.com/shakacode/react_on_rails/actions/runs/30455997425/job/90589676685
Cause: formatting, not behavior. That spec asserts the raw file text of ten workflows contains
github.event.pull_request.base.sha || github.event.merge_group.base_sha || github.event.before || 'origin/main'as a contiguous string. The first revision of this PR moved that expression into an
EVENT_BASE_REFenv var and wrapped it across lines in a YAML folded scalar, so the substring match failed even
though the expression was semantically identical.
The folded scalar was independently wrong: in YAML, continuation lines indented more than the
first content line are preserved literally rather than folded, so the wrapped form would have carried
embedded newlines into the value rather than collapsing to one line.
Fix (
021e584a8): the workflow, not the spec. The expression is restored to a single line,character-for-character what the guard expects, with an inline comment recording why it must stay
that way. The guard was deliberately not weakened or rewritten to match new behavior.
Merge-queue behavior is preserved, and that was verified before accepting the fix:
base_ref="${EVENT_BASE_REF}"remains the default, so amerge_groupevent still resolves itsbase through
github.event.merge_group.base_shaexactly as before.elif [ "${GITHUB_EVENT_NAME}" = "pull_request" ], soit cannot affect the merge queue path at all.
[ "${#parents[@]}" -eq 3 ]againstgit rev-list --parents -n 1 HEAD)means a single-parent HEAD never takes
HEAD^1.This is why hosted CI was requested as
+ci-force-fullrather than+ci-run-hosted. This PR changesthe base ref that feeds suite selection, so optimized selection would have been grading its own
homework — and the guard spec that caught this is one an optimized run would most likely have
skipped.
UNKNOWN carried forward: the stale-base path itself is still not exercised on hosted CI. This
branch's
base.shais current, so old and new logic agree on it; the hosted run proves the new stepexecutes correctly, not that it repairs a stale branch. #4820 exists to close that gap post-merge.
Batch:
ror-a-17-1-wave-a-20260729, lanelane-4756-ci-mergebase.Summary by CodeRabbit
Bug Fixes
Tests
Merge qualifications (coordinator)
Release gate:
beta(targetmain): confidence note + green required checks. Both satisfied athead
e0c835ac3edaad83af78041b3b82be8470a739ad— full rollupSUCCESS, zero failing, zero pending.Release mode:
development— the only open tracker (#4806) targets 17.0.1, already shipped.Merge ledger:
--strict --changelog-classification not_user_visible->complete_allowed: true,zero violations, zero unknown fields, CI
READY, zero unresolved current-head review threads.No changelog entry: internal CI plumbing, not user-visible, per the repo seam.
Hosted CI:
+ci-force-full, deliberately. This PR changes the base ref that feeds suiteselection, so optimized selection would have been grading its own homework. That call was vindicated:
the full matrix caught a guard-spec failure (
ruby_version_support_spec.rb:116) that optimizedselection would most likely have skipped.
Review cohort: CodeRabbit
APPROVEDat this head (superseding an earlierCHANGES_REQUESTED,resolved by a comment-accuracy fix). Nine threads, zero unresolved. Two advisory threads were
resolved in-thread without a push, per review-loop convergence: the twelve-workflow blast radius
(tracked in #4818) and the eventual-consistency tradeoff on the fail-closed path (accepted and
documented).
Coordinator verification, independent of the authoring lane:
script/lib/git-diff-basealready deepens 50 -> 100 -> ... ->--unshallowand hard-fails, so the"deepen or fail closed" half was already implemented upstream. The real cause is a stale
github.event.pull_request.base.sha.bundle-size.yml:82-99derives its baseline from the merge commit's first parent, sothis follows existing repo precedent rather than inventing a pattern.
EVENT_BASE_REFremains the default, somerge_groupevents still resolve through
github.event.merge_group.base_sha, and the first-parent logic isgated behind
GITHUB_EVENT_NAME = pull_request.result), so the fully green matrix at
124b42a7bcarries to this head.set -esemantics behind the reworded comment: a plain assignmentx=$(false)aborts underset -e; only declaration assignments (local/export/declare) maskit. The guard is retained for its actionable
::error::diagnostic, not for the reason originallystated.
classification:
GITHUB_EVENT_NAME=pull_request RUN_GENERATORS=true SHOULD_RUN_HOSTED_CI=falseexits 1 with the "Comment
+ci-run-hosted" message. Maximal classification would therefore havered-gated anyway, with the exact wrong remedy ci-required: shallow merge-base fallback misclassifies stale docs-only PRs as generator changes #4756 exists to eliminate.
Two coordinator corrections are recorded in the decision log, because both were wrong in ways a
future reader would repeat. First, an instruction to warn-and-continue on a stale merge ref; second,
an instruction to fall back to the event base, which addressed the wrong half of the diff — widening
changes what you diff from, while a stale
HEADis what you diff to, and its tree simply cannotcontain the newer head's changes. The lane's evidence overrode the coordinator on the final
mechanism, correctly.
UNKNOWN carried forward:
base.shais current, soold and new logic agree; the hosted run proves the new step executes, not that it repairs a stale
branch. Follow-up: Exercise GitHub Actions changes from PR #4819 #4820 closes that gap post-merge and is required by
.github/read-me.md.refs/pull/<n>/mergeat checkout time is unmeasured. If itproves common rather than rare, fetching the reported head becomes a better trade than failing
closed.
changed_filesguard has no executable test: injecting agitthat fails only ondiff --name-onlydid not reliably reach the child shell. The lane removed a case that waspassing for the wrong reason rather than ship it, and tracked it in CI: extract ci-required diff-base selection from workflow YAML into script/ #4824.
autonomous-merge-eligibilitygate is not adopted by this repository, so itcannot verify here by construction.
Batch:
ror-a-17-1-wave-a-20260729, lanelane-4756-ci-mergebase.merge_authority: auto_merge_when_gates_pass.