From 56166dd805c98bcb60fde9d464d406de97e0b1ed Mon Sep 17 00:00:00 2001 From: goosewobbler <432005+goosewobbler@users.noreply.github.com> Date: Sun, 19 Jul 2026 16:23:19 +0100 Subject: [PATCH 1/2] =?UTF-8?q?feat(config)!:=20unify=20per-ecosystem=20en?= =?UTF-8?q?ablement=20=E2=80=94=20detection=20enables,=20config=20opts=20o?= =?UTF-8?q?ut?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every detected ecosystem is versioned and, where publishing is configured and authenticated, published by default; explicit config is only ever an opt-out. - publish.cargo.enabled / publish.pub.enabled now default true (was false) - add version.npm.enabled (default true) so package.json handling is opt-out-able like Cargo.toml/pubspec.yaml; wired into every version-stage package.json write - keep Cargo.toml (root and hybrid packages) versioned when npm handling is disabled — the npm gate must not skip or abandon cargo updates - reframe the enabled describe() text as opt-outs; regenerate schema + config docs Default-on is safe: an enabled registry that finds zero targets no-ops, and publishing without credentials fails/skips at the auth check. Refs #554 BREAKING CHANGE: crates.io and pub.dev publishing now default on for detected packages. Set publish.cargo.enabled / publish.pub.enabled to false to opt out (version and tag only). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011i1KvoyxATU3bz5V5Sf1Xu --- docs/configuration.md | 17 ++++-- packages/config/src/schema.ts | 49 ++++++++++++++--- packages/config/test/unit/schema.spec.ts | 15 ++++-- packages/version/src/core/groupStrategy.ts | 8 ++- .../version/src/core/versionStrategies.ts | 45 +++++++++++----- .../version/src/package/packageProcessor.ts | 5 +- packages/version/src/types.ts | 4 ++ .../test/unit/core/versionStrategies.spec.ts | 52 +++++++++++++++++++ .../unit/package/packageProcessor.spec.ts | 30 +++++++++++ releasekit.schema.json | 26 +++++++--- scripts/generate-config-docs.ts | 8 +++ 11 files changed, 220 insertions(+), 39 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index f6efa963..97924cfb 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -75,6 +75,7 @@ Versioning configuration. | `allowFirstBump` | boolean | `false` | Acknowledge applying a bump on a first release with an already-stable manifest. On a first release (no prior tag), `--stable --bump ` applies the bump (e.g. 1.0.0 → 2.0.0) rather than graduating, which can silently overshoot the staged first version. By default this is flagged per `mismatchStrategy` (warn, or abort under "error"); set true (or pass --allow-first-bump) to apply the bump silently — legitimate when importing a package with prior external version history. | | `strictReachable` | boolean | `false` | Only use reachable tags | | `zeroMajor` | `"spec"` \| `"strict"` | `"spec"` | Pre-1.0 handling of commit-inferred breaking changes. 'spec' (default): bump the 0.x minor (0.24.0 → 0.25.0), per semver §4. 'strict': bump the next major (→ 1.0.0). Inferred path only — explicit overrides (--bump major, bump:major) always graduate to 1.0.0. | +| `npm` | object | — | npm/JavaScript version handling | | `pub` | object | — | Dart/Flutter pub configuration | ### `version.zeroMajor` @@ -127,13 +128,21 @@ ungrouped packages honor targets as-is. **Workspace pins.** Intra-group `workspace:*` dependencies resolve to the group version within a run, so a fixed group publishes internally consistent. +### `version.npm` + +npm/JavaScript version handling. + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| `enabled` | boolean | `true` | Version package.json manifests. Detected npm packages are versioned by default; set false to opt out (e.g. npm versioning handled elsewhere). | + ### `version.cargo` Cargo/Rust configuration. | Key | Type | Default | Description | |-----|------|---------|-------------| -| `enabled` | boolean | `true` | Enable Cargo.toml version handling | +| `enabled` | boolean | `true` | Version Cargo.toml manifests. Detected Rust packages are versioned by default; set false to opt out (e.g. a vendored crate, or Rust versioning handled elsewhere). | | `paths` | `string[]` | — | Directories to search for Cargo.toml files | --- @@ -162,7 +171,7 @@ NPM publishing configuration. | Key | Type | Default | Description | |-----|------|---------|-------------| -| `enabled` | boolean | `true` | Enable NPM publishing | +| `enabled` | boolean | `true` | Publish to npm. Detected npm packages are published by default; set false to opt out (version and tag only). | | `auth` | `"auto"` \| `"oidc"` \| `"token"` | `"auto"` | Authentication method | | `provenance` | boolean | `true` | Enable npm provenance attestation | | `access` | `"public"` \| `"restricted"` | `"public"` | Package access level | @@ -177,7 +186,7 @@ Cargo publishing configuration. | Key | Type | Default | Description | |-----|------|---------|-------------| -| `enabled` | boolean | `false` | Enable Cargo publishing | +| `enabled` | boolean | `true` | Publish to crates.io. Detected Rust packages are published by default; set false to opt out (version and tag only). | | `noVerify` | boolean | `false` | Skip verification before publish | | `publishOrder` | `string[]` | `[]` | Order in which to publish packages | | `clean` | boolean | `false` | Clean before publishing | @@ -188,7 +197,7 @@ Dart/Flutter publishing configuration via pub. | Key | Type | Default | Description | |-----|------|---------|-------------| -| `enabled` | boolean | `false` | Enable pub.dev publishing | +| `enabled` | boolean | `true` | Publish to pub.dev. Detected Dart/Flutter packages are published by default; set false to opt out (version and tag only). | | `publishOrder` | `string[]` | `[]` | Order in which to publish packages | ### `publish.githubRelease` diff --git a/packages/config/src/schema.ts b/packages/config/src/schema.ts index 79155581..0a48fcf7 100644 --- a/packages/config/src/schema.ts +++ b/packages/config/src/schema.ts @@ -26,13 +26,32 @@ export const MonorepoConfigSchema = z.object({ packagesPath: z.string().optional().describe('Path to packages directory'), }); +export const VersionNpmConfigSchema = z.object({ + enabled: z + .boolean() + .default(true) + .describe( + 'Version package.json manifests. Detected npm packages are versioned by default; set false to opt out (e.g. npm versioning handled elsewhere).', + ), +}); + export const VersionCargoConfigSchema = z.object({ - enabled: z.boolean().default(true).describe('Enable Cargo.toml version handling'), + enabled: z + .boolean() + .default(true) + .describe( + 'Version Cargo.toml manifests. Detected Rust packages are versioned by default; set false to opt out (e.g. a vendored crate, or Rust versioning handled elsewhere).', + ), paths: z.array(z.string()).optional().describe('Directories to search for Cargo.toml files'), }); export const VersionPubConfigSchema = z.object({ - enabled: z.boolean().default(true).describe('Enable pubspec.yaml version handling'), + enabled: z + .boolean() + .default(true) + .describe( + 'Version pubspec.yaml manifests. Detected Dart/Flutter packages are versioned by default; set false to opt out.', + ), paths: z.array(z.string()).optional().describe('Directories to search for pubspec.yaml files'), }); @@ -129,12 +148,18 @@ export const VersionConfigSchema = z.object({ .describe( "Pre-1.0 handling of commit-inferred breaking changes. 'spec' (default): bump the 0.x minor (0.24.0 → 0.25.0), per semver §4. 'strict': bump the next major (→ 1.0.0). Inferred path only — explicit overrides (--bump major, bump:major) always graduate to 1.0.0.", ), + npm: VersionNpmConfigSchema.optional().describe('npm/JavaScript version handling'), cargo: VersionCargoConfigSchema.optional().describe('Cargo/Rust configuration'), pub: VersionPubConfigSchema.optional().describe('Dart/Flutter pub configuration'), }); export const NpmConfigSchema = z.object({ - enabled: z.boolean().default(true).describe('Enable NPM publishing'), + enabled: z + .boolean() + .default(true) + .describe( + 'Publish to npm. Detected npm packages are published by default; set false to opt out (version and tag only).', + ), auth: z.enum(['auto', 'oidc', 'token']).default('auto').describe('Authentication method'), provenance: z.boolean().default(true).describe('Enable npm provenance attestation'), access: z.enum(['public', 'restricted']).default('public').describe('Package access level'), @@ -148,14 +173,24 @@ export const NpmConfigSchema = z.object({ }); export const CargoPublishConfigSchema = z.object({ - enabled: z.boolean().default(false).describe('Enable Cargo publishing'), + enabled: z + .boolean() + .default(true) + .describe( + 'Publish to crates.io. Detected Rust packages are published by default; set false to opt out (version and tag only).', + ), noVerify: z.boolean().default(false).describe('Skip verification before publish'), publishOrder: z.array(z.string()).default([]).describe('Order in which to publish packages'), clean: z.boolean().default(false).describe('Clean before publishing'), }); export const PubPublishConfigSchema = z.object({ - enabled: z.boolean().default(false).describe('Enable pub.dev publishing'), + enabled: z + .boolean() + .default(true) + .describe( + 'Publish to pub.dev. Detected Dart/Flutter packages are published by default; set false to opt out (version and tag only).', + ), publishOrder: z.array(z.string()).default([]).describe('Order in which to publish packages'), }); @@ -248,13 +283,13 @@ export const PublishConfigSchema = z.object({ publishOrder: [], }).describe('NPM publishing configuration'), cargo: CargoPublishConfigSchema.default({ - enabled: false, + enabled: true, noVerify: false, publishOrder: [], clean: false, }).describe('Cargo publishing configuration'), pub: PubPublishConfigSchema.default({ - enabled: false, + enabled: true, publishOrder: [], }).describe('Dart/Flutter publishing configuration via pub'), githubRelease: GitHubReleaseConfigSchema.default({ diff --git a/packages/config/test/unit/schema.spec.ts b/packages/config/test/unit/schema.spec.ts index e7a112de..0f4f919a 100644 --- a/packages/config/test/unit/schema.spec.ts +++ b/packages/config/test/unit/schema.spec.ts @@ -18,6 +18,7 @@ import { TemplateConfigSchema, VerifyConfigSchema, VersionConfigSchema, + VersionNpmConfigSchema, } from '../../src/schema.js'; describe('GitConfigSchema', () => { @@ -64,7 +65,6 @@ describe('MonorepoConfigSchema', () => { describe('VersionConfigSchema', () => { it('should apply defaults', () => { const result = VersionConfigSchema.parse({}); - // biome-ignore lint/suspicious/noTemplateCurlyInString: checking the literal default string expect(result.tagTemplate).toBe('${prefix}${version}'); expect(result.packageSpecificTags).toBe(false); expect(result.preset).toBe('conventional'); @@ -104,6 +104,11 @@ describe('VersionConfigSchema', () => { expect(result.cargo?.paths).toEqual(['crates/core', 'crates/cli']); }); + it('should default version.npm.enabled to true and accept an opt-out', () => { + expect(VersionNpmConfigSchema.parse({}).enabled).toBe(true); + expect(VersionConfigSchema.parse({ npm: { enabled: false } }).npm?.enabled).toBe(false); + }); + it('should accept version groups with fixed and linked sync modes', () => { const result = VersionConfigSchema.parse({ sync: false, @@ -172,7 +177,8 @@ describe('NpmConfigSchema', () => { describe('CargoPublishConfigSchema', () => { it('should apply defaults', () => { const result = CargoPublishConfigSchema.parse({}); - expect(result.enabled).toBe(false); + // Detection enables, config opts out: crates.io publishing defaults on. + expect(result.enabled).toBe(true); expect(result.noVerify).toBe(false); expect(result.publishOrder).toEqual([]); expect(result.clean).toBe(false); @@ -221,10 +227,11 @@ describe('VerifyConfigSchema', () => { }); describe('PublishConfigSchema', () => { - it('should apply defaults for npm and cargo', () => { + it('should default publishing on for npm, cargo, and pub (detection enables, config opts out)', () => { const result = PublishConfigSchema.parse({}); expect(result.npm.enabled).toBe(true); - expect(result.cargo.enabled).toBe(false); + expect(result.cargo.enabled).toBe(true); + expect(result.pub.enabled).toBe(true); expect(result.githubRelease.enabled).toBe(true); }); }); diff --git a/packages/version/src/core/groupStrategy.ts b/packages/version/src/core/groupStrategy.ts index 5ff91797..a2bdeb1d 100644 --- a/packages/version/src/core/groupStrategy.ts +++ b/packages/version/src/core/groupStrategy.ts @@ -407,7 +407,10 @@ async function releaseGroup( } const packageJsonPath = path.join(pkg.dir, 'package.json'); - if (fs.existsSync(packageJsonPath)) { + // npm version handling gates package.json manifests (default true). + if (config.npm?.enabled === false) { + log(`Skipping package.json update for ${name} - npm version handling disabled`, 'debug'); + } else if (fs.existsSync(packageJsonPath)) { updatePackageVersion(packageJsonPath, version, config.dryRun); } else { log(`Skipping package.json update for ${name} - no package.json found (Rust-only package)`, 'debug'); @@ -567,7 +570,8 @@ export function createGroupStrategy(config: Config): (packages: PackagesWithRoot const name = pkg.packageJson.name; const packageJsonPath = path.join(pkg.dir, 'package.json'); - if (fs.existsSync(packageJsonPath)) { + // npm version handling gates package.json manifests (default true). + if (config.npm?.enabled !== false && fs.existsSync(packageJsonPath)) { updatePackageVersion(packageJsonPath, plan.ownNext, config.dryRun); } const cargoTomlPath = path.join(pkg.dir, 'Cargo.toml'); diff --git a/packages/version/src/core/versionStrategies.ts b/packages/version/src/core/versionStrategies.ts index 59aedd36..1fc514e6 100644 --- a/packages/version/src/core/versionStrategies.ts +++ b/packages/version/src/core/versionStrategies.ts @@ -225,17 +225,29 @@ export function createSyncStrategy(config: Config): StrategyFunction { // Check if packages.root is defined before joining paths if (packages.root) { const rootPkgPath = path.join(packages.root, 'package.json'); - if (fs.existsSync(rootPkgPath)) { + // npm version handling gates package.json manifests (default true). + if (config.npm?.enabled !== false && fs.existsSync(rootPkgPath)) { // Mark as root so consumers (preview, standing PR) can exclude this lockstep // bump from package counts — it isn't a publishable package. updatePackageVersion(rootPkgPath, nextVersion, dryRun, true); files.push(rootPkgPath); updatedPackages.push('root'); - processedPaths.add(rootPkgPath); - - // Handle Cargo.toml files in root - const rootCargoFiles = updateCargoFiles(packages.root, nextVersion, config.cargo, dryRun); - files.push(...rootCargoFiles); + } + // Record the root path unconditionally (even when npm is disabled) so the per-package loop + // skips it in a single-package repo where packages.root === packages[0].dir — otherwise its + // Cargo.toml would be versioned twice (once here, once in the loop). + processedPaths.add(rootPkgPath); + + // Root Cargo.toml handling is independent of npm (updateCargoFiles honors cargo.enabled), + // so it runs whether or not npm versioning is disabled — matching the per-package loop below. + const rootCargoFiles = updateCargoFiles(packages.root, nextVersion, config.cargo, dryRun); + files.push(...rootCargoFiles); + // Record 'root' when only the root Cargo.toml was versioned (npm disabled, or no root + // package.json). In a single-package repo where packages.root === packages[0].dir the loop + // skips this path via processedPaths, so without this nothing tracks the write and the empty + // updatedPackages triggers the early return below — abandoning the already-written file. + if (rootCargoFiles.length > 0 && !updatedPackages.includes('root')) { + updatedPackages.push('root'); } } else { log('Root package path is undefined, skipping root package.json update', 'warning'); @@ -258,8 +270,10 @@ export function createSyncStrategy(config: Config): StrategyFunction { continue; } - // Skip pure Rust packages that don't have a package.json - if (!fs.existsSync(packageJsonPath)) { + // Skip when npm version handling is opted out, or for pure Rust packages with no package.json. + if (config.npm?.enabled === false) { + log(`Skipping package.json update for ${pkg.packageJson.name} - npm version handling disabled`, 'debug'); + } else if (!fs.existsSync(packageJsonPath)) { log( `Skipping package.json update for ${pkg.packageJson.name} - no package.json found (Rust-only package)`, 'debug', @@ -275,8 +289,10 @@ export function createSyncStrategy(config: Config): StrategyFunction { const pkgCargoFiles = updateCargoFiles(pkg.dir, nextVersion, config.cargo, dryRun); files.push(...pkgCargoFiles); - // Track pure-Rust packages by name when their Cargo.toml was updated - if (!fs.existsSync(packageJsonPath) && pkgCargoFiles.length > 0) { + // Track by name when only the Cargo.toml was updated: a pure-Rust package (no package.json), + // or a hybrid package whose package.json write was skipped because npm handling is disabled. + // Without this the early return below abandons the already-written Cargo.toml changes. + if ((!fs.existsSync(packageJsonPath) || config.npm?.enabled === false) && pkgCargoFiles.length > 0) { updatedPackages.push(pkg.packageJson.name); } } @@ -641,12 +657,15 @@ export function createSingleStrategy(config: Config): StrategyFunction { // Track all files that need to be committed const filesToCommit: string[] = []; - // Only update package.json if it exists (skip for pure Rust packages) - if (fs.existsSync(packageJsonPath)) { + // Update package.json when npm version handling is enabled (default true) and it exists; + // otherwise skip (opted out, or a pure Rust package with no package.json). + const npmEnabled = config.npm?.enabled !== false; + if (npmEnabled && fs.existsSync(packageJsonPath)) { updatePackageVersion(packageJsonPath, nextVersion, dryRun); filesToCommit.push(packageJsonPath); } else { - log(`Skipping package.json update for ${packageName} - no package.json found (Rust-only package)`, 'debug'); + const reason = !npmEnabled ? 'npm version handling disabled' : 'no package.json found (Rust-only package)'; + log(`Skipping package.json update for ${packageName} - ${reason}`, 'debug'); } // Handle Cargo.toml files for this package diff --git a/packages/version/src/package/packageProcessor.ts b/packages/version/src/package/packageProcessor.ts index db112ceb..918d80c1 100644 --- a/packages/version/src/package/packageProcessor.ts +++ b/packages/version/src/package/packageProcessor.ts @@ -346,8 +346,9 @@ export class PackageProcessor { // This ensures consistent versioning across language ecosystems. const packageJsonPath = path.join(pkgPath, 'package.json'); - // Always update package.json if it exists - if (fs.existsSync(packageJsonPath)) { + // Update package.json when npm version handling is enabled (default true) and it exists. + const npmEnabled = this.fullConfig.npm?.enabled !== false; + if (npmEnabled && fs.existsSync(packageJsonPath)) { updatePackageVersion(packageJsonPath, nextVersion, this.dryRun); } diff --git a/packages/version/src/types.ts b/packages/version/src/types.ts index 1d8da18d..aea36e74 100644 --- a/packages/version/src/types.ts +++ b/packages/version/src/types.ts @@ -159,6 +159,9 @@ export interface Config extends VersionConfigBase { strictReachable?: boolean; /** Pre-1.0 inferred-breaking bump policy ('spec' | 'strict'). See docs/configuration.md#versionzeromajor. */ zeroMajor?: 'spec' | 'strict'; + npm?: { + enabled?: boolean; + }; cargo?: { enabled?: boolean; paths?: string[]; @@ -240,6 +243,7 @@ export function toVersionConfig(config: VersionConfig | undefined): Config { zeroMajor: config.zeroMajor, versionPrefix: config.versionPrefix ?? '', prereleaseIdentifier: config.prereleaseIdentifier, + npm: config.npm, cargo: config.cargo, pub: config.pub, }; diff --git a/packages/version/test/unit/core/versionStrategies.spec.ts b/packages/version/test/unit/core/versionStrategies.spec.ts index 58e6f6f4..1e186e76 100644 --- a/packages/version/test/unit/core/versionStrategies.spec.ts +++ b/packages/version/test/unit/core/versionStrategies.spec.ts @@ -230,6 +230,58 @@ describe('Version Strategies', () => { expect(jsonOutput.setPackageUpdateTag).not.toHaveBeenCalled(); }); + it('should still version Cargo.toml and not abandon it when npm handling is disabled on a hybrid repo', async () => { + // Hybrid sync monorepo (existsSync mock returns true for every package.json AND Cargo.toml) with + // npm versioning opted out: package.json writes are skipped, but Cargo.toml must still be versioned + // and the run must not early-return as "no packages updated" (which would abandon the written files). + const config: Partial = { ...defaultConfig, sync: true, npm: { enabled: false } }; + const syncStrategy = strategies.createSyncStrategy(config as Config); + + await syncStrategy(mockPackages); + + // package.json manifests (root 4-arg, members 3-arg) are skipped... + expect(packageManagement.updatePackageVersion).not.toHaveBeenCalledWith( + packageAPath, + expect.anything(), + expect.anything(), + ); + expect(packageManagement.updatePackageVersion).not.toHaveBeenCalledWith( + rootPackagePath, + expect.anything(), + expect.anything(), + expect.anything(), + ); + // ...but Cargo.toml is still versioned (root + members); updateCargoFiles passes dryRun=false. + expect(packageManagement.updatePackageVersion).toHaveBeenCalledWith( + '/test/workspace/packages/a/Cargo.toml', + '1.1.0', + false, + ); + expect(packageManagement.updatePackageVersion).toHaveBeenCalledWith('/test/workspace/Cargo.toml', '1.1.0', false); + // ...and the run proceeds to tag rather than early-returning. + expect(jsonOutput.addTag).toHaveBeenCalledWith('v1.1.0'); + }); + + it('should not version a single-package repo Cargo.toml twice when npm is disabled', async () => { + // Single-package repo where the package IS the root (packages.root === packages[0].dir). With + // npm disabled, processedPaths must still record the root path so the per-package loop skips it, + // or updateCargoFiles runs for the same dir twice — writing Cargo.toml twice. + const singlePkg = { + root: '/test/workspace', + packages: [{ dir: '/test/workspace', packageJson: { name: 'root-pkg', version: '1.0.0' } }], + }; + const config: Partial = { ...defaultConfig, sync: true, npm: { enabled: false } }; + await strategies.createSyncStrategy(config as Config)(singlePkg as typeof mockPackages); + + const cargoCalls = vi + .mocked(packageManagement.updatePackageVersion) + .mock.calls.filter((c) => c[0] === '/test/workspace/Cargo.toml'); + expect(cargoCalls).toHaveLength(1); + // The run must not early-return as "nothing updated" — 'root' is tracked for the cargo-only + // update, so the shared tag is still created and the written Cargo.toml is committed. + expect(jsonOutput.addTag).toHaveBeenCalledWith('v1.1.0'); + }); + it('should use mainPackage for version calculation when specified', async () => { // Setup with mainPackage const config: Partial = { diff --git a/packages/version/test/unit/package/packageProcessor.spec.ts b/packages/version/test/unit/package/packageProcessor.spec.ts index 7c44b185..6d608e2f 100644 --- a/packages/version/test/unit/package/packageProcessor.spec.ts +++ b/packages/version/test/unit/package/packageProcessor.spec.ts @@ -929,6 +929,36 @@ describe('Package Processor', () => { false, ); }); + + it('should skip package.json but still update Cargo.toml when version.npm.enabled is false', async () => { + vi.spyOn(fs, 'existsSync').mockImplementation(() => true); + + const hybridPackage = { + ...mockPackages[0], + dir: '/path/to/hybrid-package', + packageJson: { name: 'hybrid-package', version: '0.1.0' }, + }; + + const processor = new PackageProcessor({ + getLatestTag: gitTags.getLatestTag, + config: {}, + fullConfig: { ...mockConfig, npm: { enabled: false } }, + }); + + await processor.processPackages([hybridPackage]); + + // npm opted out → package.json is left untouched; Cargo.toml is still versioned. + expect(packageManagement.updatePackageVersion).not.toHaveBeenCalledWith( + '/path/to/hybrid-package/package.json', + expect.anything(), + expect.anything(), + ); + expect(packageManagement.updatePackageVersion).toHaveBeenCalledWith( + '/path/to/hybrid-package/Cargo.toml', + '1.1.0', + false, + ); + }); }); describe('Cargo.toml handling', () => { diff --git a/releasekit.schema.json b/releasekit.schema.json index dc05d1cc..580ec17d 100644 --- a/releasekit.schema.json +++ b/releasekit.schema.json @@ -210,6 +210,18 @@ "default": "spec", "description": "Pre-1.0 handling of commit-inferred breaking changes. 'spec' (default): bump the 0.x minor (0.24.0 → 0.25.0), per semver §4. 'strict': bump the next major (→ 1.0.0). Inferred path only — explicit overrides (--bump major, bump:major) always graduate to 1.0.0." }, + "npm": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "default": true, + "description": "Version package.json manifests. Detected npm packages are versioned by default; set false to opt out (e.g. npm versioning handled elsewhere)." + } + }, + "description": "npm/JavaScript version handling" + }, "cargo": { "type": "object", "additionalProperties": false, @@ -217,7 +229,7 @@ "enabled": { "type": "boolean", "default": true, - "description": "Enable Cargo.toml version handling" + "description": "Version Cargo.toml manifests. Detected Rust packages are versioned by default; set false to opt out (e.g. a vendored crate, or Rust versioning handled elsewhere)." }, "paths": { "type": "array", @@ -236,7 +248,7 @@ "enabled": { "type": "boolean", "default": true, - "description": "Enable pubspec.yaml version handling" + "description": "Version pubspec.yaml manifests. Detected Dart/Flutter packages are versioned by default; set false to opt out." }, "paths": { "type": "array", @@ -298,7 +310,7 @@ "enabled": { "type": "boolean", "default": true, - "description": "Enable NPM publishing" + "description": "Publish to npm. Detected npm packages are published by default; set false to opt out (version and tag only)." }, "auth": { "type": "string", @@ -361,8 +373,8 @@ "properties": { "enabled": { "type": "boolean", - "default": false, - "description": "Enable Cargo publishing" + "default": true, + "description": "Publish to crates.io. Detected Rust packages are published by default; set false to opt out (version and tag only)." }, "noVerify": { "type": "boolean", @@ -391,8 +403,8 @@ "properties": { "enabled": { "type": "boolean", - "default": false, - "description": "Enable pub.dev publishing" + "default": true, + "description": "Publish to pub.dev. Detected Dart/Flutter packages are published by default; set false to opt out (version and tag only)." }, "publishOrder": { "type": "array", diff --git a/scripts/generate-config-docs.ts b/scripts/generate-config-docs.ts index 3ef82dc8..4a0c4e44 100644 --- a/scripts/generate-config-docs.ts +++ b/scripts/generate-config-docs.ts @@ -213,6 +213,14 @@ function renderVersion(prop: SchemaProperty): void { ); } + const npm = prop.properties?.npm; + if (npm) { + emit('### `version.npm`', ''); + emit(ensurePeriod(npm.description), ''); + emit(propsTable(npm.properties ?? {})); + emitBlank(); + } + const cargo = prop.properties?.cargo; if (cargo) { emit('### `version.cargo`', ''); From 25bc9e96665ade4da981ce6c8554f1df20105291 Mon Sep 17 00:00:00 2001 From: goosewobbler <432005+goosewobbler@users.noreply.github.com> Date: Sun, 19 Jul 2026 16:35:49 +0100 Subject: [PATCH 2/2] feat(notes): require an explicit llm.model; enum-validate provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve model-ID rot by requiring the consumer to name a current model rather than shipping curated defaults that rot every release — the openai default we would have shipped, gpt-4o-mini, is already deprecated. LLM enhancement is already an explicit opt-in (it needs an API key, or a running Ollama with a pulled model), so naming the model is a marginal, honest addition, and it removes a maintenance treadmill. - llm.model is required; a missing model fails at config validation, not silently inside the notes soft-fail catch. - provider is a Zod enum (openai | openai-compatible | anthropic | ollama), not a free-form string. - openai-compatible requires baseURL at the schema level. - the CLI --llm-* flags assemble a config that bypasses loadConfig's Zod pass, so validate it through LLMConfigSchema before use — a bad provider or missing model fails loud (non-zero exit) instead of degrading silently in the pipeline. - drop the curated-default machinery (LLM_DEFAULTS.models, resolveModelId, the per-provider `?? default` fallbacks). - docs: use a `` placeholder in config examples (a real id would drift). - regenerate schema + config docs. Refs #541 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011i1KvoyxATU3bz5V5Sf1Xu --- docs/configuration.md | 6 ++-- packages/config/src/index.ts | 1 + packages/config/src/schema.ts | 28 ++++++++++++--- packages/config/test/unit/schema.spec.ts | 24 ++++++++++++- packages/notes/docs/configuration.md | 6 ++-- packages/notes/docs/llm-providers.md | 8 ++--- packages/notes/docs/llm-release-notes.md | 4 +-- packages/notes/docs/monorepo.md | 2 +- packages/notes/src/command.ts | 24 ++++++++++--- packages/notes/src/core/types.ts | 2 +- packages/notes/src/llm/anthropic.ts | 7 ++-- packages/notes/src/llm/defaults.ts | 9 ++--- packages/notes/src/llm/index.ts | 8 +++++ packages/notes/src/llm/ollama.ts | 7 ++-- packages/notes/src/llm/openai-compatible.ts | 5 ++- packages/notes/src/llm/openai.ts | 7 ++-- .../test/unit/llm/createProvider.spec.ts | 34 +++++++++++++++++++ packages/publish/docs/github-releases.md | 4 +-- releasekit.schema.json | 11 ++++-- 19 files changed, 149 insertions(+), 48 deletions(-) create mode 100644 packages/notes/test/unit/llm/createProvider.spec.ts diff --git a/docs/configuration.md b/docs/configuration.md index 97924cfb..76193803 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -299,9 +299,9 @@ LLM configuration for release notes. | Key | Type | Default | Description | |-----|------|---------|-------------| -| `provider` | string | — | LLM provider | -| `model` | string | — | Model identifier | -| `baseURL` | string | — | Custom API base URL | +| `provider` | `"openai"` \| `"openai-compatible"` \| `"anthropic"` \| `"ollama"` | — | LLM provider | +| `model` | string | — | Model identifier for the selected provider. Required — releasekit ships no default, so enabling LLM enhancement needs an explicit, current model (a missing model fails config validation). | +| `baseURL` | string | — | Custom API base URL. Required for the openai-compatible provider. | | `apiKey` | string | — | API key | | `concurrency` | integer | — | Concurrent LLM requests | | `style` | string | — | Writing style for LLM | diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index 6ba3e479..6918b64b 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -24,6 +24,7 @@ export { type GitHubReleaseConfig, type LLMCategory, type LLMConfig, + LLMConfigSchema, type LLMPromptOverrides, type LLMPromptsConfig, type LocationMode, diff --git a/packages/config/src/schema.ts b/packages/config/src/schema.ts index 0a48fcf7..74ec5df8 100644 --- a/packages/config/src/schema.ts +++ b/packages/config/src/schema.ts @@ -432,10 +432,17 @@ export const LLMPromptsConfigSchema = z.object({ ), }); -export const LLMConfigSchema = z.object({ - provider: z.string().describe('LLM provider'), - model: z.string().describe('Model identifier'), - baseURL: z.string().optional().describe('Custom API base URL'), +const LLMConfigBaseSchema = z.object({ + provider: z.enum(['openai', 'openai-compatible', 'anthropic', 'ollama']).describe('LLM provider'), + model: z + .string() + // .min(1): an empty string passes z.string(), so "model": "" would slip through Zod at load and + // only fail deep in the pipeline (soft-caught). Reject it at config validation instead. + .min(1, 'model must be a non-empty string — set it to a model the provider serves.') + .describe( + 'Model identifier for the selected provider. Required — releasekit ships no default, so enabling LLM enhancement needs an explicit, current model (a missing model fails config validation).', + ), + baseURL: z.string().optional().describe('Custom API base URL. Required for the openai-compatible provider.'), apiKey: z.string().optional().describe('API key'), options: LLMOptionsSchema.optional(), concurrency: z.number().int().positive().optional().describe('Concurrent LLM requests'), @@ -483,6 +490,19 @@ export const LLMConfigSchema = z.object({ ), }); +// The openai-compatible provider points at a custom endpoint, so releasekit can't assume a base URL: +// it must be set explicitly. Enforced at config-validation time (a Zod refinement) so a misconfig +// fails loudly here rather than silently degrading to non-LLM notes inside the notes soft-fail catch. +export const LLMConfigSchema = LLMConfigBaseSchema.superRefine((cfg, ctx) => { + if (cfg.provider === 'openai-compatible' && !cfg.baseURL) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['baseURL'], + message: 'baseURL is required when provider is "openai-compatible".', + }); + } +}); + export const ReleaseNotesConfigSchema = z .object({ file: ReleaseNotesFileConfigSchema.optional().describe( diff --git a/packages/config/test/unit/schema.spec.ts b/packages/config/test/unit/schema.spec.ts index 0f4f919a..97419000 100644 --- a/packages/config/test/unit/schema.spec.ts +++ b/packages/config/test/unit/schema.spec.ts @@ -332,12 +332,34 @@ describe('LLMPromptsConfigSchema', () => { }); describe('LLMConfigSchema', () => { - it('should require provider and model', () => { + it('should accept provider and model', () => { const result = LLMConfigSchema.parse({ provider: 'openai', model: 'gpt-4' }); expect(result.provider).toBe('openai'); expect(result.model).toBe('gpt-4'); }); + it('should require a non-empty model for every provider (releasekit ships no default)', () => { + expect(() => LLMConfigSchema.parse({ provider: 'anthropic' })).toThrow(); + expect(() => LLMConfigSchema.parse({ provider: 'openai' })).toThrow(); + expect(() => LLMConfigSchema.parse({ provider: 'ollama' })).toThrow(); + // An empty string must not slip through z.string(). + expect(() => LLMConfigSchema.parse({ provider: 'openai', model: '' })).toThrow(/non-empty/); + }); + + it('should reject an unknown provider (enum, not free-form string)', () => { + expect(() => LLMConfigSchema.parse({ provider: 'gpt', model: 'x' })).toThrow(); + expect(() => LLMConfigSchema.parse({ provider: 'anthropicc', model: 'x' })).toThrow(); + }); + + it('should require baseURL for openai-compatible', () => { + expect(() => LLMConfigSchema.parse({ provider: 'openai-compatible', model: 'local-model' })).toThrow( + /baseURL is required/, + ); + const ok = LLMConfigSchema.parse({ provider: 'openai-compatible', baseURL: 'https://x/v1', model: 'local-model' }); + expect(ok.provider).toBe('openai-compatible'); + expect(ok.model).toBe('local-model'); + }); + it('should accept optional fields', () => { const result = LLMConfigSchema.parse({ provider: 'openai', diff --git a/packages/notes/docs/configuration.md b/packages/notes/docs/configuration.md index 1e347dd4..6068a7cc 100644 --- a/packages/notes/docs/configuration.md +++ b/packages/notes/docs/configuration.md @@ -138,7 +138,7 @@ LLM configuration for release notes enhancement. Requires `provider` and `model` "releaseNotes": { "llm": { "provider": "openai", - "model": "gpt-4o-mini", + "model": "", "tasks": { "enhance": true, "summarize": true } } } @@ -207,7 +207,7 @@ How existing changelog files are updated when new entries are generated. | Option | Type | Description | |--------|------|-------------| | `provider` | `string` | LLM provider key. See [LLM providers](./llm-providers.md). | -| `model` | `string` | Model identifier (e.g. `"gpt-4o-mini"`, `"claude-sonnet-4-5"`) | +| `model` | `string` | Model identifier for the selected provider (see [LLM providers](./llm-providers.md)). Required — releasekit ships no default, so pick a current model your provider serves. | ### Connection @@ -243,7 +243,7 @@ How existing changelog files are updated when new entries are generated. "releaseNotes": { "llm": { "provider": "openai", - "model": "gpt-4o", + "model": "", "options": { "temperature": 0.3, "maxTokens": 1000 } } } diff --git a/packages/notes/docs/llm-providers.md b/packages/notes/docs/llm-providers.md index 4f6edceb..3914b36a 100644 --- a/packages/notes/docs/llm-providers.md +++ b/packages/notes/docs/llm-providers.md @@ -42,7 +42,7 @@ Model names change frequently — check your provider's documentation for the cu "releaseNotes": { "llm": { "provider": "openai", - "model": "gpt-4o-mini", + "model": "", "tasks": { "enhance": true } } } @@ -62,7 +62,7 @@ See the [OpenAI models documentation](https://platform.openai.com/docs/models) f "releaseNotes": { "llm": { "provider": "anthropic", - "model": "claude-haiku-4-5", + "model": "", "tasks": { "releaseNotes": true } } } @@ -89,7 +89,7 @@ ollama pull llama3.2 "releaseNotes": { "llm": { "provider": "ollama", - "model": "llama3.2", + "model": "", "tasks": { "enhance": true } } } @@ -109,7 +109,7 @@ For self-hosted or third-party OpenAI-compatible APIs (LM Studio, vLLM, Azure Op "releaseNotes": { "llm": { "provider": "openai-compatible", - "model": "your-model-name", + "model": "", "baseURL": "http://localhost:1234/v1", "tasks": { "enhance": true } } diff --git a/packages/notes/docs/llm-release-notes.md b/packages/notes/docs/llm-release-notes.md index 8b3ac2e1..db810237 100644 --- a/packages/notes/docs/llm-release-notes.md +++ b/packages/notes/docs/llm-release-notes.md @@ -14,7 +14,7 @@ Add an `llm` block under `notes.releaseNotes` in `releasekit.config.json`: "releaseNotes": { "llm": { "provider": "openai", - "model": "gpt-4o-mini", + "model": "", "tasks": { "enhance": true, "categorize": true } } } @@ -418,7 +418,7 @@ chore: migrate bundler from webpack to esbuild 0.20 "releaseNotes": { "llm": { "provider": "anthropic", - "model": "claude-haiku-4-5", + "model": "", "tasks": { "enhance": true, "categorize": true }, "categoryOrder": ["Breaking", "New", "Fixed", "Changed", "Developer"] }, diff --git a/packages/notes/docs/monorepo.md b/packages/notes/docs/monorepo.md index ebfb2494..d5f77008 100644 --- a/packages/notes/docs/monorepo.md +++ b/packages/notes/docs/monorepo.md @@ -125,7 +125,7 @@ LLM tasks run per-package. Each package's entries are processed independently. W "releaseNotes": { "llm": { "provider": "openai", - "model": "gpt-4o-mini", + "model": "", "concurrency": 5, "tasks": { "enhance": true } } diff --git a/packages/notes/src/command.ts b/packages/notes/src/command.ts index b68096ec..78085119 100644 --- a/packages/notes/src/command.ts +++ b/packages/notes/src/command.ts @@ -1,6 +1,6 @@ import * as fs from 'node:fs'; import * as readline from 'node:readline'; -import { loadConfig, saveAuth } from '@releasekit/config'; +import { LLMConfigSchema, loadConfig, saveAuth } from '@releasekit/config'; import { EXIT_CODES, error, info, setLogLevel, setQuietMode, success, warn } from '@releasekit/core'; import { Command } from 'commander'; import { runPipeline } from './core/pipeline.js'; @@ -110,7 +110,7 @@ export function createNotesCommand(): Command { const existingLlm = existingRn.llm; const llm = { provider: existingLlm?.provider ?? 'openai-compatible', - model: existingLlm?.model ?? '', + model: existingLlm?.model, ...(existingLlm ?? {}), }; if (options.llmProvider) llm.provider = options.llmProvider; @@ -126,12 +126,28 @@ export function createNotesCommand(): Command { }; } + // Validate the CLI-assembled LLM config the same way loadConfig validates a config file, + // so a bad --llm-provider or a missing --llm-model fails loud here (non-zero exit via + // handleError) instead of reaching createProvider and being swallowed by the pipeline's + // soft-fail as a silent degradation to non-LLM notes. + let validatedLlm: import('./core/types.js').LLMConfig; + try { + validatedLlm = LLMConfigSchema.parse(llm) as import('./core/types.js').LLMConfig; + } catch (err) { + // Surface the Zod issue messages, which name the actual failing field (provider enum, + // empty/missing model, or missing baseURL for openai-compatible) — a static message + // would misattribute e.g. a missing --llm-base-url to the model. + const issues = (err as { issues?: Array<{ message: string }> }).issues; + const detail = + issues?.map((i) => i.message).join('; ') ?? (err instanceof Error ? err.message : String(err)); + throw new Error(`Invalid LLM configuration from --llm-* flags: ${detail}`); + } config.releaseNotes = { ...existingRn, - llm: llm as import('./core/types.js').LLMConfig, + llm: validatedLlm, }; - info(`LLM configured: ${llm.provider}${llm.model ? ` (${llm.model})` : ''}`); + info(`LLM configured: ${validatedLlm.provider}${validatedLlm.model ? ` (${validatedLlm.model})` : ''}`); if (llm.baseURL) { info(`LLM base URL: ${llm.baseURL}`); } diff --git a/packages/notes/src/core/types.ts b/packages/notes/src/core/types.ts index 1ebf2b5d..af54a72e 100644 --- a/packages/notes/src/core/types.ts +++ b/packages/notes/src/core/types.ts @@ -155,7 +155,7 @@ export interface TemplateConfig { } export interface LLMConfig { - provider: string; + provider: 'openai' | 'openai-compatible' | 'anthropic' | 'ollama'; model: string; baseURL?: string; apiKey?: string; diff --git a/packages/notes/src/llm/anthropic.ts b/packages/notes/src/llm/anthropic.ts index 1e1db652..1a7736db 100644 --- a/packages/notes/src/llm/anthropic.ts +++ b/packages/notes/src/llm/anthropic.ts @@ -2,14 +2,13 @@ import Anthropic from '@anthropic-ai/sdk'; import type { CompleteOptions } from '../core/types.js'; import { LLMError } from '../errors/index.js'; import { BaseLLMProvider } from './base.js'; -import { LLM_DEFAULTS } from './defaults.js'; import type { CompleteResult, LLMMessage } from './messages.js'; import { debugLogMessages } from './messages.js'; import type { ProviderCapabilities } from './provider.js'; export interface AnthropicConfig { apiKey?: string; - model?: string; + model: string; } export class AnthropicProvider extends BaseLLMProvider { @@ -23,7 +22,7 @@ export class AnthropicProvider extends BaseLLMProvider { private client: Anthropic; private model: string; - constructor(config: AnthropicConfig = {}) { + constructor(config: AnthropicConfig) { super(); const apiKey = config.apiKey ?? process.env.ANTHROPIC_API_KEY; @@ -33,7 +32,7 @@ export class AnthropicProvider extends BaseLLMProvider { } this.client = new Anthropic({ apiKey }); - this.model = config.model ?? LLM_DEFAULTS.models.anthropic; + this.model = config.model; } async complete(messages: LLMMessage[], options?: CompleteOptions): Promise { diff --git a/packages/notes/src/llm/defaults.ts b/packages/notes/src/llm/defaults.ts index 73292306..11aa8ec7 100644 --- a/packages/notes/src/llm/defaults.ts +++ b/packages/notes/src/llm/defaults.ts @@ -11,10 +11,7 @@ export const LLM_DEFAULTS = { maxDelay: 30_000, backoffFactor: 2, }, - models: { - openai: 'gpt-4o-mini', - 'openai-compatible': 'gpt-4o-mini', - anthropic: 'claude-haiku-4-5-20251001', - ollama: 'llama3.2', - }, + // No default model: releasekit ships no model ids (they rot — e.g. a provider deprecates one), so + // `llm.model` is required and validated at config load. The model choice, and keeping it current, + // is the consumer's. } as const; diff --git a/packages/notes/src/llm/index.ts b/packages/notes/src/llm/index.ts index f5058833..c2d350c9 100644 --- a/packages/notes/src/llm/index.ts +++ b/packages/notes/src/llm/index.ts @@ -59,6 +59,14 @@ export interface CategorizedEntries { } export function createProvider(config: LLMConfig): LLMProvider { + // releasekit ships no default model (they rot), so a model is required. The Zod schema enforces + // this at config load; this guard also covers configs assembled in-process (e.g. the CLI's + // --llm-* flags, which bypass schema parsing) so a missing model fails loud, not silently. + if (!config.model) { + throw new LLMError( + `llm.model is required for the '${config.provider}' provider — set it to a model the provider serves.`, + ); + } // Resolve API key: explicit config → auth.json → environment (handled per-provider) const authKeys = loadAuth(); const apiKey = config.apiKey ?? authKeys[config.provider]; diff --git a/packages/notes/src/llm/ollama.ts b/packages/notes/src/llm/ollama.ts index e1166a9d..f132f1a9 100644 --- a/packages/notes/src/llm/ollama.ts +++ b/packages/notes/src/llm/ollama.ts @@ -3,7 +3,6 @@ import type { CompleteOptions } from '../core/types.js'; import { LLMError } from '../errors/index.js'; import { extractJsonFromResponse } from '../utils/json.js'; import { BaseLLMProvider } from './base.js'; -import { LLM_DEFAULTS } from './defaults.js'; import type { CompleteResult, LLMMessage } from './messages.js'; import { debugLogMessages } from './messages.js'; import type { ProviderCapabilities } from './provider.js'; @@ -11,7 +10,7 @@ import type { ProviderCapabilities } from './provider.js'; export interface OllamaConfig { apiKey?: string; baseURL?: string; - model?: string; + model: string; } interface OllamaChatRequest { @@ -48,11 +47,11 @@ export class OllamaProvider extends BaseLLMProvider { private model: string; private apiKey?: string; - constructor(config: OllamaConfig = {}) { + constructor(config: OllamaConfig) { super(); this.baseURL = config.baseURL ?? process.env.OLLAMA_BASE_URL ?? 'http://localhost:11434'; - this.model = config.model ?? process.env.OLLAMA_MODEL ?? LLM_DEFAULTS.models.ollama; + this.model = config.model; this.apiKey = config.apiKey ?? process.env.OLLAMA_API_KEY; } diff --git a/packages/notes/src/llm/openai-compatible.ts b/packages/notes/src/llm/openai-compatible.ts index 10237463..bb85eb30 100644 --- a/packages/notes/src/llm/openai-compatible.ts +++ b/packages/notes/src/llm/openai-compatible.ts @@ -2,7 +2,6 @@ import OpenAI from 'openai'; import type { CompleteOptions } from '../core/types.js'; import { LLMError } from '../errors/index.js'; import { BaseLLMProvider } from './base.js'; -import { LLM_DEFAULTS } from './defaults.js'; import type { CompleteResult, LLMMessage } from './messages.js'; import { debugLogMessages } from './messages.js'; import type { ProviderCapabilities } from './provider.js'; @@ -10,7 +9,7 @@ import type { ProviderCapabilities } from './provider.js'; export interface OpenAICompatibleConfig { apiKey?: string; baseURL: string; - model?: string; + model: string; } export class OpenAICompatibleProvider extends BaseLLMProvider { @@ -34,7 +33,7 @@ export class OpenAICompatibleProvider extends BaseLLMProvider { baseURL: config.baseURL, }); - this.model = config.model ?? LLM_DEFAULTS.models['openai-compatible']; + this.model = config.model; } async complete(messages: LLMMessage[], options?: CompleteOptions): Promise { diff --git a/packages/notes/src/llm/openai.ts b/packages/notes/src/llm/openai.ts index 3977c32c..b4bb61dd 100644 --- a/packages/notes/src/llm/openai.ts +++ b/packages/notes/src/llm/openai.ts @@ -4,7 +4,6 @@ import type { CompleteOptions } from '../core/types.js'; import { LLMError } from '../errors/index.js'; import { extractJsonFromResponse } from '../utils/json.js'; import { BaseLLMProvider } from './base.js'; -import { LLM_DEFAULTS } from './defaults.js'; import type { CompleteResult, LLMMessage } from './messages.js'; import { debugLogMessages } from './messages.js'; import type { ProviderCapabilities } from './provider.js'; @@ -12,7 +11,7 @@ import type { ProviderCapabilities } from './provider.js'; export interface OpenAIConfig { apiKey?: string; baseURL?: string; - model?: string; + model: string; } export class OpenAIProvider extends BaseLLMProvider { @@ -26,7 +25,7 @@ export class OpenAIProvider extends BaseLLMProvider { private client: OpenAI; private model: string; - constructor(config: OpenAIConfig = {}) { + constructor(config: OpenAIConfig) { super(); const apiKey = config.apiKey ?? process.env.OPENAI_API_KEY; @@ -40,7 +39,7 @@ export class OpenAIProvider extends BaseLLMProvider { baseURL: config.baseURL, }); - this.model = config.model ?? LLM_DEFAULTS.models.openai; + this.model = config.model; } async complete(messages: LLMMessage[], options?: CompleteOptions): Promise { diff --git a/packages/notes/test/unit/llm/createProvider.spec.ts b/packages/notes/test/unit/llm/createProvider.spec.ts new file mode 100644 index 00000000..c36983e6 --- /dev/null +++ b/packages/notes/test/unit/llm/createProvider.spec.ts @@ -0,0 +1,34 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { LLMConfig } from '../../../src/core/types.js'; +import { LLMError } from '../../../src/errors/index.js'; +import { createProvider } from '../../../src/llm/index.js'; + +// The OpenAI-backed providers construct a client in their constructor; stub it so we can assert the +// factory's own guards without a network client. +vi.mock('openai', () => ({ + default: class MockOpenAI { + chat = { completions: { create: vi.fn() } }; + }, +})); + +describe('createProvider', () => { + it('should throw a clear error when llm.model is missing (no curated default)', () => { + // A config assembled in-process (e.g. from CLI --llm-* flags) can bypass schema validation. + const cfg = { provider: 'openai' } as unknown as LLMConfig; + expect(() => createProvider(cfg)).toThrow(LLMError); + expect(() => createProvider(cfg)).toThrow(/llm\.model is required/); + }); + + it('should require baseURL for the openai-compatible provider', () => { + const cfg = { provider: 'openai-compatible', model: 'local-model' } as unknown as LLMConfig; + expect(() => createProvider(cfg)).toThrow(/requires baseURL/); + }); + + it('should construct a provider when model (and baseURL for openai-compatible) are set', () => { + expect(createProvider({ provider: 'openai', model: 'gpt-x', apiKey: 'k' } as LLMConfig).name).toBe('openai'); + expect( + createProvider({ provider: 'openai-compatible', model: 'local-model', baseURL: 'https://x/v1' } as LLMConfig) + .name, + ).toBe('openai-compatible'); + }); +}); diff --git a/packages/publish/docs/github-releases.md b/packages/publish/docs/github-releases.md index 2f9b6ce7..0ff80655 100644 --- a/packages/publish/docs/github-releases.md +++ b/packages/publish/docs/github-releases.md @@ -64,7 +64,7 @@ To populate the GitHub Release body with LLM-written prose: "releaseNotes": { "llm": { "provider": "openai", - "model": "gpt-4o-mini", + "model": "", "tasks": { "releaseNotes": true } } } @@ -90,7 +90,7 @@ If `body` is `"auto"` (default), LLM release notes are used automatically when a "file": { "dir": "release-notes" }, "llm": { "provider": "anthropic", - "model": "claude-haiku-4-5", + "model": "", "tasks": { "releaseNotes": true } } } diff --git a/releasekit.schema.json b/releasekit.schema.json index 580ec17d..08696486 100644 --- a/releasekit.schema.json +++ b/releasekit.schema.json @@ -702,15 +702,22 @@ "properties": { "provider": { "type": "string", + "enum": [ + "openai", + "openai-compatible", + "anthropic", + "ollama" + ], "description": "LLM provider" }, "model": { "type": "string", - "description": "Model identifier" + "minLength": 1, + "description": "Model identifier for the selected provider. Required — releasekit ships no default, so enabling LLM enhancement needs an explicit, current model (a missing model fails config validation)." }, "baseURL": { "type": "string", - "description": "Custom API base URL" + "description": "Custom API base URL. Required for the openai-compatible provider." }, "apiKey": { "type": "string",