fix(ci): stop pinned-version-check failing with no merge base

What does this MR do and why?

The pinned version check stops with "no merge base" before checking anything once the target branch has moved past the branch's base, or when the branch is longer than the clone depth. That hits community fork pipelines, other detached pipelines in the same state, and a merged results pipeline when main moves after GitLab makes the merge commit. The job now compares against a commit the branch descends from, so it reports whether the pin was bumped.

None. I hit this on !2650 (merged) and !2651 (merged), whose fork pipelines failed the job this way.

Testing

I ran the job's script lines before and after the change in alpine:latest, cloned the way the runner clones a merge request pipeline, against a local copy of this repository standing in for a fork. Before the change, every detached case whose target had moved past the branch's base, every branch longer than the clone depth, and every merged results case whose target moved after the merge stopped at "no merge base", unless a skip marker skipped the check first. After it, each gives the right answer, and a merged results pipeline whose target has not moved answers as before. The full output is in the Agent context block.

Against the community fork itself, on 2026-10-08, I ran both versions of the job on the branch of a contributor whose fork pipeline failed this job on 2026-10-07 (job), at the branch's later head 38fe915c, which passed the job at 15:15 UTC that day while the fork's main still pointed at the branch's base: before, fatal: origin/main...HEAD: no merge base; after, ✅ query_dsl: no covered files changed.

Failed runs from before the change: the fork pipeline of !2650, a detached pipeline in this project, and a merged results pipeline whose target gained a commit two seconds after GitLab made the merge commit.

This MR's own pipelines will run the new job on itself; it changes no covered file, so it should pass with "no covered files changed".

Performance Analysis

One lint job, which now fetches one thing less, the target branch. Only a detached pipeline on a branch longer than the clone depth fetches more: the rest of its history, once. Against the community fork that took the job's packs from 7.75 MiB to 20.85 MiB.

  • This merge request does not introduce any performance regression. If a performance regression is expected, explain why.
Agent context: why the job failed, why this base, how often it failed, and the alternative I measured and did not take

Why it failed. The runner clones a merge request pipeline at depth 20 ("Fetching changes with git depth set to 20" in every trace I read). The job then fetched the target branch at depth 1 and handed origin/<target> to scripts/check-pinned-version.sh, which runs git diff --name-only "$BASE_REF"...HEAD. A three-dot diff needs a merge base. A depth 1 fetch brings the target's tip with its parents cut off, so the tip is a merge base only if HEAD's history, cut at depth 20, already holds it. That holds in a merged results pipeline whose target has not moved, because the merge commit's first parent is the target's tip, and in a detached pipeline whose target has not moved and whose branch fits in the clone. It fails everywhere else: a detached pipeline (every fork pipeline, and the detached pipelines this project runs when it does not run a merged results one) once the target gains a commit or when the branch is longer than the clone depth, and a merged results pipeline when the target gains one between the merge and the fetch.

The base the job uses now. In a merged results pipeline, CI_MERGE_REQUEST_TARGET_BRANCH_SHA is the target commit GitLab merged the branch into, the merge commit's first parent (GitLab documents it as set only in merged results pipelines; in the merged results failure linked above, merge commit 018b0bfb has parents b8ef42b1, then main's tip, and the branch's head). Otherwise the job uses CI_MERGE_REQUEST_DIFF_BASE_SHA, the base of the merge request diff whose head is the pipeline's commit. HEAD descends from both, so the three-dot diff finds the merge base whenever that commit is inside the clone. When it is not, which is a detached pipeline on a branch with more commits than the clone depth, the job fetches the rest of HEAD's history by its SHA (git fetch --unshallow origin "$CI_COMMIT_SHA") before calling the script. Setting GIT_DEPTH: 0 on the job would make that fetch unnecessary, but every run would then fetch every branch and tag in full, where this job fetches more history only for a branch longer than the clone depth. The job prints the base it compares against, since the job log shows only the first line of a multi-line command. The script is unchanged, so the lefthook hook keeps its origin/main default, and the skip marker is read as before: git log A...B, which the skip helper uses, needs no merge base.

GitLab sets CI_MERGE_REQUEST_DIFF_BASE_SHA only when a non-empty merge request diff ends at the pipeline's commit, and the service that creates merge request pipelines refuses a merge request without commits, so I know of no pipeline in which both variables are empty. If one runs, the job says so and passes, as the job on main does for an empty diff whose target has not moved, instead of fetching the whole history to fail on origin/main, which the clone does not have.

How often it failed. Of the 118 failed jobs the API lists for the community fork (back to 2026-06-10), 13 are this job, all from 2026-09-20 on: 9 stopped on no merge base, the latest on 2026-10-08, in pipelines of three contributors, me included, and 4 reported a missing bump. In this project's 1,500 most recent failed jobs, counted on 2026-10-08 (back to 2026-09-22), this job failed 120 times: 69 reporting a missing bump, 2 on a network error, and 49 on no merge base across 18 merge requests. Those 49 are all 43 of its failures in detached pipelines, 4 in merged results pipelines whose main had moved by the time of the fetch, and 2 in merged results pipelines of a merge request that targeted another branch, which had moved the same way.

