From ea44271065ff0296c4de00a57ec36e9d050886bc Mon Sep 17 00:00:00 2001 From: gongzhongqiang Date: Tue, 11 Aug 2026 18:06:53 +0800 Subject: [PATCH 01/10] feat(scripts): support prerelease and stable version bumps Add 'prerelease [identifier]' and 'stable' bump types to bump-version.mjs so both stable (vX.Y.Z) and tagged prerelease (vX.Y.Z-.) releases can be driven from the npm scripts. Prerelease/stable bumps rename the CHANGELOG section for the same release line instead of inserting duplicate sections. Adds version:prerelease / version:stable npm scripts and unit tests. --- package.json | 4 +- scripts/bump-version.mjs | 374 +++++++++++++++++++++++------ src/__tests__/bump-version.test.ts | 170 +++++++++++++ 3 files changed, 467 insertions(+), 81 deletions(-) create mode 100644 src/__tests__/bump-version.test.ts 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/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']); + }); +}); From 4bf6e23a5bcd5645e55993c7e7012d5d6a1c43c5 Mon Sep 17 00:00:00 2001 From: gongzhongqiang Date: Tue, 11 Aug 2026 18:06:58 +0800 Subject: [PATCH 02/10] fix(workflow): skip updater manifest and Homebrew for all prerelease tags The upload-updater-json and update-homebrew jobs only skipped tags containing 'alpha' or 'beta', so an rc tag (vX.Y.Z-rc.N) slipped through and would have replaced the stable latest.json / cask with a prerelease. Skip any tag with a dash (stable tags are always vX.Y.Z) so tagged prereleases never hijack the stable updater channel. --- .github/workflows/release.yml | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) 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 From 8c0547a8c49c6a36051b8d932191be460816ba35 Mon Sep 17 00:00:00 2001 From: gongzhongqiang Date: Tue, 11 Aug 2026 18:07:03 +0800 Subject: [PATCH 03/10] docs(skill): document stable vs tagged release flows in release-version The release-version skill now covers both release kinds end-to-end: stable releases (vX.Y.Z, published with --latest, drives the in-app updater + Homebrew) and tagged prerelease releases (vX.Y.Z-., published with --prerelease, never Latest). Also documents the prerelease bump types, CHANGELOG section handling, and verification steps. --- .github/skills/release-version/SKILL.md | 79 +++++++++++++++++++------ AGENTS.md | 11 +++- README.md | 9 ++- scripts/README.md | 16 +++++ 4 files changed, 92 insertions(+), 23 deletions(-) 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/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/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: From 0a86888e210c04206c77a9b5935690ed4b339792 Mon Sep 17 00:00:00 2001 From: r-shell agent Date: Fri, 21 Aug 2026 23:22:16 +0800 Subject: [PATCH 04/10] feat(scripts): harden version bump with guardrails, dry-run, and tag verification Extends PR #85's bump-version.mjs with release-tooling best practices borrowed from semantic-release / changesets and the openusage Tauri app: - Preflight guardrails: refuse to bump when the four version files (package.json, Cargo.toml, Cargo.lock, tauri.conf.json) disagree, or when the working tree has uncommitted tracked changes (--force bypass). - --dry-run prints the plan and CHANGELOG action without writing anything; --yes skips the interactive prompt for CI/agents. - Cargo.lock is updated by rewriting the root "r-shell" entry directly (like cargo set-version) instead of a full cargo build, with a cargo fallback and a post-edit verification. - CHANGELOG insertion is now robust (handles missing No Unreleased sections, multi-line entries, top-of-file insert) and asserts the section exists after updating; renameSection no longer leaves a duplicated "- YYYY-MM-DD" date on renamed headings. - New scripts/verify-release-tag.mjs validates a tag's semver shape and compares it against every version file (used by CI and version:verify). Tests: 61 unit tests for the module (was 29), full suite 626 passed. --- package.json | 3 +- scripts/bump-version.mjs | 476 ++++++++++++++++++++++------- scripts/verify-release-tag.mjs | 112 +++++++ src/__tests__/bump-version.test.ts | 285 ++++++++++++++++- 4 files changed, 763 insertions(+), 113 deletions(-) create mode 100644 scripts/verify-release-tag.mjs diff --git a/package.json b/package.json index 5e9aff58..ff3f8265 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ "version:minor": "node scripts/bump-version.mjs minor", "version:major": "node scripts/bump-version.mjs major", "version:prerelease": "node scripts/bump-version.mjs prerelease", - "version:stable": "node scripts/bump-version.mjs stable" + "version:stable": "node scripts/bump-version.mjs stable", + "version:verify": "node scripts/verify-release-tag.mjs" }, "dependencies": { "@codemirror/autocomplete": "^6.20.3", diff --git a/scripts/bump-version.mjs b/scripts/bump-version.mjs index 1de76e8d..50f47a9f 100755 --- a/scripts/bump-version.mjs +++ b/scripts/bump-version.mjs @@ -5,7 +5,7 @@ * Cross-platform version bumping for Windows, macOS, and Linux. * * Usage: - * node scripts/bump-version.mjs [identifier] [--no-commit] [--skip-changelog] + * node scripts/bump-version.mjs [identifier] [options] * * bump-type: * major | minor | patch -> stable release bump (e.g. 2.7.0 -> 2.8.0) @@ -13,9 +13,24 @@ * 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) * + * options: + * --dry-run show what would change without writing anything + * --yes, -y skip the interactive confirmation prompt (CI/automation) + * --force bypass the preflight guardrails (dirty tree, version drift) + * --no-commit update files but do not create a git commit + * --skip-changelog do not touch CHANGELOG.md + * --help, -h print this usage text + * * 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`. + * + * Preflight guardrails (fail fast, like semantic-release): + * - All four version files (package.json, Cargo.toml, Cargo.lock, + * tauri.conf.json) must agree on the current version before a bump. + * - The working tree must be clean of tracked modifications, so the bump + * commit contains exactly the version change and nothing else. + * Both can be bypassed with `--force` when you know what you are doing. */ import fs from 'fs'; @@ -42,6 +57,8 @@ const log = { /** Bump types that always land on a stable (non-prerelease) version. */ export const STABLE_BUMP_TYPES = ['major', 'minor', 'patch']; +export const BUMP_TYPES = [...STABLE_BUMP_TYPES, 'prerelease', 'stable']; + const PRERELEASE_IDENTIFIER_RE = /^[0-9A-Za-z-]+$/; const DEFAULT_PRERELEASE_IDENTIFIER = 'beta'; @@ -50,11 +67,18 @@ const DEFAULT_PRERELEASE_IDENTIFIER = 'beta'; // --------------------------------------------------------------------------- /** - * Parse a semver-ish string into { major, minor, patch, prerelease }. - * The prerelease component (everything after the first `-`) is kept verbatim. + * Parse a semver string into { major, minor, patch, prerelease, build }. + * The prerelease component (everything after the first `-`) and the build + * metadata (everything after the first `+`) are kept verbatim. Build metadata + * is never carried over by a bump, per the SemVer spec. */ +// A semver identifier: dot-separated, non-empty [0-9A-Za-z-] segments. +const SEMVER_IDENTIFIER = '(?:[0-9A-Za-z-]+)(?:\\.[0-9A-Za-z-]+)*'; + export function parseVersion(version) { - const match = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(String(version).trim()); + const match = new RegExp( + `^(\\d+)\\.(\\d+)\\.(\\d+)(?:-(${SEMVER_IDENTIFIER}))?(?:\\+(${SEMVER_IDENTIFIER}))?$` + ).exec(String(version).trim()); if (!match) { throw new Error(`Invalid version string: ${version}`); } @@ -62,10 +86,16 @@ export function parseVersion(version) { major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3]), - prerelease: match[4] || null + prerelease: match[4] || null, + build: match[5] || null }; } +/** True when the version has a prerelease suffix ("2.8.0-beta.1" -> true). */ +export function isPrereleaseVersion(version) { + return parseVersion(version).prerelease !== null; +} + /** 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}`; @@ -74,7 +104,7 @@ export function baseVersion(parsed) { /** * 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). + * starts at `.1` ("beta.3" + "rc" -> "rc.1"). */ export function nextPrereleaseTag(current, identifier) { const id = identifier || DEFAULT_PRERELEASE_IDENTIFIER; @@ -126,6 +156,88 @@ export function computeNextVersion(currentVersion, bumpType, identifier) { } } +// --------------------------------------------------------------------------- +// Version file readers (pure; take raw file content, return the version) +// --------------------------------------------------------------------------- + +/** package.json -> "2.7.0" (throws on invalid JSON or missing version). */ +export function parsePackageJsonVersion(content) { + const pkg = JSON.parse(content); + if (typeof pkg.version !== 'string' || pkg.version === '') { + throw new Error('package.json has no valid "version" field'); + } + return pkg.version; +} + +/** Cargo.toml -> "2.7.0" (first `version = "..."` line). */ +export function parseCargoTomlVersion(content) { + const match = /^version\s*=\s*"([^"]+)"/m.exec(content); + if (!match) { + throw new Error('Cargo.toml has no "version = ..." line'); + } + return match[1]; +} + +/** Cargo.lock -> version of the root package (the "r-shell" [[package]] entry). */ +export function parseCargoLockVersion(content) { + const match = /^name = "r-shell"\nversion = "([^"]+)"/m.exec(content); + if (!match) { + throw new Error('Cargo.lock has no root "r-shell" package entry'); + } + return match[1]; +} + +/** tauri.conf.json -> "2.7.0". */ +export function parseTauriConfVersion(content) { + const conf = JSON.parse(content); + if (typeof conf.version !== 'string' || conf.version === '') { + throw new Error('tauri.conf.json has no valid "version" field'); + } + return conf.version; +} + +/** + * Collect the versions declared by every version file. Keys are the file + * names; the package.json version is the source of truth. + */ +export function collectFileVersions({ packageJson, cargoToml, cargoLock, tauriConf }) { + return { + 'package.json': parsePackageJsonVersion(packageJson), + 'src-tauri/Cargo.toml': parseCargoTomlVersion(cargoToml), + 'src-tauri/Cargo.lock': parseCargoLockVersion(cargoLock), + 'src-tauri/tauri.conf.json': parseTauriConfVersion(tauriConf) + }; +} + +/** + * Return the files whose version differs from package.json, e.g. + * [{ file: 'src-tauri/Cargo.toml', version: '2.7.1' }]. Empty when in sync. + */ +export function findVersionDrift(versions) { + const reference = versions['package.json']; + return Object.entries(versions) + .filter(([file, version]) => file !== 'package.json' && version !== reference) + .map(([file, version]) => ({ file, version })); +} + +// --------------------------------------------------------------------------- +// Cargo.lock update (direct edit, with cargo fallback) +// --------------------------------------------------------------------------- + +/** + * Rewrite the version of the root "r-shell" package inside Cargo.lock. + * Editing the root package entry directly is what `cargo set-version` does + * and avoids a full `cargo build` just to refresh a lockfile. Throws when the + * root package entry cannot be found (caller falls back to `cargo build`). + */ +export function updateCargoLock(content, newVersion) { + const pattern = /^(name = "r-shell"\nversion = ")[^"]*(")/m; + if (!pattern.test(content)) { + throw new Error('Root "r-shell" package entry not found in Cargo.lock'); + } + return content.replace(pattern, `$1${newVersion}$2`); +} + // --------------------------------------------------------------------------- // CHANGELOG helpers // --------------------------------------------------------------------------- @@ -139,15 +251,16 @@ function sectionExists(changelog, version) { } function renameSection(changelog, fromVersion, toVersion, date) { + // Replace the whole heading line (including any existing " - YYYY-MM-DD" + // date suffix) so a rename never leaves a duplicated date behind. return changelog.replace( - new RegExp(`^## \\[${escapeRegex(fromVersion)}\\]`, 'm'), + new RegExp(`^## \\[${escapeRegex(fromVersion)}\\][^\\n]*`, 'm'), `## [${toVersion}] - ${date}` ); } -function insertSection(changelog, version, date) { - const newSection = ` -## [${version}] - ${date} +function buildSection(version, date) { + return `## [${version}] - ${date} ### Added @@ -161,11 +274,41 @@ function insertSection(changelog, version, date) { - _Add bug fixes here_ `; - // Insert after the Unreleased section - return changelog.replace( - /(## \[Unreleased\][^\n]*\n\n[^\n]*\n\n)/, - `$1${newSection}\n` - ); +} + +/** + * Insert a fresh version section following the Keep a Changelog convention: + * newest release on top, directly after the Unreleased section when one + * exists. Falls back to inserting before the first released section (or + * appending to the title block) when there is no Unreleased section, so the + * insertion never silently no-ops — the caller asserts the section exists. + */ +export function insertSection(changelog, version, date) { + const newSection = buildSection(version, date); + + const unreleasedMatch = /^## \[Unreleased\]/m.exec(changelog); + if (unreleasedMatch) { + const afterHeading = changelog.slice(unreleasedMatch.index + unreleasedMatch[0].length); + const nextHeading = /^## /m.exec(afterHeading); + const insertAt = nextHeading + ? unreleasedMatch.index + unreleasedMatch[0].length + nextHeading.index + : changelog.length; + return spliceSection(changelog, insertAt, newSection); + } + + const firstSection = /^## /m.exec(changelog); + if (firstSection) { + return spliceSection(changelog, firstSection.index, newSection); + } + + return `${changelog.replace(/\s+$/, '')}\n\n${newSection}`; +} + +/** Join `before` and `after` around a section with exactly one blank line each side. */ +function spliceSection(changelog, insertAt, section) { + const before = changelog.slice(0, insertAt).replace(/\s+$/, ''); + const after = changelog.slice(insertAt).replace(/^\n+/, ''); + return `${before}\n\n${section}${after ? `\n${after}` : ''}`; } /** @@ -176,6 +319,9 @@ function insertSection(changelog, version, date) { * 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. + * + * Throws when the new version's section is missing afterwards, so a broken + * changelog format fails the bump instead of silently skipping the update. */ export function updateChangelog(changelog, currentVersion, newVersion, date, bumpType, skipChangelog) { if (skipChangelog) { @@ -183,28 +329,60 @@ export function updateChangelog(changelog, currentVersion, newVersion, date, bum } if (STABLE_BUMP_TYPES.includes(bumpType)) { + if (!sectionExists(changelog, newVersion)) { + changelog = insertSection(changelog, newVersion, date); + } + } else { + // prerelease / stable: reuse the same release line's section when possible. 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; + const base = baseVersion(parseVersion(newVersion)); + if (sectionExists(changelog, base)) { + changelog = renameSection(changelog, base, newVersion, date); + } else { + const curBase = baseVersion(parseVersion(currentVersion)); + if (curBase === base && sectionExists(changelog, currentVersion)) { + changelog = renameSection(changelog, currentVersion, newVersion, date); + } else { + changelog = insertSection(changelog, newVersion, date); + } + } } - const base = baseVersion(parseVersion(newVersion)); - if (sectionExists(changelog, base)) { - return renameSection(changelog, base, newVersion, date); + if (!sectionExists(changelog, newVersion)) { + throw new Error( + `Failed to add a CHANGELOG section for ${newVersion}; the changelog format was not recognized.` + ); } + return changelog; +} - const curBase = baseVersion(parseVersion(currentVersion)); - if (curBase === base && sectionExists(changelog, currentVersion)) { - return renameSection(changelog, currentVersion, newVersion, date); - } +// --------------------------------------------------------------------------- +// Preflight guardrails +// --------------------------------------------------------------------------- - return insertSection(changelog, newVersion, date); +/** + * Inspect the working tree through git. Returns + * { clean, hasGit, modifiedPaths, untrackedPaths } where clean means no + * *tracked* modifications (untracked files do not block a bump). + */ +export function getWorkingTreeState() { + let porcelain; + try { + porcelain = execSync('git status --porcelain', { encoding: 'utf8' }); + } catch { + return { clean: true, hasGit: false, modifiedPaths: [], untrackedPaths: [] }; + } + const lines = porcelain.split('\n').filter(Boolean); + const untrackedPaths = lines + .filter((line) => line.startsWith('??')) + .map((line) => line.slice(3)); + const modifiedPaths = lines + .filter((line) => !line.startsWith('??')) + .map((line) => line.slice(3)); + return { clean: modifiedPaths.length === 0, hasGit: true, modifiedPaths, untrackedPaths }; } // --------------------------------------------------------------------------- @@ -219,26 +397,55 @@ function isDirectRun() { 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; +const USAGE = `Usage: + node scripts/bump-version.mjs [identifier] [options] + +bump-type: + major | minor | patch stable release bump (2.7.0 -> 2.8.0) + prerelease [identifier] tagged prerelease bump (2.7.0 -> 2.8.0-beta.1, + beta.1 -> beta.2, or beta.3 + "rc" -> rc.1) + stable finalize a prerelease (2.8.0-beta.3 -> 2.8.0) + +options: + --dry-run show what would change without writing anything + --yes, -y skip the interactive confirmation prompt + --force bypass preflight guardrails (dirty tree, version drift) + --no-commit update files but do not create a git commit + --skip-changelog do not touch CHANGELOG.md + --help, -h show this help + +examples: + node scripts/bump-version.mjs minor + node scripts/bump-version.mjs minor --dry-run + node scripts/bump-version.mjs prerelease rc --yes + node scripts/bump-version.mjs stable --no-commit +`; - // For `prerelease `, the next positional arg is the identifier. - if (bumpType === 'prerelease' && args[1] && !args[1].startsWith('--')) { - identifier = args[1]; +function parseArgs(argv) { + if (argv.includes('--help') || argv.includes('-h')) { + console.log(USAGE); + process.exit(0); } - - return { bumpType, identifier, noCommit, skipChangelog }; + const positional = argv.filter((arg) => !arg.startsWith('--') && arg !== '-y'); + const bumpType = positional[0] || 'patch'; + const identifier = bumpType === 'prerelease' ? positional[1] : undefined; + return { + bumpType, + identifier: identifier && !identifier.startsWith('-') ? identifier : undefined, + noCommit: argv.includes('--no-commit'), + skipChangelog: argv.includes('--skip-changelog'), + dryRun: argv.includes('--dry-run'), + yes: argv.includes('--yes') || argv.includes('-y'), + force: argv.includes('--force') + }; } function main() { - const { bumpType, identifier, noCommit, skipChangelog } = parseArgs(); + const { bumpType, identifier, noCommit, skipChangelog, dryRun, yes, force } = parseArgs( + process.argv.slice(2) + ); - // Validate bump type - if (!['major', 'minor', 'patch', 'prerelease', 'stable'].includes(bumpType)) { + if (!BUMP_TYPES.includes(bumpType)) { log.error( `Error: Invalid bump type '${bumpType}'. Use: major, minor, patch, prerelease [identifier], or stable` ); @@ -252,20 +459,48 @@ function main() { 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'); + const paths = { + packageJson: path.join(rootDir, 'package.json'), + cargoToml: path.join(rootDir, 'src-tauri', 'Cargo.toml'), + cargoLock: path.join(rootDir, 'src-tauri', 'Cargo.lock'), + tauriConf: path.join(rootDir, 'src-tauri', 'tauri.conf.json'), + changelog: path.join(rootDir, 'CHANGELOG.md') + }; + + // --- Preflight: versions must agree across all four files --- + const contents = { + packageJson: fs.readFileSync(paths.packageJson, 'utf8'), + cargoToml: fs.readFileSync(paths.cargoToml, 'utf8'), + cargoLock: fs.readFileSync(paths.cargoLock, 'utf8'), + tauriConf: fs.readFileSync(paths.tauriConf, 'utf8') + }; + const versions = collectFileVersions(contents); + const drift = findVersionDrift(versions); + if (drift.length > 0 && !force) { + log.error('Error: version files are out of sync before the bump:'); + for (const { file, version } of drift) { + log.error(` - ${file}: ${version} (package.json: ${versions['package.json']})`); + } + log.error('Fix the drift (or re-run with --force) before bumping.'); + process.exit(1); + } - // Read current version - const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); - const currentVersion = packageJson.version; + // --- Preflight: clean tracked working tree --- + const tree = getWorkingTreeState(); + if (tree.hasGit && !tree.clean && !force) { + log.error('Error: the working tree has uncommitted tracked modifications:'); + for (const file of tree.modifiedPaths) { + log.error(` - ${file}`); + } + log.error('Commit or stash them first (or re-run with --force).'); + process.exit(1); + } + const currentVersion = versions['package.json']; log.info(`Current version: ${currentVersion}`); - // Calculate new version + // --- Compute the next version --- let newVersion; try { newVersion = computeNextVersion(currentVersion, bumpType, identifier); @@ -273,105 +508,132 @@ function main() { 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); + // --- Plan the CHANGELOG action so --dry-run shows the full picture --- + const changelog = fs.readFileSync(paths.changelog, 'utf8'); + const today = new Date().toISOString().split('T')[0]; + let changelogAction = 'skip (--skip-changelog)'; + if (!skipChangelog) { + if (!STABLE_BUMP_TYPES.includes(bumpType)) { + const base = baseVersion(parseVersion(newVersion)); + const curBase = baseVersion(parseVersion(currentVersion)); + if (sectionExists(changelog, newVersion)) { + changelogAction = 'already present, no change'; + } else if (sectionExists(changelog, base)) { + changelogAction = `rename section [${base}] -> [${newVersion}]`; + } else if (curBase === base && sectionExists(changelog, currentVersion)) { + changelogAction = `rename section [${currentVersion}] -> [${newVersion}]`; + } else { + changelogAction = 'insert new section'; + } + } else if (!sectionExists(changelog, newVersion)) { + changelogAction = 'insert new section'; + } else { + changelogAction = 'already present, no change'; } + } + + log.info('Plan:'); + console.log(` - ${Object.keys(versions).join(', ')}: ${currentVersion} -> ${newVersion}`); + console.log(` - CHANGELOG.md: ${changelogAction}`); + if (dryRun) { + console.log( + ` - Commit: ${noCommit ? 'skipped (--no-commit)' : 'chore: bump version to ' + newVersion}` + ); + log.info('Dry run — no files were modified.'); + return; + } - rl.close(); - performBump({ - bumpType, - noCommit, - skipChangelog, - currentVersion, - newVersion, - packageJsonPath, - cargoTomlPath, - tauriConfPath, - changelogPath + // --- Confirm --- + if (!yes) { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + rl.question(`Bump version from ${currentVersion} to ${newVersion}? (y/n) `, (answer) => { + rl.close(); + if (answer.toLowerCase() !== 'y') { + log.warn('Version bump cancelled'); + return; + } + performBump({ bumpType, noCommit, skipChangelog, currentVersion, newVersion, paths }); }); - }); + return; + } + + performBump({ bumpType, noCommit, skipChangelog, currentVersion, newVersion, paths }); } -function performBump({ - bumpType, - noCommit, - skipChangelog, - currentVersion, - newVersion, - packageJsonPath, - cargoTomlPath, - tauriConfPath, - changelogPath -}) { +function performBump({ bumpType, noCommit, skipChangelog, currentVersion, newVersion, paths }) { const rootDir = process.cwd(); try { // Update package.json log.info('Updating package.json...'); - const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + const packageJson = JSON.parse(fs.readFileSync(paths.packageJson, 'utf8')); packageJson.version = newVersion; - fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2) + '\n'); + fs.writeFileSync(paths.packageJson, JSON.stringify(packageJson, null, 2) + '\n'); // Update Cargo.toml log.info('Updating src-tauri/Cargo.toml...'); - let cargoToml = fs.readFileSync(cargoTomlPath, 'utf8'); - cargoToml = cargoToml.replace( - /^version = ".*"$/m, - `version = "${newVersion}"` - ); - fs.writeFileSync(cargoTomlPath, cargoToml); + let cargoToml = fs.readFileSync(paths.cargoToml, 'utf8'); + if (!/^version = ".*"$/m.test(cargoToml)) { + throw new Error('Cargo.toml has no "version = ..." line'); + } + cargoToml = cargoToml.replace(/^version = ".*"$/m, `version = "${newVersion}"`); + fs.writeFileSync(paths.cargoToml, cargoToml); // Update tauri.conf.json log.info('Updating src-tauri/tauri.conf.json...'); - const tauriConf = JSON.parse(fs.readFileSync(tauriConfPath, 'utf8')); + const tauriConf = JSON.parse(fs.readFileSync(paths.tauriConf, 'utf8')); tauriConf.version = newVersion; - fs.writeFileSync(tauriConfPath, JSON.stringify(tauriConf, null, 2) + '\n'); + fs.writeFileSync(paths.tauriConf, JSON.stringify(tauriConf, null, 2) + '\n'); - // Update Cargo.lock + // Update Cargo.lock — rewrite the root package version directly (like + // `cargo set-version`), falling back to `cargo build` only when the root + // package entry cannot be found, and verify the result either way. log.info('Updating src-tauri/Cargo.lock...'); + let cargoLock = fs.readFileSync(paths.cargoLock, 'utf8'); try { + cargoLock = updateCargoLock(cargoLock, newVersion); + } catch { + log.warn('Root package entry not found in Cargo.lock; using cargo build fallback…'); execSync('cargo build --quiet', { cwd: path.join(rootDir, 'src-tauri'), stdio: 'ignore' }); - } catch (e) { - // Ignore build errors, we just need Cargo.lock updated + cargoLock = fs.readFileSync(paths.cargoLock, 'utf8'); + } + fs.writeFileSync(paths.cargoLock, cargoLock); + if (parseCargoLockVersion(cargoLock) !== newVersion) { + throw new Error( + `Cargo.lock root package version is ${parseCargoLockVersion(cargoLock)}, expected ${newVersion}` + ); } // Update CHANGELOG.md if (!skipChangelog) { log.info('Updating CHANGELOG.md...'); const today = new Date().toISOString().split('T')[0]; - const changelog = fs.readFileSync(changelogPath, 'utf8'); - const updated = updateChangelog(changelog, currentVersion, newVersion, today, bumpType, skipChangelog); - fs.writeFileSync(changelogPath, updated); + const changelog = fs.readFileSync(paths.changelog, 'utf8'); + const updated = updateChangelog( + changelog, + currentVersion, + newVersion, + today, + bumpType, + skipChangelog + ); + fs.writeFileSync(paths.changelog, 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'); } - execSync(`git commit -m "chore: bump version to ${newVersion}"`); - log.success(`✓ Version bumped to ${newVersion} and committed`); log.warn('Don\'t forget to:'); console.log(' 1. Update CHANGELOG.md with actual changes'); @@ -397,4 +659,4 @@ function performBump({ if (isDirectRun()) { main(); -} +} \ No newline at end of file diff --git a/scripts/verify-release-tag.mjs b/scripts/verify-release-tag.mjs new file mode 100644 index 00000000..2ed28786 --- /dev/null +++ b/scripts/verify-release-tag.mjs @@ -0,0 +1,112 @@ +#!/usr/bin/env node + +/** + * Verify that a release tag matches the project's declared version across all + * four version files. Runs in CI before any release build starts so a + * mis-tagged release fails fast instead of shipping mismatched artifacts + * (same pattern used by openusage's publish workflow and tauri-action's + * version checks). + * + * Usage: + * node scripts/verify-release-tag.mjs v2.8.0 + * node scripts/verify-release-tag.mjs v2.8.0-beta.1 --root /path/to/repo + * + * The tag may be passed as argv[2], or taken from the GITHUB_REF_NAME + * environment variable when invoked from a GitHub Actions tag push. + * Exit code 0 = tag matches every version file; 1 = mismatch or bad tag. + */ + +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { + parseVersion, + collectFileVersions, + findVersionDrift +} from './bump-version.mjs'; + +/** + * Pure tag validation. Returns { ok, version?, error? }. Accepts any semver + * `vX.Y.Z` or `vX.Y.Z-` tag (stable and tagged prereleases); + * rejects build metadata and non-semver shapes. + */ +export function parseReleaseTag(tag) { + const trimmed = String(tag || '').trim(); + const SEMVER_IDENTIFIER = '(?:[0-9A-Za-z-]+)(?:\\.[0-9A-Za-z-]+)*'; + const tagPattern = new RegExp(`^v\\d+\\.\\d+\\.\\d+(-${SEMVER_IDENTIFIER})?$`); + if (!tagPattern.test(trimmed)) { + return { + ok: false, + error: `Invalid release tag '${trimmed}': expected vX.Y.Z or vX.Y.Z- (e.g. v2.8.0, v2.8.0-beta.1)` + }; + } + return { ok: true, tag: trimmed, version: trimmed.slice(1) }; +} + +/** + * Pure sync check: does the tag version match every version file? + * Returns { ok, mismatches: [{file, expectedTagVersion, fileVersion}] }. + */ +export function verifyTagAgainstFiles(tag, fileContents) { + const parsed = parseReleaseTag(tag); + if (!parsed.ok) { + return { ok: false, mismatches: [], error: parsed.error }; + } + const versions = collectFileVersions(fileContents); + const mismatches = Object.entries(versions) + .filter(([, version]) => version !== parsed.version) + .map(([file, version]) => ({ + file, + expectedTagVersion: parsed.version, + fileVersion: version + })); + return { ok: mismatches.length === 0, mismatches }; +} + +function main() { + const args = process.argv.slice(2); + const tagArg = args.find((arg) => !arg.startsWith('--')); + const rootFlag = args.find((arg) => arg.startsWith('--root=')); + const rootDir = rootFlag ? rootFlag.slice('--root='.length) : process.cwd(); + + const tag = tagArg || process.env.GITHUB_REF_NAME; + if (!tag) { + console.error('Error: no tag given. Pass it as an argument or set GITHUB_REF_NAME.'); + process.exit(2); + } + + const parsed = parseReleaseTag(tag); + if (!parsed.ok) { + console.error(`Error: ${parsed.error}`); + process.exit(1); + } + + const read = (rel) => fs.readFileSync(path.join(rootDir, rel), 'utf8'); + let contents; + try { + contents = { + packageJson: read('package.json'), + cargoToml: read(path.join('src-tauri', 'Cargo.toml')), + cargoLock: read(path.join('src-tauri', 'Cargo.lock')), + tauriConf: read(path.join('src-tauri', 'tauri.conf.json')) + }; + } catch (error) { + console.error(`Error: cannot read project version files under ${rootDir}: ${error.message}`); + process.exit(1); + } + + const result = verifyTagAgainstFiles(tag, contents); + if (!result.ok) { + console.error(`Error: tag ${tag} does not match the project version:`); + for (const m of result.mismatches) { + console.error(` - ${m.file}: ${m.fileVersion} (expected ${m.expectedTagVersion})`); + } + console.error('Fix the version files (or the tag) before releasing.'); + process.exit(1); + } + console.log(`OK: tag ${tag} matches package.json, Cargo.toml, Cargo.lock, tauri.conf.json`); +} + +if (fileURLToPath(import.meta.url) === path.resolve(process.argv[1] || '')) { + main(); +} \ No newline at end of file diff --git a/src/__tests__/bump-version.test.ts b/src/__tests__/bump-version.test.ts index 643c3ea8..ccd237a8 100644 --- a/src/__tests__/bump-version.test.ts +++ b/src/__tests__/bump-version.test.ts @@ -1,27 +1,69 @@ import { describe, expect, it } from 'vitest'; import { parseVersion, + isPrereleaseVersion, baseVersion, nextPrereleaseTag, computeNextVersion, updateChangelog, + insertSection, STABLE_BUMP_TYPES, + BUMP_TYPES, + parsePackageJsonVersion, + parseCargoTomlVersion, + parseCargoLockVersion, + parseTauriConfVersion, + collectFileVersions, + findVersionDrift, + updateCargoLock, } from '../../scripts/bump-version.mjs'; +import { + parseReleaseTag, + verifyTagAgainstFiles, +} from '../../scripts/verify-release-tag.mjs'; + +const SYNCED_FILES = { + packageJson: JSON.stringify({ name: 'r-shell', version: '2.7.0' }), + cargoToml: '[package]\nname = "r-shell"\nversion = "2.7.0"\n', + cargoLock: 'version = 4\n\n[[package]]\nname = "r-shell"\nversion = "2.7.0"\ndependencies = []\n', + tauriConf: JSON.stringify({ productName: 'r-shell', version: '2.7.0' }), +}; describe('parseVersion', () => { it('parses a stable semver', () => { - expect(parseVersion('2.7.0')).toEqual({ major: 2, minor: 7, patch: 0, prerelease: null }); + expect(parseVersion('2.7.0')).toEqual({ major: 2, minor: 7, patch: 0, prerelease: null, build: 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' }); + expect(parseVersion('2.8.0-beta.1')).toEqual({ major: 2, minor: 8, patch: 0, prerelease: 'beta.1', build: null }); + expect(parseVersion('2.8.0-rc.2')).toEqual({ major: 2, minor: 8, patch: 0, prerelease: 'rc.2', build: null }); + }); + + it('parses build metadata and keeps it separate', () => { + expect(parseVersion('1.2.3+build.5')).toEqual({ major: 1, minor: 2, patch: 3, prerelease: null, build: 'build.5' }); + expect(parseVersion('2.8.0-rc.1+build.7')).toEqual({ major: 2, minor: 8, patch: 0, prerelease: 'rc.1', build: 'build.7' }); }); 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'); + expect(() => parseVersion('2.7.0-beta..1')).toThrow('Invalid version string'); + expect(() => parseVersion('2.7.0+build..1')).toThrow('Invalid version string'); + expect(() => parseVersion('')).toThrow('Invalid version string'); + }); + + it('trims surrounding whitespace', () => { + expect(parseVersion(' 2.7.0 ').major).toBe(2); + }); +}); + +describe('isPrereleaseVersion', () => { + it('distinguishes stable from prerelease', () => { + expect(isPrereleaseVersion('2.8.0')).toBe(false); + expect(isPrereleaseVersion('2.8.0-beta.1')).toBe(true); + expect(isPrereleaseVersion('2.8.0-rc.9')).toBe(true); + expect(isPrereleaseVersion('2.8.0+build.1')).toBe(false); }); }); @@ -47,6 +89,12 @@ describe('nextPrereleaseTag', () => { expect(nextPrereleaseTag('beta.3', 'rc')).toBe('rc.1'); expect(nextPrereleaseTag('rc.1', undefined)).toBe('beta.1'); }); + + it('restarts at .1 for a non-numeric or zero suffix', () => { + expect(nextPrereleaseTag('beta.0', 'beta')).toBe('beta.1'); + expect(nextPrereleaseTag('beta', 'beta')).toBe('beta.1'); + expect(nextPrereleaseTag('beta.next', 'beta')).toBe('beta.1'); + }); }); describe('computeNextVersion', () => { @@ -68,9 +116,13 @@ describe('computeNextVersion', () => { ['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'], + ['2.8.0-beta.3', 'prerelease', 'alpha', '2.8.0-alpha.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'], + // build metadata is dropped by any bump + ['1.2.3+build.5', 'patch', undefined, '1.2.4'], + ['2.8.0-beta.1+build.5', 'stable', undefined, '2.8.0'], ])('%s %s -> %s', (current, bumpType, identifier, expected) => { expect(computeNextVersion(current, bumpType, identifier)).toBe(expected); }); @@ -78,6 +130,150 @@ describe('computeNextVersion', () => { it('rejects stable from an already-stable version', () => { expect(() => computeNextVersion('2.7.0', 'stable', undefined)).toThrow('already stable'); }); + + it('rejects unknown bump types', () => { + expect(() => computeNextVersion('2.7.0', 'banana')).toThrow('Unknown bump type'); + }); +}); + +describe('bump type sets', () => { + it('defines the expected stable bump types', () => { + expect(STABLE_BUMP_TYPES).toEqual(['major', 'minor', 'patch']); + }); + + it('BUMP_TYPES contains every supported type', () => { + expect(BUMP_TYPES).toEqual(['major', 'minor', 'patch', 'prerelease', 'stable']); + }); +}); + +describe('version file readers', () => { + it('reads package.json', () => { + expect(parsePackageJsonVersion('{"version": "2.7.0"}')).toBe('2.7.0'); + expect(() => parsePackageJsonVersion('{}')).toThrow('no valid "version"'); + }); + + it('reads Cargo.toml', () => { + expect(parseCargoTomlVersion('[package]\nversion = "2.8.0-beta.1"\n')).toBe('2.8.0-beta.1'); + expect(() => parseCargoTomlVersion('[package]\nname = "x"\n')).toThrow('no "version = ..."'); + }); + + it('reads the root r-shell entry of Cargo.lock, not dependency entries', () => { + const lock = '[[package]]\nname = "adler2"\nversion = "2.0.1"\n\n[[package]]\nname = "r-shell"\nversion = "2.7.0"\n'; + expect(parseCargoLockVersion(lock)).toBe('2.7.0'); + expect(() => parseCargoLockVersion('[[package]]\nname = "adler2"\nversion = "2.0.1"\n')).toThrow('root "r-shell"'); + }); + + it('reads tauri.conf.json', () => { + expect(parseTauriConfVersion('{"productName": "r-shell", "version": "2.7.0"}')).toBe('2.7.0'); + expect(() => parseTauriConfVersion('{"version": ""}')).toThrow('no valid "version"'); + }); +}); + +describe('collectFileVersions / findVersionDrift', () => { + it('collects all four versions', () => { + expect(collectFileVersions(SYNCED_FILES)).toEqual({ + 'package.json': '2.7.0', + 'src-tauri/Cargo.toml': '2.7.0', + 'src-tauri/Cargo.lock': '2.7.0', + 'src-tauri/tauri.conf.json': '2.7.0', + }); + }); + + it('reports no drift when everything agrees', () => { + expect(findVersionDrift(collectFileVersions(SYNCED_FILES))).toEqual([]); + }); + + it('reports every file that drifted from package.json', () => { + const drifted = { + ...SYNCED_FILES, + cargoToml: '[package]\nversion = "2.7.1"\n', + tauriConf: JSON.stringify({ version: '2.8.0' }), + }; + expect(findVersionDrift(collectFileVersions(drifted))).toEqual([ + { file: 'src-tauri/Cargo.toml', version: '2.7.1' }, + { file: 'src-tauri/tauri.conf.json', version: '2.8.0' }, + ]); + }); +}); + +describe('updateCargoLock', () => { + it('rewrites only the root package version', () => { + const lock = '[[package]]\nname = "adler2"\nversion = "2.0.1"\n\n[[package]]\nname = "r-shell"\nversion = "2.7.0"\n'; + const updated = updateCargoLock(lock, '2.8.0-beta.1'); + expect(updated).toContain('name = "r-shell"\nversion = "2.8.0-beta.1"'); + expect(updated).toContain('name = "adler2"\nversion = "2.0.1"'); + }); + + it('throws when the root package entry is missing', () => { + expect(() => updateCargoLock('[[package]]\nname = "adler2"\nversion = "2.0.1"\n', '2.8.0')).toThrow( + 'not found' + ); + }); +}); + +describe('insertSection', () => { + it('inserts after the Unreleased section', () => { + const changelog = `# Changelog + +## [Unreleased] + +### Added + +- _draft_ + +## [2.7.0] - 2026-08-08 +`; + const out = insertSection(changelog, '2.8.0', '2026-08-11'); + expect(out).toContain('## [2.8.0] - 2026-08-11'); + expect(out.indexOf('## [2.8.0]')).toBeLessThan(out.indexOf('## [2.7.0]')); + expect(out.indexOf('## [Unreleased]')).toBeLessThan(out.indexOf('## [2.8.0]')); + }); + + it('handles a multi-line Unreleased section', () => { + const changelog = `# Changelog + +## [Unreleased] + +### Added + +- one +- two + +## [2.7.0] - 2026-08-08 +`; + const out = insertSection(changelog, '2.8.0', '2026-08-11'); + expect(out.indexOf('## [2.8.0]')).toBeGreaterThan(out.indexOf('- two')); + expect(out.indexOf('## [2.8.0]')).toBeLessThan(out.indexOf('## [2.7.0]')); + }); + + it('inserts at the top when there is no Unreleased section', () => { + const changelog = `# Changelog + +## [2.7.0] - 2026-08-08 +`; + const out = insertSection(changelog, '2.8.0', '2026-08-11'); + expect(out.indexOf('## [2.8.0]')).toBeLessThan(out.indexOf('## [2.7.0]')); + expect(out).toContain('## [2.7.0] - 2026-08-08'); + }); + + it('appends when the changelog has no sections at all', () => { + const out = insertSection('# Changelog\n\nintro text\n', '2.8.0', '2026-08-11'); + expect(out).toContain('## [2.8.0] - 2026-08-11'); + expect(out.indexOf('## [2.8.0]')).toBeGreaterThan(out.indexOf('intro text')); + }); + + it('appends after an Unreleased section at the end of the file', () => { + const changelog = `# Changelog + +## [Unreleased] + +### Added + +- _draft_ +`; + const out = insertSection(changelog, '2.8.0', '2026-08-11'); + expect(out.indexOf('## [2.8.0]')).toBeGreaterThan(out.indexOf('- _draft_')); + }); }); describe('updateChangelog', () => { @@ -103,6 +299,20 @@ describe('updateChangelog', () => { expect(out.indexOf('## [2.8.0]')).toBeLessThan(out.indexOf('## [2.7.0]')); }); + it('inserts a section even when the changelog has no Unreleased section', () => { + const noUnreleased = `# Changelog + +## [2.7.0] - 2026-08-08 + +### Added + +- released feature +`; + const out = updateChangelog(noUnreleased, '2.7.0', '2.8.0', '2026-08-11', 'minor', false); + expect(out).toContain('## [2.8.0] - 2026-08-11'); + 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 @@ -153,6 +363,31 @@ describe('updateChangelog', () => { expect(out).toContain('rc feature'); }); + it('renames a heading without duplicating the date suffix', () => { + 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).toMatch(/^## \[2\.8\.0-beta\.2\] - 2026-08-12$/m); + expect(out).not.toMatch(/## \[2\.8\.0-beta\.2\] - 2026-08-12 - /); + }); + + it('renames a heading with no date suffix cleanly', () => { + const bare = `# Changelog + +## [2.8.0-beta.1] + +### Added + +- beta feature +`; + const out = updateChangelog(bare, '2.8.0-beta.1', '2.8.0', '2026-08-12', 'stable', false); + expect(out).toMatch(/^## \[2\.8\.0\] - 2026-08-12$/m); + }); + it('does not duplicate a section that already exists', () => { const withSection = FIXTURE + `## [2.8.0-beta.1] - 2026-08-11 `; @@ -163,8 +398,48 @@ describe('updateChangelog', () => { 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']); +describe('verify-release-tag (parseReleaseTag)', () => { + it('accepts stable tags', () => { + expect(parseReleaseTag('v2.8.0')).toEqual({ ok: true, tag: 'v2.8.0', version: '2.8.0' }); + }); + + it('accepts prerelease tags', () => { + expect(parseReleaseTag('v2.8.0-beta.1')).toEqual({ ok: true, tag: 'v2.8.0-beta.1', version: '2.8.0-beta.1' }); + expect(parseReleaseTag('v2.8.0-rc.2')).toEqual({ ok: true, tag: 'v2.8.0-rc.2', version: '2.8.0-rc.2' }); + }); + + it('rejects malformed tags', () => { + expect(parseReleaseTag('2.8.0').ok).toBe(false); + expect(parseReleaseTag('v2.8').ok).toBe(false); + expect(parseReleaseTag('v2.8.0.1').ok).toBe(false); + expect(parseReleaseTag('v2.8.0+meta').ok).toBe(false); + expect(parseReleaseTag('').ok).toBe(false); + expect(parseReleaseTag('v2.8.0-').ok).toBe(false); + }); +}); + +describe('verify-release-tag (verifyTagAgainstFiles)', () => { + it('passes when the tag matches every version file', () => { + const result = verifyTagAgainstFiles('v2.7.0', SYNCED_FILES); + expect(result.ok).toBe(true); + expect(result.mismatches).toEqual([]); + }); + + it('reports every mismatched file for a prerelease tag', () => { + const result = verifyTagAgainstFiles('v2.8.0-beta.1', SYNCED_FILES); + expect(result.ok).toBe(false); + expect(result.mismatches).toHaveLength(4); + expect(result.mismatches[0].file).toBe('package.json'); + expect(result.mismatches[0].expectedTagVersion).toBe('2.8.0-beta.1'); + expect(result.mismatches[0].fileVersion).toBe('2.7.0'); + }); + + it('rejects a malformed tag before reading versions', () => { + const result = verifyTagAgainstFiles('not-a-tag', SYNCED_FILES); + expect(result.ok).toBe(false); + expect(result.mismatches).toEqual([]); + expect(result.error).toContain('Invalid release tag'); }); }); From 947d60e49d746c3f38963bbcb24777dceaf542a7 Mon Sep 17 00:00:00 2001 From: r-shell agent Date: Fri, 21 Aug 2026 23:22:26 +0800 Subject: [PATCH 05/10] feat(workflow): validate release tags and auto-mark tagged prereleases Release hardening informed by openusage's publish workflow and tauri-apps/tauri-action semantics: - New validate-tag job fails fast before any build when the tag is not a valid semver tag (vX.Y.Z or vX.Y.Z-) or does not match the version in every version file. - tauri-action now creates tagged prereleases (vX.Y.Z-.) with prerelease: true; with the previous hardcoded false a prerelease tag could be auto-marked "Latest" and hijack releases/latest for stable users. - concurrency group serializes overlapping release runs so two tags can never race on the latest.json manifest upload. --- .github/workflows/release.yml | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7641316d..d0c1f3b4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,8 +6,34 @@ on: - 'v*' workflow_dispatch: +# Serialize releases per tag/branch: two overlapping runs could race on the +# latest.json manifest upload or the release asset list. Never cancel a run +# that already started building. +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + jobs: + # Cheap, fails-fast gate: the tag must be a valid semver tag AND match the + # version declared in every version file, so a mis-tagged release never + # ships artifacts whose version differs from the tag. + validate-tag: + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/') + steps: + - name: Checkout repository + uses: actions/checkout@v7 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '20' + + - name: Validate tag matches project version + run: node scripts/verify-release-tag.mjs "$GITHUB_REF_NAME" + release: + needs: validate-tag permissions: contents: write strategy: @@ -80,7 +106,11 @@ jobs: releaseName: 'r-shell ${{ github.ref_name }}' releaseBody: 'See the assets to download this version and install.' releaseDraft: false - prerelease: false + # Tagged prereleases (vX.Y.Z-.) must be GitHub *prereleases*: + # a release created with prerelease: false would be auto-marked + # "Latest" and hijack releases/latest for stable users. Stable tags + # are always vX.Y.Z (no dash). + prerelease: ${{ startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, '-') }} includeUpdaterJson: true args: ${{ matrix.args }} From 105cf29c9f98f90f7e2c0d6b2ec5c0d26b22dad3 Mon Sep 17 00:00:00 2001 From: r-shell agent Date: Fri, 21 Aug 2026 23:22:53 +0800 Subject: [PATCH 06/10] docs: document release-tooling guardrails, dry-run, and tag verification - SKILL.md: note the pre-flight checks and --dry-run/--yes flags on the bump step, add a version:verify sanity check before tagging, and mention the workflow's validate-tag gate and auto-prerelease behavior. - scripts/README.md: document verify-release-tag.mjs, the new options (--dry-run/--yes/--force), and the direct Cargo.lock update. - AGENTS.md / README.md: add version:verify and the guardrail summary. --- .github/skills/release-version/SKILL.md | 22 +++++++++++++- AGENTS.md | 3 ++ README.md | 3 ++ scripts/README.md | 39 +++++++++++++++++++++---- 4 files changed, 61 insertions(+), 6 deletions(-) diff --git a/.github/skills/release-version/SKILL.md b/.github/skills/release-version/SKILL.md index ce3c4f6d..19255d68 100644 --- a/.github/skills/release-version/SKILL.md +++ b/.github/skills/release-version/SKILL.md @@ -11,7 +11,7 @@ Bumps the project version across all config files, updates the CHANGELOG, pushes - **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. +Both trigger the same `release.yml` build on a pushed `v*` tag; only the release **kind** differs. The workflow runs a `validate-tag` job first that fails fast if the tag is not a valid semver tag or does not match every version file, and it marks tagged prereleases as GitHub prereleases automatically (`prerelease: true`), so stable releases always stay the repo's "Latest". ## When to Use - Releasing a new patch, minor, or major version of r-shell (stable) @@ -49,7 +49,12 @@ For prereleases, an optional identifier selects the prerelease line (`alpha`, `b ### 2. Run the Version Bump Script +The script is **non-destructive on preview**: run `--dry-run` first to confirm the target version and its CHANGELOG action without writing anything, then run it for real: + ```bash +# Preview first (optional but recommended): +pnpm exec node scripts/bump-version.mjs --dry-run + # Replace with patch, minor, major, prerelease [identifier], or stable pnpm run version: # e.g.: @@ -60,6 +65,12 @@ 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) ``` +The script enforces two **preflight guardrails** before touching anything (both bypassed with `--force` if you know what you are doing): +1. **Version drift** — `package.json`, `Cargo.toml`, `Cargo.lock`, and `tauri.conf.json` must all agree on the current version. +2. **Dirty tree** — no uncommitted *tracked* modifications (untracked files are fine), so the bump commit contains exactly the version change. + +If the working tree is dirty (e.g. you have uncommitted version-draft edits), the bump will refuse — commit/stash, or run with `--force`. Use `--yes` to skip the interactive confirmation (useful when driving the bump from an automated agent). + This updates **all four** version locations atomically and creates a git commit: - `package.json` - `src-tauri/Cargo.toml` @@ -128,6 +139,15 @@ git commit --amend --no-edit ### 4. Create and Push the Git Tag +**Before tagging, verify the tag would match every version file** — this is exactly what the Release workflow's `validate-tag` job enforces, so catching it here saves a failed build: + +```bash +VERSION=$(node -p "require('./package.json').version") +pnpm run version:verify "v${VERSION}" # or: node scripts/verify-release-tag.mjs "v${VERSION}" +``` + +Then create and push the tag: + ```bash VERSION=$(node -p "require('./package.json').version") git tag "v${VERSION}" diff --git a/AGENTS.md b/AGENTS.md index 8fbeb8d1..0f338add 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -110,8 +110,11 @@ 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) +pnpm run version:verify "v2.8.0-beta.1" # check a tag matches every version file ``` +The bump script runs preflight guardrails (all four version files must agree; no uncommitted tracked changes) and supports `--dry-run` (preview without writing), `--yes` (skip confirmation), `--force` (bypass guardrails), `--no-commit`, and `--skip-changelog`. + 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 9cd2a1ae..2d11242e 100644 --- a/README.md +++ b/README.md @@ -287,8 +287,11 @@ 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) +pnpm run version:verify "v2.3.0-beta.1" # verify a tag matches every version file ``` +The bump script updates `package.json`, `Cargo.toml`, `Cargo.lock`, `tauri.conf.json`, and `CHANGELOG.md`, and creates a git commit. It refuses to run on a dirty tree or when the version files disagree (`--dry-run` previews, `--yes` skips confirmation, `--force` bypasses guardrails). + --- ## 📁 Project Structure diff --git a/scripts/README.md b/scripts/README.md index 8cc1c52b..9bda1de7 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -16,6 +16,7 @@ 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) +pnpm run version:verify v2.8.0 # verify a tag matches every version file # Direct usage node scripts/bump-version.mjs patch @@ -23,15 +24,20 @@ 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 +node scripts/bump-version.mjs minor --dry-run # preview without writing +node scripts/bump-version.mjs minor --yes # skip the confirmation prompt ``` **Features:** - ✅ Cross-platform compatibility - ✅ No shell dependencies -- ✅ Interactive confirmation +- ✅ Interactive confirmation (`--yes` to skip for CI/agents) +- ✅ `--dry-run` preview that writes nothing +- ✅ Preflight guardrails: refuses to bump with a dirty tree or out-of-sync version files (`--force` to bypass) - ✅ Automatic git commit -- ✅ CHANGELOG.md template generation +- ✅ CHANGELOG.md template generation (inserts a section, or renames the release-line section on prerelease/stable) - ✅ Stable (`major`/`minor`/`patch`) and tagged prerelease (`prerelease`/`stable`) bumps +- ✅ Cargo.lock updated by rewriting the root package entry (no full `cargo build` needed; falls back to `cargo build` and verifies the result) ### Bump Types @@ -39,7 +45,19 @@ node scripts/bump-version.mjs stable - `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. +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, preserving the date) instead of inserting a new one each time, so draft notes carry over without accumulating duplicate sections. Insertion follows Keep a Changelog: right after the `Unreleased` section, or at the top when no `Unreleased` section exists. + +### verify-release-tag.mjs + +Checks that a release tag matches the version declared in `package.json`, `Cargo.toml`, `Cargo.lock`, and `tauri.conf.json`. Used by the Release workflow (`validate-tag` job) as a cheap failsafe before any build starts; also handy locally before tagging. + +```bash +node scripts/verify-release-tag.mjs v2.8.0 +node scripts/verify-release-tag.mjs v2.8.0-beta.1 +# In CI the tag is taken from GITHUB_REF_NAME when no argument is passed. +``` + +Accepts `vX.Y.Z` and `vX.Y.Z-` tags; rejects build metadata and malformed tags. Exits non-zero with a per-file report on any mismatch. ### bump-version.sh @@ -61,10 +79,21 @@ For `prerelease` / `stable`, the CHANGELOG section for the release line is **ren ## Options -Both scripts support the same options: +`bump-version.mjs` supports: +- `--dry-run`: print the plan without writing anything or prompting +- `--yes` / `-y`: skip the interactive confirmation prompt +- `--force`: bypass the preflight guardrails (dirty tree, version drift) - `--no-commit`: Update files without creating a git commit - `--skip-changelog`: Don't update CHANGELOG.md +- `--help` / `-h`: show usage + +The bash script (`bump-version.sh`) supports `--no-commit` and `--skip-changelog` only. + +> ⚠️ `bump-version.mjs` runs two preflight checks before touching anything: +> 1. **Version drift** — all four version files must agree on the current version. +> 2. **Dirty tree** — no uncommitted *tracked* modifications (untracked files are fine). +> Both fail the bump with a clear message unless you pass `--force`. ## What Gets Updated @@ -72,7 +101,7 @@ When you run a version bump script, it automatically updates: 1. **package.json** - Frontend package version 2. **src-tauri/Cargo.toml** - Rust package version -3. **src-tauri/Cargo.lock** - Updated via `cargo build` +3. **src-tauri/Cargo.lock** - Root package version (edited directly when possible, `cargo build` fallback) 4. **src-tauri/tauri.conf.json** - Tauri app version 5. **CHANGELOG.md** - New version section (unless `--skip-changelog`) From bb0a94738586d218ef78075f99df28d5e7a8c5d7 Mon Sep 17 00:00:00 2001 From: r-shell agent Date: Fri, 21 Aug 2026 23:25:39 +0800 Subject: [PATCH 07/10] docs(scripts): point the changelog guide to the release-version skill docs/VERSION_BUMP.md no longer exists; the authoritative release procedure now lives in .github/skills/release-version/SKILL.md. --- scripts/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/README.md b/scripts/README.md index 9bda1de7..5bdeb7c7 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -124,8 +124,8 @@ node scripts/bump-version.mjs major --skip-changelog ## Documentation -For detailed information about version bumping workflow, see: -- [docs/VERSION_BUMP.md](../docs/VERSION_BUMP.md) - Complete version bump guide +For detailed information about the version bumping workflow, see: +- [release-version skill](../.github/skills/release-version/SKILL.md) - Full release procedure (stable and tagged prereleases) - [CHANGELOG.md](../CHANGELOG.md) - Version history ## Adding New Scripts From 594f0b7e454c9a2ea686450b281270faf925757c Mon Sep 17 00:00:00 2001 From: r-shell agent Date: Fri, 21 Aug 2026 23:25:56 +0800 Subject: [PATCH 08/10] docs(copilot): document prerelease/stable/verify commands and guardrails Replaces the stale docs/VERSION_BUMP.md reference with the release-version skill and adds the prerelease/stable/verify commands to the instructions. --- .github/copilot-instructions.md | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 72443d90..f5d0140f 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -78,10 +78,21 @@ pnpm run version:minor # Bump major version (0.6.2 → 1.0.0) pnpm run version:major + +# Tagged prerelease (0.7.0 → 0.8.0-beta.1, or beta.1 → beta.2) +pnpm run version:prerelease +pnpm run version:prerelease rc # switch the prerelease line (beta → rc) + +# Finalize a prerelease to stable (0.8.0-beta.3 → 0.8.0) +pnpm run version:stable + +# Verify a tag matches every version file before tagging +pnpm run version:verify "v0.8.0-beta.1" ``` -- Script updates: package.json, Cargo.toml, Cargo.lock, tauri.conf.json, CHANGELOG.md +- Script updates: package.json, Cargo.toml, Cargo.lock (root package entry, `cargo build` fallback), tauri.conf.json, CHANGELOG.md - Auto-creates git commit with template CHANGELOG entry -- See [docs/VERSION_BUMP.md](docs/VERSION_BUMP.md) for full guide +- Enforces preflight guardrails (version-drift + dirty-tree checks); `--dry-run` previews, `--yes` skips confirmation, `--force` bypasses guardrails +- See `.github/skills/release-version/SKILL.md` for the full release guide (stable and tagged prereleases) ### Adding Tauri Commands 1. Define function in [commands.rs](src-tauri/src/commands.rs) with `#[tauri::command]` From b40814250e66e0bcdfc64ffe34d690d3542643f7 Mon Sep 17 00:00:00 2001 From: r-shell agent Date: Fri, 21 Aug 2026 23:31:41 +0800 Subject: [PATCH 09/10] fix(scripts): keep bump-version.mjs pure ASCII for cross-platform CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The '✓'/'⚠️'/'—'/'…' glyphs made Vitest fail to load the module on Windows (and ubuntu) CI with 'SyntaxError: Invalid or unexpected token' - a pre-existing failure already present on this branch before the enhancement. Replace them with ASCII equivalents in log output and comments so the release tooling parses identically everywhere (verified pure-ASCII across the whole version-bumping dependency graph). --- scripts/bump-version.mjs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/scripts/bump-version.mjs b/scripts/bump-version.mjs index 50f47a9f..977a243d 100755 --- a/scripts/bump-version.mjs +++ b/scripts/bump-version.mjs @@ -281,7 +281,7 @@ function buildSection(version, date) { * newest release on top, directly after the Unreleased section when one * exists. Falls back to inserting before the first released section (or * appending to the title block) when there is no Unreleased section, so the - * insertion never silently no-ops — the caller asserts the section exists. + * insertion never silently no-ops - the caller asserts the section exists. */ export function insertSection(changelog, version, date) { const newSection = buildSection(version, date); @@ -541,7 +541,7 @@ function main() { console.log( ` - Commit: ${noCommit ? 'skipped (--no-commit)' : 'chore: bump version to ' + newVersion}` ); - log.info('Dry run — no files were modified.'); + log.info('Dry run - no files were modified.'); return; } @@ -587,7 +587,7 @@ function performBump({ bumpType, noCommit, skipChangelog, currentVersion, newVer tauriConf.version = newVersion; fs.writeFileSync(paths.tauriConf, JSON.stringify(tauriConf, null, 2) + '\n'); - // Update Cargo.lock — rewrite the root package version directly (like + // Update Cargo.lock - rewrite the root package version directly (like // `cargo set-version`), falling back to `cargo build` only when the root // package entry cannot be found, and verify the result either way. log.info('Updating src-tauri/Cargo.lock...'); @@ -595,7 +595,7 @@ function performBump({ bumpType, noCommit, skipChangelog, currentVersion, newVer try { cargoLock = updateCargoLock(cargoLock, newVersion); } catch { - log.warn('Root package entry not found in Cargo.lock; using cargo build fallback…'); + log.warn('Root package entry not found in Cargo.lock; using cargo build fallback...'); execSync('cargo build --quiet', { cwd: path.join(rootDir, 'src-tauri'), stdio: 'ignore' @@ -623,7 +623,7 @@ function performBump({ bumpType, noCommit, skipChangelog, currentVersion, newVer skipChangelog ); fs.writeFileSync(paths.changelog, updated); - log.warn('⚠️ Please update CHANGELOG.md with actual changes before committing'); + log.warn('! Please update CHANGELOG.md with actual changes before committing'); } // Create git commit @@ -634,14 +634,14 @@ function performBump({ bumpType, noCommit, skipChangelog, currentVersion, newVer execSync('git add CHANGELOG.md'); } execSync(`git commit -m "chore: bump version to ${newVersion}"`); - log.success(`✓ Version bumped to ${newVersion} and committed`); + log.success(`[OK] Version bumped to ${newVersion} and committed`); log.warn('Don\'t forget to:'); console.log(' 1. Update CHANGELOG.md with actual changes'); console.log(' 2. Run: git commit --amend (if needed)'); console.log(` 3. Create a git tag: git tag v${newVersion}`); console.log(' 4. Push changes: git push && git push --tags'); } else { - log.success(`✓ Version bumped to ${newVersion}`); + log.success(`[OK] Version bumped to ${newVersion}`); log.warn('Files modified (not committed):'); console.log(' - package.json'); console.log(' - src-tauri/Cargo.toml'); From 3fd1bb26dc4b940c75aa830137b15822786903f6 Mon Sep 17 00:00:00 2001 From: r-shell agent Date: Fri, 21 Aug 2026 23:47:26 +0800 Subject: [PATCH 10/10] refactor(scripts): move pure version logic to src/lib for cross-platform tests Vitest fails to transform ESM imported from outside the project root (scripts/) on Windows CI with 'SyntaxError: Invalid or unexpected token'. The failure predates this enhancement (the original PR head had the same red windows run) and is specific to out-of-root imports in the test graph. Move all pure version/CHANGELOG/tag logic into src/lib/version-bump.mjs - inside the vitest transform root, with no Node built-ins and no import.meta.url in the shared module - and turn both CLI scripts into thin shells importing from it. Unit tests now import only from src/, so the module transforms identically on macOS, ubuntu, and windows. Also drop the leftover non-ASCII glyphs from the previous attempt; the whole versioning dependency graph is now pure ASCII. --- scripts/bump-version.mjs | 328 ++------------------------ scripts/verify-release-tag.mjs | 50 +--- src/__tests__/bump-version.test.ts | 4 +- src/lib/version-bump.mjs | 364 +++++++++++++++++++++++++++++ 4 files changed, 394 insertions(+), 352 deletions(-) create mode 100644 src/lib/version-bump.mjs diff --git a/scripts/bump-version.mjs b/scripts/bump-version.mjs index 977a243d..30f55fad 100755 --- a/scripts/bump-version.mjs +++ b/scripts/bump-version.mjs @@ -1,9 +1,14 @@ #!/usr/bin/env node /** - * R-Shell Version Bump Script (Node.js version) + * R-Shell Version Bump CLI * Cross-platform version bumping for Windows, macOS, and Linux. * + * All pure version/CHANGELOG logic lives in src/lib/version-bump.mjs (shared + * with scripts/verify-release-tag.mjs and the unit tests); this file is the + * thin CLI shell: argument parsing, preflight guardrails, file writes, and + * the git commit. + * * Usage: * node scripts/bump-version.mjs [identifier] [options] * @@ -38,6 +43,20 @@ import path from 'path'; import { execSync } from 'child_process'; import readline from 'readline'; import { fileURLToPath } from 'url'; +import { + STABLE_BUMP_TYPES, + BUMP_TYPES, + PRERELEASE_IDENTIFIER_RE, + parseVersion, + baseVersion, + computeNextVersion, + sectionExists, + collectFileVersions, + findVersionDrift, + parseCargoLockVersion, + updateCargoLock, + updateChangelog +} from '../src/lib/version-bump.mjs'; const colors = { red: '\x1b[31m', @@ -54,313 +73,8 @@ const log = { error: (msg) => console.log(`${colors.red}${msg}${colors.reset}`) }; -/** Bump types that always land on a stable (non-prerelease) version. */ -export const STABLE_BUMP_TYPES = ['major', 'minor', 'patch']; - -export const BUMP_TYPES = [...STABLE_BUMP_TYPES, 'prerelease', 'stable']; - -const PRERELEASE_IDENTIFIER_RE = /^[0-9A-Za-z-]+$/; -const DEFAULT_PRERELEASE_IDENTIFIER = 'beta'; - -// --------------------------------------------------------------------------- -// Pure version math (exported for unit tests) -// --------------------------------------------------------------------------- - -/** - * Parse a semver string into { major, minor, patch, prerelease, build }. - * The prerelease component (everything after the first `-`) and the build - * metadata (everything after the first `+`) are kept verbatim. Build metadata - * is never carried over by a bump, per the SemVer spec. - */ -// A semver identifier: dot-separated, non-empty [0-9A-Za-z-] segments. -const SEMVER_IDENTIFIER = '(?:[0-9A-Za-z-]+)(?:\\.[0-9A-Za-z-]+)*'; - -export function parseVersion(version) { - const match = new RegExp( - `^(\\d+)\\.(\\d+)\\.(\\d+)(?:-(${SEMVER_IDENTIFIER}))?(?:\\+(${SEMVER_IDENTIFIER}))?$` - ).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, - build: match[5] || null - }; -} - -/** True when the version has a prerelease suffix ("2.8.0-beta.1" -> true). */ -export function isPrereleaseVersion(version) { - return parseVersion(version).prerelease !== null; -} - -/** 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}`; -} - -/** - * 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` ("beta.3" + "rc" -> "rc.1"). - */ -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`; -} - -/** - * 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}`); - } -} - -// --------------------------------------------------------------------------- -// Version file readers (pure; take raw file content, return the version) -// --------------------------------------------------------------------------- - -/** package.json -> "2.7.0" (throws on invalid JSON or missing version). */ -export function parsePackageJsonVersion(content) { - const pkg = JSON.parse(content); - if (typeof pkg.version !== 'string' || pkg.version === '') { - throw new Error('package.json has no valid "version" field'); - } - return pkg.version; -} - -/** Cargo.toml -> "2.7.0" (first `version = "..."` line). */ -export function parseCargoTomlVersion(content) { - const match = /^version\s*=\s*"([^"]+)"/m.exec(content); - if (!match) { - throw new Error('Cargo.toml has no "version = ..." line'); - } - return match[1]; -} - -/** Cargo.lock -> version of the root package (the "r-shell" [[package]] entry). */ -export function parseCargoLockVersion(content) { - const match = /^name = "r-shell"\nversion = "([^"]+)"/m.exec(content); - if (!match) { - throw new Error('Cargo.lock has no root "r-shell" package entry'); - } - return match[1]; -} - -/** tauri.conf.json -> "2.7.0". */ -export function parseTauriConfVersion(content) { - const conf = JSON.parse(content); - if (typeof conf.version !== 'string' || conf.version === '') { - throw new Error('tauri.conf.json has no valid "version" field'); - } - return conf.version; -} - -/** - * Collect the versions declared by every version file. Keys are the file - * names; the package.json version is the source of truth. - */ -export function collectFileVersions({ packageJson, cargoToml, cargoLock, tauriConf }) { - return { - 'package.json': parsePackageJsonVersion(packageJson), - 'src-tauri/Cargo.toml': parseCargoTomlVersion(cargoToml), - 'src-tauri/Cargo.lock': parseCargoLockVersion(cargoLock), - 'src-tauri/tauri.conf.json': parseTauriConfVersion(tauriConf) - }; -} - -/** - * Return the files whose version differs from package.json, e.g. - * [{ file: 'src-tauri/Cargo.toml', version: '2.7.1' }]. Empty when in sync. - */ -export function findVersionDrift(versions) { - const reference = versions['package.json']; - return Object.entries(versions) - .filter(([file, version]) => file !== 'package.json' && version !== reference) - .map(([file, version]) => ({ file, version })); -} - -// --------------------------------------------------------------------------- -// Cargo.lock update (direct edit, with cargo fallback) -// --------------------------------------------------------------------------- - -/** - * Rewrite the version of the root "r-shell" package inside Cargo.lock. - * Editing the root package entry directly is what `cargo set-version` does - * and avoids a full `cargo build` just to refresh a lockfile. Throws when the - * root package entry cannot be found (caller falls back to `cargo build`). - */ -export function updateCargoLock(content, newVersion) { - const pattern = /^(name = "r-shell"\nversion = ")[^"]*(")/m; - if (!pattern.test(content)) { - throw new Error('Root "r-shell" package entry not found in Cargo.lock'); - } - return content.replace(pattern, `$1${newVersion}$2`); -} - -// --------------------------------------------------------------------------- -// 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) { - // Replace the whole heading line (including any existing " - YYYY-MM-DD" - // date suffix) so a rename never leaves a duplicated date behind. - return changelog.replace( - new RegExp(`^## \\[${escapeRegex(fromVersion)}\\][^\\n]*`, 'm'), - `## [${toVersion}] - ${date}` - ); -} - -function buildSection(version, date) { - return `## [${version}] - ${date} - -### Added - -- _Add new features here_ - -### Changed - -- _Add changes here_ - -### Fixed - -- _Add bug fixes here_ -`; -} - -/** - * Insert a fresh version section following the Keep a Changelog convention: - * newest release on top, directly after the Unreleased section when one - * exists. Falls back to inserting before the first released section (or - * appending to the title block) when there is no Unreleased section, so the - * insertion never silently no-ops - the caller asserts the section exists. - */ -export function insertSection(changelog, version, date) { - const newSection = buildSection(version, date); - - const unreleasedMatch = /^## \[Unreleased\]/m.exec(changelog); - if (unreleasedMatch) { - const afterHeading = changelog.slice(unreleasedMatch.index + unreleasedMatch[0].length); - const nextHeading = /^## /m.exec(afterHeading); - const insertAt = nextHeading - ? unreleasedMatch.index + unreleasedMatch[0].length + nextHeading.index - : changelog.length; - return spliceSection(changelog, insertAt, newSection); - } - - const firstSection = /^## /m.exec(changelog); - if (firstSection) { - return spliceSection(changelog, firstSection.index, newSection); - } - - return `${changelog.replace(/\s+$/, '')}\n\n${newSection}`; -} - -/** Join `before` and `after` around a section with exactly one blank line each side. */ -function spliceSection(changelog, insertAt, section) { - const before = changelog.slice(0, insertAt).replace(/\s+$/, ''); - const after = changelog.slice(insertAt).replace(/^\n+/, ''); - return `${before}\n\n${section}${after ? `\n${after}` : ''}`; -} - -/** - * 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. - * - * Throws when the new version's section is missing afterwards, so a broken - * changelog format fails the bump instead of silently skipping the update. - */ -export function updateChangelog(changelog, currentVersion, newVersion, date, bumpType, skipChangelog) { - if (skipChangelog) { - return changelog; - } - - if (STABLE_BUMP_TYPES.includes(bumpType)) { - if (!sectionExists(changelog, newVersion)) { - changelog = insertSection(changelog, newVersion, date); - } - } else { - // prerelease / stable: reuse the same release line's section when possible. - if (sectionExists(changelog, newVersion)) { - return changelog; - } - - const base = baseVersion(parseVersion(newVersion)); - if (sectionExists(changelog, base)) { - changelog = renameSection(changelog, base, newVersion, date); - } else { - const curBase = baseVersion(parseVersion(currentVersion)); - if (curBase === base && sectionExists(changelog, currentVersion)) { - changelog = renameSection(changelog, currentVersion, newVersion, date); - } else { - changelog = insertSection(changelog, newVersion, date); - } - } - } - - if (!sectionExists(changelog, newVersion)) { - throw new Error( - `Failed to add a CHANGELOG section for ${newVersion}; the changelog format was not recognized.` - ); - } - return changelog; -} - // --------------------------------------------------------------------------- -// Preflight guardrails +// Preflight guardrail // --------------------------------------------------------------------------- /** diff --git a/scripts/verify-release-tag.mjs b/scripts/verify-release-tag.mjs index 2ed28786..0bd8a5a4 100644 --- a/scripts/verify-release-tag.mjs +++ b/scripts/verify-release-tag.mjs @@ -14,54 +14,20 @@ * The tag may be passed as argv[2], or taken from the GITHUB_REF_NAME * environment variable when invoked from a GitHub Actions tag push. * Exit code 0 = tag matches every version file; 1 = mismatch or bad tag. + * + * The pure tag/version logic (parseReleaseTag, verifyTagAgainstFiles, + * collectFileVersions) lives in src/lib/version-bump.mjs, shared with + * scripts/bump-version.mjs and the unit tests. */ import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; import { - parseVersion, - collectFileVersions, - findVersionDrift -} from './bump-version.mjs'; - -/** - * Pure tag validation. Returns { ok, version?, error? }. Accepts any semver - * `vX.Y.Z` or `vX.Y.Z-` tag (stable and tagged prereleases); - * rejects build metadata and non-semver shapes. - */ -export function parseReleaseTag(tag) { - const trimmed = String(tag || '').trim(); - const SEMVER_IDENTIFIER = '(?:[0-9A-Za-z-]+)(?:\\.[0-9A-Za-z-]+)*'; - const tagPattern = new RegExp(`^v\\d+\\.\\d+\\.\\d+(-${SEMVER_IDENTIFIER})?$`); - if (!tagPattern.test(trimmed)) { - return { - ok: false, - error: `Invalid release tag '${trimmed}': expected vX.Y.Z or vX.Y.Z- (e.g. v2.8.0, v2.8.0-beta.1)` - }; - } - return { ok: true, tag: trimmed, version: trimmed.slice(1) }; -} - -/** - * Pure sync check: does the tag version match every version file? - * Returns { ok, mismatches: [{file, expectedTagVersion, fileVersion}] }. - */ -export function verifyTagAgainstFiles(tag, fileContents) { - const parsed = parseReleaseTag(tag); - if (!parsed.ok) { - return { ok: false, mismatches: [], error: parsed.error }; - } - const versions = collectFileVersions(fileContents); - const mismatches = Object.entries(versions) - .filter(([, version]) => version !== parsed.version) - .map(([file, version]) => ({ - file, - expectedTagVersion: parsed.version, - fileVersion: version - })); - return { ok: mismatches.length === 0, mismatches }; -} + parseReleaseTag, + verifyTagAgainstFiles, + collectFileVersions +} from '../src/lib/version-bump.mjs'; function main() { const args = process.argv.slice(2); diff --git a/src/__tests__/bump-version.test.ts b/src/__tests__/bump-version.test.ts index ccd237a8..5c3c98f5 100644 --- a/src/__tests__/bump-version.test.ts +++ b/src/__tests__/bump-version.test.ts @@ -16,11 +16,9 @@ import { collectFileVersions, findVersionDrift, updateCargoLock, -} from '../../scripts/bump-version.mjs'; -import { parseReleaseTag, verifyTagAgainstFiles, -} from '../../scripts/verify-release-tag.mjs'; +} from '../lib/version-bump.mjs'; const SYNCED_FILES = { packageJson: JSON.stringify({ name: 'r-shell', version: '2.7.0' }), diff --git a/src/lib/version-bump.mjs b/src/lib/version-bump.mjs new file mode 100644 index 00000000..e7df1331 --- /dev/null +++ b/src/lib/version-bump.mjs @@ -0,0 +1,364 @@ +/** + * Version-bumping domain logic for r-shell (pure, framework-free ESM). + * + * Single source of truth shared by: + * - scripts/bump-version.mjs (the CLI) + * - scripts/verify-release-tag.mjs (the CI/CLI tag validator) + * - src/__tests__/bump-version.test.ts (unit tests) + * + * It deliberately lives under src/lib so that Vitest transforms it inside + * the project root: importing ESM from outside the root (scripts/) trips a + * vite-node bug on Windows ("SyntaxError: Invalid or unexpected token"). + * It must stay free of Node built-ins and import.meta so both the browser + * and the CLI can consume it without side effects. + * + * Tested by src/__tests__/bump-version.test.ts. + */ + +/** Bump types that always land on a stable (non-prerelease) version. */ +export const STABLE_BUMP_TYPES = ['major', 'minor', 'patch']; + +/** Every supported bump type. */ +export const BUMP_TYPES = [...STABLE_BUMP_TYPES, 'prerelease', 'stable']; + +export const PRERELEASE_IDENTIFIER_RE = /^[0-9A-Za-z-]+$/; +export const DEFAULT_PRERELEASE_IDENTIFIER = 'beta'; + +// --------------------------------------------------------------------------- +// Pure version math +// --------------------------------------------------------------------------- + +// A semver identifier: dot-separated, non-empty [0-9A-Za-z-] segments. +const SEMVER_IDENTIFIER = '(?:[0-9A-Za-z-]+)(?:\\.[0-9A-Za-z-]+)*'; + +/** + * Parse a semver string into { major, minor, patch, prerelease, build }. + * The prerelease component (everything after the first `-`) and the build + * metadata (everything after the first `+`) are kept verbatim. Build metadata + * is never carried over by a bump, per the SemVer spec. + */ +export function parseVersion(version) { + const match = new RegExp( + `^(\\d+)\\.(\\d+)\\.(\\d+)(?:-(${SEMVER_IDENTIFIER}))?(?:\\+(${SEMVER_IDENTIFIER}))?$` + ).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, + build: match[5] || null + }; +} + +/** True when the version has a prerelease suffix ("2.8.0-beta.1" -> true). */ +export function isPrereleaseVersion(version) { + return parseVersion(version).prerelease !== null; +} + +/** 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}`; +} + +/** + * 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` ("beta.3" + "rc" -> "rc.1"). + */ +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`; +} + +/** + * 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}`); + } +} + +// --------------------------------------------------------------------------- +// Version file readers (pure; take raw file content, return the version) +// --------------------------------------------------------------------------- + +/** package.json -> "2.7.0" (throws on invalid JSON or missing version). */ +export function parsePackageJsonVersion(content) { + const pkg = JSON.parse(content); + if (typeof pkg.version !== 'string' || pkg.version === '') { + throw new Error('package.json has no valid "version" field'); + } + return pkg.version; +} + +/** Cargo.toml -> "2.7.0" (first `version = "..."` line). */ +export function parseCargoTomlVersion(content) { + const match = /^version\s*=\s*"([^"]+)"/m.exec(content); + if (!match) { + throw new Error('Cargo.toml has no "version = ..." line'); + } + return match[1]; +} + +/** Cargo.lock -> version of the root package (the "r-shell" [[package]] entry). */ +export function parseCargoLockVersion(content) { + const match = /^name = "r-shell"\nversion = "([^"]+)"/m.exec(content); + if (!match) { + throw new Error('Cargo.lock has no root "r-shell" package entry'); + } + return match[1]; +} + +/** tauri.conf.json -> "2.7.0". */ +export function parseTauriConfVersion(content) { + const conf = JSON.parse(content); + if (typeof conf.version !== 'string' || conf.version === '') { + throw new Error('tauri.conf.json has no valid "version" field'); + } + return conf.version; +} + +/** + * Collect the versions declared by every version file. Keys are the file + * names; the package.json version is the source of truth. + */ +export function collectFileVersions({ packageJson, cargoToml, cargoLock, tauriConf }) { + return { + 'package.json': parsePackageJsonVersion(packageJson), + 'src-tauri/Cargo.toml': parseCargoTomlVersion(cargoToml), + 'src-tauri/Cargo.lock': parseCargoLockVersion(cargoLock), + 'src-tauri/tauri.conf.json': parseTauriConfVersion(tauriConf) + }; +} + +/** + * Return the files whose version differs from package.json, e.g. + * [{ file: 'src-tauri/Cargo.toml', version: '2.7.1' }]. Empty when in sync. + */ +export function findVersionDrift(versions) { + const reference = versions['package.json']; + return Object.entries(versions) + .filter(([file, version]) => file !== 'package.json' && version !== reference) + .map(([file, version]) => ({ file, version })); +} + +// --------------------------------------------------------------------------- +// Cargo.lock update (direct edit, with cargo fallback in the CLI) +// --------------------------------------------------------------------------- + +/** + * Rewrite the version of the root "r-shell" package inside Cargo.lock. + * Editing the root package entry directly is what `cargo set-version` does + * and avoids a full `cargo build` just to refresh a lockfile. Throws when the + * root package entry cannot be found (the CLI falls back to `cargo build`). + */ +export function updateCargoLock(content, newVersion) { + const pattern = /^(name = "r-shell"\nversion = ")[^"]*(")/m; + if (!pattern.test(content)) { + throw new Error('Root "r-shell" package entry not found in Cargo.lock'); + } + return content.replace(pattern, `$1${newVersion}$2`); +} + +// --------------------------------------------------------------------------- +// CHANGELOG helpers +// --------------------------------------------------------------------------- + +function escapeRegex(str) { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** True when the changelog has a "## [version]" heading. */ +export function sectionExists(changelog, version) { + return new RegExp(`^## \\[${escapeRegex(version)}\\]`, 'm').test(changelog); +} + +function renameSection(changelog, fromVersion, toVersion, date) { + // Replace the whole heading line (including any existing " - YYYY-MM-DD" + // date suffix) so a rename never leaves a duplicated date behind. + return changelog.replace( + new RegExp(`^## \\[${escapeRegex(fromVersion)}\\][^\\n]*`, 'm'), + `## [${toVersion}] - ${date}` + ); +} + +function buildSection(version, date) { + return `## [${version}] - ${date} + +### Added + +- _Add new features here_ + +### Changed + +- _Add changes here_ + +### Fixed + +- _Add bug fixes here_ +`; +} + +/** + * Insert a fresh version section following the Keep a Changelog convention: + * newest release on top, directly after the Unreleased section when one + * exists. Falls back to inserting before the first released section (or + * appending to the title block) when there is no Unreleased section, so the + * insertion never silently no-ops - the caller asserts the section exists. + */ +export function insertSection(changelog, version, date) { + const newSection = buildSection(version, date); + + const unreleasedMatch = /^## \[Unreleased\]/m.exec(changelog); + if (unreleasedMatch) { + const afterHeading = changelog.slice(unreleasedMatch.index + unreleasedMatch[0].length); + const nextHeading = /^## /m.exec(afterHeading); + const insertAt = nextHeading + ? unreleasedMatch.index + unreleasedMatch[0].length + nextHeading.index + : changelog.length; + return spliceSection(changelog, insertAt, newSection); + } + + const firstSection = /^## /m.exec(changelog); + if (firstSection) { + return spliceSection(changelog, firstSection.index, newSection); + } + + return `${changelog.replace(/\s+$/, '')}\n\n${newSection}`; +} + +/** Join `before` and `after` around a section with exactly one blank line each side. */ +function spliceSection(changelog, insertAt, section) { + const before = changelog.slice(0, insertAt).replace(/\s+$/, ''); + const after = changelog.slice(insertAt).replace(/^\n+/, ''); + return `${before}\n\n${section}${after ? `\n${after}` : ''}`; +} + +/** + * 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. + * + * Throws when the new version's section is missing afterwards, so a broken + * changelog format fails the bump instead of silently skipping the update. + */ +export function updateChangelog(changelog, currentVersion, newVersion, date, bumpType, skipChangelog) { + if (skipChangelog) { + return changelog; + } + + if (STABLE_BUMP_TYPES.includes(bumpType)) { + if (!sectionExists(changelog, newVersion)) { + changelog = insertSection(changelog, newVersion, date); + } + } else { + // prerelease / stable: reuse the same release line's section when possible. + if (sectionExists(changelog, newVersion)) { + return changelog; + } + + const base = baseVersion(parseVersion(newVersion)); + if (sectionExists(changelog, base)) { + changelog = renameSection(changelog, base, newVersion, date); + } else { + const curBase = baseVersion(parseVersion(currentVersion)); + if (curBase === base && sectionExists(changelog, currentVersion)) { + changelog = renameSection(changelog, currentVersion, newVersion, date); + } else { + changelog = insertSection(changelog, newVersion, date); + } + } + } + + if (!sectionExists(changelog, newVersion)) { + throw new Error( + `Failed to add a CHANGELOG section for ${newVersion}; the changelog format was not recognized.` + ); + } + return changelog; +} + +// --------------------------------------------------------------------------- +// Release tag validation +// --------------------------------------------------------------------------- + +/** + * Pure tag validation. Returns { ok, version?, error? }. Accepts any semver + * `vX.Y.Z` or `vX.Y.Z-` tag (stable and tagged prereleases); + * rejects build metadata and non-semver shapes. + */ +export function parseReleaseTag(tag) { + const trimmed = String(tag || '').trim(); + const tagPattern = new RegExp(`^v\\d+\\.\\d+\\.\\d+(-${SEMVER_IDENTIFIER})?$`); + if (!tagPattern.test(trimmed)) { + return { + ok: false, + error: `Invalid release tag '${trimmed}': expected vX.Y.Z or vX.Y.Z- (e.g. v2.8.0, v2.8.0-beta.1)` + }; + } + return { ok: true, tag: trimmed, version: trimmed.slice(1) }; +} + +/** + * Pure sync check: does the tag version match every version file? + * Returns { ok, mismatches: [{file, expectedTagVersion, fileVersion}], error? }. + */ +export function verifyTagAgainstFiles(tag, fileContents) { + const parsed = parseReleaseTag(tag); + if (!parsed.ok) { + return { ok: false, mismatches: [], error: parsed.error }; + } + const versions = collectFileVersions(fileContents); + const mismatches = Object.entries(versions) + .filter(([, version]) => version !== parsed.version) + .map(([file, version]) => ({ + file, + expectedTagVersion: parsed.version, + fileVersion: version + })); + return { ok: mismatches.length === 0, mismatches }; +} \ No newline at end of file