Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 33 additions & 20 deletions docs/versioning.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,28 @@ There is no manual version bumping and no GitVersion/.NET dependency.
- **Version selection** is derived from commit messages. A commit that does not match a
release rule triggers no release.
- **Tags** are created automatically as `vMAJOR.MINOR.PATCH` (e.g. `v1.2.3`) and are the
only tags the repository uses.
- **Changelog / GitHub releases** are generated from the same commits.
only tags the repository uses — except on `feature/*` branches, which are
npm-only: the tag is deleted right after publishing and no GitHub Release is
created.
- **Changelog / GitHub releases** are generated from the same commits (stable,
beta and rc channels only).

## Branch → channel mapping

| Branch | Version channel | Example | npm dist-tag |
| ----------- | ---------------------------- | --------------- | ------------ |
| `master` | Stable release | `1.2.3` | `latest` |
| `develop` | Beta prerelease | `1.2.3-beta.1` | `beta` |
| `feature/*` | Alpha prerelease | `1.2.3-alpha.1` | `alpha` |
| `hotfix/*` | Release-candidate prerelease | `1.2.3-rc.1` | `rc` |
| Branch | Version channel | Example | npm dist-tag |
| ----------- | ---------------------------------------------- | -------------------- | ---------------- |
| `master` | Stable release | `1.2.3` | `latest` |
| `develop` | Beta prerelease | `1.2.3-beta.1` | `beta` |
| `feature/*` | Feature-name prerelease (npm-only, no git tag) | `1.2.3-my-feature.1` | `<feature-name>` |
| `hotfix/*` | Release-candidate prerelease | `1.2.3-rc.1` | `rc` |

The prerelease identifier (and npm dist-tag) for a `feature/*` branch is the
sanitized branch name after `feature/`: lowercased, with runs of characters
outside `[a-z0-9-]` collapsed to a single `-` and leading/trailing `-` stripped
(e.g. `feature/My_Cool thing` → `my-cool-thing`). Keep feature branch names to
lowercase alphanumerics and dashes — two branches that sanitize to the same
identifier will conflict. If sanitization yields an empty string it falls back to
`alpha`.

Stable releases happen **only** on `master`. Pre-release channels never touch `latest`.
Work that lands on a pre-release branch is re-analyzed when merged into `master`, so the
Expand Down Expand Up @@ -58,21 +69,23 @@ published `0.0.1`.
- updates `package.json` in the packed artifact,
- publishes to npm with OIDC **trusted publishing** and **provenance**
(no `NPM_TOKEN` secret needed; the job carries `id-token: write`),
- pushes the `vX.Y.Z` tag,
- opens a GitHub Release with the generated changelog.
- pushes the `vX.Y.Z` tag — except on `feature/*`, where the tag is deleted
immediately after publishing so no git tag remains,
- opens a GitHub Release with the generated changelog — except on `feature/*`,
which skips the `@semantic-release/github` plugin entirely.

If no release-worthy commit exists, `semantic-release` exits without releasing.

## Manual operations

| Goal | How |
| ------------------- | --------------------------------------------------------------- |
| Cut a patch release | Commit `fix:` on `master` (or merge a PR with one) and push. |
| Cut a minor release | Commit `feat:` on `master` and push. |
| Cut a major release | Commit with `BREAKING CHANGE:` / `feat!:` on `master` and push. |
| Release a beta | Push to `develop`. |
| Release an alpha | Push to a `feature/*` branch. |
| Publish no version | Use `chore:`, `docs:`, `refactor:`, etc. |
| Goal | How |
| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| Cut a patch release | Commit `fix:` on `master` (or merge a PR with one) and push. |
| Cut a minor release | Commit `feat:` on `master` and push. |
| Cut a major release | Commit with `BREAKING CHANGE:` / `feat!:` on `master` and push. |
| Release a beta | Push to `develop`. |
| Release a feature preview | Push to a `feature/*` branch. Publishes `x.y.z-<feature-name>.n` to npm under the `<feature-name>` dist-tag; no git tag, no GitHub Release. |
| Publish no version | Use `chore:`, `docs:`, `refactor:`, etc. |

