diff --git a/packages/publish/src/stages/prepare.ts b/packages/publish/src/stages/prepare.ts index 616bf2e1..fa745db1 100644 --- a/packages/publish/src/stages/prepare.ts +++ b/packages/publish/src/stages/prepare.ts @@ -79,8 +79,10 @@ export async function runPrepareStage(ctx: PipelineContext): Promise { } const lockfiles = await syncCargoLockfiles(cwd, bumps); - if (lockfiles.length > 0) { - ctx.additionalFiles = [...new Set([...(ctx.additionalFiles ?? []), ...lockfiles])]; + // A hybrid package's update names only its package.json, so stage its Cargo.toml explicitly. + const written = [...bumps.map((bump) => bump.cargoTomlPath), ...lockfiles]; + if (written.length > 0) { + ctx.additionalFiles = [...new Set([...(ctx.additionalFiles ?? []), ...written])]; } } } diff --git a/packages/publish/test/unit/stages/prepare.spec.ts b/packages/publish/test/unit/stages/prepare.spec.ts index a861e545..fc3ebe97 100644 --- a/packages/publish/test/unit/stages/prepare.spec.ts +++ b/packages/publish/test/unit/stages/prepare.spec.ts @@ -302,6 +302,34 @@ describe('prepare stage', () => { expect(ctx.additionalFiles ?? []).not.toContain(lockPath); }); + it("should stage a hybrid package's Cargo.toml", async () => { + const dir = createTmpDir(); + const pkgDir = path.join(dir, 'packages', 'foo'); + fs.mkdirSync(pkgDir, { recursive: true }); + fs.writeFileSync(path.join(pkgDir, 'package.json'), '{ "name": "foo", "version": "1.1.0" }\n'); + const cargoPath = path.join(pkgDir, 'Cargo.toml'); + fs.writeFileSync(cargoPath, '[package]\nname = "foo"\nversion = "1.0.0"\n'); + + const config = getDefaultConfig(); + config.cargo.enabled = true; + const ctx = createContext({ + cwd: dir, + config, + additionalFiles: ['CHANGELOG.md'], + input: { + dryRun: false, + updates: [{ packageName: 'foo', newVersion: '1.1.0', filePath: 'packages/foo/package.json' }], + changelogs: [], + tags: [], + }, + }); + + await runPrepareStage(ctx); + + expect(fs.readFileSync(cargoPath, 'utf-8')).toContain('version = "1.1.0"'); + expect(ctx.additionalFiles).toEqual(['CHANGELOG.md', cargoPath]); + }); + it('should not stage a Cargo.lock in dry-run mode', async () => { const dir = createTmpDir(); const crateDir = path.join(dir, 'crates', 'foo'); diff --git a/packages/release/src/release.ts b/packages/release/src/release.ts index da15899c..1c0c9a2f 100644 --- a/packages/release/src/release.ts +++ b/packages/release/src/release.ts @@ -305,9 +305,10 @@ export async function runRelease(inputOptions: ReleaseOptions): Promise { }); mockVersionEngineRun.mockResolvedValue(undefined); mockGetJsonData.mockReturnValue(versionOutputWithChanges); + mockFlushPendingWrites.mockReturnValue([]); mockNotesLoadConfig.mockReturnValue(mockNotesConfig); mockVersionOutputToChangelogInput.mockReturnValue({ source: 'version', packages: [] }); mockNotesRunPipeline.mockResolvedValue({ @@ -247,6 +248,20 @@ describe('runRelease', () => { expect(mockFlushPendingWrites).toHaveBeenCalledOnce(); }); + it('should stage every flushed file in the release commit, with the notes files', async () => { + mockFlushPendingWrites.mockReturnValue(['/test/project/package.json', '/test/project/Cargo.toml']); + mockNotesRunPipeline.mockResolvedValue({ packageNotes: {}, files: ['/test/project/CHANGELOG.md'] }); + + await runRelease(defaultOptions); + + const publishOptions = vi.mocked(mockPublishRunPipeline).mock.calls[0]?.[2] as { additionalFiles?: string[] }; + expect(publishOptions.additionalFiles).toEqual([ + '/test/project/package.json', + '/test/project/Cargo.toml', + '/test/project/CHANGELOG.md', + ]); + }); + it('should not flush when dryRun is true', async () => { await runRelease({ ...defaultOptions, dryRun: true }); diff --git a/packages/version/src/utils/jsonOutput.ts b/packages/version/src/utils/jsonOutput.ts index 1410b590..79902e82 100644 --- a/packages/version/src/utils/jsonOutput.ts +++ b/packages/version/src/utils/jsonOutput.ts @@ -62,16 +62,19 @@ export function recordPendingWrite(path: string, content: string): void { } /** - * Apply all pending writes to disk and clear the buffer. + * Apply all pending writes to disk and clear the buffer. Returns the paths written. */ -export function flushPendingWrites(): void { +export function flushPendingWrites(): string[] { + const written = new Set(); try { for (const { path, content } of _pendingWrites) { fs.writeFileSync(path, content); + written.add(path); } } finally { _pendingWrites.length = 0; } + return [...written]; } /** diff --git a/packages/version/test/unit/utils/jsonOutput.spec.ts b/packages/version/test/unit/utils/jsonOutput.spec.ts index f4ad55c4..f3d6f587 100644 --- a/packages/version/test/unit/utils/jsonOutput.spec.ts +++ b/packages/version/test/unit/utils/jsonOutput.spec.ts @@ -334,6 +334,14 @@ describe('JSON Output Utilities', () => { expect(getPendingWriteCount()).toBe(0); }); + it('should return each flushed path once', () => { + recordPendingWrite('/a/package.json', 'a'); + recordPendingWrite('/a/Cargo.toml', 'b'); + recordPendingWrite('/a/Cargo.toml', 'c'); + + expect(flushPendingWrites()).toEqual(['/a/package.json', '/a/Cargo.toml']); + }); + it('should clear pending writes when enableJsonOutput is called', () => { recordPendingWrite('/some/path.json', 'data'); expect(getPendingWriteCount()).toBe(1); diff --git a/test/integration/direct-release-hybrid.spec.ts b/test/integration/direct-release-hybrid.spec.ts new file mode 100644 index 00000000..4d8b80d5 --- /dev/null +++ b/test/integration/direct-release-hybrid.spec.ts @@ -0,0 +1,96 @@ +import { execFileSync, spawnSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { devNull, tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +const RELEASE_CLI = fileURLToPath(new URL('../../packages/release/dist/cli.js', import.meta.url)); + +// Ignore the developer's git config (signing, hooks) and any CI GitHub context (PR label lookups). +const env = { + ...Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith('GITHUB_'))), + GIT_CONFIG_GLOBAL: devNull, + GIT_CONFIG_NOSYSTEM: '1', +}; + +function git(cwd: string, ...args: string[]): string { + return execFileSync('git', args, { cwd, encoding: 'utf-8', env }); +} + +function write(path: string, content: string): void { + mkdirSync(join(path, '..'), { recursive: true }); + writeFileSync(path, content); +} + +const lock = (version: string): string => `version = 4\n\n[[package]]\nname = "rk-napi"\nversion = "${version}"\n`; + +describe('direct-commit release of hybrid packages', () => { + let repo: string; + + beforeEach(() => { + repo = mkdtempSync(join(tmpdir(), 'rk-direct-release-')); + }); + + afterEach(() => rmSync(repo, { recursive: true, force: true })); + + it('should commit the Cargo.toml, Cargo.lock and pubspec.yaml it bumps and leave the tree clean', () => { + write(join(repo, 'package.json'), '{ "name": "rk-fixture", "private": true }\n'); + write(join(repo, 'pnpm-workspace.yaml'), 'packages:\n - "packages/*"\n'); + write( + join(repo, 'releasekit.config.json'), + JSON.stringify({ + version: { + preset: 'angular', + // The sync strategy doesn't bump a pubspec.yaml. + sync: false, + versionPrefix: 'v', + tagTemplate: '${packageName}@v${version}', + packageSpecificTags: true, + }, + publish: { + npm: { enabled: false }, + // As for a napi-rs crate: publish leaves the Cargo.toml alone, so only the version step bumps it. + cargo: { enabled: false }, + git: { push: false }, + githubRelease: { enabled: false }, + }, + }), + ); + write(join(repo, 'packages', 'napi', 'package.json'), '{ "name": "rk-napi", "version": "0.1.0" }\n'); + write( + join(repo, 'packages', 'napi', 'Cargo.toml'), + '[package]\nname = "rk-napi"\nversion = "0.1.0"\nedition = "2021"\n', + ); + write(join(repo, 'packages', 'napi', 'Cargo.lock'), lock('0.1.0')); + write(join(repo, 'packages', 'napi', 'src', 'lib.rs'), '// napi\n'); + write(join(repo, 'packages', 'dart', 'package.json'), '{ "name": "rk-dart", "version": "0.1.0" }\n'); + write(join(repo, 'packages', 'dart', 'pubspec.yaml'), 'name: rk_dart\nversion: 0.1.0\npublish_to: none\n'); + write(join(repo, 'packages', 'dart', 'lib', 'rk_dart.dart'), '// dart\n'); + + git(repo, 'init', '-q'); + git(repo, 'config', 'user.name', 'test'); + git(repo, 'config', 'user.email', 'test@example.com'); + git(repo, 'add', '-A'); + git(repo, 'commit', '-qm', 'chore: init'); + git(repo, 'tag', 'rk-napi@v0.1.0'); + git(repo, 'tag', 'rk-dart@v0.1.0'); + write(join(repo, 'packages', 'napi', 'src', 'lib.rs'), '// napi feature\n'); + write(join(repo, 'packages', 'dart', 'lib', 'rk_dart.dart'), '// dart feature\n'); + git(repo, 'commit', '-qam', 'feat: add features'); + + const result = spawnSync( + process.execPath, + [RELEASE_CLI, 'release', '--skip-notes', '--skip-github-release', '--skip-verification'], + { cwd: repo, encoding: 'utf-8', env }, + ); + expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); + + expect(git(repo, 'log', '-1', '--format=%s')).toMatch(/^chore: release /); + expect(git(repo, 'show', 'HEAD:packages/napi/package.json')).toContain('"version": "0.2.0"'); + expect(git(repo, 'show', 'HEAD:packages/napi/Cargo.toml')).toContain('version = "0.2.0"'); + expect(git(repo, 'show', 'HEAD:packages/napi/Cargo.lock')).toBe(lock('0.2.0')); + expect(git(repo, 'show', 'HEAD:packages/dart/pubspec.yaml')).toContain('version: 0.2.0'); + expect(git(repo, 'status', '--porcelain')).toBe(''); + }); +});