From 71066f4a1cafd621a0b735c5c8f5d2abe2a1bda9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 22 Oct 2026 22:46:44 +0100 Subject: [PATCH 1/3] fix(publish)!: apply schema publish defaults when the config has no publish block Without a publish block, loadPublishConfig returned undefined and the publish package fell back to a hand-written getDefaultConfig() that still had crates.io and pub.dev publishing off, contrary to ADR-0004. It also dropped the top-level git config, so e.g. git.push: false was ignored. loadPublishConfig now parses an absent block through PublishConfigSchema and always merges the top-level git config, and getDefaultConfig() derives from the schema so the two can't drift again. toPublishConfig deep-clones its input because Zod shallow-clones defaults, which would otherwise share nested default objects between configs. BREAKING CHANGE: a repo with detected Rust crates or Dart/Flutter packages and no publish block now publishes them. Set publish.cargo.enabled or publish.pub.enabled to false to version and tag only. Closes #667 Co-Authored-By: Claude Opus 5.5 --- docs/architecture.md | 3 +- docs/dart.md | 6 +- docs/rust.md | 6 +- packages/config/src/index.ts | 1 + packages/config/src/load.ts | 13 +- packages/config/test/unit/load.spec.ts | 18 ++- packages/publish/README.md | 2 +- packages/publish/src/config.ts | 11 +- packages/publish/src/types.ts | 144 +++--------------- .../publish/test/integration/pipeline.spec.ts | 3 +- packages/publish/test/unit/config.spec.ts | 38 ++++- packages/release/docs/ci-setup.md | 2 +- 12 files changed, 87 insertions(+), 160 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index cb910424..cfbb902c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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. diff --git a/docs/dart.md b/docs/dart.md index 2ffad9f1..eb8b7e91 100644 --- a/docs/dart.md +++ b/docs/dart.md @@ -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. @@ -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` diff --git a/docs/rust.md b/docs/rust.md index 3aefbcd2..35f8ad06 100644 --- a/docs/rust.md +++ b/docs/rust.md @@ -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. @@ -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. | diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index 2aa9f02b..a9cccf24 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -33,6 +33,7 @@ export { type NotesConfig, type NpmConfig, type PublishConfig, + PublishConfigSchema, type PublishGitConfig, type PubPublishConfig, type ReleaseCIConfig, diff --git a/packages/config/src/load.ts b/packages/config/src/load.ts index 237b4de3..611698e5 100644 --- a/packages/config/src/load.ts +++ b/packages/config/src/load.ts @@ -9,6 +9,7 @@ import { type GitConfig, type NotesConfig, type PublishConfig, + PublishConfigSchema, type ReleaseConfig, type ReleaseKitConfig, ReleaseKitConfigSchema, @@ -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, diff --git a/packages/config/test/unit/load.spec.ts b/packages/config/test/unit/load.spec.ts index bdf3b50b..eb323253 100644 --- a/packages/config/test/unit/load.spec.ts +++ b/packages/config/test/unit/load.spec.ts @@ -9,6 +9,7 @@ import { loadPublishConfig, loadVersionConfig, } from '../../src/load.js'; +import { PublishConfigSchema } from '../../src/schema.js'; vi.mock('node:fs', () => ({ existsSync: vi.fn(), @@ -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', () => { diff --git a/packages/publish/README.md b/packages/publish/README.md index 94d7caa2..e5b1c4f1 100644 --- a/packages/publish/README.md +++ b/packages/publish/README.md @@ -139,7 +139,7 @@ Configure via `releasekit.config.json`: "httpsTokenEnv": "GITHUB_TOKEN" }, "cargo": { - "enabled": false, + "enabled": true, "noVerify": false }, "githubRelease": { diff --git a/packages/publish/src/config.ts b/packages/publish/src/config.ts index eb68fc39..a0530a2c 100644 --- a/packages/publish/src/config.ts +++ b/packages/publish/src/config.ts @@ -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)); } diff --git a/packages/publish/src/types.ts b/packages/publish/src/types.ts index add8ef5c..764d6271 100644 --- a/packages/publish/src/types.ts +++ b/packages/publish/src/types.ts @@ -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'; @@ -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: unset keys inherit the top-level git config. + 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, }; } diff --git a/packages/publish/test/integration/pipeline.spec.ts b/packages/publish/test/integration/pipeline.spec.ts index 233c05a8..8696b702 100644 --- a/packages/publish/test/integration/pipeline.spec.ts +++ b/packages/publish/test/integration/pipeline.spec.ts @@ -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); diff --git a/packages/publish/test/unit/config.spec.ts b/packages/publish/test/unit/config.spec.ts index 56d426f9..01a4c705 100644 --- a/packages/publish/test/unit/config.spec.ts +++ b/packages/publish/test/unit/config.spec.ts @@ -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'); @@ -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', () => { diff --git a/packages/release/docs/ci-setup.md b/packages/release/docs/ci-setup.md index aaa746ac..e7601630 100644 --- a/packages/release/docs/ci-setup.md +++ b/packages/release/docs/ci-setup.md @@ -888,7 +888,7 @@ ReleaseKit detects OIDC availability automatically (`npm-auth: auto`). To force ## pub.dev Publishing (Dart / Flutter) -ReleaseKit publishes Dart and Flutter packages to pub.dev. Set `publish.pub.enabled: true` in your config and, if the repo contains Flutter packages, ReleaseKit detects them automatically from the `environment.flutter` key in `pubspec.yaml`. +ReleaseKit publishes detected Dart and Flutter packages to pub.dev by default; set `publish.pub.enabled: false` to opt out. Flutter packages are detected automatically from the `environment.flutter` key in `pubspec.yaml`. ### OIDC automated publishing (recommended) From 886259d2323a07399be8a46d457cdba404161a84 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 22 Oct 2026 22:55:55 +0100 Subject: [PATCH 2/3] test(publish): disable cargo/pub explicitly in the skip-when-disabled tests Both relied on the old off-by-default and, with no manifest on disk, passed whatever the setting. They now write a crate/pubspec and turn the registry off. Co-Authored-By: Claude Opus 5.5 --- packages/publish/test/unit/stages/cargo-publish.spec.ts | 5 ++++- packages/publish/test/unit/stages/pub-publish.spec.ts | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/publish/test/unit/stages/cargo-publish.spec.ts b/packages/publish/test/unit/stages/cargo-publish.spec.ts index 6d7e6ab1..18ddbef2 100644 --- a/packages/publish/test/unit/stages/cargo-publish.spec.ts +++ b/packages/publish/test/unit/stages/cargo-publish.spec.ts @@ -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); diff --git a/packages/publish/test/unit/stages/pub-publish.spec.ts b/packages/publish/test/unit/stages/pub-publish.spec.ts index 7820f5a9..3aab2abc 100644 --- a/packages/publish/test/unit/stages/pub-publish.spec.ts +++ b/packages/publish/test/unit/stages/pub-publish.spec.ts @@ -94,8 +94,11 @@ describe('pub-publish stage', () => { it('should skip when pub disabled', async () => { const { execCommand } = await import('../../../src/utils/exec.js'); const dir = createTmpDir(); + const pkgDir = path.join(dir, 'packages', 'my_package'); + fs.mkdirSync(pkgDir, { recursive: true }); + writePubspec(pkgDir, 'my_package'); const config = getDefaultConfig(); - // pub.enabled defaults to false + config.pub.enabled = false; const ctx = createContext(dir, { config }); await runPubPublishStage(ctx); From 0a68047eb42c422ca98766bb28a673292d79cae8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 23 Oct 2026 15:41:17 +0100 Subject: [PATCH 3/3] refactor(publish): say where publish.git's fallbacks come from Co-Authored-By: Claude Opus 5.5 --- packages/publish/src/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/publish/src/types.ts b/packages/publish/src/types.ts index 764d6271..72fd758c 100644 --- a/packages/publish/src/types.ts +++ b/packages/publish/src/types.ts @@ -163,7 +163,7 @@ export function toPublishConfig(config: BasePublishConfig): PublishConfig { npm, cargo, pub, - // publish.git has no schema defaults: unset keys inherit the top-level git config. + // 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',