Nothing unreleased, said the shallow clone

Release · No. 104

Shipped

First release of a skill that cuts a release for one named component and proves the tag exists by reading it back from the remote. Two fixes landed alongside it, and both are about the same failure: a release tool producing a confident number it had no way to know.

One was a commit range computed in a shallow clone. The other was the release itself firing on a merge, which is not a decision anybody made.

The question underneath every release flow

“What is unreleased?” is what a bump, a changelog draft, and a suggested semver level are all derived from. The usual implementation is one line:

git log <last-tag>..HEAD

Git’s own revision documentation defines that notation precisely: you “ask for commits that are reachable from r2 excluding those that are reachable from r1.” The word carrying all the weight is reachable. The exclusion is computed by walking ancestry, and a clone with grafted history does not have the ancestry to walk.

It does not error. It walks what it has and returns a plausible list.

This is a CI problem more than a laptop problem, because actions/checkout documents fetch-depth as “number of commits to fetch. 0 indicates all history for all branches and tags” with a default of 1. Every job that checks out normally and then asks a range question is asking it of a repository that cannot answer.

Reproduce the wrong answer with real git

This takes about ten seconds and is worth doing once, because the failure is quiet enough that reading about it does not stick.

mkdir -p demo/origin && cd demo/origin
git init -q -b main .
git config user.email [email protected] && git config user.name T
for i in 1 2 3 4 5; do echo "$i" > f$i.txt; git add f$i.txt; git commit -q -m "feat: thing $i"; done
git tag mypkg-v0.1.0
for i in 6 7; do echo "$i" > f$i.txt; git add f$i.txt; git commit -q -m "fix: thing $i"; done
cd ..
git clone -q file://$PWD/origin full
git clone -q --depth 1 file://$PWD/origin shallow
git -C shallow fetch -q --tags origin

Two commits landed after the tag. Ask both clones:

echo "full    $(git -C full    log --oneline mypkg-v0.1.0..HEAD | wc -l)"
echo "shallow $(git -C shallow log --oneline mypkg-v0.1.0..HEAD | wc -l)"
full           2
shallow        1

No warning, no non-zero exit, one number quietly wrong. Which direction it is wrong in depends on where the graft point falls relative to the tag; in the incident that prompted this fix, a CI job saw one unreleased commit where a full clone saw zero, which failed a test asserting that a component with nothing to release is refused. A tool deriving a suggested version bump from that list is deriving it from fiction.

Refuse instead of guessing

The fix is not a better range query. There isn’t one. The fix is to detect that the question is unanswerable and say so, because every number downstream of the commit list inherits the error. Save this as unreleased.mjs:

#!/usr/bin/env node
/** What has landed since the last tag, or a refusal. Never a guess. */
import { execFileSync } from 'node:child_process';

const git = (repo, args) =>
  execFileSync('git', ['-C', repo, ...args], { encoding: 'utf8' }).trim();

export function unreleased(repo, tag) {
  // `tag..HEAD` means "reachable from HEAD, excluding everything reachable
  // from tag". A shallow clone's history is grafted, so the exclusion is
  // computed over ancestry that is not all there -- and git answers anyway.
  if (git(repo, ['rev-parse', '--is-shallow-repository']) === 'true') {
    return {
      ok: false,
      error: 'shallow repository: commit ranges are not answerable here',
      fix: 'git fetch --unshallow --tags   (or actions/checkout with fetch-depth: 0)',
    };
  }
  if (!git(repo, ['tag', '--list', tag])) {
    return { ok: false, error: `no such tag: ${tag}`, fix: 'git fetch --tags' };
  }

  const log = git(repo, ['log', '--format=%h %s', `${tag}..HEAD`]);
  const commits = log ? log.split('\n') : [];
  const bump = commits.some((c) => /^\w+ \w+!:|BREAKING/.test(c))
    ? 'major'
    : commits.some((c) => /^\w+ feat/.test(c))
      ? 'minor'
      : commits.length ? 'patch' : 'none';
  return { ok: true, count: commits.length, commits, suggestedBump: bump };
}

