Skip to content

Commit b63f217

Browse files
BYKGPT-6 Sol (OpenAI)
andauthored
feat(changelog): Scope commits to selected workspace (#887)
Include shared commits in each workspace release, exclude commits owned only by other workspaces, and support optional extra paths. Use a portable temporary directory in regression tests. Co-Authored-By: GPT-6 Sol (OpenAI) <agent@openai.com>
1 parent 94a7332 commit b63f217

8 files changed

Lines changed: 304 additions & 10 deletions

File tree

‎docs/src/content/docs/configuration.md‎

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -396,6 +396,31 @@ Example output with scope grouping:
396396
- General improvement by @dave in [#4](https://github.com/...)
397397
```
398398
399+
### Workspace-aware changelogs
400+
401+
When you select a Craft release workspace, automatic changelogs include commits
402+
that change files in that workspace and commits that change shared files outside
403+
all workspaces. Commits confined to another workspace are excluded. Craft detects
404+
package ownership from pnpm, npm, or Yarn workspaces as well as the release
405+
workspaces in `.craft.yml`. Commits spanning workspaces appear in each affected
406+
workspace's changelog. This also scopes automatic version detection; single-unit
407+
repositories keep their existing behavior.
408+
409+
If a release includes files from another workspace, add its directory to
410+
`includePaths` on that release unit. Paths are relative to the repository root,
411+
match whole directory names, and cannot escape the repository:
412+
413+
```yaml
414+
workspaces:
415+
packages/cli:
416+
changelog:
417+
policy: auto
418+
includePaths:
419+
- apps/cli-docs
420+
```
421+
422+
Most workspaces need no `includePaths` setting.
423+
399424
### Title Stripping (Default Behavior)
400425

401426
By default, conventional commit prefixes are stripped from changelog entries.
@@ -455,12 +480,13 @@ changelog:
455480

456481
### Configuration Options
457482

458-
| Option | Description |
459-
| ------------------------- | ------------------------------------------------------- |
460-
| `changelog` | Path to changelog file (string) OR configuration object |
461-
| `changelog.filePath` | Path to changelog file. Default: `CHANGELOG.md` |
462-
| `changelog.policy` | Mode: `none`, `simple`, or `auto`. Default: `none` |
463-
| `changelog.scopeGrouping` | Enable scope-based grouping. Default: `true` |
483+
| Option | Description |
484+
| ------------------------- | ---------------------------------------------------------------------------- |
485+
| `changelog` | Path to changelog file (string) OR configuration object |
486+
| `changelog.filePath` | Path to changelog file. Default: `CHANGELOG.md` |
487+
| `changelog.policy` | Mode: `none`, `simple`, or `auto`. Default: `none` |
488+
| `changelog.scopeGrouping` | Enable scope-based grouping. Default: `true` |
489+
| `changelog.includePaths` | Extra workspace directories to include in automatic changelogs. Default: `[]` |
464490

465491
## Versioning
466492

‎src/__tests__/config.test.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ describe('validateConfiguration', () => {
4444
filePath: 'CHANGELOG.md',
4545
policy: 'auto',
4646
scopeGrouping: true,
47+
includePaths: ['apps/cli-docs'],
4748
},
4849
};
4950

@@ -89,6 +90,19 @@ describe('validateConfiguration', () => {
8990
validateConfiguration({ changelog: { policy: 'invalid' } }),
9091
).toThrow(/changelog/);
9192
});
93+
94+
test.each([
95+
'../outside',
96+
'/absolute',
97+
'apps//docs',
98+
'apps/../docs',
99+
'-option',
100+
'apps\\docs',
101+
])('rejects unsafe changelog include path %s', includePath => {
102+
expect(() =>
103+
validateConfiguration({ changelog: { includePaths: [includePath] } }),
104+
).toThrow(/changelog/);
105+
});
92106
});
93107

94108
describe('CraftProjectConfigSchema', () => {

‎src/config.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -900,6 +900,8 @@ export interface NormalizedChangelogConfig {
900900
policy: ChangelogPolicy;
901901
/** Whether to group entries by conventional commit scope */
902902
scopeGrouping: boolean;
903+
/** Additional workspace paths to include in automatic changelogs */
904+
includePaths: string[];
903905
}
904906

905907
const DEFAULT_CHANGELOG_FILE_PATH = 'CHANGELOG.md';
@@ -917,6 +919,7 @@ export function getChangelogConfig(): NormalizedChangelogConfig {
917919
let filePath = DEFAULT_CHANGELOG_FILE_PATH;
918920
let policy = ChangelogPolicy.None;
919921
let scopeGrouping = true;
922+
let includePaths: string[] = [];
920923

921924
// Handle legacy changelogPolicy (deprecated)
922925
if (config.changelogPolicy !== undefined) {
@@ -942,13 +945,17 @@ export function getChangelogConfig(): NormalizedChangelogConfig {
942945
if (config.changelog.scopeGrouping !== undefined) {
943946
scopeGrouping = config.changelog.scopeGrouping;
944947
}
948+
if (config.changelog.includePaths !== undefined) {
949+
includePaths = config.changelog.includePaths;
950+
}
945951
}
946952
}
947953

948954
return {
949955
filePath,
950956
policy,
951957
scopeGrouping,
958+
includePaths,
952959
};
953960
}
954961

‎src/schemas/project_config.ts‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,28 @@ export const ChangelogConfigSchema = z.union([
162162
filePath: z.string().optional(),
163163
policy: z.enum(['auto', 'simple', 'none']).optional(),
164164
scopeGrouping: z.boolean().optional(),
165+
includePaths: z
166+
.array(
167+
z
168+
.string()
169+
.refine(
170+
name =>
171+
name
172+
.split('/')
173+
.every(
174+
segment =>
175+
/^[A-Za-z0-9_.-]+$/.test(segment) &&
176+
segment !== '.' &&
177+
segment !== '..' &&
178+
segment !== '__proto__' &&
179+
!segment.startsWith('-'),
180+
),
181+
{
182+
message: 'Changelog paths must use safe relative ASCII segments.',
183+
},
184+
),
185+
)
186+
.optional(),
165187
}),
166188
]);
167189

‎src/utils/__tests__/changelog-generate.test.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,24 @@ describe('generateChangesetFromGit', () => {
136136
}
137137
}
138138

139+
it('keeps cached changelogs separate for each selected workspace', async () => {
140+
try {
141+
config.setActiveWorkspace('packages/cli');
142+
setup([{ hash: 'abcdef1234567890', title: 'fix: CLI', body: '' }], null);
143+
const cli = await generateChangesetFromGit(dummyGit, '1.0.0');
144+
145+
config.setActiveWorkspace('packages/mcp');
146+
setup([{ hash: 'bcdef1234567890a', title: 'fix: MCP', body: '' }], null);
147+
const mcp = await generateChangesetFromGit(dummyGit, '1.0.0');
148+
149+
expect(cli.changelog).toContain('CLI');
150+
expect(mcp.changelog).toContain('MCP');
151+
expect(mockGetChangesSince).toHaveBeenCalledTimes(2);
152+
} finally {
153+
config.setActiveWorkspace(undefined);
154+
}
155+
});
156+
139157
// ============================================================================
140158
// Basic output formatting tests - use snapshots
141159
// ============================================================================

‎src/utils/__tests__/git.test.ts‎

Lines changed: 131 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { vi } from 'vitest';
22
import { execFileSync } from 'node:child_process';
3-
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
3+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
44
import { tmpdir } from 'node:os';
55
import { join } from 'node:path';
66
import {
@@ -12,6 +12,7 @@ import {
1212
} from '../git';
1313
import * as loggerModule from '../../logger';
1414
import type { StatusResult } from 'simple-git';
15+
import { setActiveWorkspace } from '../../config';
1516

1617
describe('getLatestTag', () => {
1718
it('returns latest tag in the repo by calling `git describe`', async () => {
@@ -129,6 +130,135 @@ describe('getChangesSince', () => {
129130
rmSync(dir, { recursive: true, force: true });
130131
}
131132
});
133+
134+
it('includes shared and selected workspace changes, with optional related paths', async () => {
135+
const dir = mkdtempSync(join(tmpdir(), 'craft-changelog-workspaces-'));
136+
const previousDirectory = process.cwd();
137+
const git = (...args: string[]) =>
138+
execFileSync('git', ['-C', dir, ...args], {
139+
env: {
140+
...process.env,
141+
GIT_CONFIG_GLOBAL: '/dev/null',
142+
GIT_CONFIG_NOSYSTEM: '1',
143+
},
144+
});
145+
const commit = (
146+
file: string,
147+
title: string,
148+
additionalFiles: string[] = [],
149+
) => {
150+
writeFileSync(join(dir, file), title);
151+
git('add', '--', file, ...additionalFiles);
152+
git('-c', 'commit.gpgsign=false', 'commit', '--quiet', '-m', title);
153+
};
154+
155+
try {
156+
git('init', '--quiet');
157+
git('config', 'user.name', 'Craft Test');
158+
git('config', 'user.email', 'craft-test@example.com');
159+
for (const workspace of [
160+
'packages/cli',
161+
'packages/mcp',
162+
'apps/cli-docs',
163+
'apps/cli-docs-extra',
164+
]) {
165+
mkdirSync(join(dir, workspace), { recursive: true });
166+
writeFileSync(
167+
join(dir, workspace, 'package.json'),
168+
JSON.stringify({ name: workspace }),
169+
);
170+
}
171+
mkdirSync(join(dir, 'docs'));
172+
writeFileSync(
173+
join(dir, '.craft.yml'),
174+
'minVersion: 2.29.0\nworkspaces:\n packages/cli:\n changelog:\n policy: auto\n packages/mcp: {}\n',
175+
);
176+
writeFileSync(
177+
join(dir, 'pnpm-workspace.yaml'),
178+
'packages:\n - packages/*\n - apps/*\n',
179+
);
180+
git('add', '.');
181+
git('-c', 'commit.gpgsign=false', 'commit', '--quiet', '-m', 'initial');
182+
git('tag', 'cli@0.1.0');
183+
commit('packages/cli/cli.ts', 'cli change');
184+
commit('packages/mcp/mcp.ts', 'mcp change');
185+
commit('apps/cli-docs/site.ts', 'cli docs change');
186+
commit('apps/cli-docs-extra/site.ts', 'other docs change');
187+
commit('docs/readme.md', 'unowned docs change');
188+
commit('root.txt', 'root change');
189+
writeFileSync(join(dir, 'packages/cli/cli.ts'), 'shared change');
190+
commit('packages/mcp/mcp.ts', 'both change', ['packages/cli/cli.ts']);
191+
192+
process.chdir(dir);
193+
setActiveWorkspace('packages/cli');
194+
const client = createGitClient(dir);
195+
const titles = async () =>
196+
(await getChangesSince(client, 'cli@0.1.0')).map(({ title }) => title);
197+
198+
expect(await titles()).toEqual([
199+
'both change',
200+
'root change',
201+
'unowned docs change',
202+
'cli change',
203+
]);
204+
expect(
205+
(await getChangesSince(client, '', 'cli@0.1.0')).map(
206+
({ title }) => title,
207+
),
208+
).toEqual(['initial']);
209+
210+
setActiveWorkspace('packages/mcp');
211+
expect(await titles()).toEqual([
212+
'both change',
213+
'root change',
214+
'unowned docs change',
215+
'mcp change',
216+
]);
217+
setActiveWorkspace('packages/cli');
218+
219+
writeFileSync(
220+
join(dir, '.craft.yml'),
221+
'minVersion: 2.29.0\nworkspaces:\n packages/cli:\n changelog:\n policy: auto\n includePaths:\n - apps/cli-docs\n packages/mcp: {}\n',
222+
);
223+
setActiveWorkspace('packages/cli');
224+
expect(await titles()).toEqual([
225+
'both change',
226+
'root change',
227+
'unowned docs change',
228+
'cli docs change',
229+
'cli change',
230+
]);
231+
232+
git('mv', 'packages/cli/cli.ts', 'packages/mcp/moved.ts');
233+
git(
234+
'-c',
235+
'commit.gpgsign=false',
236+
'commit',
237+
'--quiet',
238+
'-m',
239+
'moved across workspaces',
240+
);
241+
commit('packages/cli/line\nbreak.ts', 'newline filename');
242+
expect(await titles()).toEqual([
243+
'newline filename',
244+
'moved across workspaces',
245+
'both change',
246+
'root change',
247+
'unowned docs change',
248+
'cli docs change',
249+
'cli change',
250+
]);
251+
setActiveWorkspace('packages/mcp');
252+
expect((await titles()).slice(0, 2)).toEqual([
253+
'moved across workspaces',
254+
'both change',
255+
]);
256+
} finally {
257+
setActiveWorkspace(undefined);
258+
process.chdir(previousDirectory);
259+
rmSync(dir, { recursive: true, force: true });
260+
}
261+
});
132262
});
133263

134264
describe('isRepoDirty', () => {

‎src/utils/changelog.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { captureException, withScope } from '@sentry/node';
77
import { logger } from '../logger';
88

99
import {
10+
getActiveWorkspace,
1011
getConfigFileDir,
1112
getGlobalGitHubConfig,
1213
getChangelogConfig,
@@ -1485,7 +1486,7 @@ interface RawChangelogResult {
14851486
const changesetCache = new Map<string, Promise<ChangelogResult>>();
14861487

14871488
function getChangesetCacheKey(rev: string, maxLeftovers: number): string {
1488-
return `${rev}:${maxLeftovers}`;
1489+
return JSON.stringify([getActiveWorkspace(), rev, maxLeftovers]);
14891490
}
14901491

14911492
/**

0 commit comments

Comments
 (0)