Channel promotion silently skipped when source branch (e.g. `next`) is auto-deleted on merge

Author: gr2mCreated Jun 10, 2026Updated Aug 12, 2026

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: nextbase: 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 latest was not updated by the run (it stayed behind; we corrected it manually afterwards)
  • git note for v25.0.5 is still {"channels":["next"]} (no default channel)
  • GitHub releases v25.0.4/v25.0.5 are still flagged Pre-release; v25.0.3 is still Latest

Root cause

semantic-release builds its entire branch model from branches that currently exist on the remote. In lib/branches/expand.js:

javascript
const gitBranches = await getBranches(repositoryUrl, { cwd });
// micromatch each configured branch against the *existing* git branches

A 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:

javascript
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:

javascript
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 during normalize, 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 flows master → N.x and already has its own mergeRange guard).
  • 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 with channels:["next"] reachable from master but no next branch 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 next branch at master before the release job runs, so it's still present in the branch set when promotion is evaluated.

Source: semantic-release/semantic-release