Do **not** tag releases by hand. `v*` tags created manually bypass the changelog and
provenance flow and can confuse the next analysis.
Expand All @@ -92,7 +105,7 @@ The old `GitVersion.yml` (Invoicify) maps as follows:
| `mode: ContinuousDeployment` | release on every push to a release branch |
| `branches.main` (`^master$`, release) | `master` (stable) |
| `branches.develop` (label `beta`) | `develop` → `prerelease: beta` |
| `branches.feature` (label `alpha`) | `feature/*` → `prerelease: alpha` |
| `branches.feature` (label `alpha`) | `feature/*` → `prerelease:`/channel from sanitized branch name (npm-only, tag deleted) |
| `branches.hotfix` (patch) | `hotfix/*` → `prerelease: rc` |
| `prevent-increment.when-current-commit-tagged` | built-in: tagged commits are skipped |
| `mathieudutour/github-tag-action` | tag created by `semantic-release` itself |
Expand All @@ -102,7 +115,7 @@ The old `GitVersion.yml` (Invoicify) maps as follows:

| File | Purpose |
| -------------------------- | ---------------------------------------- |
| `.releaserc.yml` | Branch → channel mapping and plugin list |
| `release.config.mjs` | Branch → channel mapping and plugin list |
| `.github/workflows/ci.yml` | Build/test gate + `release` job |
| `package.json` | `semantic-release` in `devDependencies` |

Expand Down
50 changes: 48 additions & 2 deletions release.config.mjs
Original file line number Diff line number Diff line change
@@ -1,10 +1,55 @@
import { execFileSync } from 'node:child_process';

Check warning on line 1 in release.config.mjs

View workflow job for this annotation

GitHub Actions / Install, lint, test, build

effecttsgo(node-builtin-import)

release.config.mjs:1:30: This module reference uses the `child_process` module, the corresponding Effect API is `CommandExecutor` from `@effect/platform`.

Check warning on line 1 in release.config.mjs

View workflow job for this annotation

GitHub Actions / Install, lint, test, build

effecttsgo(node-builtin-import)

release.config.mjs:1:30: This module reference uses the `child_process` module, the corresponding Effect API is `CommandExecutor` from `@effect/platform`.

// Sanitizes a `feature/*` branch name into a valid semver prerelease identifier
// (and npm dist-tag). Evaluated by semantic-release as a lodash template with
// the `name` variable bound to the full branch name (e.g. `feature/my-thing`).
const featureIdentifier =
'${name.replace(/^feature\\//, "").toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "") || "alpha"}';

// Best-effort git: deleting an already-absent tag is expected, so swallow the
// failure rather than aborting an otherwise successful release.
const tryGit = (...args) => {
try {
execFileSync('git', args, { stdio: 'ignore' });
} catch {
// Tag may not exist locally or remotely; the release itself succeeded.
}
};

// Feature/* releases are npm-only: semantic-release always creates a git tag,
// so delete it (local + remote) right after a successful feature release.
// The matching `@semantic-release/github` exclusion below avoids leaving a
// GitHub Release behind that points at the deleted tag.
const removeGitTagOnFeature = {
async success(_pluginConfig, context) {
const branchName = context.branch.name;
if (!branchName.startsWith('feature/')) {
return;
}
const tag = `v${context.nextRelease.version}`;
tryGit('tag', '-d', tag);
tryGit('push', 'origin', `:refs/tags/${tag}`);
context.logger.log(`Deleted git tag ${tag} (feature branch releases are npm-only).`);
},
};

// In CI the release job runs on push, so GITHUB_REF_NAME holds the branch name.
// Feature branches publish to npm only, with no GitHub Release (which requires
// a git tag).
const isFeatureBranch = (process.env.GITHUB_REF_NAME ?? '').startsWith('feature/');

/**
* @type {import('semantic-release').GlobalConfig}
*/
export default {
// Semantic-release configuration
// Full strategy: see docs/versioning.md
branches: ['master', { name: 'develop', prerelease: 'beta' }, { name: 'feature/*', prerelease: 'alpha' }, { name: 'hotfix/*', prerelease: 'rc' }],
branches: [
'master',
{ name: 'develop', prerelease: 'beta' },
{ name: 'feature/*', prerelease: featureIdentifier, channel: featureIdentifier },
{ name: 'hotfix/*', prerelease: 'rc' },
],
tagFormat: 'v${version}',
plugins: [
[
Expand All @@ -21,6 +66,7 @@
],
'@semantic-release/release-notes-generator',
'@semantic-release/npm',
'@semantic-release/github',
...(isFeatureBranch ? [] : ['@semantic-release/github']),
removeGitTagOnFeature,
],
};
Loading