fix(ci): give the nightly's merge check a common ancestor (#1941) - #1952
fix(ci): give the nightly's merge check a common ancestor (#1941)#1952dolho wants to merge 2 commits into
Conversation
d51bcf2 to
f4db514
Compare
|
Resolve by running |
`backend-unit-nightly.yml` merges each open PR into `dev` and runs the suite on the result. It shallow-fetched BOTH sides — `fetch-depth: 1` on the checkout and `--depth=1` on `pull/N/head` — so the two single-commit histories shared no ancestor and git exited `fatal: refusing to merge unrelated histories`. The step's `if ! git merge` recorded that as `merge_conflict=true`. The result was a detector whose output did not depend on its input: every open PR was flagged (9/9 at the time of the report, 8 of them MERGEABLE per GitHub), so a real conflict read exactly like the false ones, `steps.merge` short-circuited before the suite ran, and `regression` defaulted to false. The job's entire purpose — catching regressions that only appear once a PR is merged into dev — had not executed for any PR, while its sticky comment told every author to run an unnecessary `git merge dev`. Fix: `fetch-depth: 0` on the base checkout and no `--depth` on the PR-head fetch. Deliberately NOT `--allow-unrelated-histories`, which would make the merge succeed by grafting two unrelated trees — the suite would then run against a worktree that is not the real merge result. Also hardened the verdict itself: `merge_conflict=true` now requires actual unmerged paths (`git diff --name-only --diff-filter=U`). A non-zero `git merge` is not proof of a content conflict — that conflation is what made this survivable for so long — so any other git failure now fails the job loudly instead of posting a confident wrong answer to every PR. Verified against the real repo, replicating the workflow's exact steps: - before, on PR #1950 (MERGEABLE): `fatal: refusing to merge unrelated histories`, exit 128 -> merge_conflict=true - after, PR #1950: merge succeeds -> the suite would run - after, PR #1918 (CONFLICTING): fails with real content conflicts in 4 files — a genuine conflict is still detected `tests/unit/test_1941_nightly_merge_depth.py` pins all of it; 3 of its 5 checks fail against the pre-fix workflow. It strips comment lines before matching, because the fix's own comment says "no --depth" and a naive substring search matches that and passes while the command is shallow again. Related to #1941 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
f4db514 to
d42bce5
Compare
|
Rebased onto current Conflicts were in Local verification after the rebase is in the individual runs; no source conflicts, only the registry. Ready for review. |
|
The red check here is not this PR: it is It did not fail an assertion. The job hit the workflow's A 3m37s gap with no output between two progress lines — one test blocking, not a slow runner (the sibling shards of the same commit finished in ~10m). Root cause and fix filed as #2019: the workflow installs Re-running the failed job to unblock this PR. Nothing to change on this branch. |
obasilakis
left a comment
There was a problem hiding this comment.
The core fix is right, and I reproduced both sides against the real repo using this PR's own branch:
shallow BOTH → fatal: refusing to merge unrelated histories unmerged paths: []
full BOTH → Automatic merge failed; fix conflicts unmerged paths: [docs/memory/learnings.md tests/registry.json]
That is the defect in two lines — a non-zero exit with zero conflicted files was being reported as a content conflict, and the unmerged-path gate now separates them. I also mutation-tested the guard: fetch-depth: 1, a shallow PR-head fetch, dropping --diff-filter=U, and --allow-unrelated-histories each fail it, 4/4.
Requesting changes on one thing only, below. Everything else is a note.
Blocking — the new exit 1 branch posts a false ✅ "clean"
backend-unit-nightly.yml:141-145 exits without ever writing the output:
else
git merge --abort || true
echo "::error::git merge failed with no conflicted paths — infrastructure failure…"
exit 1 # merge_conflict is never written
fiWrite status JSON is if: always() (:204), so it still runs, with merge_conflict=''. Simulating that step verbatim:
{ "pr_number": 1952, "merge_conflict": false, "regression": false }which lands in the comment script's final else (:289) — ✅ **Nightly unit-suite clean**. And :313 updates an existing sticky; the create-only-on-bad-news guard at :326 covers only new comments. Every open PR is carrying the
This is the case your own learnings entry calls out: "anything else must fail the job loudly rather than post a confident wrong answer." The job does fail loudly. The comment job doesn't hear about it — it's if: always() with needs: [discover, test], so it runs regardless and posts the green tick.
Fair provenance: this path is inherited, not introduced — a failure in the checkout or the PR-head fetch already produced an empty merge_conflict and the same false clean. But this PR turns an accident into a designed branch, for precisely the failure class it exists to handle, and it's the one change whose whole thesis is that this signal must be trustworthy. Worth closing here rather than filing.
Smallest fix, in the step you're already touching:
- name: Write status JSON
if: always()
run: |
merge_conflict='${{ steps.merge.outputs.merge_conflict }}'
if [ -z "$merge_conflict" ]; then
echo "::warning::merge verdict unknown — no status written, sticky left untouched"
exit 0
fiNo status JSON means ls status-pr*.json (:253) skips that PR entirely and the existing sticky is left alone. Upload is already if-no-files-found: warn, so nothing else changes. A guard over it would want to assert that the unknown verdict produces no status file — the current suite has no coverage of that path.
Non-blocking notes
1. test_remediation_text_is_scoped_to_the_conflict_branch self-disables on refactor. The pytest.skip at :130 fires if the comment step stops inlining its script. It's documented, and I'd normally not mention it — except a silently-skipped check is how #1941 itself survived this long. A pytest.fail there would match the posture of _merge_job(), which already fails rather than skips when it can't find its target.
2. fetch-depth: 0 × up to 50 PRs nightly. .git is ~181 MB over 1921 commits, so the matrix pulls roughly 9 GB a night. Fine on GitHub's internal network, and the issue explicitly accepts the cost — noting it only so it's a known number if the nightly ever starts timing out. --filter=blob:none would cut most of it without reintroducing a depth.
3. Two guards use proximity rather than structure — body.split("merge_conflict=true")[0], and "every checkout in this job must be depth 0". Both fail safe, so this is a nit rather than a finding.
Worth saying: the if ! collapse-every-failure-mode observation, and the learnings entry that generalises it, are the most valuable part of this PR — more than the depth fix itself. Point 1 above is the same idea applied to the test file, and the blocking item is the same idea applied one job downstream.
Also heads-up: this branch now conflicts on two files, tests/registry.json and docs/memory/learnings.md, both from #1901 landing on dev a couple of hours ago. Same additive-append pattern you resolved this morning.
…2029) `Write status JSON` is `if: always()`, so it also runs when the job died before a verdict existed. The unset step output stringifies to `''`, `'' == "true"` is false, and the JSON becomes `{merge_conflict: false, regression: false}` — byte-identical to a genuinely clean run. The comment job then posts ✅ "Nightly unit-suite clean" for a suite that never ran. This is #1941's own mistake one job downstream, in the more dangerous direction. #1941 produced a false ALARM, which is self-limiting because someone investigates. This produces a false ALL-CLEAR on a regression detector, which nothing corrects. And this PR made it more reachable: the `exit 1` branch added here for a non-conflict merge failure routes straight into it. Now the step refuses to write when the merge verdict is unknown, and only defaults `regression` to false when the merge actually conflicted — that is the one case where the diff legitimately never ran. Both comment jobs enumerate the status files that exist, so a missing one means the PR is skipped and its sticky is left untouched. I did NOT add the coarser belt #2029 offers — gating the comment step on `needs.test.result == 'success'`. `test` is a matrix job, so its result is 'failure' when any single leg fails, and one PR's infrastructure hiccup would suppress the report for every other PR in the sweep. That trades a false green for a silence that is equally wrong and hits PRs that were fine. The per-PR guard is precise and complete on its own, including the all-legs-failed case, and a test pins that decision so it reads as deliberate rather than forgotten. 4 guard tests, each mutation-verified. The assertions run through the existing `_commands()` helper, which strips comments — this fix's own explanation mentions the strings being asserted. Refs #2029 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Problem
backend-unit-nightly.ymlmerges each open PR intodevand runs the suite on the result. It shallow-fetched both sides —fetch-depth: 1on the checkout,--depth=1onpull/N/head— so the two single-commit histories shared no ancestor:The step's
if ! git merge …recorded that asmerge_conflict=true. The detector's output did not depend on its input: every open PR was flagged (9/9 at the time of the report, 8 of themMERGEABLE), so a genuine conflict read exactly like the false ones,steps.mergeshort-circuited before the suite ran, andregressiondefaulted tofalse. The job's whole purpose — catching regressions that only appear once a PR is merged intodev— had not executed for any PR, while its sticky comment told every author to run an unnecessarygit merge dev.Fix
fetch-depth: 0on the base checkout, no--depthon the PR-head fetch.Deliberately not
--allow-unrelated-histories, per the issue: that makes the merge succeed by grafting two unrelated trees, so the suite would then run against a worktree that isn't the real merge result — a green light that means nothing.Plus one hardening the issue didn't ask for. The root cause survived this long because
if ! git mergecollapses every failure mode into the one the branch names.merge_conflict=truenow requires actual unmerged paths:So the next setup failure fails the job loudly instead of posting a confident wrong verdict to every PR author.
Verification (against the real repo, replicating the workflow's exact steps)
MERGEABLEfatal: refusing to merge unrelated histories, exit 128 →merge_conflict=trueCONFLICTINGcore-agent.md,metadata.py,package-lock.json,network.js)That covers the AC's "verified against a known-conflicting PR and a known-mergeable one" — and shows the two are now distinguishable, which is the actual defect.
Acceptance criteria
merge_conflict=trueonly for PRs GitHub reports asCONFLICTING— the unrelated-histories path is gone, and the verdict is gated on unmerged paths✅ cleanor a real regression report — the merge succeeds, so the suite runsgit merge devremediation only appears for real conflicts — it lives in thestatus.merge_conflictbranch, which can no longer fire spuriouslyGuard
tests/unit/test_1941_nightly_merge_depth.py— 5 checks; 3 fail against the pre-fix workflow. It pins full-depth base, non-shallow head fetch, the unmerged-paths gate, the absence of--allow-unrelated-histories, and the scoping of the remediation text.One detail worth keeping: it strips comment lines before matching. The fix's own comment says "no
--depth", and a naive substring search matches that and passes while the real command is shallow again — I hit exactly that false green while writing it.Related to #1941
🤖 Generated with Claude Code