diff --git a/docs/src/content/docs/configuration.md b/docs/src/content/docs/configuration.md index 6258a934..3115e704 100644 --- a/docs/src/content/docs/configuration.md +++ b/docs/src/content/docs/configuration.md @@ -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. @@ -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 diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index 58c0f1ad..98b89c8e 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -44,6 +44,7 @@ describe('validateConfiguration', () => { filePath: 'CHANGELOG.md', policy: 'auto', scopeGrouping: true, + includePaths: ['apps/cli-docs'], }, }; @@ -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', () => { diff --git a/src/config.ts b/src/config.ts index 02188395..65e8b4c0 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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'; @@ -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) { @@ -942,6 +945,9 @@ export function getChangelogConfig(): NormalizedChangelogConfig { if (config.changelog.scopeGrouping !== undefined) { scopeGrouping = config.changelog.scopeGrouping; } + if (config.changelog.includePaths !== undefined) { + includePaths = config.changelog.includePaths; + } } } @@ -949,6 +955,7 @@ export function getChangelogConfig(): NormalizedChangelogConfig { filePath, policy, scopeGrouping, + includePaths, }; } diff --git a/src/schemas/project_config.ts b/src/schemas/project_config.ts index 608c0a75..3842fcc5 100644 --- a/src/schemas/project_config.ts +++ b/src/schemas/project_config.ts @@ -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(), }), ]); diff --git a/src/utils/__tests__/changelog-generate.test.ts b/src/utils/__tests__/changelog-generate.test.ts index 8c7a8273..786aa2d4 100644 --- a/src/utils/__tests__/changelog-generate.test.ts +++ b/src/utils/__tests__/changelog-generate.test.ts @@ -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 // ============================================================================ diff --git a/src/utils/__tests__/git.test.ts b/src/utils/__tests__/git.test.ts index 01e4dd5a..c4f01b09 100644 --- a/src/utils/__tests__/git.test.ts +++ b/src/utils/__tests__/git.test.ts @@ -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 { @@ -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 () => { @@ -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', () => { diff --git a/src/utils/changelog.ts b/src/utils/changelog.ts index 61f68ef3..b7e36019 100644 --- a/src/utils/changelog.ts +++ b/src/utils/changelog.ts @@ -7,6 +7,7 @@ import { captureException, withScope } from '@sentry/node'; import { logger } from '../logger'; import { + getActiveWorkspace, getConfigFileDir, getGlobalGitHubConfig, getChangelogConfig, @@ -1485,7 +1486,7 @@ interface RawChangelogResult { const changesetCache = new Map>(); function getChangesetCacheKey(rev: string, maxLeftovers: number): string { - return `${rev}:${maxLeftovers}`; + return JSON.stringify([getActiveWorkspace(), rev, maxLeftovers]); } /** diff --git a/src/utils/git.ts b/src/utils/git.ts index 00785da3..39859b69 100644 --- a/src/utils/git.ts +++ b/src/utils/git.ts @@ -5,11 +5,18 @@ import simpleGit, { type StatusResult, } from 'simple-git'; -import { getConfigFileDir } from '../config'; +import { + getActiveWorkspace, + getChangelogConfig, + getConfigFileDir, + getWorkspaceNames, +} from '../config'; import { ConfigurationError } from './errors'; import { createDryRunGit } from './dryRun'; import { logger } from '../logger'; import { distance as levenshtein } from 'fastest-levenshtein'; +import path from 'node:path'; +import { discoverWorkspaces } from './workspaces'; export interface GitChange { hash: string; @@ -106,12 +113,81 @@ export async function getChangesSince( const { all: commits } = rev ? await git.log({ ...gitLogArgs, from: rev }) : await git.log(['--no-merges', until || 'HEAD', '--', '.']); - return commits.map(commit => ({ + const changes = commits.map(commit => ({ hash: commit.hash, title: commit.message, body: commit.body, pr: commit.message.match(PRExtractor)?.[0] || null, })); + const workspace = getActiveWorkspace(); + if (!workspace || changes.length === 0) { + return changes; + } + + const root = getConfigFileDir() || process.cwd(); + const discovered = await discoverWorkspaces(root); + const ownedPaths = new Set(getWorkspaceNames()); + for (const pkg of discovered.packages) { + const relative = path + .relative(root, pkg.location) + .split(path.sep) + .join('/'); + if (relative === '' || relative === '..' || relative.startsWith('../')) { + throw new ConfigurationError( + `Workspace package is outside the repository: ${pkg.name}`, + ); + } + ownedPaths.add(relative); + } + const orderedOwners = [...ownedPaths].sort( + (left, right) => right.length - left.length, + ); + const { includePaths } = getChangelogConfig(); + const contains = (directory: string, file: string) => + file === directory || file.startsWith(`${directory}/`); + + // NUL-delimited filenames remain unambiguous even when they contain spaces + // or newlines. Disabling renames includes both sides of a moved file. + const raw = await git.raw([ + 'log', + '--no-merges', + '--no-renames', + '--format=%x00%x00%H%x00', + '--name-only', + '-z', + '--end-of-options', + rev ? `${rev}..${until || 'HEAD'}` : until || 'HEAD', + '--', + '.', + ]); + const sections = raw.split(/\0\0([a-f0-9]{40})\0\0\n/g); + if (sections[0] !== '' || sections.length !== commits.length * 2 + 1) { + throw new Error('Could not parse the changelog commit paths'); + } + const pathsByHash = new Map(); + for (const index of commits.keys()) { + const sectionIndex = index * 2 + 1; + const hash = sections[sectionIndex]; + const names = sections[sectionIndex + 1]; + if (hash === undefined || names === undefined || !names.endsWith('\0')) { + throw new Error('Could not parse the changelog commit paths'); + } + pathsByHash.set(hash, names.slice(0, -1).split('\0')); + } + + return changes.filter(commit => { + const files = pathsByHash.get(commit.hash); + if (!files) { + throw new Error(`Missing changelog paths for commit ${commit.hash}`); + } + return files.some(file => { + if (includePaths.some(directory => contains(directory, file))) { + return true; + } + const owner = orderedOwners.find(directory => contains(directory, file)); + return owner === undefined || owner === workspace; + }); + }); } export function stripRemoteName(