@cenk1cenk2/semantic-release-config
v5.1.0
Published
Shared semantic-release configuration for my taste.
Downloads
1,054
Readme
@cenk1cenk2/semantic-release-config
Shared semantic-release defaults for the repositories on gitlab.kilic.dev.
There are two ways to use it, and both are supported.
A release preset is a whole release in one plugin entry. It owns the branch list, the commit-analysis preset, the changelog, the release commit and the publish, and the repository says only what is true of that repository.
// release.config.js — a chart repository, entire
export default {
extends: '@cenk1cenk2/semantic-release-config',
plugins: [['@cenk1cenk2/semantic-release-config/presets/helm', { assets: { extend: ['Chart.yaml'] } }], '@semantic-release/gitlab']
}extends plus your own plugins array is the older shape, and nothing about it has changed. The package contributes the branch list and the commit-analysis preset, the repository writes out every plugin, and the array in front of you is the array that runs.
Reach for a preset when a repository releases the way the rest of the estate does. Write the array out when it does not, or when you would rather read the whole pipeline than open this package to find out what it does.
Release presets
Two things are called a preset here, and they are not the same thing. A release preset is one of this package's entrypoints —
presets/npm,presets/helmand the rest — and is what this section is about. The analyzer'spresetoption names aconventional-changelog-*package and is what decides how a commit message parses. A release preset owns that option rather than exposing it, which is the only place the two meet.
A release preset is one plugin object implementing every step of a release. Where the older shape asks each repository to write out the same six plugins and keep them in the right order, a preset puts that order inside itself and asks for the handful of things that actually differ.
That inverts the merge problem below rather than dodging it. semantic-release replaces an extended plugins array outright, which is why this package could never ship a useful one — every repository needing a provider would throw it away. A preset makes the repository's array one entry long, so replacing it costs nothing.
What a preset owns
- The branch list, the commit-analysis preset and the release rules. The analyzer's
presetoption is not exposed: the plugins default toangular, whose parser does not matchfeat!:at all, and a repository that could set it could turn that back on by accident. Which commit types earn a release, and which earn a changelog section, are settled the same way — see Which commit types release.analysiscarries everything else the analyzer and the notes generator take. - The changelog and the release commit, in that order, after the version bump — which is the order the estate got wrong by hand often enough to be worth fixing once.
- The publish, through the providers named below, and the dist-tag promotion that follows it: a version released on
nextand later merged tomainbecomeslatestwithout being republished.
A preset does not own the provider. Whether a release goes to GitLab or to GitHub is the repository's fact, so it names its own — a preset entry and a provider entry is still a plugins array a person can read at a glance.
Preset entrypoints
| Entrypoint | For | provider | tagsFile | tagPrefix | publish | Release commit carries |
| ----------------- | --------------------------------- | ---------- | ---------- | ----------- | --------- | ------------------------- |
| /presets/npm | npm packages | ['npm'] | — | v | direct | changelog, package.json |
| /presets/helm | helm charts | ['tag'] | .tags | v | false | changelog |
| /presets/docker | container images | ['tag'] | .tags | v | false | changelog |
| /presets/tag | ansible roles, CI catalogs, sites | ['tag'] | .tags | v | false | changelog |
Every family writes a v-prefixed version, /presets/docker included — the image tag is v1.2.3, the same string the git tag carries, so a released version reads the same wherever it is written down. /presets/helm, /presets/docker and /presets/tag carry the same defaults today; they are three names so that a repository declares what it is, and so the day a chart preset has to rewrite Chart.yaml there is one place to do it.
Options
Every preset takes the same options. A family only decides what they default to.
| Option | Default | What it does |
| --- | --- | --- |
| provider | the family's | Publish targets, in the order they run. npm, tag, or both. [] publishes nothing and leaves the tag as the release. |
| assets | the family's | Files in the release commit. { extend: [...] } adds to the family's, { set: [...] } replaces them, a bare array is set. |
| changelog | { changelogFile: 'CHANGELOG.md' } | Passed to @semantic-release/changelog. false writes no changelog. |
| commit | chore(release): … [skip ci] | Passed to @semantic-release/git. false writes nothing back to the branch. |
| tagsFile | $TAGS_FILE, else the family's | Where the tag provider writes the version. |
| tagPrefix | the family's | Prefix on the version the tag provider writes. |
| client | npm | npm or pnpm. Picks the plugin behind the version bump and the publish. |
| publish | the family's | direct, staged, or false — how the npm provider ships the tarball. |
| workspace | unset | pnpm package selector, passed to a staged publish as --filter. |
| analysis | the estate's rules and sections | Extra analyzer and notes options: releaseRules, presetConfig, parserOpts. Not the analyzer's preset. releaseRules takes extend / set like assets. |
assets is two words rather than one because there are two things a repository means. extend adds to what the family already commits and is what almost every repository wants; set replaces the list, for the one that does not. A bare array is read as set — that is the shape semantic-release's own plugins take, so it is the shape written without thinking, and quietly treating it as extend would commit files nobody listed.
Providers
provider is an array because a repository can release to more than one place, and the order is the order they run in.
npmverifies the registry, writes the version intopackage.json, and ships the tarball according topublish.tagwrites<tagPrefix><version>totagsFilefor the job that runs after semantic-release. Charts, images and roles are not published by semantic-release at all — the release run decides the version and a later job builds and pushes something tagged with it. This replaces the@semantic-release/execentry the estate hand-copied for it.
semantic-release shows one row per publish hook, so a preset with several providers reports the first one — the array is ordered, and what a repository lists first is what it releases. Nothing is dropped: every provider's result rides along on that row, and success reports from all of them.
The one combination a preset rejects is more than one provider together with a release commit. It fails at verifyConditions with EMULTIPROVIDERPUSH, before a release is decided:
// rejected — the release commit is pushed before the first publish, and cannot be taken back
plugins: [['@cenk1cenk2/semantic-release-config/presets/npm', { provider: ['npm', 'tag'] }]]// fine — the tag alone marks the release
plugins: [['@cenk1cenk2/semantic-release-config/presets/npm', { provider: ['npm', 'tag'], commit: false }]]With one provider, a publish failure leaves a pushed commit and tag for a version that exists nowhere, which is recoverable by hand. With several, the run can also stop between them, and the branch is then claiming a release that happened in one registry and not the other. semantic-release cannot roll a pushed commit back, so the preset refuses the shape rather than discovering it during an incident.
What the guards raise
Every check below runs at verifyConditions, before a release is decided, and fails with a code the job log carries:
| Code | Raised when |
| --------------------- | ------------------------------------------------------------------------------------------- |
| EPRESETDOUBLEENTRY | the same preset is listed twice — a preset is the whole release, so two of them do it twice |
| EMULTIPROVIDERPUSH | several providers together with a release commit |
| EASSETSMERGE | assets was given both extend and set, or an object with neither |
| ERELEASERULESMERGE | analysis.releaseRules was given both extend and set, or an object with neither |
| EINVALIDPROVIDER | provider names something other than npm or tag |
| EINVALIDCLIENT | client is neither npm nor pnpm |
| EINVALIDPUBLISH | publish is not direct, staged or false |
| ENOTAGSFILE | the tag provider has nowhere to write: tagsFile is false and $TAGS_FILE is unset |
| EINVALIDSTAGECLIENT | a staged publish was asked for through a client that has no stage publish |
| ENOSTAGECLIENT | that client is not on PATH in the release job |
| ESTAGECLIENTVERSION | it is on PATH but older than the version that carries stage |
| ENOSTAGECREDENTIALS | nothing in the config chain holds a credential for the registry being staged to |
A preset aggregates them, so a repository being configured for the first time learns about the missing token and the unknown provider in one pipeline run rather than three.
Staged publishing
Staged publishing uploads the tarball to the registry in a state nothing resolves, and a maintainer approves it later with a 2FA challenge. The release job never holds a credential that can publish on its own.
Through a preset it is one option:
plugins: [['@cenk1cenk2/semantic-release-config/presets/npm', { client: 'pnpm', publish: 'staged' }]]Standalone, it is a plugin, for a repository that writes its own array:
plugins: [
['@semantic-release/commit-analyzer', { preset: 'conventionalcommits' }],
['@semantic-release/release-notes-generator', { preset: 'conventionalcommits' }],
['@anolilab/semantic-release-pnpm', { npmPublish: false }],
['@cenk1cenk2/semantic-release-config/plugin/staged-publish', { client: 'pnpm' }],
'@semantic-release/changelog',
['@semantic-release/git', { assets: ['CHANGELOG.md', 'package.json'] }],
'@semantic-release/gitlab'
]Either way:
It never blocks. A release job that waits on a human is a release job that times out.
It reports what happened.
publishstages, so the run would otherwise report a published version that nobody can install. It reportsnpm package (staged, pending approval: <stage-id>)instead, which is what the run's summary shows, andsuccessprints the one command that finishes it:@scope/[email protected] is STAGED, not published — nothing resolves it until a maintainer approves it with a 2FA challenge. approve with: pnpm stage approve <stage-id>No file is written. The stage id comes back on the release object the plugin's own
publishreturned, so semantic-release already carries it intosuccessand into the summary.It authenticates itself.
NPM_TOKENin the environment is the whole setup — nobefore_script, no npmrc for the job to write. This was the trap it exists to close:@semantic-release/npmand@anolilab/semantic-release-pnpmbuild a temporary npmrc fromNPM_TOKENand pass it to their own client calls as--userconfig, and that file is invisible to any other process, so a staged publish saw no credentials at all and every repository had to echo one into~/.npmrcfirst. This plugin builds its own the same way and passes it the same way —pnpmtakes--userconfigtoo, aliased to--npmrc-auth-file.What lands in that file is the literal
${NPM_TOKEN}, which both clients expand from the environment as they read it, so the token itself is never on disk. An npmrc the job already has wins: when the existing config chain carries a token for the registry, it is passed through unchanged.It checks what it can up front.
verifyConditionsrejects an unknown client, one too old for the subcommand (npm11.15.0,pnpm11.3.0), and a registry it has no credential for — all before a release is decided.
Options are client, tag, access, workspace, pkgRoot and rejectOnFail. rejectOnFail is off by default and best-effort when on: stage reject wants proof of presence too, so without an OTP in the job it logs what to reject by hand rather than masking the failure that got there.
Migrating
Nothing is removed. /base and /npm still resolve, the recipes below still work, and a repository can stay on them indefinitely — the presets are an additional shape, not a replacement.
Moving one repository over is deleting its plugins array and naming a preset. Read the options table for what its old array said that the family's defaults do not, and check two things in particular: whether the release commit's asset list matches, and whether it published to more than one place, which a preset will reject unless commit: false.
Bringing your own plugins array
Everything below is the older shape, unchanged.
Why the plugins array is repeated
semantic-release merges an extended config shallowly, per top-level option (lib/get-config.js). A repository that declares plugins replaces the extended array outright — there is no append, no deep merge, no way to add one plugin to an inherited list.
That is not a limitation to work around. It is the reason this package does not ship pipelines: any plugin it put in plugins would be thrown away by every repository that needed to add a provider, so shipping one would only create the illusion of a shared default. What survives the merge is branches, and that is what extends is for here.
So yes, the two analysis plugins are written out again in each repository. In exchange, the plugin list in front of you is the plugin list that runs.
One thing does not survive that trade. The estate's release rules are a plugin option, not a top-level one, so a hand-written array gets @semantic-release/commit-analyzer's defaults and not this package's — a refactor: or revert: commit releases nothing there. Extend /base, use a preset, or write the rules out beside the plugin: Which commit types release has the array.
Entrypoints for extends
| Entrypoint | Contents |
| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| /base | branches, commit-analyzer and release-notes-generator with preset: conventionalcommits, the estate's release rules and changelog sections |
| /npm | the same, plus @semantic-release/npm at its own defaults |
The bare specifier @cenk1cenk2/semantic-release-config resolves to /base.
/npm exists because the registry auth check and the package.json version bump are done identically everywhere; the publish mode is not. A package that publishes directly and writes nothing back can use it through extends alone. Anything else declares its own array.
Neither /chart, /docker nor /tag is an extends entrypoint. Once write-back and tag behaviour belong to the repository, those three had nothing left in them that /base did not already have, and three names for one object is worse than one. The /presets/* entrypoints above carry those names because a preset does own that behaviour, and there the names mean something.
Building the config yourself
The bare specifier also exports the two factories the entrypoints above are built from, for a config file that wants the estate's defaults with one of them moved:
// release.config.js
import { base } from '@cenk1cenk2/semantic-release-config'
export default base({ branches: ['main', { name: 'edge', prerelease: true }] })| Option | Default | What it does |
| ---------- | ------------------------------------------------------------- | ---------------------------------------------------------------------------- |
| branches | main, master, next, next-major, alpha, beta, rc | The release branch list, replaced outright rather than added to |
| preset | conventionalcommits | The conventional-changelog-* package the analyzer and the notes parse with |
base() and npm() called with no arguments are exactly what /base and /npm are: the subpaths are those calls, prebuilt, and stay the extends targets to name. Reach for a factory only where a repository has to change one of the two — an extends string needs no node_modules in the release job, and an import does.
Repository recipes
Copy, adjust, own. Each needs extends for the branch list.
Chart — changelog committed back, GitLab release, v-prefixed tag for the chart publish job:
plugins: [
['@semantic-release/commit-analyzer', { preset: 'conventionalcommits' }],
['@semantic-release/release-notes-generator', { preset: 'conventionalcommits' }],
'@semantic-release/changelog',
['@semantic-release/git', { assets: ['CHANGELOG.md'] }],
'@semantic-release/gitlab',
['@semantic-release/exec', { publishCmd: "echo 'v${nextRelease.version}' > $TAGS_FILE" }]
]Docker — the same again, v prefix included: the image tag is v1.2.3, so the exec line is the chart one unchanged.
npm, published directly — extends /npm, and order @semantic-release/npm before @semantic-release/git so the version bump it writes into package.json is staged into the release commit:
plugins: [
['@semantic-release/commit-analyzer', { preset: 'conventionalcommits' }],
['@semantic-release/release-notes-generator', { preset: 'conventionalcommits' }],
'@semantic-release/npm',
'@semantic-release/changelog',
['@semantic-release/git', { assets: ['CHANGELOG.md', 'README.md', 'package.json'] }],
'@semantic-release/gitlab'
]npm, staged publish — npmPublish: false plus npm stage publish shelled out, because @semantic-release/npm still has no custom publish command (semantic-release/npm#1160). Swap @semantic-release/gitlab for @semantic-release/github on a repository that releases to a GitHub mirror:
plugins: [
['@semantic-release/commit-analyzer', { preset: 'conventionalcommits' }],
['@semantic-release/release-notes-generator', { preset: 'conventionalcommits' }],
['@semantic-release/npm', { npmPublish: false }],
['@semantic-release/exec', { publishCmd: 'npm stage publish ${nextRelease.channel ? "--tag " + nextRelease.channel : ""}' }],
'@semantic-release/changelog',
['@semantic-release/git', { assets: ['CHANGELOG.md', 'README.md', 'package.json'] }],
'@semantic-release/gitlab'
]Prefer plugin/staged-publish over the exec entry above. Shelling the command out means the job also has to hand it credentials, because the plugin beside it keeps its own npmrc private — that is the before_script echoing //registry.npmjs.org/:_authToken=${NPM_TOKEN} into ~/.npmrc that the estate carried in every repository. The plugin writes its own and needs nothing but NPM_TOKEN.
It handles the rest of it too. pnpm runs its publish git checks at a point in the run where the tree cannot pass them — the version bump is written into package.json and @semantic-release/git has not committed yet — so --no-git-checks is required, and the same flag waives the publish-branch check that would otherwise reject every prerelease branch. The plugin passes it; a hand-written exec command has to remember to.
@anolilab/semantic-release-pnpm is a drop-in replacement for @semantic-release/npm that does all of it through pnpm, and takes the same npmPublish: false. It has no staged mode of its own, which is what plugin/staged-publish is for. It is not one of the plugins the CI image carries — semantic-release also resolves plugins from the repository's own node_modules, so a repository using it declares it as a devDependency and relies on the install job's artifact.
This repository releases itself that way, through its own /presets/npm rather than by hand — see Releasing this package.
Tag only — ansible roles, CI component catalogs, static sites:
plugins: [
['@semantic-release/commit-analyzer', { preset: 'conventionalcommits' }],
['@semantic-release/release-notes-generator', { preset: 'conventionalcommits' }],
'@semantic-release/changelog',
['@semantic-release/git', { assets: ['CHANGELOG.md'] }],
'@semantic-release/gitlab'
]Notes
Plugin names resolve from the image, not from the repository
Name plugins as strings. semantic-release resolves a plugin the repository names from its own install directory, which in the CI image holds every plugin, so a repository needs no node_modules of its own. extends resolves the same way. pnpm test checks both against a real install.
Importing a plugin module in release.config.js instead would make node resolve it from the repository, which is the one thing that does not work there.
A preset looks like it breaks that rule and does not. The repository still names a string — @cenk1cenk2/semantic-release-config/presets/helm — which semantic-release resolves from the image exactly like any other plugin. The plugins a preset delegates to are resolved by node from this package, where they are declared dependencies, so they come along with it. That is why @semantic-release/changelog, @semantic-release/git, @semantic-release/npm and @anolilab/semantic-release-pnpm are dependencies here rather than devDependencies: a preset that imports them has to carry them. A repository consuming a preset declares none of them, and still needs no node_modules of its own.
The exports map has no conditions
Every target in exports is a plain string. That is load-bearing, not tidiness.
semantic-release finds a plugin with resolve-from, which is the CommonJS resolver, and then import()s the path it gets back (lib/plugins/utils.js). So a subpath is resolved under the require condition and loaded as ESM. Split it — { import: './x.js', require: './x.cjs' } — and semantic-release resolves the CommonJS file and then imports it, which is the one combination nobody tested. A plain string has one answer for both conditions. pnpm test asserts each subpath resolves under require.resolve and under import(), from the packed package.
What it reads off the loaded module is the default export where there is one and the named exports where there is not. A release preset is one object implementing every step, so it is the default; plugin/staged-publish is a module of hooks, so it is the names. A repository names either as a plugin string and never sees the difference.
Types ship with the package
The sources are plain JavaScript with JSDoc, and tsc -p tsconfig.build.json emits a .d.ts beside each of them. It runs on prepack, so a consumer installs declarations for the entrypoints, the preset options and the factory options without this package shipping TypeScript. semantic-release supplies the option and context types the declarations refer to — it is already a peer dependency, so an editor that can see one can see the other.
Which commit types release
| Type | Release | Changelog section |
| ------------------------------------------------- | ------- | ------------------------ |
| feat | minor | Features |
| fix | patch | Bug Fixes |
| perf | patch | Performance Improvements |
| refactor | patch | Code Refactoring |
| revert | patch | Reverts |
| docs, style, chore, test, build, ci | none | hidden |
| any type with !, or a BREAKING CHANGE: footer | major | Breaking Changes |
| git's own Revert "..." commit | patch | Reverts |
refactor and revert are this package's addition; the rest is what @semantic-release/commit-analyzer already did. Two things decided that pair:
- The bot writes none of them. The estate's renovate configuration emits
feat,perf,ci,build,docsand — throughconfig:recommended—fix(deps)andchore(deps). A rule forbuildorchorewould turn every dependency bump into a release. A rule forrefactororrevertchanges only what a person wrote by hand. revertwas a gap rather than a policy. The plugin's{ revert: true }default matches the shapegit revertwrites and not arevert:type, so a hand-writtenrevert:released nothing while already printing under Reverts in someone else's changelog.
The rules are configured, not restated. The analyzer consults its own defaults for any commit that no configured rule matched, so only the difference is written down:
// lib/rules.js
export const RELEASE_RULES = [
{ breaking: true, release: 'major' },
{ type: 'refactor', release: 'patch' },
{ type: 'revert', release: 'patch' }
]breaking is in that list on purpose. The fallback is per commit and only for a commit nothing matched, so refactor!: matching the refactor rule never reaches the defaults — and without the first line a breaking change would land as a patch. Anything that adds a type rule has to carry it.
The changelog side works the other way round: presetConfig.types replaces the preset's list outright, so COMMIT_TYPES is built from conventional-changelog-conventionalcommits' own exported default with refactor unhidden, and a type the preset adds later arrives on its own.
A repository changes either through analysis, which merges the way assets does:
// releases on docs as well, and stops releasing on refactor
plugins: [
[
'@cenk1cenk2/semantic-release-config/presets/npm',
{
analysis: {
releaseRules: {
extend: [
{ type: 'docs', release: 'patch' },
{ type: 'refactor', release: false }
]
}
}
}
]
]extend appends, and for release rules that ordering is the behaviour rather than a detail: the analyzer keeps the highest release type a commit matched, but release: false outranks every named type, so a rule landing after this package's can promote a type and can also switch one off. { set: [...] }, or a bare array, replaces the estate's rules entirely.
conventionalcommits, and why not the default
The analyzer's preset option is pinned to conventionalcommits, by the release presets and by /base and /npm alike. The plugins default to angular, whose parser has no ! support: its header pattern is ^(\w*)(?:\((.*)\))?: (.*)$, which does not match feat!:. Under it a ! commit parses with no type at all — so it is not a major, and not a release either. conventionalcommits carries breakingHeaderPattern, and accepts BREAKING-CHANGE as well as BREAKING CHANGE in the footer.
Nothing else about bump edges moves with it. @semantic-release/commit-analyzer decides releases from releaseRules and its own defaultReleaseRules, never from a preset's whatBump, so a conventional-changelog-* package supplies the parser and the writer and no more — what moves the edges is the rules above. What the switch changes is !, the hyphenated keyword, and how the notes render.
It costs a dependency. The plugins bundle conventional-changelog-angular and nothing else, so this package depends on conventional-changelog-conventionalcommits itself. That is enough to make it resolvable: the plugins look beside their own directory before the repository's cwd, and pnpm's hidden hoist at node_modules/.pnpm/node_modules sits on that path. Nothing is added to the CI image, and a repository consuming this package declares nothing of its own — but the suite installs its scratch image with pnpm rather than npm, because npm's flat hoisting resolves preset packages through paths the image does not have, and a scratch image built differently from the real one cannot answer the question.
The dependency is held at major 9, and renovate.json pins it there. Major 10 renders only through conventional-changelog-writer@9, while @semantic-release/release-notes-generator@14 pins writer ^8 — so 10 throws Missing helper out of handlebars while generating the notes, which happens after the release type has been decided. The pin lifts when the plugin carries writer 9.
rc appears once
The estate's inlined configs carried a duplicated rc entry in 26+ repositories. The list here has it once.
$TAGS_FILE has no default
The chart and docker recipes write to $TAGS_FILE, and it has to be set — the devops/pipelines semantic-release component exports it. There is no shell fallback: semantic-release runs @semantic-release/exec commands through a lodash template that claims ${...} for itself, so ${TAGS_FILE:-.tags} is not expressible.
Gates
pnpm lint, pnpm typecheck (tsc --noEmit over JSDoc-typed plain JavaScript) and pnpm test.
pnpm test is the real gate — test/consumption.test.js, on node's built-in runner, no framework. Nothing in it asserts against the working tree. It packs the package, installs it beside semantic-release in a scratch directory standing in for the CI image, and drives everything through that copy. A unit test over the source would restate the config objects as assertions and could only fail when someone edited one of two copies; this can fail for the reasons a release job fails.
That also puts files and exports under test with everything else — a module present in the working tree and missing from either is missing here. The suite reaches the installed package through a bridge module written into the scratch directory rather than by file path, so bare specifiers resolve from there, under the CommonJS resolver semantic-release actually uses as well as the ESM one.
What it covers:
extends, from a repository with nonode_modulesof its own —/baseand/npmresolve and their plugins load.- A repository-declared
pluginsarray — the branch list survives it, and a provider the repository names itself resolves. - Against a previous release — a
!commit reaches a major, which only happens whenconventionalcommitsis the commit preset parsing; a plugins array holding nothing but a preset entry still does the same. A negative control proves the branch assertions are not vacuous. - The exports map — every subpath answers
require.resolveandimport()alike, and each one carries the hooks in the shape semantic-release reads them from. - The
extendsfactories —base()andnpm()are what the prebuilt subpaths were built from, and a branch list handed to one replaces the branch list and nothing else. - The preset entrypoints — no two presets share a hook function, each reports its own name, both guards reject what they exist to reject and allow what they do not, and each family writes the tag shape it is named for.
- The staged publish — it reads a stage id out of its client, and refuses to run without credentials it can reach.
Every scratch run gets a whitelisted environment rather than the ambient one, and asserts which branch semantic-release thought it was on before asserting anything about branches — see below. It needs network. TEST_DEBUG=1 dumps raw semantic-release output.
The whitelisted environment is not tidiness. semantic-release takes the branch it thinks it is on from env-ci, which reads the surrounding CI's variables, and --no-ci does not turn that off — inside a GitLab job the fixture would otherwise be judged against the outer pipeline's branch and every branch assertion would quietly answer the wrong question. That is exactly how this passed locally and failed in CI once.
Releasing this package
Through its own preset, in release.config.js: /presets/npm with client: 'pnpm' and publish: 'staged', plus @semantic-release/gitlab.
It imports the preset from the working tree — ./presets/npm.js — rather than by package name. The specifier is the point. A bare specifier resolves the last published version, so the release shipping a change to the engine would be cut by the engine as it was before the change, which is the one shape of dogfooding that proves nothing.
The branch list comes from ./lib/defaults.js for the same reason, and because the package still cannot extends itself: semantic-release resolves extends from the repository's node_modules, and this package is not a dependency of itself.
