From e9df052fca31942999d4ff090410229546ba7f10 Mon Sep 17 00:00:00 2001 From: cadamsdev Date: Sun, 11 Jan 2026 15:08:44 -0500 Subject: [PATCH 1/6] added publish command --- AGENTS.md | 2 +- PUBLISH_FEATURE.md | 13 +++ package.json | 2 +- src/index.ts | 12 ++ src/publish.ts | 267 +++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 294 insertions(+), 2 deletions(-) create mode 100644 PUBLISH_FEATURE.md create mode 100644 src/publish.ts diff --git a/AGENTS.md b/AGENTS.md index e93f3e3..6b52e7c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,7 +9,7 @@ - `bun test -t "test name"` - Run tests matching pattern ### Development -- `bun dev` - Run the CLI directly using tsx +- `bun changeset` - Run the CLI directly using tsx ## Code Style Guidelines diff --git a/PUBLISH_FEATURE.md b/PUBLISH_FEATURE.md new file mode 100644 index 0000000..fa5de7e --- /dev/null +++ b/PUBLISH_FEATURE.md @@ -0,0 +1,13 @@ +There should be a new command for "publish". This command should... +1. Go through all the package.json files. +2. Grab the version +3. Create and push git tags in the format package-name@version. Skip this step if the tag already exists on the remote +4. Publish the packages to npm in each package directory run npm install or pnpm install or bun install depending on what package manager the user is using. Skip publishing to npm if the package.json contains "private": true +5. Create GitHub Release - Check the last commit get all the changeset files that were deleted under .changesets/*.md. Create a GitHub release for each package using the tag that was created in step 3. The markdown in the GitHub release should be formatted like this + +# @scope/package-name + +## 0.1.0 + +### šŸš€ feat +- Description diff --git a/package.json b/package.json index 4ef1162..61d5c88 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "changeset": "./dist/changeset" }, "scripts": { - "dev": "bun src/index.ts", + "changeset": "bun src/index.ts", "build": "bun build --compile --outfile=dist/changeset src/index.ts", "test": "vitest" }, diff --git a/src/index.ts b/src/index.ts index f7c8229..b7a92c2 100755 --- a/src/index.ts +++ b/src/index.ts @@ -16,6 +16,7 @@ import { humanId } from 'human-id'; import pc from 'picocolors'; import { ChangesetConfig, readConfig } from './config.js'; import { version } from './version.js'; +import { publish } from './publish.js'; async function findPackages(config: ChangesetConfig): Promise> { const packageJsonPaths = globSync({ @@ -243,6 +244,17 @@ async function createChangeset(args: { empty?: boolean }) { process.exit(0); }, }, + publish: { + meta: { + name: 'publish', + description: 'Publish packages to npm and create GitHub releases', + }, + args: {}, + run: async () => { + await publish(); + process.exit(0); + }, + }, }, args: { empty: { diff --git a/src/publish.ts b/src/publish.ts new file mode 100644 index 0000000..8f5ffe6 --- /dev/null +++ b/src/publish.ts @@ -0,0 +1,267 @@ +import { readFileSync } from 'node:fs'; +import { globSync } from 'tinyglobby'; +import pc from 'picocolors'; +import { execSync } from 'node:child_process'; +import { detect } from 'package-manager-detector'; +import { readConfig } from './config.js'; +import type { ChangesetConfig } from './config.js'; + +interface PackageInfo { + name: string; + version: string; + dir: string; + isPrivate: boolean; +} + +export async function publish() { + const config = readConfig(); + const packages = await findPackages(config); + + if (packages.length === 0) { + console.log(pc.yellow('No packages found.')); + return; + } + + console.log(pc.dim('Found'), pc.cyan(`${packages.length} package(s)`)); + + for (const pkg of packages) { + await publishPackage(pkg); + } + + console.log(pc.green('\nāœ” Publish complete!')); +} + +async function findPackages(config: ChangesetConfig): Promise { + const packageJsonPaths = globSync({ + patterns: ['**/package.json', '!**/node_modules/**', '!**/dist/**'], + }); + + const packages: PackageInfo[] = []; + + for (const packageJsonPath of packageJsonPaths) { + const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8')); + const packageName = packageJson.name; + const packageVersion = packageJson.version; + + if (!packageName || !packageVersion) { + console.warn(`Skipping ${packageJsonPath} - missing name or version`); + continue; + } + + if (config.ignore.includes(packageName)) { + console.warn(pc.dim(`Ignoring package ${packageName}`)); + continue; + } + + const dirPath = './' + packageJsonPath.replace(/\/?package\.json$/, ''); + packages.push({ + name: packageName, + version: packageVersion, + dir: dirPath, + isPrivate: packageJson.private === true, + }); + } + + return packages; +} + +async function publishPackage(pkg: PackageInfo) { + const tag = `${pkg.name}@${pkg.version}`; + + console.log(pc.dim('\n---')); + console.log(pc.cyan(pkg.name), pc.dim(`v${pkg.version}`)); + + if (await tagExistsRemote(tag)) { + console.log(pc.dim(`Tag ${tag} already exists on remote. Skipping.`)); + return; + } + + try { + execSync(`git tag -a ${tag} -m "${tag}"`, { stdio: 'pipe' }); + console.log(pc.dim('Created tag'), pc.cyan(tag)); + + execSync(`git push origin ${tag}`, { stdio: 'pipe' }); + console.log(pc.dim('Pushed tag'), pc.cyan(tag)); + } catch (error) { + console.error(pc.red('Failed to create or push tag'), pc.cyan(tag)); + throw error; + } + + if (pkg.isPrivate) { + console.log(pc.dim('Package is private. Skipping npm publish.')); + } else { + await publishToNpm(pkg); + } + + await createGitHubRelease(pkg, tag); +} + +async function tagExistsRemote(tag: string): Promise { + try { + execSync(`git ls-remote --tags origin refs/tags/${tag}`, { stdio: 'pipe' }); + return true; + } catch { + return false; + } +} + +async function publishToNpm(pkg: PackageInfo) { + const detected = await detect(); + if (!detected) { + console.warn(pc.yellow('Could not detect package manager. Skipping npm publish.')); + return; + } + + const agent = detected.agent || detected.name; + let publishCmd = ''; + + switch (agent) { + case 'npm': + publishCmd = 'npm publish'; + break; + case 'yarn': + case 'yarn@berry': + publishCmd = 'yarn publish --non-interactive'; + break; + case 'pnpm': + case 'pnpm@6': + publishCmd = 'pnpm publish --no-git-checks'; + break; + case 'bun': + publishCmd = 'bun publish'; + break; + default: + console.warn(pc.yellow(`Unsupported package manager: ${agent}. Skipping npm publish.`)); + return; + } + + console.log(pc.dim('Publishing to npm...')); + + try { + execSync(publishCmd, { cwd: pkg.dir, stdio: 'pipe' }); + console.log(pc.green('āœ”'), 'Published to npm'); + } catch (error) { + console.error(pc.red('āœ—'), 'Failed to publish to npm'); + throw error; + } +} + +async function createGitHubRelease(pkg: PackageInfo, tag: string) { + const changesetContent = await getChangesetContent(); + + if (!changesetContent) { + console.log(pc.dim('No changesets found in last commit. Skipping GitHub release.')); + return; + } + + const releaseNotes = generateReleaseNotes(pkg, changesetContent); + + console.log(pc.dim('Creating GitHub release...')); + + try { + execSync( + `gh release create ${tag} --title "${pkg.name} v${pkg.version}" --notes "${escapeShell(releaseNotes)}"`, + { stdio: 'pipe' } + ); + console.log(pc.green('āœ”'), 'Created GitHub release'); + } catch (error) { + console.error(pc.red('āœ—'), 'Failed to create GitHub release'); + console.warn(pc.yellow('Make sure you have gh CLI installed and authenticated.')); + throw error; + } +} + +async function getChangesetContent(): Promise { + try { + const deletedChangesets = execSync( + 'git diff HEAD~1 HEAD --name-only --diff-filter=D | grep "^\\.changeset/.*\\.md$" || true', + { encoding: 'utf-8' } + ); + + if (!deletedChangesets.trim()) { + return null; + } + + const files = deletedChangesets.trim().split('\n').filter(Boolean); + const contents: string[] = []; + + for (const file of files) { + try { + const content = execSync(`git show HEAD~1:${file}`, { encoding: 'utf-8' }); + contents.push(content); + } catch { + console.warn(pc.dim(`Could not read ${file}`)); + } + } + + return contents; + } catch { + return null; + } +} + +function generateReleaseNotes(pkg: PackageInfo, changesetContents: string[]): string { + let notes = `# ${pkg.name}\n\n## ${pkg.version}\n\n`; + + const typeGroups: Map = new Map(); + + for (const content of changesetContents) { + const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/); + if (!frontmatterMatch) continue; + + const frontmatter = frontmatterMatch[1]; + const lines = frontmatter.split('\n'); + + const messageMatch = content.match(/^---\n[\s\S]*?\n---\n([\s\S]*)$/); + const message = messageMatch?.[1]?.trim() || ''; + + for (const line of lines) { + const match = line.match(/^"([^"]+)":\s*(\w+)(!?)/); + if (match && match[1] === pkg.name) { + const changesetType = match[2]; + const isBreaking = match[3] === '!'; + + const existing = typeGroups.get(changesetType) || []; + typeGroups.set(changesetType, [...existing, message]); + break; + } + } + } + + if (typeGroups.size === 0) { + return notes + 'No changes recorded.\n'; + } + + const typeEmojis: Record = { + feat: 'šŸš€', + fix: 'šŸ›', + perf: 'āš”ļø', + chore: 'šŸ ', + docs: 'šŸ“š', + style: 'šŸŽØ', + refactor: 'ā™»ļø', + test: 'āœ…', + build: 'šŸ“¦', + ci: 'šŸ¤–', + revert: 'āŖ', + }; + + const typeOrder = ['feat', 'fix', 'perf', 'refactor', 'chore', 'docs', 'style', 'test', 'build', 'ci', 'revert']; + + for (const type of typeOrder) { + const messages = typeGroups.get(type); + if (!messages || messages.length === 0) continue; + + notes += `### ${typeEmojis[type] || '•'} ${type}\n`; + for (const msg of messages) { + notes += `- ${msg}\n`; + } + notes += '\n'; + } + + return notes; +} + +function escapeShell(str: string): string { + return str.replace(/'/g, "'\\''").replace(/\n/g, '\\n').replace(/"/g, '\\"'); +} From 56a225b7ed5eda8330bb80a5199489139479342d Mon Sep 17 00:00:00 2001 From: cadamsdev Date: Sun, 11 Jan 2026 15:09:47 -0500 Subject: [PATCH 2/6] added --dry-run flag to publish command --- src/index.ts | 13 ++++++++++--- src/publish.ts | 46 ++++++++++++++++++++++++++++++++-------------- 2 files changed, 42 insertions(+), 17 deletions(-) diff --git a/src/index.ts b/src/index.ts index b7a92c2..9544581 100755 --- a/src/index.ts +++ b/src/index.ts @@ -249,9 +249,16 @@ async function createChangeset(args: { empty?: boolean }) { name: 'publish', description: 'Publish packages to npm and create GitHub releases', }, - args: {}, - run: async () => { - await publish(); + args: { + 'dry-run': { + type: 'boolean', + description: 'Show what would be published without actually publishing', + required: false, + default: false, + }, + }, + run: async ({ args }) => { + await publish({ dryRun: args['dry-run'] }); process.exit(0); }, }, diff --git a/src/publish.ts b/src/publish.ts index 8f5ffe6..35eddf9 100644 --- a/src/publish.ts +++ b/src/publish.ts @@ -13,7 +13,7 @@ interface PackageInfo { isPrivate: boolean; } -export async function publish() { +export async function publish({ dryRun = false } = {}) { const config = readConfig(); const packages = await findPackages(config); @@ -22,13 +22,21 @@ export async function publish() { return; } + if (dryRun) { + console.log(pc.yellow('\nDry run - no actual publishing will occur.\n')); + } + console.log(pc.dim('Found'), pc.cyan(`${packages.length} package(s)`)); for (const pkg of packages) { - await publishPackage(pkg); + await publishPackage(pkg, dryRun); } - console.log(pc.green('\nāœ” Publish complete!')); + if (dryRun) { + console.log(pc.yellow('\nDry run complete - no changes were made.')); + } else { + console.log(pc.green('\nāœ” Publish complete!')); + } } async function findPackages(config: ChangesetConfig): Promise { @@ -65,7 +73,7 @@ async function findPackages(config: ChangesetConfig): Promise { return packages; } -async function publishPackage(pkg: PackageInfo) { +async function publishPackage(pkg: PackageInfo, dryRun: boolean) { const tag = `${pkg.name}@${pkg.version}`; console.log(pc.dim('\n---')); @@ -76,24 +84,34 @@ async function publishPackage(pkg: PackageInfo) { return; } - try { - execSync(`git tag -a ${tag} -m "${tag}"`, { stdio: 'pipe' }); - console.log(pc.dim('Created tag'), pc.cyan(tag)); - - execSync(`git push origin ${tag}`, { stdio: 'pipe' }); - console.log(pc.dim('Pushed tag'), pc.cyan(tag)); - } catch (error) { - console.error(pc.red('Failed to create or push tag'), pc.cyan(tag)); - throw error; + if (dryRun) { + console.log(pc.yellow('[DRY RUN]'), pc.dim('Would create and push tag'), pc.cyan(tag)); + } else { + try { + execSync(`git tag -a ${tag} -m "${tag}"`, { stdio: 'pipe' }); + console.log(pc.dim('Created tag'), pc.cyan(tag)); + + execSync(`git push origin ${tag}`, { stdio: 'pipe' }); + console.log(pc.dim('Pushed tag'), pc.cyan(tag)); + } catch (error) { + console.error(pc.red('Failed to create or push tag'), pc.cyan(tag)); + throw error; + } } if (pkg.isPrivate) { console.log(pc.dim('Package is private. Skipping npm publish.')); + } else if (dryRun) { + console.log(pc.yellow('[DRY RUN]'), pc.dim('Would publish to npm')); } else { await publishToNpm(pkg); } - await createGitHubRelease(pkg, tag); + if (dryRun) { + console.log(pc.yellow('[DRY RUN]'), pc.dim('Would create GitHub release')); + } else { + await createGitHubRelease(pkg, tag); + } } async function tagExistsRemote(tag: string): Promise { From c2063dafa88d8141b2e3b97db1d65c383fcea42e Mon Sep 17 00:00:00 2001 From: cadamsdev Date: Sun, 11 Jan 2026 15:11:44 -0500 Subject: [PATCH 3/6] fix overwritting package.json file --- src/version.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/version.test.ts b/src/version.test.ts index 449e4bf..5d90481 100644 --- a/src/version.test.ts +++ b/src/version.test.ts @@ -278,6 +278,7 @@ describe('version command', () => { if (pathStr.includes('.changeset')) return true; return false; }); + spyOn(fs, 'writeFileSync').mockImplementation(() => {}); }); afterEach(() => { From 48b746517406bacff0a9173865101278722f031f Mon Sep 17 00:00:00 2001 From: cadamsdev Date: Sun, 11 Jan 2026 15:18:04 -0500 Subject: [PATCH 4/6] added unit tests --- src/publish.test.ts | 546 ++++++++++++++++++++++++++++++++++++++++++++ src/publish.ts | 6 +- 2 files changed, 549 insertions(+), 3 deletions(-) create mode 100644 src/publish.test.ts diff --git a/src/publish.test.ts b/src/publish.test.ts new file mode 100644 index 0000000..688a0b4 --- /dev/null +++ b/src/publish.test.ts @@ -0,0 +1,546 @@ +import { describe, test, expect, beforeEach, afterEach, spyOn, mock } from 'bun:test'; + +mock.module('./config.js', () => ({ + readConfig: () => ({ + access: 'restricted', + baseBranch: 'main', + updateInternalDependencies: 'patch', + ignore: [], + lazyChangesets: { + types: { + feat: { + displayName: 'New Features', + emoji: 'šŸš€', + sort: 0, + releaseType: 'minor', + promptBreakingChange: true, + }, + }, + }, + }), +})); + +import * as fs from 'node:fs'; +import * as tinyglobby from 'tinyglobby'; +import * as childProcess from 'node:child_process'; +import * as packageManagerDetector from 'package-manager-detector'; +import { + publish, + type PackageInfo, + escapeShell, + generateReleaseNotes +} from './publish.js'; + +describe('escapeShell', () => { + test('should escape single quotes', () => { + const input = "test'string"; + const output = escapeShell(input); + expect(output).toBe("test'\\''string"); + }); + + test('should escape double quotes', () => { + const input = 'test"string'; + const output = escapeShell(input); + expect(output).toBe('test\\"string'); + }); + + test('should escape newlines', () => { + const input = 'test\nstring'; + const output = escapeShell(input); + expect(output).toBe('test\\nstring'); + }); + + test('should escape multiple characters', () => { + const input = "test'string\nwith\"quotes"; + const output = escapeShell(input); + expect(output).toBe("test'\\''string\\nwith\\\"quotes"); + }); +}); + +describe('generateReleaseNotes', () => { + let pkg: PackageInfo; + + beforeEach(() => { + pkg = { + name: '@test/package', + version: '1.0.0', + dir: './', + isPrivate: false, + }; + }); + + test('should generate release notes with feat changes', () => { + const changesetContent = [ + `--- +"@test/package": feat +--- + +Added new feature` + ]; + + const result = generateReleaseNotes(pkg, changesetContent); + + expect(result).toContain('# @test/package'); + expect(result).toContain('## 1.0.0'); + expect(result).toContain('### šŸš€ feat'); + expect(result).toContain('- Added new feature'); + }); + + test('should generate release notes with multiple types', () => { + const changesetContent = [ + `--- +"@test/package": feat +--- + +Added feature`, + `--- +"@test/package": fix +--- + +Fixed bug` + ]; + + const result = generateReleaseNotes(pkg, changesetContent); + + expect(result).toContain('### šŸš€ feat'); + expect(result).toContain('### šŸ› fix'); + expect(result).toContain('- Added feature'); + expect(result).toContain('- Fixed bug'); + }); + + test('should generate release notes with breaking changes', () => { + const changesetContent = [ + `--- +"@test/package": feat! +--- + +Breaking change` + ]; + + const result = generateReleaseNotes(pkg, changesetContent); + + expect(result).toContain('### šŸš€ feat'); + expect(result).toContain('- Breaking change'); + }); + + test('should handle empty changeset content', () => { + const changesetContent: string[] = []; + + const result = generateReleaseNotes(pkg, changesetContent); + + expect(result).toContain('# @test/package'); + expect(result).toContain('## 1.0.0'); + expect(result).toContain('No changes recorded.'); + }); + + test('should skip changesets for other packages', () => { + const changesetContent = [ + `--- +"@other/package": feat +--- + +Other package feature` + ]; + + const result = generateReleaseNotes(pkg, changesetContent); + + expect(result).toContain('No changes recorded.'); + }); + + test('should handle malformed changeset content', () => { + const changesetContent = ['No frontmatter here']; + + const result = generateReleaseNotes(pkg, changesetContent); + + expect(result).toContain('No changes recorded.'); + }); + + test('should handle changeset without message', () => { + const changesetContent = [ + `--- +"@test/package": feat +--- + +` + ]; + + const result = generateReleaseNotes(pkg, changesetContent); + + expect(result).toContain('### šŸš€ feat'); + expect(result).toContain('- '); + }); + + test('should order types correctly', () => { + const changesetContent = [ + `--- +"@test/package": fix +--- + +Fix`, + `--- +"@test/package": feat +--- + +Feature`, + `--- +"@test/package": docs +--- + +Docs` + ]; + + const result = generateReleaseNotes(pkg, changesetContent); + + const featIndex = result.indexOf('### šŸš€ feat'); + const fixIndex = result.indexOf('### šŸ› fix'); + const docsIndex = result.indexOf('### šŸ“š docs'); + + expect(featIndex).toBeLessThan(fixIndex); + expect(fixIndex).toBeLessThan(docsIndex); + }); +}); + +describe('publish command', () => { + let consoleLogSpy: any; + let consoleErrorSpy: any; + let consoleWarnSpy: any; + + beforeEach(() => { + consoleLogSpy = spyOn(console, 'log').mockImplementation(() => {}); + consoleErrorSpy = spyOn(console, 'error').mockImplementation(() => {}); + consoleWarnSpy = spyOn(console, 'warn').mockImplementation(() => {}); + spyOn(fs, 'readFileSync').mockImplementation((path: any) => { + const pathStr = typeof path === 'string' ? path : path.toString(); + if (pathStr.includes('package.json')) { + return JSON.stringify({ + name: '@test/package', + version: '1.0.0', + }, null, 2); + } + return ''; + }); + }); + + afterEach(() => { + consoleLogSpy.mockRestore(); + consoleErrorSpy.mockRestore(); + consoleWarnSpy.mockRestore(); + mock.clearAllMocks(); + }); + + test('should log message when no packages found', async () => { + spyOn(tinyglobby, 'globSync').mockReturnValue([]); + + await publish({ dryRun: false }); + + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('No packages found')); + }); + + test('should publish packages in dry run mode', async () => { + spyOn(tinyglobby, 'globSync').mockReturnValue(['package.json']); + spyOn(childProcess, 'execSync').mockImplementation((cmd: string) => { + if (cmd.includes('ls-remote')) { + throw new Error('Tag not found'); + } + return ''; + }); + + await publish({ dryRun: true }); + + const dryRunCalls = consoleLogSpy.mock.calls.filter((call: any) => + call.some((arg: any) => typeof arg === 'string' && arg.includes('Dry run')) + ); + expect(dryRunCalls.length).toBeGreaterThan(0); + + const calls = consoleLogSpy.mock.calls.flat(); + const hasDryRun = calls.some((arg: any) => typeof arg === 'string' && arg.includes('[DRY RUN]')); + expect(hasDryRun).toBe(true); + }); + + test('should skip packages if tag exists on remote', async () => { + spyOn(tinyglobby, 'globSync').mockReturnValue(['package.json']); + spyOn(childProcess, 'execSync').mockReturnValue(''); + + await publish({ dryRun: false }); + + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('already exists on remote')); + }); + + test('should create and push git tags', async () => { + spyOn(tinyglobby, 'globSync').mockReturnValue(['package.json']); + let execCallCount = 0; + spyOn(childProcess, 'execSync').mockImplementation((cmd: string) => { + execCallCount++; + if (cmd.includes('ls-remote')) { + throw new Error('Tag not found'); + } + return ''; + }); + + await publish({ dryRun: false }); + + const calls = consoleLogSpy.mock.calls.flat(); + const hasCreatedTag = calls.some((arg: any) => typeof arg === 'string' && arg.includes('Created tag')); + const hasPushedTag = calls.some((arg: any) => typeof arg === 'string' && arg.includes('Pushed tag')); + expect(hasCreatedTag).toBe(true); + expect(hasPushedTag).toBe(true); + }); + + test('should handle private packages', async () => { + spyOn(tinyglobby, 'globSync').mockReturnValue(['package.json']); + spyOn(fs, 'readFileSync').mockReturnValue(JSON.stringify({ + name: '@test/package', + version: '1.0.0', + private: true, + }, null, 2)); + let execCallCount = 0; + spyOn(childProcess, 'execSync').mockImplementation((cmd: string) => { + execCallCount++; + if (cmd.includes('ls-remote')) { + throw new Error('Tag not found'); + } + return ''; + }); + + await publish({ dryRun: false }); + + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Package is private')); + }); + + test('should publish to npm for public packages', async () => { + spyOn(tinyglobby, 'globSync').mockReturnValue(['package.json']); + let execCallCount = 0; + spyOn(childProcess, 'execSync').mockImplementation((cmd: string) => { + execCallCount++; + if (cmd.includes('ls-remote')) { + throw new Error('Tag not found'); + } + if (cmd.includes('publish')) { + return ''; + } + return ''; + }); + spyOn(packageManagerDetector, 'detect').mockResolvedValue({ name: 'npm', agent: 'npm' }); + + await publish({ dryRun: false }); + + const calls = consoleLogSpy.mock.calls.flat(); + const hasPublished = calls.some((arg: any) => typeof arg === 'string' && arg.includes('Published to npm')); + expect(hasPublished).toBe(true); + }); + + test('should use npm for publishing', async () => { + spyOn(tinyglobby, 'globSync').mockReturnValue(['package.json']); + let execCallCount = 0; + spyOn(childProcess, 'execSync').mockImplementation((cmd: string) => { + execCallCount++; + if (cmd.includes('ls-remote')) { + throw new Error('Tag not found'); + } + return ''; + }); + spyOn(packageManagerDetector, 'detect').mockResolvedValue({ name: 'npm', agent: 'npm' }); + + await publish({ dryRun: false }); + + const calls = (childProcess.execSync as any).mock.calls; + const publishCall = calls.find((call: any) => call[0].includes('npm publish')); + expect(publishCall).toBeDefined(); + }); + + test('should use yarn for publishing', async () => { + spyOn(tinyglobby, 'globSync').mockReturnValue(['package.json']); + let execCallCount = 0; + spyOn(childProcess, 'execSync').mockImplementation((cmd: string) => { + execCallCount++; + if (cmd.includes('ls-remote')) { + throw new Error('Tag not found'); + } + return ''; + }); + spyOn(packageManagerDetector, 'detect').mockResolvedValue({ name: 'yarn', agent: 'yarn' }); + + await publish({ dryRun: false }); + + const calls = (childProcess.execSync as any).mock.calls; + const publishCall = calls.find((call: any) => call[0].includes('yarn publish')); + expect(publishCall).toBeDefined(); + }); + + test('should use pnpm for publishing', async () => { + spyOn(tinyglobby, 'globSync').mockReturnValue(['package.json']); + let execCallCount = 0; + spyOn(childProcess, 'execSync').mockImplementation((cmd: string) => { + execCallCount++; + if (cmd.includes('ls-remote')) { + throw new Error('Tag not found'); + } + return ''; + }); + spyOn(packageManagerDetector, 'detect').mockResolvedValue({ name: 'pnpm', agent: 'pnpm' }); + + await publish({ dryRun: false }); + + const calls = (childProcess.execSync as any).mock.calls; + const publishCall = calls.find((call: any) => call[0].includes('pnpm publish')); + expect(publishCall).toBeDefined(); + }); + + test('should use bun for publishing', async () => { + spyOn(tinyglobby, 'globSync').mockReturnValue(['package.json']); + let execCallCount = 0; + spyOn(childProcess, 'execSync').mockImplementation((cmd: string) => { + execCallCount++; + if (cmd.includes('ls-remote')) { + throw new Error('Tag not found'); + } + return ''; + }); + spyOn(packageManagerDetector, 'detect').mockResolvedValue({ name: 'bun', agent: 'bun' }); + + await publish({ dryRun: false }); + + const calls = (childProcess.execSync as any).mock.calls; + const publishCall = calls.find((call: any) => call[0].includes('bun publish')); + expect(publishCall).toBeDefined(); + }); + + test('should warn for unsupported package manager', async () => { + spyOn(tinyglobby, 'globSync').mockReturnValue(['package.json']); + let execCallCount = 0; + spyOn(childProcess, 'execSync').mockImplementation((cmd: string) => { + execCallCount++; + if (cmd.includes('ls-remote')) { + throw new Error('Tag not found'); + } + return ''; + }); + spyOn(packageManagerDetector, 'detect').mockResolvedValue({ name: 'deno', agent: 'deno' } as any); + + await publish({ dryRun: false }); + + expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('Unsupported package manager')); + }); + + test('should skip npm publish when package manager not detected', async () => { + spyOn(tinyglobby, 'globSync').mockReturnValue(['package.json']); + let execCallCount = 0; + spyOn(childProcess, 'execSync').mockImplementation((cmd: string) => { + execCallCount++; + if (cmd.includes('ls-remote')) { + throw new Error('Tag not found'); + } + return ''; + }); + spyOn(packageManagerDetector, 'detect').mockResolvedValue(null); + + await publish({ dryRun: false }); + + expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('Could not detect package manager')); + }); + + test('should create GitHub release', async () => { + spyOn(tinyglobby, 'globSync').mockReturnValue(['package.json']); + let execCallCount = 0; + spyOn(childProcess, 'execSync').mockImplementation((cmd: string) => { + execCallCount++; + if (cmd.includes('ls-remote')) { + throw new Error('Tag not found'); + } + if (cmd.includes('git diff')) { + return '.changeset/test.md\n'; + } + if (cmd.includes('git show')) { + return `--- +"@test/package": feat +--- + +Test changeset`; + } + return ''; + }); + spyOn(packageManagerDetector, 'detect').mockResolvedValue({ name: 'npm', agent: 'npm' }); + + await publish({ dryRun: false }); + + const calls = consoleLogSpy.mock.calls.flat(); + const hasCreatedRelease = calls.some((arg: any) => typeof arg === 'string' && arg.includes('Created GitHub release')); + expect(hasCreatedRelease).toBe(true); + }); + + test('should skip GitHub release when no changesets found', async () => { + spyOn(tinyglobby, 'globSync').mockReturnValue(['package.json']); + let execCallCount = 0; + spyOn(childProcess, 'execSync').mockImplementation((cmd: string) => { + execCallCount++; + if (cmd.includes('ls-remote')) { + throw new Error('Tag not found'); + } + if (cmd.includes('git diff')) { + return ''; + } + return ''; + }); + spyOn(packageManagerDetector, 'detect').mockResolvedValue({ name: 'npm', agent: 'npm' }); + + await publish({ dryRun: false }); + + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('No changesets found')); + }); + + test('should ignore packages in config ignore list', async () => { + spyOn(tinyglobby, 'globSync').mockReturnValue(['package.json']); + spyOn(fs, 'readFileSync').mockImplementation((path: any) => { + return JSON.stringify({ + name: '@ignored/package', + version: '1.0.0', + }, null, 2); + }); + mock.module('./config.js', () => ({ + readConfig: () => ({ + access: 'restricted', + baseBranch: 'main', + updateInternalDependencies: 'patch', + ignore: ['@ignored/package'], + lazyChangesets: { + types: {}, + }, + }), + })); + + await publish({ dryRun: false }); + + expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('Ignoring package')); + }); + + test('should skip packages with missing name or version', async () => { + spyOn(tinyglobby, 'globSync').mockReturnValue(['package.json']); + spyOn(fs, 'readFileSync').mockReturnValue(JSON.stringify({ + version: '1.0.0', + }, null, 2)); + + await publish({ dryRun: false }); + + expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('Skipping')); + }); + + test('should handle multiple packages', async () => { + spyOn(tinyglobby, 'globSync').mockReturnValue([ + 'packages/package1/package.json', + 'packages/package2/package.json', + ]); + let execCallCount = 0; + spyOn(childProcess, 'execSync').mockImplementation((cmd: string) => { + execCallCount++; + if (cmd.includes('ls-remote')) { + throw new Error('Tag not found'); + } + return ''; + }); + spyOn(packageManagerDetector, 'detect').mockResolvedValue({ name: 'npm', agent: 'npm' }); + + await publish({ dryRun: true }); + + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Found'), expect.stringContaining('2 package(s)')); + }); +}); diff --git a/src/publish.ts b/src/publish.ts index 35eddf9..f7344c5 100644 --- a/src/publish.ts +++ b/src/publish.ts @@ -6,7 +6,7 @@ import { detect } from 'package-manager-detector'; import { readConfig } from './config.js'; import type { ChangesetConfig } from './config.js'; -interface PackageInfo { +export interface PackageInfo { name: string; version: string; dir: string; @@ -218,7 +218,7 @@ async function getChangesetContent(): Promise { } } -function generateReleaseNotes(pkg: PackageInfo, changesetContents: string[]): string { +export function generateReleaseNotes(pkg: PackageInfo, changesetContents: string[]): string { let notes = `# ${pkg.name}\n\n## ${pkg.version}\n\n`; const typeGroups: Map = new Map(); @@ -280,6 +280,6 @@ function generateReleaseNotes(pkg: PackageInfo, changesetContents: string[]): st return notes; } -function escapeShell(str: string): string { +export function escapeShell(str: string): string { return str.replace(/'/g, "'\\''").replace(/\n/g, '\\n').replace(/"/g, '\\"'); } From 8b201b5f8a938ed053f912f3859803d1104423d3 Mon Sep 17 00:00:00 2001 From: cadamsdev Date: Sun, 11 Jan 2026 15:41:30 -0500 Subject: [PATCH 5/6] fix github release markdown --- src/publish.test.ts | 50 ++++++++++++++++++------- src/publish.ts | 70 +++++++++++++++++++--------------- src/version.test.ts | 46 +++++++++++------------ src/version.ts | 91 +++++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 186 insertions(+), 71 deletions(-) diff --git a/src/publish.test.ts b/src/publish.test.ts index 688a0b4..fb809dd 100644 --- a/src/publish.test.ts +++ b/src/publish.test.ts @@ -209,6 +209,10 @@ describe('publish command', () => { consoleLogSpy = spyOn(console, 'log').mockImplementation(() => {}); consoleErrorSpy = spyOn(console, 'error').mockImplementation(() => {}); consoleWarnSpy = spyOn(console, 'warn').mockImplementation(() => {}); + spyOn(fs, 'existsSync').mockImplementation((path: any) => { + const pathStr = typeof path === 'string' ? path : path.toString(); + return pathStr.includes('.changeset'); + }); spyOn(fs, 'readFileSync').mockImplementation((path: any) => { const pathStr = typeof path === 'string' ? path : path.toString(); if (pathStr.includes('package.json')) { @@ -217,6 +221,9 @@ describe('publish command', () => { version: '1.0.0', }, null, 2); } + if (pathStr.includes('CHANGELOG.md')) { + return `## 1.0.0\n\n### šŸš€ feat\n- Test changeset`; + } return ''; }); }); @@ -442,21 +449,28 @@ describe('publish command', () => { test('should create GitHub release', async () => { spyOn(tinyglobby, 'globSync').mockReturnValue(['package.json']); let execCallCount = 0; + spyOn(fs, 'readFileSync').mockImplementation((path: any) => { + const pathStr = typeof path === 'string' ? path : path.toString(); + if (pathStr.includes('package.json')) { + return JSON.stringify({ + name: '@test/package', + version: '1.0.0', + }, null, 2); + } + if (pathStr.includes('CHANGELOG.md')) { + return `## 1.0.0\n\n### šŸš€ feat\n- Test changeset`; + } + return ''; + }); + spyOn(fs, 'existsSync').mockImplementation((path: any) => { + const pathStr = typeof path === 'string' ? path : path.toString(); + return pathStr.includes('CHANGELOG.md'); + }); spyOn(childProcess, 'execSync').mockImplementation((cmd: string) => { execCallCount++; if (cmd.includes('ls-remote')) { throw new Error('Tag not found'); } - if (cmd.includes('git diff')) { - return '.changeset/test.md\n'; - } - if (cmd.includes('git show')) { - return `--- -"@test/package": feat ---- - -Test changeset`; - } return ''; }); spyOn(packageManagerDetector, 'detect').mockResolvedValue({ name: 'npm', agent: 'npm' }); @@ -471,21 +485,29 @@ Test changeset`; test('should skip GitHub release when no changesets found', async () => { spyOn(tinyglobby, 'globSync').mockReturnValue(['package.json']); let execCallCount = 0; + spyOn(fs, 'readFileSync').mockImplementation((path: any) => { + const pathStr = typeof path === 'string' ? path : path.toString(); + if (pathStr.includes('package.json')) { + return JSON.stringify({ + name: '@test/package', + version: '1.0.0', + }, null, 2); + } + return ''; + }); + spyOn(fs, 'existsSync').mockReturnValue(false); spyOn(childProcess, 'execSync').mockImplementation((cmd: string) => { execCallCount++; if (cmd.includes('ls-remote')) { throw new Error('Tag not found'); } - if (cmd.includes('git diff')) { - return ''; - } return ''; }); spyOn(packageManagerDetector, 'detect').mockResolvedValue({ name: 'npm', agent: 'npm' }); await publish({ dryRun: false }); - expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('No changesets found')); + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('No changelog found')); }); test('should ignore packages in config ignore list', async () => { diff --git a/src/publish.ts b/src/publish.ts index f7344c5..a549d34 100644 --- a/src/publish.ts +++ b/src/publish.ts @@ -1,5 +1,6 @@ -import { readFileSync } from 'node:fs'; +import { readFileSync, existsSync } from 'node:fs'; import { globSync } from 'tinyglobby'; +import path from 'node:path'; import pc from 'picocolors'; import { execSync } from 'node:child_process'; import { detect } from 'package-manager-detector'; @@ -79,13 +80,10 @@ async function publishPackage(pkg: PackageInfo, dryRun: boolean) { console.log(pc.dim('\n---')); console.log(pc.cyan(pkg.name), pc.dim(`v${pkg.version}`)); - if (await tagExistsRemote(tag)) { - console.log(pc.dim(`Tag ${tag} already exists on remote. Skipping.`)); - return; - } - if (dryRun) { console.log(pc.yellow('[DRY RUN]'), pc.dim('Would create and push tag'), pc.cyan(tag)); + } else if (await tagExistsRemote(tag)) { + console.log(pc.dim(`Tag ${tag} already exists on remote. Skipping.`)); } else { try { execSync(`git tag -a ${tag} -m "${tag}"`, { stdio: 'pipe' }); @@ -108,7 +106,20 @@ async function publishPackage(pkg: PackageInfo, dryRun: boolean) { } if (dryRun) { + const changelogContent = getChangelogForVersion(pkg); + const releaseNotes = changelogContent ? changelogContent : ''; + const title = `${pkg.name}@${pkg.version}`; + console.log(pc.yellow('[DRY RUN]'), pc.dim('Would create GitHub release')); + console.log(pc.dim(' Tag:'), pc.cyan(tag)); + console.log(pc.dim(' Title:'), pc.cyan(title)); + + if (releaseNotes) { + console.log(pc.dim(' Body:\n')); + console.log(releaseNotes); + } else { + console.log(pc.dim(' Body:'), pc.yellow('(No changelog found for this version)')); + } } else { await createGitHubRelease(pkg, tag); } @@ -165,14 +176,14 @@ async function publishToNpm(pkg: PackageInfo) { } async function createGitHubRelease(pkg: PackageInfo, tag: string) { - const changesetContent = await getChangesetContent(); + const changelogContent = getChangelogForVersion(pkg); - if (!changesetContent) { - console.log(pc.dim('No changesets found in last commit. Skipping GitHub release.')); + if (!changelogContent) { + console.log(pc.dim(`No changelog found for version ${pkg.version}. Skipping GitHub release.`)); return; } - const releaseNotes = generateReleaseNotes(pkg, changesetContent); + const releaseNotes = `# ${pkg.name}\n\n${changelogContent}`; console.log(pc.dim('Creating GitHub release...')); @@ -189,33 +200,30 @@ async function createGitHubRelease(pkg: PackageInfo, tag: string) { } } -async function getChangesetContent(): Promise { - try { - const deletedChangesets = execSync( - 'git diff HEAD~1 HEAD --name-only --diff-filter=D | grep "^\\.changeset/.*\\.md$" || true', - { encoding: 'utf-8' } - ); +function getChangelogForVersion(pkg: PackageInfo): string | null { + const changelogPath = path.join(pkg.dir, 'CHANGELOG.md'); - if (!deletedChangesets.trim()) { - return null; - } + if (!existsSync(changelogPath)) { + return null; + } - const files = deletedChangesets.trim().split('\n').filter(Boolean); - const contents: string[] = []; + const changelogContent = readFileSync(changelogPath, 'utf-8'); - for (const file of files) { - try { - const content = execSync(`git show HEAD~1:${file}`, { encoding: 'utf-8' }); - contents.push(content); - } catch { - console.warn(pc.dim(`Could not read ${file}`)); - } - } + const versionHeaderRegex = new RegExp(`^##\\s+${pkg.version.replace(/\./g, '\\.')}$`, 'm'); + const versionMatch = changelogContent.match(versionHeaderRegex); - return contents; - } catch { + if (!versionMatch || versionMatch.index === undefined) { return null; } + + const startIndex = versionMatch.index; + const nextVersionHeader = changelogContent.indexOf('\n## ', startIndex + 1); + + if (nextVersionHeader === -1) { + return changelogContent.substring(startIndex).trim(); + } + + return changelogContent.substring(startIndex, nextVersionHeader).trim(); } export function generateReleaseNotes(pkg: PackageInfo, changesetContents: string[]): string { diff --git a/src/version.test.ts b/src/version.test.ts index 5d90481..ed5f8e6 100644 --- a/src/version.test.ts +++ b/src/version.test.ts @@ -46,7 +46,7 @@ Added new feature`; const result = parseChangesetFile('.changeset/test.md'); expect(result).toEqual([ - { type: 'minor', packageName: '@test/package' } + { type: 'minor', packageName: '@test/package', changesetType: 'feat', message: 'Added new feature' } ]); }); @@ -63,7 +63,7 @@ Breaking change added`; const result = parseChangesetFile('.changeset/test.md'); expect(result).toEqual([ - { type: 'major', packageName: '@test/package' } + { type: 'major', packageName: '@test/package', changesetType: 'feat', message: 'Breaking change added' } ]); }); @@ -80,7 +80,7 @@ Bug fix`; const result = parseChangesetFile('.changeset/test.md'); expect(result).toEqual([ - { type: 'patch', packageName: '@test/package' } + { type: 'patch', packageName: '@test/package', changesetType: 'fix', message: 'Bug fix' } ]); }); @@ -98,8 +98,8 @@ Multiple packages updated`; const result = parseChangesetFile('.changeset/test.md'); expect(result).toEqual([ - { type: 'minor', packageName: '@test/package' }, - { type: 'patch', packageName: '@other/package' } + { type: 'minor', packageName: '@test/package', changesetType: 'feat', message: 'Multiple packages updated' }, + { type: 'patch', packageName: '@other/package', changesetType: 'fix', message: 'Multiple packages updated' } ]); }); @@ -118,7 +118,7 @@ Test`; const result = parseChangesetFile('.changeset/test.md'); expect(result).toEqual([ - { type: 'minor', packageName: '@test/package' } + { type: 'minor', packageName: '@test/package', changesetType: 'feat', message: 'Test' } ]); }); @@ -136,8 +136,8 @@ Multiple breaking changes`; const result = parseChangesetFile('.changeset/test.md'); expect(result).toEqual([ - { type: 'major', packageName: '@test/package' }, - { type: 'major', packageName: '@other/package' } + { type: 'major', packageName: '@test/package', changesetType: 'feat', message: 'Multiple breaking changes' }, + { type: 'major', packageName: '@other/package', changesetType: 'fix', message: 'Multiple breaking changes' } ]); }); @@ -169,52 +169,52 @@ Multiple breaking changes`; describe('getHighestReleaseType', () => { test('should return major when any release is major', () => { const releases: ChangesetReleaseType[] = [ - { type: 'major', packageName: '@test/package' }, - { type: 'patch', packageName: '@test/package' } + { type: 'major', packageName: '@test/package', changesetType: 'feat', message: '' }, + { type: 'patch', packageName: '@test/package', changesetType: 'fix', message: '' } ]; - + expect(getHighestReleaseType(releases)).toBe('major'); }); test('should return minor when no major but has minor', () => { const releases: ChangesetReleaseType[] = [ - { type: 'minor', packageName: '@test/package' }, - { type: 'patch', packageName: '@test/package' } + { type: 'minor', packageName: '@test/package', changesetType: 'feat', message: '' }, + { type: 'patch', packageName: '@test/package', changesetType: 'fix', message: '' } ]; - + expect(getHighestReleaseType(releases)).toBe('minor'); }); test('should return patch when only patches', () => { const releases: ChangesetReleaseType[] = [ - { type: 'patch', packageName: '@test/package' }, - { type: 'patch', packageName: '@test/package' } + { type: 'patch', packageName: '@test/package', changesetType: 'fix', message: '' }, + { type: 'patch', packageName: '@test/package', changesetType: 'fix', message: '' } ]; - + expect(getHighestReleaseType(releases)).toBe('patch'); }); test('should return patch for single patch', () => { const releases: ChangesetReleaseType[] = [ - { type: 'patch', packageName: '@test/package' } + { type: 'patch', packageName: '@test/package', changesetType: 'fix', message: '' } ]; - + expect(getHighestReleaseType(releases)).toBe('patch'); }); test('should return major for single major', () => { const releases: ChangesetReleaseType[] = [ - { type: 'major', packageName: '@test/package' } + { type: 'major', packageName: '@test/package', changesetType: 'feat', message: '' } ]; - + expect(getHighestReleaseType(releases)).toBe('major'); }); test('should return minor for single minor', () => { const releases: ChangesetReleaseType[] = [ - { type: 'minor', packageName: '@test/package' } + { type: 'minor', packageName: '@test/package', changesetType: 'feat', message: '' } ]; - + expect(getHighestReleaseType(releases)).toBe('minor'); }); }); diff --git a/src/version.ts b/src/version.ts index 6de08c6..ed1c608 100644 --- a/src/version.ts +++ b/src/version.ts @@ -5,10 +5,13 @@ import pc from 'picocolors'; import { execSync } from 'node:child_process'; import { detect } from 'package-manager-detector'; import { readConfig } from './config.js'; +import type { ChangesetType } from './changeset.js'; export interface ChangesetReleaseType { type: 'major' | 'minor' | 'patch'; packageName: string; + message: string; + changesetType: string; } export function parseChangesetFile(filePath: string): ChangesetReleaseType[] { @@ -23,6 +26,9 @@ export function parseChangesetFile(filePath: string): ChangesetReleaseType[] { const frontmatter = frontmatterMatch[1]; const lines = frontmatter.split('\n'); + const messageMatch = content.match(/^---\n[\s\S]*?\n---\n([\s\S]*)$/); + const message = messageMatch?.[1]?.trim() || ''; + for (const line of lines) { const match = line.match(/^"([^"]+)":\s*(\w+)(!?)/); if (match) { @@ -38,7 +44,7 @@ export function parseChangesetFile(filePath: string): ChangesetReleaseType[] { releaseType = 'minor'; } - releases.push({ type: releaseType, packageName }); + releases.push({ type: releaseType, packageName, message, changesetType }); } } @@ -68,6 +74,67 @@ export function bumpVersion(version: string, releaseType: ChangesetReleaseType[' } } +export function generateChangelog(packageName: string, version: string, changesetContents: string[]): string { + let changelog = `## ${version}\n\n`; + + const typeGroups: Map = new Map(); + + for (const content of changesetContents) { + const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/); + if (!frontmatterMatch) continue; + + const frontmatter = frontmatterMatch[1]; + const lines = frontmatter.split('\n'); + + const messageMatch = content.match(/^---\n[\s\S]*?\n---\n([\s\S]*)$/); + const message = messageMatch?.[1]?.trim() || ''; + + for (const line of lines) { + const match = line.match(/^"([^"]+)":\s*(\w+)(!?)/); + if (match && match[1] === packageName) { + const changesetType = match[2]; + + const existing = typeGroups.get(changesetType) || []; + typeGroups.set(changesetType, [...existing, message]); + break; + } + } + } + + if (typeGroups.size === 0) { + return changelog + 'No changes recorded.\n'; + } + + const typeEmojis: Record = { + feat: 'šŸš€', + fix: 'šŸ›', + perf: 'āš”ļø', + chore: 'šŸ ', + docs: 'šŸ“š', + style: 'šŸŽØ', + refactor: 'ā™»ļø', + test: 'āœ…', + build: 'šŸ“¦', + ci: 'šŸ¤–', + revert: 'āŖ', + }; + + const typeOrder = ['feat', 'fix', 'perf', 'refactor', 'chore', 'docs', 'style', 'test', 'build', 'ci', 'revert']; + + for (const type of typeOrder) { + const messages = typeGroups.get(type); + if (!messages || messages.length === 0) continue; + + changelog += `### ${typeEmojis[type] || '•'} ${type}\n`; + for (const msg of messages) { + changelog += `- ${msg}\n`; + } + changelog += '\n'; + } + + return changelog; +} + export async function version({ dryRun = false, ignore = [] as string[], install = false } = {}) { const config = readConfig(); const changesetDir = path.join(process.cwd(), '.changeset'); @@ -88,12 +155,17 @@ export async function version({ dryRun = false, ignore = [] as string[], install } const packageReleases: Map = new Map(); + const packageChangelogs: Map = new Map(); for (const changesetFile of changesetFiles) { + const content = readFileSync(changesetFile, 'utf-8'); const releases = parseChangesetFile(changesetFile); for (const release of releases) { - const existing = packageReleases.get(release.packageName) || []; - packageReleases.set(release.packageName, [...existing, release]); + const existingReleases = packageReleases.get(release.packageName) || []; + packageReleases.set(release.packageName, [...existingReleases, release]); + + const existingChangelogs = packageChangelogs.get(release.packageName) || []; + packageChangelogs.set(release.packageName, [...existingChangelogs, content]); } } @@ -125,6 +197,19 @@ export async function version({ dryRun = false, ignore = [] as string[], install if (!dryRun) { writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2) + '\n', 'utf-8'); + + const packageDir = path.dirname(packageJsonPath); + const changelogPath = path.join(packageDir, 'CHANGELOG.md'); + + const changesetContents = packageChangelogs.get(packageName) || []; + const newChangelog = generateChangelog(packageName, newVersion, changesetContents); + + let existingChangelog = ''; + if (existsSync(changelogPath)) { + existingChangelog = readFileSync(changelogPath, 'utf-8'); + } + + writeFileSync(changelogPath, newChangelog + '\n' + existingChangelog, 'utf-8'); } console.log( From fd2ebf26bee8585c0455d5f5bde8c299f48ed0c8 Mon Sep 17 00:00:00 2001 From: cadamsdev Date: Sun, 11 Jan 2026 15:42:45 -0500 Subject: [PATCH 6/6] added changeset --- .changeset/all-eels-lie.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/all-eels-lie.md diff --git a/.changeset/all-eels-lie.md b/.changeset/all-eels-lie.md new file mode 100644 index 0000000..fdcd1a2 --- /dev/null +++ b/.changeset/all-eels-lie.md @@ -0,0 +1,5 @@ +--- +"@cadamsdev/lazy-changesets": feat +--- + +Added publish command