diff --git a/README.md b/README.md index 2d14f86..94931e6 100644 --- a/README.md +++ b/README.md @@ -280,7 +280,9 @@ Which package a commit belongs to comes from its conventional-commit scope. By d @nuxt/schema: schema ``` -Commits with no scope, or a scope that doesn't match a package (`docs:`, `feat(playground):`), don't bump anything. +A comma-separated scope routes to every package it names, so `fix(fontaine,fontless):` bumps both and appears in both changelog sections. Commits with no scope, or a scope that doesn't match a package (`docs:`, `feat(playground):`), don't bump anything. + +Within a package's changelog section the scope is dropped when it names only that package: the heading already says which package it is. Multi-package scopes keep their full list. Bumping a package also releases anything that depends on it. (Only `dependencies`, `peerDependencies` and `optionalDependencies` count.) diff --git a/scripts/_workspaces.ts b/scripts/_workspaces.ts index 23e0717..344d62a 100644 --- a/scripts/_workspaces.ts +++ b/scripts/_workspaces.ts @@ -240,6 +240,13 @@ export function parseScopesInput (raw: string): Map { export interface ScopeMap { /** Resolve a commit scope to the workspace that owns it, or `null`. */ resolve (scope: string): Workspace | null + /** + * Resolve a raw commit scope string, which may list several + * comma-separated scopes (`fontaine,fontless`), to every workspace it + * routes to. Unknown scopes are ignored; the result is deduplicated and + * empty when nothing matches. + */ + resolveAll (scope: string): Workspace[] /** Every (workspace, declared scopes) pair, in the input workspace order. */ entries (): Array<{ workspace: Workspace, scopes: string[] }> } @@ -291,6 +298,14 @@ export function buildScopeMap ( return { resolve: scope => inverse.get(scope) ?? null, + resolveAll: (scope) => { + const matched = new Set() + for (const part of scope.split(',')) { + const ws = inverse.get(part.trim()) + if (ws) matched.add(ws) + } + return [...matched] + }, entries: () => perWorkspace, } } diff --git a/scripts/update-changelog.ts b/scripts/update-changelog.ts index 62a5cee..2973ed6 100644 --- a/scripts/update-changelog.ts +++ b/scripts/update-changelog.ts @@ -358,9 +358,9 @@ export function computeIndependentPlan (opts: { const routed = new Map(opts.workspaces.map(ws => [ws.name, []])) const unrouted: Commit[] = [] for (const commit of opts.commits) { - const ws = commit.scope ? scopeMap.resolve(commit.scope) : null - if (ws) routed.get(ws.name)!.push(commit) - else unrouted.push(commit) + const matched = commit.scope ? scopeMap.resolveAll(commit.scope) : [] + if (!matched.length) unrouted.push(commit) + for (const ws of matched) routed.get(ws.name)!.push(commit) } const lockstepTag = latestLockstepTag(opts.tags) diff --git a/test/_workspaces.test.ts b/test/_workspaces.test.ts index 3a902ba..6575f62 100644 --- a/test/_workspaces.test.ts +++ b/test/_workspaces.test.ts @@ -363,6 +363,18 @@ describe('buildScopeMap', () => { ]))).toThrowError(/"shared" is claimed by both/) }) + it('resolves comma-separated scopes to every named workspace', () => { + writePackage('packages/a', { name: '@x/a', version: '1.0.0' }) + writePackage('packages/b', { name: '@x/b', version: '1.0.0' }) + const workspaces = resolveWorkspaces(tmp, 'packages/*') + + const map = buildScopeMap(workspaces, new Map()) + expect(map.resolveAll('a, b')).toEqual(workspaces) + expect(map.resolveAll('a,a')).toEqual([workspaces[0]]) + expect(map.resolveAll('a,docs')).toEqual([workspaces[0]]) + expect(map.resolveAll('docs')).toEqual([]) + }) + it('exposes entries in workspace order', () => { writePackage('packages/a', { name: '@x/a', version: '1.0.0' }) writePackage('packages/b', { name: '@x/b', version: '1.0.0' }) diff --git a/test/update-changelog.test.ts b/test/update-changelog.test.ts index 90aea83..cc9c9d5 100644 --- a/test/update-changelog.test.ts +++ b/test/update-changelog.test.ts @@ -555,13 +555,20 @@ describe('independent release PR', () => { expect(body).not.toContain('**fontaine:**') }) - it('keeps the full scope list for multi-package commits', () => { - const changelog = formatChangelog( - [commit('fix(fontaine,fontless): shared fix')], - { owner: 'unjs', repo: 'fontaine', fromRef: null, toRef: 'main', packageScopes: ['fontaine'] }, - ) + it('routes a comma-separated scope to every named package, keeping the full scope list', () => { + const plan = fontainePlan([commit('fix(fontaine,fontless): shared fix')]) + const body = buildIndependentBody(plan, bodyOpts) + + expect(plan.unrouted).toHaveLength(0) + expect(plan.releases.map(r => `${r.name}@${r.newVersion}`)).toEqual(['fontaine@0.8.1', 'fontless@0.2.2']) + expect(body.match(/\*\*fontaine,fontless:\*\* shared fix/g)).toHaveLength(2) + }) + + it('ignores unknown scopes alongside a known one', () => { + const plan = fontainePlan([commit('fix(fontaine,docs): partly known')]) - expect(changelog).toContain('**fontaine,fontless:** shared fix') + expect(plan.unrouted).toHaveLength(0) + expect(plan.releases.find(r => r.name === 'fontaine')?.commits).toHaveLength(1) }) it('keeps a scope the section does not own, and renders unscoped commits unchanged', () => {