jenkinsci / jenkinsci/github-branch-source-plugin
Merge-strategy builds of stacked PRs always fail with "not something we can merge" (baseHash derived from parent PR's synthetic merge ref)
- Dominant language
- Java
- Stars
- 217
- Forks
- 398
- Avg merge
- 30m
- Merged PRs (30d)
- 1
Description
## Summary
For a **stacked PR** — one whose base branch is the head branch of another *open* PR — merge-strategy builds fail **100% of the time, permanently**, with:
```
ERROR: Checkout failed
hudson.plugins.git.GitException: Command "/usr/bin/git merge 735f70a5a6bd3d80d9cb773c0ac969226fb88df7" returned status code 1:
stdout:
stderr: merge: 735f70a5a6bd3d80d9cb773c0ac969226fb88df7 - not something we can merge
at ...MergeWithGitSCMExtension.decorateRevisionToBuild(MergeWithGitSCMExtension.java:120)
at ...GitSCM.determineRevisionToBuild(GitSCM.java:1162)
```
The `baseHash` the plugin picks is a commit that exists **only** under `refs/pull//merge` and is reachable from no branch, so the fetch refspec can never bring it into the workspace.
This is the same code path as #1534, but a different trigger, and unlike #1534 it never self-heals — no rescan, close/reopen, or plugin upgrade helps.
## Environment
| | |
|---|---|
| github-branch-source | 1917.v9ee8a_39b_3d0d (also present on `master` today) |
| git / git-client | 5.7.0 / 6.5.0 |
| branch-api | 2.1244.vf95c81f1641c |
| Discovery trait | `OriginPullRequestDiscoveryTrait` strategyId=1 (*Merging the pull request with the current target branch revision*) |
## Root cause
**Step 1 — GitHub stacks the merge preview.** For a PR whose base branch is another open PR's head, GitHub computes the child's merge ref against the *parent PR's merge ref*, not against the base branch tip:
```
refs/pull/15618/merge = 7334a316 "Merge e6f95c52 into 56f891d6"
parents = [56f891d6, e6f95c52]
^^^^^^^^ == refs/pull/15617/merge (synthetic!)
refs/heads/feature/minicloud-integration = 6e317e6a <-- the actual base tip
```
`56f891d6` is reachable from **no branch** — it only exists as `refs/pull/15617/merge`.
**Step 2 — the plugin trusts `merge_commit_sha`.** [`GitHubSCMSource.createPullRequestSCMRevision()`](https://github.com/jenkinsci/github-branch-source-plugin/blob/master/src/main/java/org/jenkinsci/plugins/github_branch_source/GitHubSCMSource.java#L1743-L1751):
```java
// We found a merge_commit_sha with 2 parents and one matches the prHeadHash
// Use the other parent hash as the base. ...
mergeHash = proposedMergeHash;
baseHash = prHeadHash.equals(parents.get(0)) ? parents.get(1) : parents.get(0);
```
The guard above it only checks `parents.size() == 2` and `parents.contains(prHeadHash)` — both true here. Nothing checks that the *other* parent is on the base branch. The comment "Merge commits always merge against the most recent base commit they can detect" is false for stacks.
**Step 3 — the fetch can never satisfy it.** [`GitHubSCMBuilder`](https://github.com/jenkinsci/github-branch-source-plugin/blob/master/src/main/java/org/jenkinsci/plugins/github_branch_source/GitHubSCMBuilder.java#L319) only ever emits:
```
+refs/pull/15618/head:refs/remotes/origin/PR-15618
+refs/heads/feature/minicloud-integration:refs/remotes/origin/feature/minicloud-integration
```
Never `refs/pull/*/merge`. So `baseHash` is absent locally, and `MergeWithGitSCMExtension` runs `git merge 56f891d6` → *not something we can merge*.
## Reproducer
1. PR **A** → `master`, head branch `feature/a`.
2. PR **B** → `feature/a`, head branch `feature/b`. Both open, both mergeable.
3. Discover origin PRs with strategy *"Merging the pull request with the current target branch revision"*.
4. Every build of **B** fails in checkout. **A** builds fine.
## Live proof it is deterministic, not stale state
Calling the plugin's own resolver from the script console (`SCMSource.fetch(head, listener)`) reproduces it on demand:
```
PR-15617 target=master strategy=MERGE
-> 6e317e6a+4698ab66 (56f891d6) base = real master tip ✅ builds green
PR-15618 target=feature/minicloud-integration strategy=MERGE
-> e6f95c52+56f891d6 (7334a316) base = refs/pull/15617/merge ❌ always fails
```
Recorded `SCMRevisionAction` for every build of the child PR — the base always equals the *parent PR's* `merge_commit_sha` at that moment:
| child build | baseHash used | equals |
|---|---|---|
| #21–24 | `948d822c` | parent's merge ref, rev 1 |
| #25 | `18f54ec4` | parent's merge ref, rev 2 |
| #26–27 | `735f70a5` | parent's merge ref, rev 3 |
| #28 | `56f891d6` | parent's merge ref, rev 4 |
8/8 FAILURE. It also propagates down a 3-deep stack: `refs/pull/15668/merge` = `"Merge into 7334a316"`, i.e. the grandchild's base becomes the child's merge ref. 10/10 FAILURE there.
**Control:** a PR based on a long-lived branch with *no open PR of its own* resolves to a real reachable commit and builds green — confirming the trigger is precisely "base branch has an open PR".
## Why this is worse than #1534
#1534 describes the same `baseHash`-from-merge-parents inference going stale after a **retarget** — transient, self-heals once GitHub recomputes. Here the derived base is unreachable **by construction**: it is a synthetic merge ref that will never be reachable from any branch for as long as the parent PR stays open. Rescan, close/reopen, and upgrading the plugin all have no effect. Stacked PRs simply cannot be built with merge strategy.
## Proposed fix
This is #1534's proposed fix (a), and it resolves both issues:
> After deriving `baseHash` from the merge parents, verify it is reachable from `heads/`; if not, fall back to the existing branch-tip lookup.
The fallback already exists a few lines below, for the `mergeHash == null` path:
```java
baseHash = ghRepository.getRef("heads/" + pr.getBase().getRef()).getObject().getSha();
```
So the change is to extend the validity check at line ~1736 — e.g. also require that the non-head parent is reachable from the base ref (one `compare` call, or simply prefer the branch-tip lookup and let `MergeWithGitSCMExtension` merge against the real tip).
A cheaper heuristic that covers this case without an extra API call: if the non-head parent is itself a 2-parent commit whose parents include the base branch's tip, it is a synthetic PR merge ref and must not be used as `baseHash`.
## Workaround
Switch origin PR discovery from strategyId 1 (*merge with target*) to strategyId 2 (*the current pull request revision*). `MergeWithGitSCMExtension` and the base-branch refspec are both inside `if (head.isMerge())` in `GitHubSCMBuilder`, so HEAD strategy sidesteps the failure — at the cost of no longer testing PRs against their target branch. Flattening the stack (retargeting children to the default branch) also works.
Contributor guide
Research direction
Start in GitHubSCMSource.java at createPullRequestSCMRevision(), then inspect GitHubSCMBuilder.java and MergeWithGitSCMExtension for how the base revision is fetched and merged. Reproduce the stacked PR scenario described in the issue and verify that merge-strategy builds use a reachable base branch tip instead of the parent PR's synthetic merge ref.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- java
- Domain
- ci-cd, devops
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 52/100