Channel promotion silently skipped when source branch (e.g. `next`) is auto-deleted on merge
Summary
When a prerelease/next-channel branch is merged into the release branch and then deleted in the same step, semantic-release fails to promote the merged version to the release channel (@latest). The release run sees "no relevant changes" and does nothing — the @latest dist-tag, the git note channel, and the GitHub release "Latest" flag are never updated.
This repository (and our repos generally) have GitHub's "Automatically delete head branches" setting enabled, which is what triggers the problem: merging a next → master PR deletes the next branch one second before the master release job runs.
Real-world occurrence
Run: https://github.com/semantic-release/semantic-release/actions/runs/27242327567/job/80448862070
Timeline (all 2026-06-09):
| Time (UTC) | Event |
|---|---|
| 23:09:46 | v25.0.5 published from next → @next dist-tag (git note {"channels":["next"]}) |
| 23:24:06 | PR #4202 (head: next → base: master) merged |
| 23:24:07 | next branch auto-deleted (delete_branch_on_merge: true) |
| 23:26 | master release runs [email protected] → "There are no relevant changes, so no new version is released." |
Resulting inconsistent state:
- npm
latestwas not updated by the run (it stayed behind; we corrected it manually afterwards) - git note for
v25.0.5is still{"channels":["next"]}(no default channel) - GitHub releases
v25.0.4/v25.0.5are still flagged Pre-release;v25.0.3is still Latest
Root cause
semantic-release builds its entire branch model from branches that currently exist on the remote. In lib/branches/expand.js:
const gitBranches = await getBranches(repositoryUrl, { cwd });
// micromatch each configured branch against the *existing* git branchesA configured branch (even a literal "next") that matches no live git ref yields zero entries, so next disappears from the normalized branch list once it's deleted.
The channel-promotion logic in lib/get-release-to-add.js then derives the set of promotable "higher" channels from that live list:
const higherChannels = branches // live, existing-only branches
.slice(branches.findIndex(({ name }) => name === branch.name) + 1)
.filter(({ type }) => type !== "prerelease")
.map(({ channel }) => channel || null);
const versiontoAdd = uniqBy(branch.tags.filter(({ channels, version }) =>
!channels.includes(branch.channel || null) &&
intersection(channels, higherChannels).length > 0 && // ← fails when `next` is gone
...The tag's channel metadata survives the deletion — v25.0.5 is correctly read as {"channels":["next"]} — but "next" is no longer in higherChannels, so the intersection(...) guard fails and getReleaseToAdd returns nothing. No addChannel → @latest never updated.
Note: this is not #4073 (the
getTagsNotes()multi-note JSON-parse bug). The notes parsed correctly here; the failure is that the source channel/branch had vanished from the config, not that its note was unreadable.
Proposed fix
Derive higherChannels from the configured branch definitions (context.options.branches, e.g. the default "next" in lib/get-config.js) rather than from the live, existing-only branch list. Those definitions persist after the git branch is deleted:
import { isString } from "lodash-es";
const configured = context.options.branches.map((b) => (isString(b) ? { name: b } : b));
const currentIdx = configured.findIndex((b) => b.name === branch.name);
const higherChannels = configured
.slice(currentIdx + 1)
.filter((b) => !b.prerelease) // keep excluding beta/alpha
.map((b) => b.channel ?? b.name ?? null); // next → "next", next-major → "next-major"All the other guards stay intact (!channels.includes(branch.channel || null), the prerelease exclusion, and the semver.gt(getLastRelease(...).version, version) check), so this only widens the set of recognized release channels — it never promotes a prerelease channel or re-promotes an already-promoted tag.
Caveats for the implementation
- Maintenance branches (
N.x) and regex names derive their channels/ranges from existing tags duringnormalize, which can't be fully reconstructed statically. Scope the change to release-channel sources (next/next-major/ named release branches) and leave the maintenance path keyed off live branches (maintenance promotion flowsmaster → N.xand already has its ownmergeRangeguard). - Ordering for release branches comes from config order, which is stable, so "higher than current" remains well-defined without the live list.
- Add a regression test in
test/get-release-to-add.test.js: tags withchannels:["next"]reachable frommasterbut nonextbranch in the expanded set → still produces a release-to-add.
Workarounds today
- Disable "Automatically delete head branches" for repos using a
next/prerelease promotion flow, or - Recreate the
nextbranch atmasterbefore the release job runs, so it's still present in the branch set when promotion is evaluated.
Source: semantic-release/semantic-release