DEV Community

jidonglab
jidonglab

Posted on

GitHub Actions fetch-depth: 1 Made My CI Skip Tests for 41 PRs

A PR broke main on a Tuesday. CI had been green. I opened the run log and found the test job had finished in 4 seconds, with one line of output: No code changes, skipping tests.

The PR changed 14 files in src/.

I scrolled back through the history. For three weeks, 41 pull requests had skipped the test suite. Every one of them was green. The cause was one default in actions/checkout: fetch-depth: 1, plus a || true I had written myself and felt clever about.

TL;DR

  • actions/checkout defaults to fetch-depth: 1. You get one commit, with no parents, no other branches and no tags.
  • That means git diff origin/main...HEAD, git merge-base, git describe, HEAD~1 and git rev-list --count HEAD all fail or return the wrong answer.
  • If your step has no shell: key, GitHub runs it with bash -e {0}, which has no pipefail. Add a || true and a fatal git error turns into an empty string.
  • Empty string means "nothing changed", so the tests get skipped and the job still passes.
  • Fix: use fetch-depth: 0, or on pull_request use fetch-depth: 2 with git diff HEAD^1 HEAD. Then make git failures stop the job.

What does fetch-depth: 1 actually fetch in actions/checkout?

It fetches exactly one commit: the one the workflow runs on. There are no parent commits, no remote branches besides the one checked out, and no tags. The result is a shallow repository whose history is a single node.

On a pull_request event that commit isn't even your branch tip. It's the merge commit GitHub creates at refs/pull/<N>/merge, which merges your PR head into the current base. That detail matters for the fix later.

You can see it for yourself with a throwaway workflow:

name: shallow-check
on: pull_request
jobs:
  look:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: |
          git rev-parse --is-shallow-repository
          git log --oneline | wc -l
          git branch -a
          git tag | wc -l
          git rev-parse HEAD~1 || echo "no parent"
          git rev-parse origin/main || echo "no origin/main"
Enter fullscreen mode Exit fullscreen mode

On a repo with years of history, mine printed true, 1, a single remote ref for the PR, 0 tags, and both fallback messages. One commit is the whole repo as far as git knows.

Why did my changed-files check return nothing instead of failing?

The git command did fail, loudly, in stderr. The shell just never treated that as a failure. Here's the step I had written, which I'd bet a lot of monorepos have some version of:

- name: Detect code changes
  id: changes
  run: |
    CHANGED=$(git diff --name-only origin/main...HEAD | grep -E '^(src|tests)/' || true)
    if [ -z "$CHANGED" ]; then
      echo "No code changes, skipping tests"
      echo "skip=true" >> "$GITHUB_OUTPUT"
    fi
Enter fullscreen mode Exit fullscreen mode

Three things went wrong at the same time.

1. origin/main doesn't exist. With a depth-1 checkout there's no origin/main ref, so git prints fatal: ambiguous argument 'origin/main...HEAD': unknown revision or path not in the working tree and exits 128.

2. No pipefail. If a run: step doesn't set shell:, GitHub Actions on Linux runs it as bash -e {0}. That's errexit without pipefail. A pipeline's exit status is the status of its last command, so the pipeline's result came from grep, not from git.

3. The || true. I added it because grep exits 1 when nothing matches, and "a docs-only PR" is a normal case I didn't want to fail. But || true doesn't care why the command failed. It swallowed git's fatal error together with grep's no-match.

So CHANGED came back empty. Empty means skip. The fatal message was right there in the log, gray text above a green checkmark, and I never read it, because nobody reads the logs of passing jobs.

The nasty part is that this works fine on your laptop. Your local clone has origin/main and full history. It only breaks on the runner.

Which git commands break in a shallow GitHub Actions checkout?

Pretty much anything that needs history, a second branch, or tags. Here's what I found when I went looking through my other workflows:

Command What happens at depth 1
git diff origin/main...HEAD fatal: unknown revision
git merge-base origin/main HEAD fails, no base ref
git diff HEAD~1 fatal: unknown revision
git describe --tags fatal: No names found
git rev-list --count HEAD returns 1, so build numbers reset to 1
git log v1.2.0..HEAD (changelog) fails, tag not present
git diff ${{ github.event.before }} on push fails, the before commit isn't fetched

The rev-list --count one is my favorite because it doesn't even error. It just quietly says your project has one commit. Any tool that builds a version string from tags or commit counts will fall back to some default version, and you'll ship artifacts labeled like a first release.