if (import.meta.url === `file://${process.argv[1]}`) {
  const [repo, tag] = process.argv.slice(2);
  const r = unreleased(repo, tag);
  console.log(JSON.stringify(r, null, 2));
  if (!r.ok) process.exit(2);
}

git rev-parse --is-shallow-repository is the whole detection, and it is one call. Run it against both clones:

node unreleased.mjs full mypkg-v0.1.0
node unreleased.mjs shallow mypkg-v0.1.0
{
  "ok": true,
  "count": 2,
  "commits": [
    "ae0b215 fix: thing 7",
    "fd27825 fix: thing 6"
  ],
  "suggestedBump": "patch"
}
{
  "ok": false,
  "error": "shallow repository: commit ranges are not answerable here",
  "fix": "git fetch --unshallow --tags   (or actions/checkout with fetch-depth: 0)"
}

(Your short hashes will differ, since you just created that repository.)

The refusal carries the command that fixes it. A blocker that does not tell you how to unblock is how people learn to pass --force.

Two details worth stealing. The shallow check runs before the tag check, because in a shallow clone a missing tag is a symptom rather than the cause, and diagnosing the symptom sends you looking for the wrong thing. And the failure exits 2 rather than 1, so a caller can distinguish “cannot answer” from “answered: nothing to release”.

The second half: a merge is a push

Being able to trust the number is only useful if something then makes a decision with it. The other fix in this release is that promoting a branch no longer cuts a tag at all.

Auto-merging a promotion branch fires a push event, and a release job listening for pushes to the release branch therefore releases on merge. GitHub’s events reference is plain that push “runs your workflow when you push a commit or tag,” and a merge commit is a commit. So the release fires with nobody having chosen it.

That cost two real releases. One package was tagged 28 seconds after its pull request merged, before a planned changelog edit had landed, so the published release notes were stale; the later, intentional dispatch was a silent no-op because the release workflow skips a tag that already exists. A second package was tagged and published to a registry seconds after its merge, with no dispatch and no decision.

The replacement is workflow_dispatch, which the same docs describe as the way “to enable a workflow to be triggered manually.” The version bump lands on the release branch and stops there, sitting as an untagged bump until something dispatches deliberately.

Gotchas

The two halves have to land together. Removing the push trigger without adding a dispatch leaves the release tool waiting forever for a tag nobody cuts. Adding the dispatch without removing the push trigger double-releases. Escape: treat “what fires the release” as a single switch with exactly one position, change both ends in one commit, and pin it with a check asserting the release job is dispatch-only and does not admit push, because that is the property a well-meaning future edit is most likely to undo.

A default of one commit is a default that answers wrongly rather than loudly. fetch-depth: 1 is the right default for building and testing, and the wrong one for any job that asks a history question. Escape: set fetch-depth: 0 on the jobs that need ancestry, and have the tool itself refuse rather than trusting every caller to remember.

An audit that flags something harmless gets ignored, and then it protects nothing. The check enforcing dispatch-only initially matched any workflow mentioning the release workflow, which included one that only referenced it in a comment and has no release job at all. Escape: match on the structural thing that makes a file capable of the behaviour, in this case the uses: line, not on the string appearing anywhere in the file.

A baseline that reads live repository state is not a baseline. The trap test asserting “a component with nothing to release is refused” shelled out to the real repository, so it passed on a full local clone and failed in CI for reasons unrelated to the code. Escape: drive frozen fixtures through an explicit input path so the test touches git not at all, and treat any baseline that reads live state as a bug in the baseline rather than an environment quirk.

Sources

Changelog

  • fix(release): match release callers on their uses: line, not a mention (5f09952)
  • feat(shipflow)!: a merge never cuts a tag — the dispatch is the release (9436595)
  • fix(shipflow): a shallow clone cannot answer “what is unreleased?” (#160) (164ab34)