Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 32 additions & 6 deletions docs/src/content/docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,31 @@ Example output with scope grouping:
- General improvement by @dave in [#4](https://github.com/...)
```

### Workspace-aware changelogs

When you select a Craft release workspace, automatic changelogs include commits
that change files in that workspace and commits that change shared files outside
all workspaces. Commits confined to another workspace are excluded. Craft detects
package ownership from pnpm, npm, or Yarn workspaces as well as the release
workspaces in `.craft.yml`. Commits spanning workspaces appear in each affected
workspace's changelog. This also scopes automatic version detection; single-unit
repositories keep their existing behavior.

If a release includes files from another workspace, add its directory to
`includePaths` on that release unit. Paths are relative to the repository root,
match whole directory names, and cannot escape the repository:

```yaml
workspaces:
packages/cli:
changelog:
policy: auto
includePaths:
- apps/cli-docs
```

Most workspaces need no `includePaths` setting.

### Title Stripping (Default Behavior)

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

### Configuration Options

| Option | Description |
| ------------------------- | ------------------------------------------------------- |
| `changelog` | Path to changelog file (string) OR configuration object |
| `changelog.filePath` | Path to changelog file. Default: `CHANGELOG.md` |
| `changelog.policy` | Mode: `none`, `simple`, or `auto`. Default: `none` |
| `changelog.scopeGrouping` | Enable scope-based grouping. Default: `true` |
| Option | Description |
| ------------------------- | ---------------------------------------------------------------------------- |
| `changelog` | Path to changelog file (string) OR configuration object |
| `changelog.filePath` | Path to changelog file. Default: `CHANGELOG.md` |
| `changelog.policy` | Mode: `none`, `simple`, or `auto`. Default: `none` |
| `changelog.scopeGrouping` | Enable scope-based grouping. Default: `true` |
| `changelog.includePaths` | Extra workspace directories to include in automatic changelogs. Default: `[]` |

## Versioning

Expand Down
14 changes: 14 additions & 0 deletions src/__tests__/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ describe('validateConfiguration', () => {
filePath: 'CHANGELOG.md',
policy: 'auto',
scopeGrouping: true,
includePaths: ['apps/cli-docs'],
},
};

Expand Down Expand Up @@ -89,6 +90,19 @@ describe('validateConfiguration', () => {
validateConfiguration({ changelog: { policy: 'invalid' } }),
).toThrow(/changelog/);
});

test.each([
'../outside',
'/absolute',
'apps//docs',
'apps/../docs',
'-option',
'apps\\docs',
])('rejects unsafe changelog include path %s', includePath => {
expect(() =>
validateConfiguration({ changelog: { includePaths: [includePath] } }),
).toThrow(/changelog/);
});
});

describe('CraftProjectConfigSchema', () => {
Expand Down
7 changes: 7 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -900,6 +900,8 @@ export interface NormalizedChangelogConfig {
policy: ChangelogPolicy;
/** Whether to group entries by conventional commit scope */
scopeGrouping: boolean;
/** Additional workspace paths to include in automatic changelogs */
includePaths: string[];
}

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

// Handle legacy changelogPolicy (deprecated)
if (config.changelogPolicy !== undefined) {
Expand All @@ -942,13 +945,17 @@ export function getChangelogConfig(): NormalizedChangelogConfig {
if (config.changelog.scopeGrouping !== undefined) {
scopeGrouping = config.changelog.scopeGrouping;
}
if (config.changelog.includePaths !== undefined) {
includePaths = config.changelog.includePaths;
}
}
}

return {
filePath,
policy,
scopeGrouping,
includePaths,
};
}

Expand Down
22 changes: 22 additions & 0 deletions src/schemas/project_config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,28 @@ export const ChangelogConfigSchema = z.union([
filePath: z.string().optional(),
policy: z.enum(['auto', 'simple', 'none']).optional(),
scopeGrouping: z.boolean().optional(),
includePaths: z
.array(
z
.string()
.refine(
name =>
name
.split('/')
.every(
segment =>
/^[A-Za-z0-9_.-]+$/.test(segment) &&
segment !== '.' &&
segment !== '..' &&
segment !== '__proto__' &&
!segment.startsWith('-'),
),
{
message: 'Changelog paths must use safe relative ASCII segments.',
},
),
)
.optional(),
}),
]);

Expand Down
18 changes: 18 additions & 0 deletions src/utils/__tests__/changelog-generate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,24 @@ describe('generateChangesetFromGit', () => {
}
}

it('keeps cached changelogs separate for each selected workspace', async () => {
try {
config.setActiveWorkspace('packages/cli');
setup([{ hash: 'abcdef1234567890', title: 'fix: CLI', body: '' }], null);
const cli = await generateChangesetFromGit(dummyGit, '1.0.0');

config.setActiveWorkspace('packages/mcp');
setup([{ hash: 'bcdef1234567890a', title: 'fix: MCP', body: '' }], null);
const mcp = await generateChangesetFromGit(dummyGit, '1.0.0');

expect(cli.changelog).toContain('CLI');
expect(mcp.changelog).toContain('MCP');
expect(mockGetChangesSince).toHaveBeenCalledTimes(2);
} finally {
config.setActiveWorkspace(undefined);
}
});

// ============================================================================
// Basic output formatting tests - use snapshots
// ============================================================================
Expand Down
132 changes: 131 additions & 1 deletion src/utils/__tests__/git.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { vi } from 'vitest';
import { execFileSync } from 'node:child_process';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
Expand All @@ -12,6 +12,7 @@ import {
} from '../git';
import * as loggerModule from '../../logger';
import type { StatusResult } from 'simple-git';
import { setActiveWorkspace } from '../../config';

describe('getLatestTag', () => {
it('returns latest tag in the repo by calling `git describe`', async () => {
Expand Down Expand Up @@ -129,6 +130,135 @@ describe('getChangesSince', () => {
rmSync(dir, { recursive: true, force: true });
}
});

it('includes shared and selected workspace changes, with optional related paths', async () => {
const dir = mkdtempSync(join(tmpdir(), 'craft-changelog-workspaces-'));
const previousDirectory = process.cwd();
const git = (...args: string[]) =>
execFileSync('git', ['-C', dir, ...args], {
env: {
...process.env,
GIT_CONFIG_GLOBAL: '/dev/null',
GIT_CONFIG_NOSYSTEM: '1',
},
});
const commit = (
file: string,
title: string,
additionalFiles: string[] = [],
) => {
writeFileSync(join(dir, file), title);
git('add', '--', file, ...additionalFiles);
git('-c', 'commit.gpgsign=false', 'commit', '--quiet', '-m', title);
};

try {
git('init', '--quiet');
git('config', 'user.name', 'Craft Test');
git('config', 'user.email', 'craft-test@example.com');
for (const workspace of [
'packages/cli',
'packages/mcp',
'apps/cli-docs',
'apps/cli-docs-extra',
]) {
mkdirSync(join(dir, workspace), { recursive: true });
writeFileSync(
join(dir, workspace, 'package.json'),
JSON.stringify({ name: workspace }),
);
}
mkdirSync(join(dir, 'docs'));
writeFileSync(
join(dir, '.craft.yml'),
'minVersion: 2.29.0\nworkspaces:\n packages/cli:\n changelog:\n policy: auto\n packages/mcp: {}\n',
);
writeFileSync(
join(dir, 'pnpm-workspace.yaml'),
'packages:\n - packages/*\n - apps/*\n',
);
git('add', '.');
git('-c', 'commit.gpgsign=false', 'commit', '--quiet', '-m', 'initial');
git('tag', 'cli@0.1.0');
commit('packages/cli/cli.ts', 'cli change');
commit('packages/mcp/mcp.ts', 'mcp change');
commit('apps/cli-docs/site.ts', 'cli docs change');
commit('apps/cli-docs-extra/site.ts', 'other docs change');
commit('docs/readme.md', 'unowned docs change');
commit('root.txt', 'root change');
writeFileSync(join(dir, 'packages/cli/cli.ts'), 'shared change');
commit('packages/mcp/mcp.ts', 'both change', ['packages/cli/cli.ts']);

process.chdir(dir);
setActiveWorkspace('packages/cli');
const client = createGitClient(dir);
const titles = async () =>
(await getChangesSince(client, 'cli@0.1.0')).map(({ title }) => title);

expect(await titles()).toEqual([
'both change',
'root change',
'unowned docs change',
'cli change',
]);
expect(
(await getChangesSince(client, '', 'cli@0.1.0')).map(
({ title }) => title,
),
).toEqual(['initial']);

setActiveWorkspace('packages/mcp');
expect(await titles()).toEqual([
'both change',
'root change',
'unowned docs change',
'mcp change',
]);
setActiveWorkspace('packages/cli');

writeFileSync(
join(dir, '.craft.yml'),
'minVersion: 2.29.0\nworkspaces:\n packages/cli:\n changelog:\n policy: auto\n includePaths:\n - apps/cli-docs\n packages/mcp: {}\n',
);
setActiveWorkspace('packages/cli');
expect(await titles()).toEqual([
'both change',
'root change',
'unowned docs change',
'cli docs change',
'cli change',
]);

git('mv', 'packages/cli/cli.ts', 'packages/mcp/moved.ts');
git(
'-c',
'commit.gpgsign=false',
'commit',
'--quiet',
'-m',
'moved across workspaces',
);
commit('packages/cli/line\nbreak.ts', 'newline filename');
expect(await titles()).toEqual([
'newline filename',
'moved across workspaces',
'both change',
'root change',
'unowned docs change',
'cli docs change',
'cli change',
]);
setActiveWorkspace('packages/mcp');
expect((await titles()).slice(0, 2)).toEqual([
'moved across workspaces',
'both change',
]);
} finally {
setActiveWorkspace(undefined);
process.chdir(previousDirectory);
rmSync(dir, { recursive: true, force: true });
}
});
});

describe('isRepoDirty', () => {
Expand Down
3 changes: 2 additions & 1 deletion src/utils/changelog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { captureException, withScope } from '@sentry/node';
import { logger } from '../logger';

import {
getActiveWorkspace,
getConfigFileDir,
getGlobalGitHubConfig,
getChangelogConfig,
Expand Down Expand Up @@ -1485,7 +1486,7 @@ interface RawChangelogResult {
const changesetCache = new Map<string, Promise<ChangelogResult>>();

function getChangesetCacheKey(rev: string, maxLeftovers: number): string {
return `${rev}:${maxLeftovers}`;
return JSON.stringify([getActiveWorkspace(), rev, maxLeftovers]);
}

/**
Expand Down
Loading
Loading