diff --git a/docs/versioning.md b/docs/versioning.md index 78a7728..4ae3667 100644 --- a/docs/versioning.md +++ b/docs/versioning.md @@ -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` | `` | +| `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 @@ -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-.n` to npm under the `` 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. @@ -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 | @@ -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` | diff --git a/release.config.mjs b/release.config.mjs index 7558c56..840f757 100644 --- a/release.config.mjs +++ b/release.config.mjs @@ -1,10 +1,55 @@ +import { execFileSync } from 'node:child_process'; + +// 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: [ [ @@ -21,6 +66,7 @@ export default { ], '@semantic-release/release-notes-generator', '@semantic-release/npm', - '@semantic-release/github', + ...(isFeatureBranch ? [] : ['@semantic-release/github']), + removeGitTagOnFeature, ], };