Affected-only test runners in monorepo tools hit the same wall when they compare against a base branch. If yours has a "fall back to running nothing" mode, check it.

How do I fix fetch-depth: 1 for git diff in GitHub Actions?

You've got three workable options. Pick based on what you actually need from history.

Option 1: fetch-depth: 0 (full history)

- uses: actions/checkout@v4
  with:
    fetch-depth: 0
Enter fullscreen mode Exit fullscreen mode

This fetches all history, all branches and all tags. origin/main...HEAD, git describe and changelog generation all work like they do locally. It's the right choice when you need tags or versioning.

The cost is clone time, and it grows with the repo. On my repo (around 6,000 commits) it added roughly 8 seconds. On a huge monorepo with a decade of history it can be a lot more, and you pay it on every job that checks out code. Only set it on the jobs that need it, not across the board.

Option 2: fetch-depth: 2 with the merge commit (PRs only)

This is the cheap and exact option for "what did this PR change", and it's the one I use now.

- uses: actions/checkout@v4
  with:
    fetch-depth: 2
- run: git diff --name-only HEAD^1 HEAD
Enter fullscreen mode Exit fullscreen mode

It works because of the merge-commit detail from earlier. On pull_request, HEAD is the merge of your PR into the base. Its first parent, HEAD^1, is the tip of the base branch. Depth 2 fetches the merge commit plus its parents, so HEAD^1 HEAD is exactly the set of changes the PR brings into base.

Two caveats. This only holds on the default pull_request checkout of the merge ref. If you check out github.event.pull_request.head.sha instead, HEAD^1 is just the previous commit on your branch. And it doesn't work for push events at all.

Option 3: fetch the base branch yourself

If you need a true merge-base on a deep branch but don't want full history:

git fetch --no-tags --depth=200 origin "$GITHUB_BASE_REF"
git merge-base "origin/$GITHUB_BASE_REF" HEAD
Enter fullscreen mode Exit fullscreen mode

This still fails if the branch split off more than 200 commits ago. You can loop with git fetch --deepen=200 until merge-base succeeds, but at that point you're writing a clone strategy, and Option 1 is usually simpler.

How do I make git failures fail the job instead of passing?

Changing the depth fixes today's bug. Making the step loud fixes the next one. I added three guards.

Set shell: bash explicitly. With an explicit shell: bash, GitHub runs bash --noprofile --norc -eo pipefail {0}. Same shell, but now pipefail is on. You can set it once for the whole workflow:

defaults:
  run:
    shell: bash
Enter fullscreen mode Exit fullscreen mode

Split git from grep. Let git fail on its own line, and only tolerate grep's no-match:

FILES=$(git diff --name-only HEAD^1 HEAD)
CHANGED=$(printf '%s\n' "$FILES" | grep -E '^(src|tests)/' || true)
Enter fullscreen mode Exit fullscreen mode

If git diff fails now, errexit kills the step right there.

Assert the ref exists before diffing:

git rev-parse --verify --quiet HEAD^1 >/dev/null \
  || { echo "::error::Base commit missing. Check fetch-depth."; exit 1; }
Enter fullscreen mode Exit fullscreen mode

After this, I broke it on purpose by setting the depth back to 1. The job went red on the first line with a message telling me exactly what to fix. That's the behavior I'd wanted all along.

Should "skip tests" ever be the default path?

My actual mistake wasn't the depth. It was designing a check where "I couldn't figure out what changed" and "nothing changed" produced the same output. When detection is uncertain, run everything. A wasted 6-minute test run costs you 6 minutes. A skipped one cost me 41 unverified PRs and a broken main.

If your workflow decides to skip work, it should be able to prove it has nothing to do. An empty string isn't proof.

So why does GitHub Actions fetch-depth: 1 break git diff?

Because actions/checkout with the default fetch-depth: 1 clones a single commit with no parents, no other branches and no tags, so any git command that compares against origin/main, HEAD~1 or a tag has nothing to compare against and exits with a fatal error. In a run: step without an explicit shell:, bash runs without pipefail, so a | grep ... || true pipeline turns that error into an empty result, and a changed-files check reads it as "nothing changed". Use fetch-depth: 0 when you need tags or full history, use fetch-depth: 2 with git diff HEAD^1 HEAD for pull request diffs, set shell: bash so pipefail is on, and make any skip logic fail closed.


Written by the developer behind Preterview, an interview prep platform.

Top comments (0)