Nine nightly builds in a row shipped to our internal package bucket with the exact same version: 0.1.dev1+g3f9c2a1. Then 0.1.dev1+g8be04d7. Then 0.1.dev1+gc1172e0. Same prefix, different hash, every night, for nine nights.
The repo had 40-something tags and a few thousand commits. The latest tag was v1.4.0. Locally, python -m build gave me 1.4.1.dev12+g... like it should. In CI it acted like the project was born that morning.
Nothing failed. No red X. The cause was one default in actions/checkout that I had read past a hundred times: GitHub Actions fetch-depth: 1. The runner was building from a repository that contained exactly one commit and zero tags.
TL;DR
-
actions/checkoutdefaults tofetch-depth: 1: a shallow clone with only the triggering commit and no tags (fetch-tagsdefaults tofalse). - Anything that reads git history breaks quietly:
git describefails,setuptools_scmfalls back to0.1.devN,git rev-list --count HEADreturns1, andgit merge-baseagainstmainfinds nothing. - Fix it with
fetch-depth: 0. For big repos, addfilter: blob:noneto get full history without downloading every old file. -
fetch-tags: truealone is usually not enough, because a tag is useless if its commit isn't reachable fromHEADinside the shallow history. - Add a one-line guard step that fails the job if
git rev-parse --is-shallow-repositoryprintstrue.
What does actions/checkout fetch by default?
By default, actions/checkout fetches a single commit: the SHA that triggered the workflow, at depth 1, with no tags. It is fast and cheap, which is a great default for "run the tests" and a terrible one for "figure out what version this is."
Here is the step almost every workflow starts with:
- uses: actions/checkout@v4
And here is what that runner actually has afterwards. I added this debug step to prove it to myself:
- run: |
git rev-parse --is-shallow-repository
git rev-list --count HEAD
git tag | wc -l
git describe --tags || true
Output on the runner:
true
1
0
fatal: No names found, cannot describe anything.
One commit. Zero tags. The repo on the runner is a single frame cut out of the movie.
Why does setuptools_scm give 0.1.dev1 in GitHub Actions?
setuptools_scm gives 0.1.dev1+g<hash> in GitHub Actions because it finds no tag in the shallow clone, so it uses its fallback: version 0.1, plus "dev" and the number of commits it can see since the start of history. In a depth-1 clone that number is 1.
That explains the nine identical nightlies exactly:
-
0.1→ no reachable tag, fallback base version -
.dev1→ one commit visible -
+g3f9c2a1→ the commit hash, as a local version label
The local version label is the second trap. PyPI and TestPyPI reject versions with a +local segment. So if your publish job runs on a branch push instead of a tag push, the upload fails with a complaint about local versions, and you spend twenty minutes reading packaging docs when the real problem is in your checkout step.
Our internal bucket didn't validate versions. It happily accepted nine 0.1.dev1 builds, and "latest" resolution became whatever sorted last by hash. That is how I found out: a teammate installed "the newest nightly" and got a four-day-old one.
What else breaks with a shallow clone?
Anything that counts, walks or compares git history breaks with a shallow clone, and most of it breaks without an error. These are the ones I have personally hit or watched teammates hit:
-
git describefails outright withNo names found. Scripts that doVERSION=$(git describe --tags 2>/dev/null || echo 0.0.0)will happily ship0.0.0. -
Build numbers from
git rev-list --count HEADare always1. Every AndroidversionCodeor Docker tag built that way collides. -
Changelog generators that read
git log v1.4.0..HEADfail becausev1.4.0isn't there. Some print an empty changelog instead of failing. -
"Only lint changed files" scripts using
git diff origin/main...HEADfail withno merge base, or worse, a fallback in the script lints nothing and passes. -
git blame-based tooling (ownership checks, "who touched this file" bots) sees one author for every line.
The common pattern: tools that were written against a full clone treat "no history" as "new repo," not as "broken environment." Quiet fallbacks turn a missing commit graph into wrong output.
How do I fix fetch-depth in GitHub Actions?
Set fetch-depth: 0 on the checkout step. That fetches all history for all branches and tags, and git describe, setuptools_scm and commit counts all work again.
- uses: actions/checkout@v4
with:
fetch-depth: 0
That was the whole fix for the nightly job. The next build came out as 1.4.1.dev212+g..., which was correct and also slightly embarrassing.
On a small repo, the extra fetch time is noise. On a big monorepo with years of binary assets in history, fetch-depth: 0 can make checkout noticeably slower, because you download every version of every file ever committed.
Is there a faster option than fetch-depth: 0?
Yes. Combine full history with a partial clone filter so you get every commit and tag, but only download file contents you actually check out:
- uses: actions/checkout@v4
with:
fetch-depth: 0
filter: blob:none
blob:none tells git to fetch all commits and trees but skip file blobs until something needs them. git describe, rev-list --count, merge-base and log only need commits, so they work at full speed. Commands that need old file contents, like git diff against an old commit, still work. Git just fetches those blobs on demand, which is slower per command but fine for occasional use.
For my repo, this got checkout back to roughly the same speed as the shallow default while giving setuptools_scm everything it needed.
Doesn't fetch-tags: true fix it?
Usually not. fetch-tags: true with the default depth of 1 downloads the tag refs, but git describe walks backwards from HEAD through parent commits looking for a tag. In a shallow clone, the walk stops at the shallow boundary after one commit. Unless the tag points at HEAD itself, git never reaches it.
This is the version that confuses people the most, because git tag now lists all your tags and git describe still says it can't find one. The tags are present. The path to them is not.
There is one case where shallow works: workflows triggered by pushing a tag, when the tagged commit is HEAD. That's why so many release jobs work fine while the nightly or main-branch job, building from the same repo with the same checkout step, quietly produces garbage. Same YAML, different trigger, different result.
How do I stop this from happening again?
Add a guard step right after checkout in any job that depends on git history. It fails loudly instead of letting a tool fall back:
- name: Require full git history
run: |
if [ "$(git rev-parse --is-shallow-repository)" = "true" ]; then
echo "::error::Shallow clone. Set fetch-depth: 0 on actions/checkout."
exit 1
fi
Two more habits that have saved me since:
-
Make version fallbacks fail. Replace
|| echo 0.0.0with an explicit error. A wrong version number is worse than a failed build, because the failed build gets fixed today. -
Pin the version when you truly can't fetch history.
setuptools_scmrespects theSETUPTOOLS_SCM_PRETEND_VERSIONenvironment variable. If some job builds from a tarball or a sparse environment, compute the version once in a job that has full history and pass it down, instead of letting each job guess.
If you have jobs that compare against main for "changed files" logic and don't want full history, fetch just what you need:
git fetch --no-tags --depth=50 origin main
git merge-base origin/main HEAD
Fifty is arbitrary. If your branches live longer than that, merge-base will fail again, which is at least a loud failure.
Why does GitHub use a shallow clone by default?
Because most workflows only run tests on the current code, and a depth-1 fetch is the fastest way to get it. Thousands of runs a day across millions of repos make that a sensible default. The cost lands only on the minority of jobs that read history, and those jobs fail in the quietest way possible.
I don't think the default is wrong. I think every workflow that computes a version, a build number or a diff should say fetch-depth out loud in its YAML, so the next person reading it knows the job depends on history.
The short answer
GitHub Actions fetch-depth: 1 is the default in actions/checkout, and it gives your runner a shallow clone with one commit and no tags. That makes git describe fail, makes setuptools_scm report 0.1.dev1+g<hash>, makes git rev-list --count HEAD return 1, and breaks any diff against main, often without a failing step. Set fetch-depth: 0 (with filter: blob:none on large repos) on any job that reads git history, don't rely on fetch-tags: true alone, and add a guard that fails the job when git rev-parse --is-shallow-repository returns true. My nine 0.1.dev1 nightlies took one line of YAML to fix and nine days to notice.
Written by the developer behind Preterview, an interview prep platform.
Top comments (0)