diff --git a/.github/skills/release-version/SKILL.md b/.github/skills/release-version/SKILL.md index 97366cad..ce3c4f6d 100644 --- a/.github/skills/release-version/SKILL.md +++ b/.github/skills/release-version/SKILL.md @@ -1,15 +1,21 @@ --- name: release-version -description: "Release a new r-shell version and create a published GitHub release with contributor credits. Use when: releasing, publishing, bumping version, tagging, creating release notes, gh release create, version bump, patch release, minor release, major release." -argument-hint: "bump type: patch | minor | major" +description: "Release a new r-shell version and create a published GitHub release with contributor credits. Supports both stable releases (vX.Y.Z) and tagged prerelease versions (vX.Y.Z-beta.N / -rc.N). Use when: releasing, publishing, bumping version, tagging, creating release notes, gh release create, version bump, patch release, minor release, major release, prerelease, beta, rc, tagged release, stable release." +argument-hint: "bump type: patch | minor | major | prerelease [identifier] | stable" --- # Release New Version & Create GitHub Release -Bumps the project version across all config files, updates the CHANGELOG, pushes a tag, and creates a **published** GitHub release using `gh`, with release notes that credit the contributors. +Bumps the project version across all config files, updates the CHANGELOG, pushes a tag, and creates a **published** GitHub release using `gh`, with release notes that credit the contributors. Two release kinds are supported: + +- **Stable release** — `vX.Y.Z` (e.g. `v2.8.0`), published as the repo's **Latest** release. The Release workflow uploads `latest.json` (the in-app updater manifest) and updates the Homebrew cask, so every stable user sees it. +- **Tagged (prerelease) release** — `vX.Y.Z-.` (e.g. `v2.8.0-beta.1`, `v2.8.0-rc.1`), published as a GitHub **prerelease** (never Latest). The Release workflow skips `latest.json` and Homebrew for prerelease tags, so stable users are never offered a prerelease and Homebrew is untouched. + +Both trigger the same `release.yml` build on a pushed `v*` tag; only the release **kind** differs. ## When to Use -- Releasing a new patch, minor, or major version of r-shell +- Releasing a new patch, minor, or major version of r-shell (stable) +- Releasing a tagged prerelease of r-shell (`-alpha`, `-beta`, `-rc`) before it goes stable - Creating a GitHub release (published) with changelog notes and contributor credits - Tagging a new version and pushing to origin @@ -26,19 +32,32 @@ Bumps the project version across all config files, updates the CHANGELOG, pushes ### 1. Determine Bump Type -Ask (or infer from the argument) whether this is a `patch`, `minor`, or `major` bump: +Ask (or infer from the argument) which kind of release this is: + +| Bump type | Release kind | When | Example | +|-----------|--------------|------|---------| +| `patch` | Stable | Bug fixes, small tweaks | `1.2.3 → 1.2.4` | +| `minor` | Stable | New features, backward-compatible | `1.2.3 → 1.3.0` | +| `major` | Stable | Breaking changes | `1.2.3 → 2.0.0` | +| `prerelease` | Tagged | A pre-release of the next version | `2.7.0 → 2.8.0-beta.1` | +| `stable` | Tagged → Stable | Finalize a prerelease to stable | `2.8.0-beta.3 → 2.8.0` | -| Type | When | Example | -|------|------|---------| -| `patch` | Bug fixes, small tweaks | `1.2.3 → 1.2.4` | -| `minor` | New features, backward-compatible | `1.2.3 → 1.3.0` | -| `major` | Breaking changes | `1.2.3 → 2.0.0` | +For prereleases, an optional identifier selects the prerelease line (`alpha`, `beta`, `rc`, ...) and defaults to `beta`: +- `2.8.0-beta.1 → 2.8.0-beta.2` continues the same beta line +- `2.8.0-beta.3 → 2.8.0-rc.1` switches from beta to the rc line +- `2.8.0-beta.3 → 2.8.0` (via `stable`) promotes the prerelease to the stable release ### 2. Run the Version Bump Script ```bash -# Replace with patch, minor, or major +# Replace with patch, minor, major, prerelease [identifier], or stable pnpm run version: +# e.g.: +pnpm run version:patch # 2.7.0 -> 2.7.1 (stable) +pnpm run version:minor # 2.7.0 -> 2.8.0 (stable) +pnpm run version:prerelease # 2.7.0 -> 2.8.0-beta.1 (tagged) +pnpm run version:prerelease rc # 2.8.0-beta.3 -> 2.8.0-rc.1 (tagged) +pnpm run version:stable # 2.8.0-beta.3 -> 2.8.0 (finalize) ``` This updates **all four** version locations atomically and creates a git commit: @@ -46,7 +65,7 @@ This updates **all four** version locations atomically and creates a git commit: - `src-tauri/Cargo.toml` - `src-tauri/Cargo.lock` - `src-tauri/tauri.conf.json` -- `CHANGELOG.md` (adds a skeleton section) +- `CHANGELOG.md` (adds a skeleton section — for prerelease/stable bumps it renames the existing release-line section instead of adding duplicates) Read the new version from `package.json`: ```bash @@ -93,11 +112,14 @@ If a commit's PR number can't be resolved, omit `in #PR`; if the author has no G Add a release headline as the first paragraph after the version header (see existing entries for the pattern: `### 🔖 R-Shell X.Y — Codename`). -```markdown +The `**Full Changelog**` line must use the actual previous tag → new tag (for a prerelease, `PREV_TAG` is the previous tag and `NEW_TAG` is `v${VERSION}`, e.g. `v2.8.0-beta.1`): -**Full Changelog**: https://github.com/GOODBOY008/r-shell/compare/v2.7.0...v2.8.0 +```markdown +**Full Changelog**: https://github.com/GOODBOY008/r-shell/compare/... ``` +> For a prerelease, the CHANGELOG section header is the exact prerelease version (`## [2.8.0-beta.1]`); it is renamed to `## [2.8.0]` when the prerelease is finalized. Keep the notes under whichever header matches the version you are releasing. + After editing, amend the commit to include the updated CHANGELOG: ```bash git add CHANGELOG.md @@ -133,7 +155,10 @@ If the file is empty, do NOT continue — fix the CHANGELOG header format first. ### 6. Create the GitHub Release (Published) -The release is created in a **published** state — visible immediately to users and triggering any release notifications/webhooks. Use `--notes-file` (not `--notes`) to pass multiline content reliably: +The release is created in a **published** state — visible immediately to users and triggering any release notifications/webhooks. Use `--notes-file` (not `--notes`) to pass multiline content reliably. + +**Stable release** (`v2.8.0`) — mark it `--latest` so it drives the in-app updater and becomes the repo's "Latest": + ```bash VERSION=$(node -p "require('./package.json').version") @@ -146,7 +171,21 @@ gh release create "v${VERSION}" \ rm -f "${NOTES_FILE}" ``` -The `--latest` flag marks this release as the repo's current "Latest" release. Do **not** use `--draft` — the release should publish immediately. +**Tagged (prerelease) release** (`v2.8.0-beta.1`, `v2.8.0-rc.1`) — use `--prerelease` **instead of** `--latest`. Do **not** mark a prerelease as latest: the Release workflow's `upload-updater-json` and `update-homebrew` jobs skip prerelease tags, so leaving `--latest` off keeps the stable updater channel and Homebrew pointing at the last stable version. + +```bash +VERSION=$(node -p "require('./package.json').version") + +gh release create "v${VERSION}" \ + --title "v${VERSION}" \ + --notes-file "${NOTES_FILE}" \ + --prerelease \ + --repo GOODBOY008/r-shell + +rm -f "${NOTES_FILE}" +``` + +Neither path uses `--draft` — the release should publish immediately. > The release notes already include the `### Contributors` section added in step 3. @@ -159,12 +198,18 @@ gh release view "v${VERSION}" --repo GOODBOY008/r-shell Check the output includes the release body text (not just "See the assets…"). If the body is empty, the notes file was empty or the `awk` pattern didn't match — re-run step 5 to debug, then use `gh release edit "v${VERSION}" --notes-file --repo GOODBOY008/r-shell` to fix it. +**For a stable release**, also confirm it is marked "Latest" (`gh release view` shows the tag without a `prerelease:` line), so `releases/latest/download/latest.json` serves this version to the in-app updater. + +**For a tagged (prerelease) release**, confirm: +- `gh release view` shows it as **Pre-release** (`prerelease: true` in the API: `gh api repos/GOODBOY008/r-shell/releases/tags/v${VERSION} --jq .prerelease`). +- `latest.json` was **not** attached to this release (check the assets list), and the stable `releases/latest` endpoint still points at the last stable release — stable users must not be offered a prerelease. + ## Decision Points - **Changelog already accurate?** Skip step 3's changelog edits (but still add the `### Contributors` section) and the amend. - **Want to keep the release hidden until you publish it manually?** Add `--draft` to the `gh release create` command in step 6. - **Attaching build artifacts?** Add file paths after the tag in `gh release create`: `gh release create "v${VERSION}" ./dist/*.dmg ./dist/*.exe --latest ...` -- **Pre-release?** Append `--prerelease` to the `gh release create` command (this replaces `--latest`). +- **Stable vs tagged (prerelease)?** A stable release uses `--latest` and updates the in-app updater + Homebrew. A tagged prerelease (`-alpha`/`-beta`/`-rc`) uses `--prerelease` instead of `--latest`; the Release workflow skips `latest.json` and Homebrew for prerelease tags, so stable users and Homebrew are never switched to a prerelease. Finalize a prerelease with `pnpm run version:stable` before tagging it as `vX.Y.Z`. ## Prerequisites diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3390998d..7641316d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -85,12 +85,13 @@ jobs: args: ${{ matrix.args }} # Generate and upload latest.json for the in-app updater. - # Skips pre-release tags so a beta/alpha never becomes the `latest` release - # and hijack the updater manifest for stable users. + # Skips prerelease tags (vX.Y.Z-.) so a beta/rc never becomes the + # `latest` release and hijack the updater manifest for stable users. Stable + # tags are always vX.Y.Z (no dash), so `!contains('-')` is the robust test. upload-updater-json: needs: release runs-on: ubuntu-latest - if: startsWith(github.ref, 'refs/tags/') && !contains(github.ref_name, 'alpha') && !contains(github.ref_name, 'beta') + if: startsWith(github.ref, 'refs/tags/') && !contains(github.ref_name, '-') permissions: contents: write steps: @@ -247,11 +248,13 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # Update Homebrew tap (requires HOMEBREW_TAP_TOKEN secret) + # Update Homebrew tap (requires HOMEBREW_TAP_TOKEN secret). + # Skips prerelease tags just like upload-updater-json — a beta/rc must not + # replace the stable cask. update-homebrew: needs: generate-checksums runs-on: ubuntu-latest - if: startsWith(github.ref, 'refs/tags/') && !contains(github.ref_name, 'alpha') && !contains(github.ref_name, 'beta') + if: startsWith(github.ref, 'refs/tags/') && !contains(github.ref_name, '-') steps: - name: Update Homebrew Cask uses: peter-evans/repository-dispatch@v4 diff --git a/AGENTS.md b/AGENTS.md index 21dec702..8fbeb8d1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -104,11 +104,16 @@ pnpm lint:fix ### Version Bumping ```bash -pnpm run version:patch # 0.7.1 → 0.7.2 -pnpm run version:minor # 0.7.1 → 0.8.0 -pnpm run version:major # 0.7.1 → 1.0.0 +pnpm run version:patch # 0.7.1 → 0.7.2 (stable) +pnpm run version:minor # 0.7.1 → 0.8.0 (stable) +pnpm run version:major # 0.7.1 → 1.0.0 (stable) +pnpm run version:prerelease # 0.7.1 → 0.8.0-beta.1, or 0.8.0-beta.1 → 0.8.0-beta.2 (tagged) +pnpm run version:prerelease rc # 0.8.0-beta.3 → 0.8.0-rc.1 (switch prerelease line) +pnpm run version:stable # 0.8.0-beta.3 → 0.8.0 (finalize to stable) ``` +Stable releases tag as `vX.Y.Z` and publish as the GitHub **Latest** release; tagged prereleases tag as `vX.Y.Z-.` (e.g. `v0.8.0-beta.1`) and publish with `--prerelease`, never as Latest. See `.github/skills/release-version/SKILL.md` for the full release procedure. + Updates `package.json`, `Cargo.toml`, `Cargo.lock`, `tauri.conf.json`, `CHANGELOG.md` and creates a git commit. --- diff --git a/README.md b/README.md index 8c905ba8..9cd2a1ae 100644 --- a/README.md +++ b/README.md @@ -281,9 +281,12 @@ pnpm test:e2e # E2E ### Version Bumping ```bash -pnpm run version:patch # 2.2.0 → 2.2.1 -pnpm run version:minor # 2.2.0 → 2.3.0 -pnpm run version:major # 2.2.0 → 3.0.0 +pnpm run version:patch # 2.2.0 → 2.2.1 (stable) +pnpm run version:minor # 2.2.0 → 2.3.0 (stable) +pnpm run version:major # 2.2.0 → 3.0.0 (stable) +pnpm run version:prerelease # 2.2.0 → 2.3.0-beta.1, or 2.3.0-beta.1 → 2.3.0-beta.2 (tagged) +pnpm run version:prerelease rc # 2.3.0-beta.3 → 2.3.0-rc.1 (switch prerelease line) +pnpm run version:stable # 2.3.0-beta.3 → 2.3.0 (finalize to stable) ``` --- diff --git a/package.json b/package.json index a64f4fc1..5e9aff58 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,9 @@ "tauri": "tauri", "version:patch": "node scripts/bump-version.mjs patch", "version:minor": "node scripts/bump-version.mjs minor", - "version:major": "node scripts/bump-version.mjs major" + "version:major": "node scripts/bump-version.mjs major", + "version:prerelease": "node scripts/bump-version.mjs prerelease", + "version:stable": "node scripts/bump-version.mjs stable" }, "dependencies": { "@codemirror/autocomplete": "^6.20.3", diff --git a/scripts/README.md b/scripts/README.md index 6a571441..8cc1c52b 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -13,11 +13,16 @@ This directory contains utility scripts for R-Shell development and maintenance. pnpm run version:patch pnpm run version:minor pnpm run version:major +pnpm run version:prerelease # stable -> 2.8.0-beta.1, or 2.8.0-beta.1 -> 2.8.0-beta.2 +pnpm run version:prerelease rc # continue/switch the prerelease line (alpha|beta|rc|...) +pnpm run version:stable # finalize a prerelease -> stable (2.8.0-beta.3 -> 2.8.0) # Direct usage node scripts/bump-version.mjs patch node scripts/bump-version.mjs minor --no-commit node scripts/bump-version.mjs major --skip-changelog +node scripts/bump-version.mjs prerelease beta +node scripts/bump-version.mjs stable ``` **Features:** @@ -26,6 +31,15 @@ node scripts/bump-version.mjs major --skip-changelog - ✅ Interactive confirmation - ✅ Automatic git commit - ✅ CHANGELOG.md template generation +- ✅ Stable (`major`/`minor`/`patch`) and tagged prerelease (`prerelease`/`stable`) bumps + +### Bump Types + +- `major` / `minor` / `patch` — stable release bump (`2.7.0 -> 2.8.0`); a fresh CHANGELOG section is inserted. +- `prerelease [identifier]` — tagged prerelease bump. From a stable version it opens the next minor line (`2.7.0 -> 2.8.0-beta.1`); from a prerelease it continues the same identifier (`2.8.0-beta.1 -> 2.8.0-beta.2`) or switches to another one at `.1` (`2.8.0-beta.3 -> 2.8.0-rc.1`). Identifier defaults to `beta`. +- `stable` — finalize a prerelease to its base version (`2.8.0-beta.3 -> 2.8.0`). Errors if the current version is already stable. + +For `prerelease` / `stable`, the CHANGELOG section for the release line is **renamed** (e.g. `## [2.8.0-beta.2]` → `## [2.8.0-beta.3]`, or → `## [2.8.0]` on finalize) instead of inserting a new one each time, so draft notes carry over without accumulating duplicate sections. ### bump-version.sh @@ -43,6 +57,8 @@ node scripts/bump-version.mjs major --skip-changelog - ✅ Colored output - ✅ Same functionality as Node.js version +> ⚠️ The bash script supports only **stable** bumps (`major`/`minor`/`patch`). Use `bump-version.mjs` for prerelease (`prerelease [identifier]`) and finalize (`stable`) bumps. + ## Options Both scripts support the same options: diff --git a/scripts/bump-version.mjs b/scripts/bump-version.mjs index c2302271..1de76e8d 100755 --- a/scripts/bump-version.mjs +++ b/scripts/bump-version.mjs @@ -2,13 +2,27 @@ /** * R-Shell Version Bump Script (Node.js version) - * Cross-platform version bumping for Windows, macOS, and Linux + * Cross-platform version bumping for Windows, macOS, and Linux. + * + * Usage: + * node scripts/bump-version.mjs [identifier] [--no-commit] [--skip-changelog] + * + * bump-type: + * major | minor | patch -> stable release bump (e.g. 2.7.0 -> 2.8.0) + * prerelease [identifier] -> tagged prerelease bump (e.g. 2.7.0 -> 2.8.0-beta.1, + * or 2.8.0-beta.1 -> 2.8.0-beta.2) + * stable -> finalize a prerelease (e.g. 2.8.0-beta.3 -> 2.8.0) + * + * The `identifier` argument (alpha, beta, rc, ...) selects the prerelease line; + * it defaults to `beta`. Releasing a prerelease uses `version:prerelease` / + * `version:stable`, while stable releases use `version:patch|minor|major`. */ import fs from 'fs'; import path from 'path'; import { execSync } from 'child_process'; import readline from 'readline'; +import { fileURLToPath } from 'url'; const colors = { red: '\x1b[31m', @@ -25,70 +39,288 @@ const log = { error: (msg) => console.log(`${colors.red}${msg}${colors.reset}`) }; -// Parse arguments -const args = process.argv.slice(2); -const bumpType = args[0] || 'patch'; -const noCommit = args.includes('--no-commit'); -const skipChangelog = args.includes('--skip-changelog'); +/** Bump types that always land on a stable (non-prerelease) version. */ +export const STABLE_BUMP_TYPES = ['major', 'minor', 'patch']; -// Validate bump type -if (!['major', 'minor', 'patch'].includes(bumpType)) { - log.error(`Error: Invalid bump type '${bumpType}'. Use: major, minor, or patch`); - process.exit(1); +const PRERELEASE_IDENTIFIER_RE = /^[0-9A-Za-z-]+$/; +const DEFAULT_PRERELEASE_IDENTIFIER = 'beta'; + +// --------------------------------------------------------------------------- +// Pure version math (exported for unit tests) +// --------------------------------------------------------------------------- + +/** + * Parse a semver-ish string into { major, minor, patch, prerelease }. + * The prerelease component (everything after the first `-`) is kept verbatim. + */ +export function parseVersion(version) { + const match = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(String(version).trim()); + if (!match) { + throw new Error(`Invalid version string: ${version}`); + } + return { + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + prerelease: match[4] || null + }; } -// File paths -const rootDir = process.cwd(); -const packageJsonPath = path.join(rootDir, 'package.json'); -const cargoTomlPath = path.join(rootDir, 'src-tauri', 'Cargo.toml'); -const tauriConfPath = path.join(rootDir, 'src-tauri', 'tauri.conf.json'); -const changelogPath = path.join(rootDir, 'CHANGELOG.md'); - -// Read current version -const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); -const currentVersion = packageJson.version; - -log.info(`Current version: ${currentVersion}`); - -// Calculate new version -const [major, minor, patch] = currentVersion.split('.').map(Number); -let newVersion; - -switch (bumpType) { - case 'major': - newVersion = `${major + 1}.0.0`; - break; - case 'minor': - newVersion = `${major}.${minor + 1}.0`; - break; - case 'patch': - newVersion = `${major}.${minor}.${patch + 1}`; - break; +/** The core release line without any prerelease suffix: "2.8.0-beta.1" -> "2.8.0". */ +export function baseVersion(parsed) { + return `${parsed.major}.${parsed.minor}.${parsed.patch}`; } -log.success(`New version: ${newVersion}`); +/** + * Advance a prerelease suffix within a line. Same identifier increments the + * number ("beta.1" -> "beta.2"); a different identifier (or a fresh line) + * starts at `.1` ("rc.1" -> "rc.1" when switching from beta to rc). + */ +export function nextPrereleaseTag(current, identifier) { + const id = identifier || DEFAULT_PRERELEASE_IDENTIFIER; + if (current) { + const dot = current.lastIndexOf('.'); + const curId = dot === -1 ? current : current.slice(0, dot); + const num = dot === -1 ? null : Number(current.slice(dot + 1)); + if (curId === id && Number.isInteger(num) && num > 0) { + return `${id}.${num + 1}`; + } + return `${id}.1`; + } + return `${id}.1`; +} -// Prompt for confirmation -const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout -}); +/** + * Compute the next version for a bump. Throws for impossible transitions + * (e.g. `stable` from a version that is already stable). + */ +export function computeNextVersion(currentVersion, bumpType, identifier) { + const parsed = parseVersion(currentVersion); + const base = baseVersion(parsed); + const isPrerelease = parsed.prerelease !== null; + + switch (bumpType) { + case 'major': + return `${parsed.major + 1}.0.0`; + case 'minor': + return `${parsed.major}.${parsed.minor + 1}.0`; + case 'patch': + return `${parsed.major}.${parsed.minor}.${parsed.patch + 1}`; + case 'prerelease': { + if (isPrerelease) { + return `${base}-${nextPrereleaseTag(parsed.prerelease, identifier)}`; + } + // From a stable release, open a new prerelease line for the next minor. + return `${parsed.major}.${parsed.minor + 1}.0-${nextPrereleaseTag(null, identifier)}`; + } + case 'stable': { + if (!isPrerelease) { + throw new Error( + `Version ${currentVersion} is already stable. Use patch, minor, or major to bump it.` + ); + } + return base; + } + default: + throw new Error(`Unknown bump type: ${bumpType}`); + } +} -rl.question(`Bump version from ${currentVersion} to ${newVersion}? (y/n) `, (answer) => { - if (answer.toLowerCase() !== 'y') { - log.warn('Version bump cancelled'); - rl.close(); - process.exit(0); +// --------------------------------------------------------------------------- +// CHANGELOG helpers +// --------------------------------------------------------------------------- + +function escapeRegex(str) { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function sectionExists(changelog, version) { + return new RegExp(`^## \\[${escapeRegex(version)}\\]`, 'm').test(changelog); +} + +function renameSection(changelog, fromVersion, toVersion, date) { + return changelog.replace( + new RegExp(`^## \\[${escapeRegex(fromVersion)}\\]`, 'm'), + `## [${toVersion}] - ${date}` + ); +} + +function insertSection(changelog, version, date) { + const newSection = ` +## [${version}] - ${date} + +### Added + +- _Add new features here_ + +### Changed + +- _Add changes here_ + +### Fixed + +- _Add bug fixes here_ +`; + // Insert after the Unreleased section + return changelog.replace( + /(## \[Unreleased\][^\n]*\n\n[^\n]*\n\n)/, + `$1${newSection}\n` + ); +} + +/** + * Add or update the CHANGELOG section for the bumped version. + * + * - Stable bumps (major/minor/patch) always insert a fresh section. + * - Prerelease / stable-finalize bumps reuse the section for the same release + * line: rename an existing base ("## [2.8.0]") or current prerelease + * ("## [2.8.0-beta.2]") header so the notes drafted for one version carry + * over instead of accumulating duplicate sections. + */ +export function updateChangelog(changelog, currentVersion, newVersion, date, bumpType, skipChangelog) { + if (skipChangelog) { + return changelog; + } + + if (STABLE_BUMP_TYPES.includes(bumpType)) { + if (sectionExists(changelog, newVersion)) { + return changelog; + } + return insertSection(changelog, newVersion, date); + } + + // prerelease / stable: reuse the same release line's section when possible. + if (sectionExists(changelog, newVersion)) { + return changelog; } - rl.close(); - performBump(); -}); + const base = baseVersion(parseVersion(newVersion)); + if (sectionExists(changelog, base)) { + return renameSection(changelog, base, newVersion, date); + } + + const curBase = baseVersion(parseVersion(currentVersion)); + if (curBase === base && sectionExists(changelog, currentVersion)) { + return renameSection(changelog, currentVersion, newVersion, date); + } + + return insertSection(changelog, newVersion, date); +} + +// --------------------------------------------------------------------------- +// CLI +// --------------------------------------------------------------------------- + +// Run only when invoked directly (not when imported by tests). +function isDirectRun() { + if (!process.argv[1]) { + return false; + } + return fileURLToPath(import.meta.url) === path.resolve(process.argv[1]); +} + +function parseArgs() { + const args = process.argv.slice(2); + const bumpType = args[0] || 'patch'; + const noCommit = args.includes('--no-commit'); + const skipChangelog = args.includes('--skip-changelog'); + let identifier; + + // For `prerelease `, the next positional arg is the identifier. + if (bumpType === 'prerelease' && args[1] && !args[1].startsWith('--')) { + identifier = args[1]; + } + + return { bumpType, identifier, noCommit, skipChangelog }; +} + +function main() { + const { bumpType, identifier, noCommit, skipChangelog } = parseArgs(); + + // Validate bump type + if (!['major', 'minor', 'patch', 'prerelease', 'stable'].includes(bumpType)) { + log.error( + `Error: Invalid bump type '${bumpType}'. Use: major, minor, patch, prerelease [identifier], or stable` + ); + process.exit(1); + } + + if (identifier !== undefined && !PRERELEASE_IDENTIFIER_RE.test(identifier)) { + log.error( + `Error: Invalid prerelease identifier '${identifier}'. Use e.g. alpha, beta, rc (letters, digits, or hyphens)` + ); + process.exit(1); + } + + // File paths + const rootDir = process.cwd(); + const packageJsonPath = path.join(rootDir, 'package.json'); + const cargoTomlPath = path.join(rootDir, 'src-tauri', 'Cargo.toml'); + const tauriConfPath = path.join(rootDir, 'src-tauri', 'tauri.conf.json'); + const changelogPath = path.join(rootDir, 'CHANGELOG.md'); + + // Read current version + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + const currentVersion = packageJson.version; + + log.info(`Current version: ${currentVersion}`); + + // Calculate new version + let newVersion; + try { + newVersion = computeNextVersion(currentVersion, bumpType, identifier); + } catch (error) { + log.error(`Error: ${error.message}`); + process.exit(1); + } + + log.success(`New version: ${newVersion}`); + + // Prompt for confirmation + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout + }); + + rl.question(`Bump version from ${currentVersion} to ${newVersion}? (y/n) `, (answer) => { + if (answer.toLowerCase() !== 'y') { + log.warn('Version bump cancelled'); + rl.close(); + process.exit(0); + } + + rl.close(); + performBump({ + bumpType, + noCommit, + skipChangelog, + currentVersion, + newVersion, + packageJsonPath, + cargoTomlPath, + tauriConfPath, + changelogPath + }); + }); +} + +function performBump({ + bumpType, + noCommit, + skipChangelog, + currentVersion, + newVersion, + packageJsonPath, + cargoTomlPath, + tauriConfPath, + changelogPath +}) { + const rootDir = process.cwd(); -function performBump() { try { // Update package.json log.info('Updating package.json...'); + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); packageJson.version = newVersion; fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2) + '\n'); @@ -110,7 +342,7 @@ function performBump() { // Update Cargo.lock log.info('Updating src-tauri/Cargo.lock...'); try { - execSync('cargo build --quiet', { + execSync('cargo build --quiet', { cwd: path.join(rootDir, 'src-tauri'), stdio: 'ignore' }); @@ -122,40 +354,18 @@ function performBump() { if (!skipChangelog) { log.info('Updating CHANGELOG.md...'); const today = new Date().toISOString().split('T')[0]; - let changelog = fs.readFileSync(changelogPath, 'utf8'); - - const newSection = ` -## [${newVersion}] - ${today} - -### Added - -- _Add new features here_ - -### Changed - -- _Add changes here_ - -### Fixed - -- _Add bug fixes here_ -`; - - // Insert after the Unreleased section - changelog = changelog.replace( - /(## \[Unreleased\][^\n]*\n\n[^\n]*\n\n)/, - `$1${newSection}\n` - ); - - fs.writeFileSync(changelogPath, changelog); + const changelog = fs.readFileSync(changelogPath, 'utf8'); + const updated = updateChangelog(changelog, currentVersion, newVersion, today, bumpType, skipChangelog); + fs.writeFileSync(changelogPath, updated); log.warn('⚠️ Please update CHANGELOG.md with actual changes before committing'); } // Create git commit if (!noCommit) { log.info('Creating git commit...'); - + execSync('git add package.json src-tauri/Cargo.toml src-tauri/Cargo.lock src-tauri/tauri.conf.json'); - + if (!skipChangelog) { execSync('git add CHANGELOG.md'); } @@ -184,3 +394,7 @@ function performBump() { process.exit(1); } } + +if (isDirectRun()) { + main(); +} diff --git a/src/__tests__/bump-version.test.ts b/src/__tests__/bump-version.test.ts new file mode 100644 index 00000000..643c3ea8 --- /dev/null +++ b/src/__tests__/bump-version.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it } from 'vitest'; +import { + parseVersion, + baseVersion, + nextPrereleaseTag, + computeNextVersion, + updateChangelog, + STABLE_BUMP_TYPES, +} from '../../scripts/bump-version.mjs'; + +describe('parseVersion', () => { + it('parses a stable semver', () => { + expect(parseVersion('2.7.0')).toEqual({ major: 2, minor: 7, patch: 0, prerelease: null }); + }); + + it('parses a prerelease suffix', () => { + expect(parseVersion('2.8.0-beta.1')).toEqual({ major: 2, minor: 8, patch: 0, prerelease: 'beta.1' }); + expect(parseVersion('2.8.0-rc.2')).toEqual({ major: 2, minor: 8, patch: 0, prerelease: 'rc.2' }); + }); + + it('rejects malformed versions', () => { + expect(() => parseVersion('2.7')).toThrow('Invalid version string'); + expect(() => parseVersion('2.7.0.1')).toThrow('Invalid version string'); + expect(() => parseVersion('v2.7.0')).toThrow('Invalid version string'); + }); +}); + +describe('baseVersion', () => { + it('strips the prerelease suffix', () => { + expect(baseVersion(parseVersion('2.8.0-beta.3'))).toBe('2.8.0'); + expect(baseVersion(parseVersion('2.7.0'))).toBe('2.7.0'); + }); +}); + +describe('nextPrereleaseTag', () => { + it('defaults to beta', () => { + expect(nextPrereleaseTag(null, undefined)).toBe('beta.1'); + expect(nextPrereleaseTag(null, 'rc')).toBe('rc.1'); + }); + + it('increments within the same identifier', () => { + expect(nextPrereleaseTag('beta.1', 'beta')).toBe('beta.2'); + expect(nextPrereleaseTag('rc.4', 'rc')).toBe('rc.5'); + }); + + it('starts at .1 when switching identifiers', () => { + expect(nextPrereleaseTag('beta.3', 'rc')).toBe('rc.1'); + expect(nextPrereleaseTag('rc.1', undefined)).toBe('beta.1'); + }); +}); + +describe('computeNextVersion', () => { + it.each([ + // stable bumps stay stable + ['2.7.0', 'patch', undefined, '2.7.1'], + ['2.7.0', 'minor', undefined, '2.8.0'], + ['2.7.0', 'major', undefined, '3.0.0'], + // stable bumps from a prerelease drop the suffix + ['2.8.0-beta.3', 'patch', undefined, '2.8.1'], + ['2.8.0-beta.3', 'minor', undefined, '2.9.0'], + ['2.8.0-beta.3', 'major', undefined, '3.0.0'], + // prerelease from stable opens the next minor line + ['2.7.0', 'prerelease', undefined, '2.8.0-beta.1'], + ['2.7.0', 'prerelease', 'rc', '2.8.0-rc.1'], + // prerelease iteration within the same line + ['2.8.0-beta.1', 'prerelease', 'beta', '2.8.0-beta.2'], + ['2.8.0-beta.4', 'prerelease', undefined, '2.8.0-beta.5'], + ['2.8.0-rc.1', 'prerelease', 'rc', '2.8.0-rc.2'], + // prerelease with a different identifier switches lines at .1 + ['2.8.0-beta.3', 'prerelease', 'rc', '2.8.0-rc.1'], + // stable finalizes a prerelease to its base version + ['2.8.0-beta.3', 'stable', undefined, '2.8.0'], + ['2.8.0-rc.1', 'stable', undefined, '2.8.0'], + ])('%s %s -> %s', (current, bumpType, identifier, expected) => { + expect(computeNextVersion(current, bumpType, identifier)).toBe(expected); + }); + + it('rejects stable from an already-stable version', () => { + expect(() => computeNextVersion('2.7.0', 'stable', undefined)).toThrow('already stable'); + }); +}); + +describe('updateChangelog', () => { + const FIXTURE = `# Changelog + +## [Unreleased] + +### Added + +- _draft_ + +## [2.7.0] - 2026-08-08 + +### Added + +- released feature +`; + + it('inserts a fresh section for stable bumps', () => { + const out = updateChangelog(FIXTURE, '2.7.0', '2.8.0', '2026-08-11', 'minor', false); + expect(out).toContain('## [2.8.0] - 2026-08-11'); + expect(out).toContain('## [2.7.0] - 2026-08-08'); + expect(out.indexOf('## [2.8.0]')).toBeLessThan(out.indexOf('## [2.7.0]')); + }); + + it('renames the base section when a prerelease line opens from a draft', () => { + const drafted = `# Changelog + +## [Unreleased] + +### Added + +- _draft_ + +## [2.8.0] - 2026-08-11 + +### Added + +- drafted notes + +## [2.7.0] - 2026-08-08 +`; + const out = updateChangelog(drafted, '2.7.0', '2.8.0-beta.1', '2026-08-11', 'prerelease', false); + expect(out).toContain('## [2.8.0-beta.1] - 2026-08-11'); + expect(out).not.toContain('## [2.8.0] - 2026-08-11'); + expect(out).toContain('drafted notes'); + }); + + it('renames the current prerelease section when iterating', () => { + const prereleased = FIXTURE + `## [2.8.0-beta.1] - 2026-08-11 + +### Added + +- beta feature +`; + const out = updateChangelog(prereleased, '2.8.0-beta.1', '2.8.0-beta.2', '2026-08-12', 'prerelease', false); + expect(out).toContain('## [2.8.0-beta.2] - 2026-08-12'); + expect(out).not.toContain('## [2.8.0-beta.1] - 2026-08-11'); + expect(out).toContain('beta feature'); + expect(out).toContain('## [2.7.0] - 2026-08-08'); + }); + + it('renames the prerelease section to its base on finalize', () => { + const prereleased = FIXTURE + `## [2.8.0-beta.3] - 2026-08-11 + +### Added + +- rc feature +`; + const out = updateChangelog(prereleased, '2.8.0-beta.3', '2.8.0', '2026-08-12', 'stable', false); + expect(out).toContain('## [2.8.0] - 2026-08-12'); + expect(out).not.toContain('## [2.8.0-beta.3]'); + expect(out).toContain('rc feature'); + }); + + it('does not duplicate a section that already exists', () => { + const withSection = FIXTURE + `## [2.8.0-beta.1] - 2026-08-11 +`; + const out = updateChangelog(withSection, '2.8.0-beta.1', '2.8.0-beta.1', '2026-08-11', 'prerelease', false); + expect(out.match(/## \[2\.8\.0-beta\.1\]/g)).toHaveLength(1); + }); + + it('respects skipChangelog', () => { + expect(updateChangelog(FIXTURE, '2.7.0', '2.8.0', '2026-08-11', 'minor', true)).toBe(FIXTURE); + }); + + it('defines the expected stable bump types', () => { + expect(STABLE_BUMP_TYPES).toEqual(['major', 'minor', 'patch']); + }); +});