From 8ffab2d6a5e5538ffd565611d625cd1eb1ffdcb7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 22 Oct 2026 22:48:33 +0100 Subject: [PATCH 1/5] fix(release): commit a hybrid package's Cargo.toml in a direct release A hybrid package (package.json plus Cargo.toml or pubspec.yaml in one directory) records a single update, its package.json, and the direct release's git-commit stage stages only update paths and additionalFiles. The bumped Cargo.toml was left out of the release commit and the tree was left dirty. - Publish's prepare stage stages each Cargo.toml it writes, next to the synced locks. - flushPendingWrites returns the paths it wrote, and the direct release passes them to publish with the notes files. Files only the version step writes are committed too: a hybrid's Cargo.toml when publish.cargo.enabled is false, an async-mode hybrid's pubspec.yaml, and the synced Cargo.lock files in that config. Closes #678 Co-Authored-By: Claude Opus 5.5 --- packages/publish/src/stages/prepare.ts | 6 +- .../publish/test/unit/stages/prepare.spec.ts | 28 ++++++ packages/release/src/release.ts | 6 +- packages/release/test/unit/release.spec.ts | 15 +++ packages/version/src/utils/jsonOutput.ts | 7 +- .../test/unit/utils/jsonOutput.spec.ts | 8 ++ .../integration/direct-release-hybrid.spec.ts | 97 +++++++++++++++++++ 7 files changed, 161 insertions(+), 6 deletions(-) create mode 100644 test/integration/direct-release-hybrid.spec.ts diff --git a/packages/publish/src/stages/prepare.ts b/packages/publish/src/stages/prepare.ts index 616bf2e1..ee6f479e 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 points at its package.json, so git-commit wouldn't stage its Cargo.toml. + 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..e7f22799 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, which its package.json update doesn't cover", 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..2c14c0b0 100644 --- a/packages/release/src/release.ts +++ b/packages/release/src/release.ts @@ -305,9 +305,11 @@ 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..8521ae51 --- /dev/null +++ b/test/integration/direct-release-hybrid.spec.ts @@ -0,0 +1,97 @@ +// A hybrid package's update records only its package.json; the release commit must still hold every manifest bumped. +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'); + // Cargo publishing off, as for a napi-rs crate: then only the version step writes the Cargo.toml. + // Per-package versioning, since the sync strategy doesn't bump a pubspec.yaml. + write( + join(repo, 'releasekit.config.json'), + JSON.stringify({ + version: { + preset: 'angular', + sync: false, + versionPrefix: 'v', + tagTemplate: '${packageName}@v${version}', + packageSpecificTags: true, + }, + publish: { + npm: { enabled: false }, + 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(''); + }); +}); From 895d69016124faff42f050d91aa9c542bfe1e30b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 23 Oct 2026 13:33:28 +0100 Subject: [PATCH 2/5] test: put the hybrid release test's config notes next to their settings Co-Authored-By: Claude Opus 5.5 --- test/integration/direct-release-hybrid.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/integration/direct-release-hybrid.spec.ts b/test/integration/direct-release-hybrid.spec.ts index 8521ae51..c7277da2 100644 --- a/test/integration/direct-release-hybrid.spec.ts +++ b/test/integration/direct-release-hybrid.spec.ts @@ -38,13 +38,12 @@ describe('direct-commit release of hybrid packages', () => { 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'); - // Cargo publishing off, as for a napi-rs crate: then only the version step writes the Cargo.toml. - // Per-package versioning, since the sync strategy doesn't bump a pubspec.yaml. 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}', @@ -52,6 +51,7 @@ describe('direct-commit release of hybrid packages', () => { }, 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 }, From 257c7a95b952aed60ea55aa54fa5db935da88be8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 23 Oct 2026 13:34:52 +0100 Subject: [PATCH 3/5] refactor: move and tighten the hybrid staging comments Co-Authored-By: Claude Opus 5.5 --- packages/publish/src/stages/prepare.ts | 2 +- packages/release/src/release.ts | 2 +- test/integration/direct-release-hybrid.spec.ts | 1 - 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/publish/src/stages/prepare.ts b/packages/publish/src/stages/prepare.ts index ee6f479e..19456f01 100644 --- a/packages/publish/src/stages/prepare.ts +++ b/packages/publish/src/stages/prepare.ts @@ -79,7 +79,7 @@ export async function runPrepareStage(ctx: PipelineContext): Promise { } const lockfiles = await syncCargoLockfiles(cwd, bumps); - // A hybrid package's update points at its package.json, so git-commit wouldn't stage its Cargo.toml. + // A hybrid package's update names only its package.json, so git-commit wouldn't stage its Cargo.toml. const written = [...bumps.map((bump) => bump.cargoTomlPath), ...lockfiles]; if (written.length > 0) { ctx.additionalFiles = [...new Set([...(ctx.additionalFiles ?? []), ...written])]; diff --git a/packages/release/src/release.ts b/packages/release/src/release.ts index 2c14c0b0..d9df956a 100644 --- a/packages/release/src/release.ts +++ b/packages/release/src/release.ts @@ -305,7 +305,6 @@ export async function runRelease(inputOptions: ReleaseOptions): Promise Date: Fri, 23 Oct 2026 13:40:03 +0100 Subject: [PATCH 4/5] refactor(publish): say what the hybrid staging comment's code does Co-Authored-By: Claude Opus 5.5 --- packages/publish/src/stages/prepare.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/publish/src/stages/prepare.ts b/packages/publish/src/stages/prepare.ts index 19456f01..fa745db1 100644 --- a/packages/publish/src/stages/prepare.ts +++ b/packages/publish/src/stages/prepare.ts @@ -79,7 +79,7 @@ export async function runPrepareStage(ctx: PipelineContext): Promise { } const lockfiles = await syncCargoLockfiles(cwd, bumps); - // A hybrid package's update names only its package.json, so git-commit wouldn't stage its Cargo.toml. + // 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])]; From 02e852c96cda2459b6d77996eb592155241369b3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 23 Oct 2026 13:42:12 +0100 Subject: [PATCH 5/5] refactor: trim the hybrid staging comment and test name Co-Authored-By: Claude Opus 5.5 --- packages/publish/test/unit/stages/prepare.spec.ts | 2 +- packages/release/src/release.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/publish/test/unit/stages/prepare.spec.ts b/packages/publish/test/unit/stages/prepare.spec.ts index e7f22799..fc3ebe97 100644 --- a/packages/publish/test/unit/stages/prepare.spec.ts +++ b/packages/publish/test/unit/stages/prepare.spec.ts @@ -302,7 +302,7 @@ describe('prepare stage', () => { expect(ctx.additionalFiles ?? []).not.toContain(lockPath); }); - it("should stage a hybrid package's Cargo.toml, which its package.json update doesn't cover", async () => { + 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 }); diff --git a/packages/release/src/release.ts b/packages/release/src/release.ts index d9df956a..1c0c9a2f 100644 --- a/packages/release/src/release.ts +++ b/packages/release/src/release.ts @@ -347,7 +347,7 @@ export async function runRelease(inputOptions: ReleaseOptions): Promise