Skip to content

feat!: find the last release from tags, not package.json - #10

Merged
Mearman merged 8 commits into
mainfrom
feat/tag-derived-last-release
Oct 13, 2026
Merged

Mearman merged 8 commits into
mainfrom
feat/tag-derived-last-release

Conversation

@Mearman

@Mearman Mearman commented Oct 13, 2026 •

Copy link
Copy Markdown
Member

resolveBuildIdentity and predictNextVersion took the last release from package.json's version and then looked for its tag. That only works while every release also commits a version bump. A repository that releases with just a tag and a GitHub Release (no @semantic-release/git or @semantic-release/npm commit) keeps an old package.json version, so after its next release the prediction keeps analysing commits since the stale tag and resolveBuildIdentity never reports kind: 'release'.

Releases are now read from tags alone, and nothing reads package.json.

resolveBuildIdentity reports kind: 'release' when any release tag points at HEAD (git tag --points-at HEAD), with the version read out of the tag; with several, the highest wins, and a lower one on HEAD (a backport tagged after a newer release) still counts. It looks at HEAD only, so it works in a shallow clone, and a clone made without tags reports kind: 'commit' rather than throwing.

findLastRelease is the highest major.minor.patch tag in the tag template among the tags git tag --merged HEAD lists, which is how semantic-release's own core picks the last release. Prerelease tags, tags in another format and tags on commits HEAD does not contain are ignored. It throws, saying what to fetch (fetch-depth: 0), in any shallow clone, since a higher tag could sit beyond the cut, and when no release tag is reachable. predictNextVersion analyses the commits since that tag and inherits both errors. There is no first-release fallback: a clone made without its tags looks exactly like a repository that never released, and predicting 1.0.0 there names a version that may already exist.

This is a breaking change and should release as a major. The tagName function option becomes tagTemplate, a string with {version} exactly once, because a tag has to be read back into a version and a function cannot be inverted; the CLI's --tag-name already took this template, so the CLI is unchanged. readPackageVersion is removed; a caller that used it for "the last released version" gets that from findLastRelease(root).version, which needs the full history and tags wherever it runs.

Every git call the library makes now drops GIT_DIR, GIT_WORK_TREE and GIT_INDEX_FILE from its environment. A git hook exports them for its own repository and git then ignores repoRoot; this repository's pre-push hook runs the test suite, so the fixtures were exposed to it too.

Since the first push: the release check moved from "the highest reachable release is on HEAD" to "any release tag is on HEAD"; findLastRelease throws instead of returning undefined, and the 1.0.0 first-release prediction is gone, because a shallow or tagless clone of a repository at v4.2.0 predicted 1.0.0; the hook environment fix; the log range names refs/tags/ in full; version comparison parses typed parts instead of defaulting missing fields; and tests for shallow and tagless clones, annotated tags, a merged side-branch tag, a higher tag on an unmerged branch, a tag on an unreachable commit, vNext, the backport case and the hook environment. Each new test was checked by breaking the code it covers and watching it fail.

Verified with the repository's own pnpm typecheck, pnpm lint, pnpm test (including the CLI e2e suite against a fresh build), pnpm build and attw --pack, on every commit.

Known consumers, all private repositories, found by code search across the owning organisations and by reading each one's package.json, call sites and workflow checkouts. None passes the tagName option. Two call only resolveBuildIdentity, predictNextVersion and loadCommitAnalyzer with the default template and need nothing beyond the version bump; every job of theirs that predicts checks out with fetch-depth: 0, and their shallow jobs only resolve the identity, which still works there. One imports readPackageVersion for an API document's version; its bump replaces that call with findLastRelease(root).version, and the jobs that build it already check out with fetch-depth: 0. Its exact pin means the major does not reach it until it is bumped on purpose.

resolveBuildIdentity and predictNextVersion took the last release from
package.json's version and looked up its tag. A repository whose
releases are only a tag and a GitHub Release, with no release commit
bumping package.json, therefore predicted from a stale version forever
and never resolved a release.

Both now start from findLastRelease: the highest major.minor.patch tag
in the tag template that git tag --merged HEAD lists, the way
semantic-release's core finds the last release. Prerelease tags, tags in
another format and tags on commits HEAD does not contain are ignored.
With no release tag reachable, prediction analyses the whole history and
gives 1.0.0, as semantic-release does for a first release.

BREAKING CHANGE: the tagName function option of resolveBuildIdentity
and predictNextVersion is replaced by tagTemplate, a string holding
{version} exactly once (the CLI's --tag-name template, unchanged).
readPackageVersion and package.json reading are removed; findLastRelease
gives the last released version from tags instead.
A git hook exports GIT_DIR, GIT_WORK_TREE and GIT_INDEX_FILE for the
repository it fired in, and git honours them over its working directory.
Every git call here took repoRoot as that working directory, so a build
or test run launched from a hook would inspect the hook's repository
instead. Strip the three variables from the environment of every git
call, the test fixtures' included.
resolveBuildIdentity only checked whether the single highest release
reachable from HEAD was on HEAD, so a commit carrying a lower release
tag (a backport tagged after a newer release) reported as a plain
commit. It now lists the tags at HEAD (git tag --points-at HEAD) and
reports the highest release among them. Only HEAD is inspected, so a
shallow or tagless clone still resolves, to kind 'commit'.
With no release tag visible, predictNextVersion assumed a first release
and predicted 1.0.0. A clone made without its tags looks exactly like
that, so a repository already released several times got 1.0.0, a
version that may already exist as a tag.

findLastRelease now throws instead of returning undefined: in any
shallow clone, since a higher tag could sit beyond the cut, and when no
release tag is reachable from HEAD. Each message says what to fetch
(fetch-depth: 0). predictNextVersion inherits both, and its log range
names refs/tags/ in full.
@Mearman
Mearman marked this pull request as ready for review October 13, 2026 19:50
@chatgpt-codex-connector

Copy link
Copy Markdown

This security review couldn’t start because the paying account has exhausted its Codex allowance and has insufficient available credits. The payer can check their balance and add credits in the Codex usage dashboard, then try again. If you do not manage the paying account, contact this repository's admins.

@Mearman
Mearman merged commit 0ef1c24 into main Oct 13, 2026
4 checks passed
@Mearman
Mearman deleted the feat/tag-derived-last-release branch October 13, 2026 19:50
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 3.0.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant