Skip to content

fix(ci): give the nightly's merge check a common ancestor (#1941) - #1952

Open
dolho wants to merge 2 commits into
devfrom
fix/1941-nightly-shallow-fetch
Open

fix(ci): give the nightly's merge check a common ancestor (#1941)#1952
dolho wants to merge 2 commits into
devfrom
fix/1941-nightly-shallow-fetch

Conversation

@dolho

@dolho dolho commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Problem

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, --depth=1 on pull/N/head — so the two single-commit histories shared no ancestor:

$ git fetch -q --depth=1 origin dev && git checkout -q FETCH_HEAD -b dev
$ git fetch -q --depth=1 origin pull/1950/head:pr-head
$ git merge --no-edit --no-ff pr-head
fatal: refusing to merge unrelated histories        # exit 128

The step's if ! git merge … recorded that as merge_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 them MERGEABLE), so a genuine conflict read exactly like the false ones, steps.merge short-circuited before the suite ran, and regression defaulted to false. The job's whole 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, no --depth on 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 merge collapses every failure mode into the one the branch names. merge_conflict=true now requires actual unmerged paths:

if [ -n "$(git diff --name-only --diff-filter=U)" ]; then
  echo "merge_conflict=true" >> "$GITHUB_OUTPUT"
else
  echo "::error::git merge failed with no conflicted paths — infrastructure failure, not a PR conflict"
  exit 1
fi

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)

Case Before After
PR #1950MERGEABLE fatal: refusing to merge unrelated histories, exit 128 → merge_conflict=true merge succeeds → the suite runs
PR #1918CONFLICTING same false verdict, indistinguishable fails with real content conflicts in 4 files (core-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

  • Reports merge_conflict=true only for PRs GitHub reports as CONFLICTING — the unrelated-histories path is gone, and the verdict is gated on unmerged paths
  • A mergeable PR now gets a real ✅ clean or a real regression report — the merge succeeds, so the suite runs
  • Verified against both a known-conflicting and a known-mergeable PR (table above)
  • The git merge dev remediation only appears for real conflicts — it lives in the status.merge_conflict branch, which can no longer fire spuriously

Guard

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

@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown

⚠️ Nightly unit-suite check skipped — merge conflict against dev.

Resolve by running git merge dev locally and pushing the result. The next nightly run will re-test once the conflict is gone.

`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>
@dolho
dolho force-pushed the fix/1941-nightly-shallow-fetch branch from f4db514 to d42bce5 Compare August 5, 2026 09:04
@dolho

dolho commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto current dev — the PR was conflicting, so there was no refs/pull/1952/merge for CI to build and no checks were reporting at all. They should run now.

Conflicts were in tests/registry.json (both sides appended entries; unioned, ours last). Resolved by re-serializing from parsed JSON rather than splicing lines, so the separating comma can't be lost.

Local verification after the rebase is in the individual runs; no source conflicts, only the registry.

Ready for review.

@dolho

dolho commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

The red check here is not this PR: it is pytest (base, seed 67890) — the base side, i.e. plain dev with none of this branch's changes.

It did not fail an assertion. The job hit the workflow's timeout-minutes: 25 and was cancelled at 81%:

09:30:42  ........................................ [ 79%]
09:34:19  ........................................ [ 80%]
09:35:34  ##[error]The operation was canceled.

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 pytest-timeout but never passes --timeout, so one hang consumes the whole job budget, the JUnit upload step is skipped (no if: always()), and the culprit is never named. One flag makes a hang fail that test, by name, in 60s instead of taking the shard down opaquely.

Re-running the failed job to unblock this PR. Nothing to change on this branch.

@obasilakis obasilakis left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
fi

Write 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 ⚠️ sticky right now, from this very bug — so one infra failure flips them all from a false warning to a false all-clear on a regression detector.

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
          fi

No 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 structurebody.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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants