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
3 changes: 1 addition & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,8 +149,7 @@ Supported providers: OpenAI, Anthropic, Ollama (local), and any OpenAI-compatibl
npm and Rust packages can coexist in a single repo under a single config. The version, notes, and publish stages are all package-manager aware:

- `version.cargo.paths` lists directories containing `Cargo.toml` files (auto-detected by default).
- `publish.cargo.enabled` must be set to `true` — it defaults to `false` to avoid accidental crates.io publishes.
- `publish.npm.enabled` defaults to `true`.
- `publish.npm.enabled` and `publish.cargo.enabled` both default to `true`; set either to `false` to version and tag without publishing ([ADR-0004](./adr/0004-ecosystem-enablement-detection-enables-config-opts-out.md)).

Each Rust crate appears as a named entry in `VersionOutput.updates` alongside npm packages; the publish pipeline runs `cargo publish` per-crate after npm.

Expand Down
6 changes: 2 additions & 4 deletions docs/dart.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,7 @@ Minimum `releasekit.config.json`:
}
```

`version.pub.enabled` defaults to `true`, so that key is optional but makes intent explicit.

**Important**: `publish.pub.enabled` defaults to `false`. You must set it to `true` explicitly — nothing is published to pub.dev unless you opt in.
`version.pub.enabled` and `publish.pub.enabled` both default to `true`, so those keys are optional but make intent explicit. Set `publish.pub.enabled` to `false` to version and tag without publishing to pub.dev.

Configure authentication via OIDC or `PUB_TOKEN`. See [Auth](#auth) below.

Expand Down Expand Up @@ -97,7 +95,7 @@ When `version.pub.paths` is omitted, releasekit looks for `pubspec.yaml` files a

| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `enabled` | boolean | `false` | Enable publishing to pub.dev. Must be set to `true` explicitly. |
| `enabled` | boolean | `true` | Publish detected packages to pub.dev. Set `false` to version and tag only. |
| `publishOrder` | string[] | `[]` | Explicit package publish order by package name. Packages not listed are appended at the end in discovery order. |

### `publish.verify.pub`
Expand Down
6 changes: 2 additions & 4 deletions docs/rust.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,7 @@ Minimum `releasekit.config.json`:
}
```

`version.cargo.enabled` defaults to `true`, so that key is optional but makes intent explicit.

**Important**: `publish.cargo.enabled` defaults to `false`. You must set it to `true` explicitly — nothing is published to crates.io unless you opt in.
`version.cargo.enabled` and `publish.cargo.enabled` both default to `true`, so those keys are optional but make intent explicit. Set `publish.cargo.enabled` to `false` to version and tag without publishing to crates.io.

Set `CARGO_REGISTRY_TOKEN` in your CI secrets. See [Auth](#auth) below.

Expand Down Expand Up @@ -97,7 +95,7 @@ When `version.cargo.paths` is omitted, releasekit looks for `Cargo.toml` files a

| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `enabled` | boolean | `false` | Enable publishing to crates.io. Must be set to `true` explicitly. |
| `enabled` | boolean | `true` | Publish detected crates to crates.io. Set `false` to version and tag only. |
| `noVerify` | boolean | `false` | Pass `--no-verify` to `cargo publish`, skipping pre-publish tarball verification. Use only as a workaround — see [Edge cases](#edge-cases-and-troubleshooting). |
| `publishOrder` | string[] | `[]` | Explicit crate publish order by crate name. When set, this overrides the automatic topological sort. Crates not listed are appended at the end. |
| `clean` | boolean | `false` | Run `cargo clean` in the crate directory before publishing. |
Expand Down
1 change: 1 addition & 0 deletions packages/config/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export {
type NotesConfig,
type NpmConfig,
type PublishConfig,
PublishConfigSchema,
type PublishGitConfig,
type PubPublishConfig,
type ReleaseCIConfig,
Expand Down
13 changes: 5 additions & 8 deletions packages/config/src/load.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
type GitConfig,
type NotesConfig,
type PublishConfig,
PublishConfigSchema,
type ReleaseConfig,
type ReleaseKitConfig,
ReleaseKitConfigSchema,
Expand Down Expand Up @@ -167,17 +168,13 @@ export function loadVersionConfig(options?: LoadOptions): VersionConfig | undefi
return config.version;
}

export function loadPublishConfig(options?: LoadOptions): PublishConfig | undefined {
export function loadPublishConfig(options?: LoadOptions): PublishConfig {
const config = loadConfig(options);

if (!config.publish) {
return undefined;
}

const mergedGit = mergeGitConfig(config.git, config.publish.git);
const publish = config.publish ?? PublishConfigSchema.parse({});
const mergedGit = mergeGitConfig(config.git, publish.git);

return {
...config.publish,
...publish,
git: mergedGit
? {
push: mergedGit.push ?? true,
Expand Down
18 changes: 15 additions & 3 deletions packages/config/test/unit/load.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
loadPublishConfig,
loadVersionConfig,
} from '../../src/load.js';
import { PublishConfigSchema } from '../../src/schema.js';

vi.mock('node:fs', () => ({
existsSync: vi.fn(),
Expand Down Expand Up @@ -225,12 +226,23 @@ describe('loadPublishConfig', () => {
expect(result?.npm.access).toBe('public');
});

it('should return undefined when no publish config', () => {
it.each([
['no publish block', {}],
['an empty publish block', { publish: {} }],
])('should apply the schema defaults with %s', (_label, config) => {
mockedFs.existsSync.mockReturnValue(true);
mockedFs.readFileSync.mockReturnValue('{}');
mockedFs.readFileSync.mockReturnValue(JSON.stringify(config));

expect(loadPublishConfig()).toEqual({ ...PublishConfigSchema.parse({}), git: undefined });
});

it('should inherit the top-level git config without a publish block', () => {
mockedFs.existsSync.mockReturnValue(true);
mockedFs.readFileSync.mockReturnValue(JSON.stringify({ git: { push: false, remote: 'upstream' } }));

const result = loadPublishConfig();
expect(result).toBeUndefined();
expect(result.git?.push).toBe(false);
expect(result.git?.remote).toBe('upstream');
});

it('should merge top-level git config with publish git config', () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/publish/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ Configure via `releasekit.config.json`:
"httpsTokenEnv": "GITHUB_TOKEN"
},
"cargo": {
"enabled": false,
"enabled": true,
"noVerify": false
},
"githubRelease": {
Expand Down
11 changes: 3 additions & 8 deletions packages/publish/src/config.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,8 @@
import { type LoadOptions, loadPublishConfig } from '@releasekit/config';
import { type PublishConfig, toPublishConfig } from './types.js';
import { getDefaultConfig, type PublishConfig, toPublishConfig } from './types.js';

export { type LoadOptions, type PublishConfig, toPublishConfig };
export { getDefaultConfig, type LoadOptions, type PublishConfig, toPublishConfig };

export function loadConfig(options?: LoadOptions): PublishConfig {
const baseConfig = loadPublishConfig(options);
return toPublishConfig(baseConfig);
}

export function getDefaultConfig(): PublishConfig {
return toPublishConfig(undefined);
return toPublishConfig(loadPublishConfig(options));
}
144 changes: 18 additions & 126 deletions packages/publish/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { PublishConfig as BasePublishConfig } from '@releasekit/config';
import { type PublishConfig as BasePublishConfig, PublishConfigSchema } from '@releasekit/config';
import type { VersionOutput } from '@releasekit/core';
import type { PackageManager } from './utils/package-manager.js';

Expand Down Expand Up @@ -153,134 +153,26 @@ export interface PipelineContext {
}

export function getDefaultConfig(): PublishConfig {
return {
npm: {
enabled: true,
auth: 'auto',
provenance: true,
access: 'public',
registry: 'https://registry.npmjs.org',
copyFiles: ['LICENSE'],
tag: 'latest',
publishOrder: [],
},
cargo: {
enabled: false,
noVerify: false,
publishOrder: [],
clean: false,
},
pub: {
enabled: false,
publishOrder: [],
},
git: {
push: true,
pushMethod: 'auto',
remote: 'origin',
branch: undefined,
httpsTokenEnv: undefined,
skipHooks: false,
},
githubRelease: {
enabled: true,
draft: true,
perPackage: true,
prerelease: 'auto',
body: 'auto',
/* biome-ignore lint/suspicious/noTemplateCurlyInString: default template value */
titleTemplate: '${packageName}: ${version}',
skipPackages: [],
},
verify: {
npm: {
enabled: true,
maxAttempts: 5,
initialDelay: 15000,
backoffMultiplier: 2,
},
cargo: {
enabled: true,
maxAttempts: 10,
initialDelay: 30000,
backoffMultiplier: 2,
},
pub: {
enabled: true,
maxAttempts: 10,
initialDelay: 30000,
backoffMultiplier: 2,
},
},
};
return toPublishConfig(PublishConfigSchema.parse({}));
}

export function toPublishConfig(config: BasePublishConfig | undefined): PublishConfig {
const defaults = getDefaultConfig();

if (!config) {
return defaults;
}

export function toPublishConfig(config: BasePublishConfig): PublishConfig {
// Zod shallow-clones defaults, so nested default objects would otherwise be shared between configs.
const { npm, cargo, pub, git, githubRelease, verify } = structuredClone(config);
return {
npm: {
enabled: config.npm?.enabled ?? defaults.npm.enabled,
auth: config.npm?.auth ?? defaults.npm.auth,
provenance: config.npm?.provenance ?? defaults.npm.provenance,
access: config.npm?.access ?? defaults.npm.access,
registry: config.npm?.registry ?? defaults.npm.registry,
copyFiles: config.npm?.copyFiles ?? defaults.npm.copyFiles,
tag: config.npm?.tag ?? defaults.npm.tag,
publishOrder: config.npm?.publishOrder ?? defaults.npm.publishOrder,
},
cargo: {
enabled: config.cargo?.enabled ?? defaults.cargo.enabled,
noVerify: config.cargo?.noVerify ?? defaults.cargo.noVerify,
publishOrder: config.cargo?.publishOrder ?? defaults.cargo.publishOrder,
clean: config.cargo?.clean ?? defaults.cargo.clean,
},
pub: {
enabled: config.pub?.enabled ?? defaults.pub.enabled,
publishOrder: config.pub?.publishOrder ?? defaults.pub.publishOrder,
},
git: config.git
? {
push: config.git.push ?? defaults.git.push,
pushMethod: config.git.pushMethod ?? defaults.git.pushMethod,
remote: config.git.remote ?? defaults.git.remote,
branch: config.git.branch ?? defaults.git.branch,
httpsTokenEnv: config.git.httpsTokenEnv ?? defaults.git.httpsTokenEnv,
skipHooks: config.git.skipHooks ?? defaults.git.skipHooks,
}
: defaults.git,
githubRelease: {
enabled: config.githubRelease?.enabled ?? defaults.githubRelease.enabled,
draft: config.githubRelease?.draft ?? defaults.githubRelease.draft,
perPackage: config.githubRelease?.perPackage ?? defaults.githubRelease.perPackage,
prerelease: config.githubRelease?.prerelease ?? defaults.githubRelease.prerelease,
body: config.githubRelease?.body ?? defaults.githubRelease.body,
titleTemplate: config.githubRelease?.titleTemplate ?? defaults.githubRelease.titleTemplate,
skipPackages: config.githubRelease?.skipPackages ?? defaults.githubRelease.skipPackages,
},
verify: {
npm: {
enabled: config.verify?.npm?.enabled ?? defaults.verify.npm.enabled,
maxAttempts: config.verify?.npm?.maxAttempts ?? defaults.verify.npm.maxAttempts,
initialDelay: config.verify?.npm?.initialDelay ?? defaults.verify.npm.initialDelay,
backoffMultiplier: config.verify?.npm?.backoffMultiplier ?? defaults.verify.npm.backoffMultiplier,
},
cargo: {
enabled: config.verify?.cargo?.enabled ?? defaults.verify.cargo.enabled,
maxAttempts: config.verify?.cargo?.maxAttempts ?? defaults.verify.cargo.maxAttempts,
initialDelay: config.verify?.cargo?.initialDelay ?? defaults.verify.cargo.initialDelay,
backoffMultiplier: config.verify?.cargo?.backoffMultiplier ?? defaults.verify.cargo.backoffMultiplier,
},
pub: {
enabled: config.verify?.pub?.enabled ?? defaults.verify.pub.enabled,
maxAttempts: config.verify?.pub?.maxAttempts ?? defaults.verify.pub.maxAttempts,
initialDelay: config.verify?.pub?.initialDelay ?? defaults.verify.pub.initialDelay,
backoffMultiplier: config.verify?.pub?.backoffMultiplier ?? defaults.verify.pub.backoffMultiplier,
},
npm,
cargo,
pub,
// publish.git has no schema defaults (it inherits the top-level git at load), so its fallbacks live here.
git: {
push: git?.push ?? true,
pushMethod: git?.pushMethod ?? 'auto',
remote: git?.remote ?? 'origin',
branch: git?.branch,
httpsTokenEnv: git?.httpsTokenEnv,
skipHooks: git?.skipHooks ?? false,
},
githubRelease,
verify,
};
}
3 changes: 2 additions & 1 deletion packages/publish/test/integration/pipeline.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ describe('Pipeline integration (dry-run)', () => {
const config = getDefaultConfig();

expect(config.npm.enabled).toBe(true);
expect(config.cargo.enabled).toBe(false);
expect(config.cargo.enabled).toBe(true);
expect(config.pub.enabled).toBe(true);
expect(config.git.push).toBe(true);
expect(config.githubRelease.enabled).toBe(true);
expect(config.verify.npm.enabled).toBe(true);
Expand Down
38 changes: 36 additions & 2 deletions packages/publish/test/unit/config.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,13 @@ describe('config', () => {
expect(config.npm.copyFiles).toEqual(['LICENSE']);
expect(config.npm.tag).toBe('latest');

expect(config.cargo.enabled).toBe(false);
expect(config.cargo.enabled).toBe(true);
expect(config.cargo.noVerify).toBe(false);
expect(config.cargo.publishOrder).toEqual([]);

expect(config.pub.enabled).toBe(true);
expect(config.pub.publishOrder).toEqual([]);

expect(config.git.push).toBe(true);
expect(config.git.pushMethod).toBe('auto');
expect(config.git.remote).toBe('origin');
Expand All @@ -51,13 +54,44 @@ describe('config', () => {
expect(config.verify.cargo.maxAttempts).toBe(10);
expect(config.verify.cargo.initialDelay).toBe(30000);
});

it('should not share nested objects between calls', () => {
const first = getDefaultConfig();
first.verify.pub.enabled = false;
first.npm.copyFiles.push('README.md');

const second = getDefaultConfig();
expect(second.verify.pub.enabled).toBe(true);
expect(second.npm.copyFiles).toEqual(['LICENSE']);
});
});

describe('loadConfig', () => {
it('should return defaults when no config file exists', () => {
it.each([
['no config file', undefined],
['no publish block', { version: {} }],
['an empty publish block', { publish: {} }],
])('should return the defaults with %s', (_label, fileConfig) => {
const dir = createTmpDir();
if (fileConfig) {
fs.writeFileSync(path.join(dir, 'releasekit.config.json'), JSON.stringify(fileConfig));
}

const config = loadConfig({ cwd: dir });
expect(config).toEqual(getDefaultConfig());
expect([config.npm.enabled, config.cargo.enabled, config.pub.enabled]).toEqual([true, true, true]);
});

it('should inherit the top-level git config without a publish block', () => {
const dir = createTmpDir();
fs.writeFileSync(
path.join(dir, 'releasekit.config.json'),
JSON.stringify({ git: { push: false, remote: 'upstream' } }),
);

const config = loadConfig({ cwd: dir });
expect(config.git.push).toBe(false);
expect(config.git.remote).toBe('upstream');
});

it('should load and parse config file', () => {
Expand Down
5 changes: 4 additions & 1 deletion packages/publish/test/unit/stages/cargo-publish.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,11 @@ describe('cargo-publish stage', () => {
it('should skip when cargo disabled', async () => {
const { execCommand } = await import('../../../src/utils/exec.js');
const dir = createTmpDir();
const crateDir = path.join(dir, 'crates', 'my-crate');
fs.mkdirSync(crateDir, { recursive: true });
fs.writeFileSync(path.join(crateDir, 'Cargo.toml'), '[package]\nname = "my-crate"\nversion = "0.5.0"\n');
const config = getDefaultConfig();
// cargo.enabled defaults to false
config.cargo.enabled = false;
const ctx = createContext(dir, { config });

await runCargoPublishStage(ctx);
Expand Down
Loading
Loading