Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions packages/publish/src/stages/prepare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,10 @@ export async function runPrepareStage(ctx: PipelineContext): Promise<void> {
}

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])];
}
}
}
28 changes: 28 additions & 0 deletions packages/publish/test/unit/stages/prepare.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
6 changes: 4 additions & 2 deletions packages/release/src/release.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,9 +305,10 @@ export async function runRelease(inputOptions: ReleaseOptions): Promise<ReleaseO

// All guards passed. For a real (non-dry) run, flush the pending writes captured
// during the dryRun pass above so version bumps land on disk exactly once.
let versionFiles: string[] = [];
if (!options.dryRun) {
const { flushPendingWrites } = await import('@releasekit/version');
flushPendingWrites();
versionFiles = flushPendingWrites();
}

info(`Found ${versionOutput.updates.length} package update(s)`);
Expand Down Expand Up @@ -346,7 +347,8 @@ export async function runRelease(inputOptions: ReleaseOptions): Promise<ReleaseO
if (!options.skipPublish) {
info('Publishing...');
try {
publishOutput = await runPublishStep(versionOutput, options, releaseNotes, notesFiles);
// Stage what `updates` misses, e.g. a hybrid's Cargo.toml.
publishOutput = await runPublishStep(versionOutput, options, releaseNotes, [...versionFiles, ...notesFiles]);
} catch (err) {
// On a partial-publish failure the pipeline throws a PipelineError carrying the per-package
// ledger. Surface a failure report on the release-driving PR (or the step summary), then
Expand Down
15 changes: 15 additions & 0 deletions packages/release/test/unit/release.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@ describe('runRelease', () => {
});
mockVersionEngineRun.mockResolvedValue(undefined);
mockGetJsonData.mockReturnValue(versionOutputWithChanges);
mockFlushPendingWrites.mockReturnValue([]);
mockNotesLoadConfig.mockReturnValue(mockNotesConfig);
mockVersionOutputToChangelogInput.mockReturnValue({ source: 'version', packages: [] });
mockNotesRunPipeline.mockResolvedValue({
Expand Down Expand Up @@ -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 });

Expand Down
7 changes: 5 additions & 2 deletions packages/version/src/utils/jsonOutput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();
try {
for (const { path, content } of _pendingWrites) {
fs.writeFileSync(path, content);
written.add(path);
}
} finally {
_pendingWrites.length = 0;
}
return [...written];
}

/**
Expand Down
8 changes: 8 additions & 0 deletions packages/version/test/unit/utils/jsonOutput.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
96 changes: 96 additions & 0 deletions test/integration/direct-release-hybrid.spec.ts
Original file line number Diff line number Diff line change
@@ -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('');
});
});
Loading