The alternative I measured and did not take. skill-version-bump-check and prompt-version-bump-check fetch the diff base at depth 1, falling back to the target at depth 50. Applied here, that fixes the short detached cases but not the other two:

  • A detached pipeline on a branch longer than the clone depth still has no merge base: the diff base comes in with its parents cut off and HEAD's history, cut at depth 20, does not reach it. skill-version-bump-check already fails this way in this project's detached pipelines of long branches, for example fatal: 87c02a18736c5964de33323273b7cdc1a8189c7c...HEAD: no merge base (job).
  • In a merged results pipeline, a diff from the diff base to the merge commit includes everything the target gained since the branch point. In the reproduction, a branch changing input.rs without a bump passed, because main had bumped query_dsl from 12.1.9 to 12.1.11 since the branch point; and a branch changing no covered file failed, because its target had changed validate.rs without a bump. The target's own commit, used here, gives neither.

Reproduction, in full. The stand-in fork was a local copy of this repository, with test branches based on an older main (0393f66b) while its main sat at 94a9eaf0, main's tip when I ran it, and merged results commits built on de7719c0. In the long-branch case whose target has not moved, the target is a branch at 0393f66b, main-at-base in the output. Every run used a fresh alpine:latest container (image 320994c3b997, the one the failed jobs ran): git init, git remote add origin <fork>, git fetch --depth 20 origin +<pipeline ref>, git checkout --detach <sha>, the CI variables the job reads, and the job's script lines, its apk add included, run under sh -e as the runner's shell runs them. The CI_MERGE_REQUEST_DIFF_BASE_SHA the skip helper fetches was set in every run but the two empty-diff ones, so its depth 1 fetch of the diff base happened as it does in CI. In those two neither variable was set, and the pipeline's commit was fetched without creating a remote-tracking branch, as the runner's +<sha>:refs/pipelines/<id> refspec does, so the clone holds no origin/main.

case                                  variant    exit  result
detached short, no bump               before     128   fatal: origin/main...HEAD: no merge base
detached short, no bump               after      1     ❌ query_dsl: covered files changed but the pin was not bumped.
detached short, bumped                before     128   fatal: origin/main...HEAD: no merge base
detached short, bumped                after      0     ✅ query_dsl: covered files changed and the pin was bumped.
detached 25 commits, no bump          before     128   fatal: origin/main...HEAD: no merge base
detached 25 commits, no bump          after      1     ❌ query_dsl: covered files changed but the pin was not bumped.
detached 25 commits, no bump          diff base  128   fatal: 0393f66b2769b2063f56ad50b1b2091576ee45c6...HEAD: no merge base
detached 26 commits, bumped           after      0     ✅ query_dsl: covered files changed and the pin was bumped.
detached 25 commits, target not moved before     128   fatal: origin/main-at-base...HEAD: no merge base
detached 25 commits, target not moved after      1     ❌ query_dsl: covered files changed but the pin was not bumped.
detached, skip marker                 before     0     (the script's skip message)
detached, skip marker                 after      0     (the script's skip message)
detached, no covered file             after      0     ✅ query_dsl: no covered files changed.
empty diff, target not moved          before     0     ✅ query_dsl: no covered files changed.
empty diff, target not moved          after      0     No merge request diff base for this commit, so nothing to compare.
empty diff, target moved              before     128   fatal: origin/main...HEAD: no merge base
empty diff, target moved              after      0     No merge request diff base for this commit, so nothing to compare.
merged, target moved, no bump         before     128   fatal: origin/main...HEAD: no merge base
merged, target moved, no bump         after      1     ❌ query_dsl: covered files changed but the pin was not bumped.
merged, target moved, no bump         diff base  0     ✅ query_dsl: covered files changed and the pin was bumped.
merged, target not moved, no bump     before     1     ❌ query_dsl: covered files changed but the pin was not bumped.
merged, target not moved, no bump     after      1     ❌ query_dsl: covered files changed but the pin was not bumped.
merged, target moved, bumped          before     128   fatal: origin/main...HEAD: no merge base
merged, target moved, bumped          after      0     ✅ query_dsl: covered files changed and the pin was bumped.
merged, target changed validate.rs    before     0     ✅ query_dsl: no covered files changed.
merged, target changed validate.rs    after      0     ✅ query_dsl: no covered files changed.
merged, target changed validate.rs    diff base  1     ❌ query_dsl: covered files changed but the pin was not bumped.

The unshallow path also ran against the community fork itself: the same contributor's branch with a diff base 150 first-parent commits further back, outside the clone, fetched the branch's history (4,380 commits) and the check ran to its answer, where the job as it is on main and the diff-base block both stopped at no merge base.

!2730 (merged). It changes the script to take the merge base itself with git merge-base and to diff the index against it. The job's new base works with that version too: I ran the after column of three of the cases above (short detached, long detached, merged results with a moved target) with !2730 (merged)'s script in place of main's and got the same results. With main's job, that version exits 1 and prints nothing in the short detached case, since git merge-base finds nothing either and the script runs under set -e.

Not changed here. migration-ledger-check reads the base's config/versions.yaml with git show and needs no merge base; it passed in the same fork pipeline in which this job failed. skill-version-bump-check has both of the problems above. If you want it to compare against the same base, I can send that as its own MR.

I found this while sending !2650 (merged) and !2651 (merged), which came out of maintaining gitlab-mcp-server; that project records what it sends to other projects in upstream-bugs.md.

Merge request reports

Loading
Loading