GitHub Actions: prove the merge base before the diff
Fix GitHub Actions shallow-clone comparisons by proving commit ancestry, choosing the right diff semantics and preserving a trustworthy changed-path list.
Quick answer
GitHub Actions shallow checkouts can contain the requested commit without enough ancestry to compute its merge base. Define whether your job compares branch tips or pull-request changes, fetch the history that comparison needs, and fail if the base cannot be established. Changing a three-dot diff to a two-dot diff can change which files the job selects. [1][2][4]
On this page
GitHub Actions shallow checkouts can contain the requested commit without enough ancestry to compute its merge base. Define whether your job compares branch tips or pull-request changes, fetch the history that comparison needs, and fail if the base cannot be established. Changing a three-dot diff to a two-dot diff can change which files the job selects. [1][2][4]
Scenario: a monorepo runs billing tests only when a pull request changes the billing directory. The filter works on a developer checkout, then reports no merge base in CI. Someone suggests comparing the current target branch directly with the feature tip. That command may succeed while answering a different question. This illustrative case follows the comparison all the way to its selected files.
Save a comparison record: the immutable input commits, their established common ancestor, the comparison rule and the selected paths. A green checkout step is only an input to that record. It is not proof that the path filter examined the intended change.
Which changes should the job select?#
Decide whether the job needs the difference between two snapshots or the changes made on a feature branch since a shared ancestor. Git supports both, and they serve different tasks. A deployment artifact comparison might want two exact trees. A pull-request filter often wants the feature branch's contribution. The diff manual describes three-dot comparison as changes from a common ancestor to the second commit. [4]
Imagine a shared commit named BASE. The target branch then changes a billing file. The feature branch independently changes a catalogue file. A direct comparison of the target tip with the feature tip includes the billing difference, even though the feature author did not edit billing. Comparing BASE with the feature tip selects the catalogue change. Neither command is inherently broken. Their contracts differ.
That distinction can affect more than wasted test time. A script might interpret a selected file as a request to regenerate, publish or delete something. The filter's owner must describe what selected means before choosing a fallback. A successful command should not silently redefine that meaning during an outage.
Write the comparison in the workflow's own documentation. For this case: “Select paths changed between the best common ancestor of the recorded target and feature commits, and the recorded feature commit.” That is a recommendation for this filter, not a universal rule for every pull-request workflow. If your policy compares the merged result, record that separately.
What did checkout actually give the runner?#
Inspect the actual commit and the available graph before changing the action. Checkout's default fetch retrieves a single commit; its documented full-history option is fetch-depth: 0. Its ref input also determines what is checked out. Those are separate choices. [1]
git rev-parse HEAD
git rev-parse --is-shallow-repository
git cat-file -e "${BASE_SHA}^{commit}"
git cat-file -e "${HEAD_SHA}^{commit}"
git merge-base --all "$BASE_SHA" "$HEAD_SHA"The variables must come from a validated workflow input, such as the event's base and head commit fields. Do not paste an untrusted branch title into a shell program. Recording immutable commit IDs also prevents a later branch movement from silently changing the investigation.
Read the results in order. Missing objects mean the comparison cannot start. Existing objects with no established merge base mean the runner still lacks the required ancestry, or the histories really are unrelated. A shallow repository makes the first explanation plausible; it does not prove it. Fetching more history is the experiment that distinguishes the explanations.
Do not diagnose this only from the branch name shown in the interface. For ordinary pull-request events, GitHub's workflow reference describes a merge ref and merge commit. Checkout can instead be configured for the pull-request head commit. If your filter assumes the head but reads the default checkout, it may examine the merge result. [1][5]
Keep that distinction beside the workflow's deployment guard: one protects what gets selected, the other protects whether a selected deployment is still current. A valid comparison does not make an old workflow safe to promote.
Repair the history contract, then compute the diff#
For a modest repository, the most understandable repair is to fetch full history during checkout and then verify the recorded inputs. Keep your reviewed action pin; the important setting here is fetch depth. Do not copy a moving version tag into a security-sensitive workflow merely because a tutorial uses it. [1]
# Fragment inside an existing, reviewed checkout step:
with:
fetch-depth: 0Full history trades transfer and storage for a simpler correctness argument. It does not create an object that the remote cannot provide, authenticate a private fork, or make an unavailable ref available. Run the object and merge-base checks after checkout. Treat an absent requested object as a failed input contract rather than an empty change set.
Large repositories may need an explicit fetch strategy. Git's --deepen extends a shallow boundary; --unshallow can remove the shallow restriction when the source repository is complete. Their effects depend on the refs being fetched. Increasing a number against an unrelated ref is not a proof that the feature ancestry became reachable. [3]
Use a bounded escalation: fetch the required refs, attempt the comparison, deepen deliberately if it remains shallow, and stop with a diagnostic if the budget is exhausted. Select that budget from your repository's transfer cost and job deadline. Do not present an arbitrary depth as a generally sufficient number of commits.
Credentials belong in this plan. If your checkout configuration removes persisted credentials, a later private fetch needs an explicitly authorized authentication path. Avoid changing the workflow to a privileged trigger to make the fetch work. That would alter the trust boundary around code execution, a much larger change than supplying ancestry. [1][5]
For a deliberately narrow branch-comparison contract, the next shell fragment rejects missing or ambiguous merge bases. Multiple best common ancestors are possible in Git; rejecting them is this filter's chosen policy, not a claim that Git cannot handle such histories. [2]
set -eu
: "${BASE_SHA:?missing recorded base commit}"
: "${HEAD_SHA:?missing recorded head commit}"
git cat-file -e "${BASE_SHA}^{commit}"
git cat-file -e "${HEAD_SHA}^{commit}"
bases=$(git merge-base --all "$BASE_SHA" "$HEAD_SHA")
test -n "$bases"
test "$(printf '%s\n' "$bases" | wc -l | tr -d ' ')" = 1
git diff --name-only -z "$bases" "$HEAD_SHA" > changed-paths.binThe output is NUL-delimited because a filename can contain whitespace. The consumer must preserve that representation. Converting it into an ordinary space-separated shell variable can corrupt the file list even after the graph calculation is correct. Keep comparison correctness and path transport correctness as separate checks. [4]
Why can a fallback produce the wrong success?#
A direct tree diff removes the ancestry requirement by changing the calculation. In the illustrative graph, the target-only billing change now appears in the selected paths. The filter no longer means “what this branch changed since its ancestor.” It means “how these snapshots differ.” Git's diff reference documents the distinction. [4]
| Intended operation | Required evidence | Failure response |
|---|---|---|
| Feature contribution | Recorded tips and established merge base | Fetch missing history or fail the filter |
| Exact snapshot comparison | Both recorded trees | Fail if either input is unavailable |
| Merged-result verification | Recorded merge result and stated baseline | Verify that the job checked out that result |
Failing the filter need not mean skipping every test. A team may choose a conservative full test run when the path calculation fails. That is an explicit product decision: it spends more CI resources to avoid trusting an incomplete file selection. Label the fallback as “selection unavailable; running all tests,” rather than manufacturing an empty selection.
Conversely, a job that controls publishing may need to stop completely. Running a broader test suite does not establish the correct artifact baseline. Choose the response according to the action that consumes the file list. The same ancestry failure can reasonably trigger different responses in a linter and a release workflow.
Partial and shallow clones also solve different cost problems. GitHub's clone guide distinguishes omitting historical objects from postponing blob retrieval. If the expensive part is old file contents while the job still needs ancestry, evaluate a partial-clone design against the commands the job actually runs. Do not assume every “smaller clone” removes the same information. [1][3][6]
Before narrowing fetches, inventory every history-dependent command in the job. A file selector may share a checkout with a version generator or changelog task. Fixing the selector's merge base does not establish that tags and older commits required by those other tasks are present. Give each task an input requirement, then choose one fetch policy that satisfies the declared requirements or split the checkouts deliberately. [1][3]
There is also a timing choice in the baseline. The target commit recorded when the event was created can differ from the target branch when a delayed runner starts. For an event-specific comparison, retain the recorded commit. For a deliberate latest-target comparison, record the new target and accept that rerunning the job may change the answer. Neither choice should be hidden behind a mutable remote-tracking name. [5]
Review the file-selection artifact alongside a representative pull request before enabling a narrowed test matrix. Include a target-only change and a feature-only change, because a trivial linear history can make both comparison styles appear equivalent. A fixture that never diverges cannot establish that the chosen fallback preserves the intended semantics.
What evidence should the fixture preserve?#
Rehearse a diverged graph in a disposable repository: a shared ancestor, a target-only edit and a feature-only edit. Confirm that the feature-contribution filter selects only the feature edit. Then repeat with ancestry removed. The expected failure must be visible; an empty file list is not the expected success.
Next, restore sufficient history without changing the recorded tips. The selected feature paths should return. Add a filename with whitespace and check the downstream consumer, not just the diff output. Finally, compare the tips directly and observe that target-only differences enter the result. This is a semantic negative control, not a performance benchmark.
Preserve the fixture's graph and expected paths in the workflow test suite. Avoid relying on a developer's long-lived reflog or local remote-tracking branches. A clean runner should reconstruct the evidence from declared inputs. If the fixture requires an undeclared local object, it has reproduced the original problem instead of fixing it.
Record only the details needed to investigate selection: event type, relevant commit IDs, shallow status, fetch policy, established base and selected-path artifact. Repository content can be private, so apply the same retention policy as other CI artifacts. Do not print authentication configuration or tokens while diagnosing a fetch failure.
Frequently asked questions#
- Does fetch-depth zero guarantee a correct file list?
- It supplies full history for the checkout's fetch policy, but the filter must still use the intended commits and comparison rule. Verify object availability and the established base. A correct graph calculation can also be damaged by a downstream filename parser. [1][4]
- Is a detached HEAD the same problem as missing ancestry?
- No. Detached HEAD describes how the current commit is referenced. Missing ancestry describes which graph objects Git can traverse. A detached checkout can contain full history, while a named local branch can still be shallow. Diagnose the graph required by the comparison. [1][3]
- Should every merge-base failure run all tests?
- That is a useful conservative policy for some test selectors, but it is not a general repair for release logic. Decide what consumes the file list. Preserve an explicit selection failure even when the approved response is to run a broader suite.
- Can a two-dot diff ever be the right choice?
- Yes. Use it when the decision concerns the difference between two recorded snapshots. The mistake is silently switching to that operation after an ancestry-based calculation fails. Write the intended semantics and verify the exact inputs before relying on the result. [4]