diff --git a/docs/src/content/docs/configuration.md b/docs/src/content/docs/configuration.md index 3115e704..7f013b41 100644 --- a/docs/src/content/docs/configuration.md +++ b/docs/src/content/docs/configuration.md @@ -483,7 +483,7 @@ changelog: | Option | Description | | ------------------------- | ---------------------------------------------------------------------------- | | `changelog` | Path to changelog file (string) OR configuration object | -| `changelog.filePath` | Path to changelog file. Default: `CHANGELOG.md` | +| `changelog.filePath` | Path to changelog file. Defaults to `/CHANGELOG.md` for a directory workspace, otherwise `CHANGELOG.md`. Explicit paths take precedence. | | `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: `[]` | diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index 98b89c8e..f32163b4 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -10,6 +10,7 @@ import { join } from 'path'; import { getGitTagPrefix, + getChangelogConfig, loadConfigurationFromString, validateConfiguration, setActiveWorkspace, @@ -21,6 +22,8 @@ import { } from '../config'; import { CraftProjectConfigSchema } from '../schemas/project_config'; import { logger } from '../logger'; +import { GitHubTarget } from '../targets/github'; +import { NoneArtifactProvider } from '../artifact_providers/none'; describe('validateConfiguration', () => { test('parses minimal configuration', () => { @@ -316,6 +319,7 @@ describe('workspaces', () => { rmSync(directory, { recursive: true, force: true }); } setActiveWorkspace(undefined); + vi.unstubAllEnvs(); vi.restoreAllMocks(); }); @@ -362,6 +366,97 @@ describe('workspaces', () => { expect(config.workspaces).toBeUndefined(); }); + test('defaults a directory workspace changelog to its own file', () => { + const directory = mkdtempSync(join(tmpdir(), 'craft-workspace-changelog-')); + temporaryDirectories.push(directory); + mkdirSync(join(directory, 'packages', 'cli'), { recursive: true }); + writeFileSync(join(directory, 'CHANGELOG.md'), '# Root changelog\n'); + writeFileSync( + join(directory, 'packages', 'cli', 'CHANGELOG.md'), + '# CLI changelog\n', + ); + writeFileSync( + join(directory, '.craft.yml'), + [ + `minVersion: ${WORKSPACES_MIN_VERSION}`, + 'workspaces:', + ' packages/cli:', + ' changelog:', + ' policy: auto', + ].join('\n'), + ); + process.chdir(directory); + + setActiveWorkspace('packages/cli'); + expect(getChangelogConfig().filePath).toBe('packages/cli/CHANGELOG.md'); + vi.stubEnv('GITHUB_TOKEN', 'test-token'); + const target = new GitHubTarget( + { name: 'github' }, + new NoneArtifactProvider(), + { owner: 'test-owner', repo: 'test-repo' }, + ); + expect(target.githubConfig.changelog).toBe('packages/cli/CHANGELOG.md'); + }); + + test('uses a workspace changelog path before the file is created', () => { + const directory = mkdtempSync(join(tmpdir(), 'craft-workspace-changelog-')); + temporaryDirectories.push(directory); + mkdirSync(join(directory, 'packages', 'cli'), { recursive: true }); + writeFileSync( + join(directory, '.craft.yml'), + `minVersion: ${WORKSPACES_MIN_VERSION}\nworkspaces:\n packages/cli: {}\n`, + ); + process.chdir(directory); + + setActiveWorkspace('packages/cli'); + expect(getChangelogConfig().filePath).toBe('packages/cli/CHANGELOG.md'); + }); + + test('preserves an explicitly configured root changelog for a workspace', () => { + const directory = mkdtempSync(join(tmpdir(), 'craft-workspace-changelog-')); + temporaryDirectories.push(directory); + mkdirSync(join(directory, 'packages', 'cli'), { recursive: true }); + writeFileSync( + join(directory, '.craft.yml'), + `minVersion: ${WORKSPACES_MIN_VERSION}\nchangelog: CHANGELOG.md\nworkspaces:\n packages/cli: {}\n`, + ); + process.chdir(directory); + + setActiveWorkspace('packages/cli'); + expect(getChangelogConfig().filePath).toBe('CHANGELOG.md'); + }); + + test('keeps the root changelog default for a workspace alias', () => { + const directory = mkdtempSync(join(tmpdir(), 'craft-workspace-changelog-')); + temporaryDirectories.push(directory); + writeFileSync( + join(directory, '.craft.yml'), + `minVersion: ${WORKSPACES_MIN_VERSION}\nworkspaces:\n cli: {}\n`, + ); + process.chdir(directory); + + setActiveWorkspace('cli'); + expect(getChangelogConfig().filePath).toBe('CHANGELOG.md'); + }); + + test('rejects a workspace directory redirected outside the repository', () => { + const directory = mkdtempSync(join(tmpdir(), 'craft-workspace-changelog-')); + const outside = mkdtempSync(join(tmpdir(), 'craft-workspace-outside-')); + temporaryDirectories.push(directory, outside); + mkdirSync(join(outside, 'cli')); + symlinkSync(outside, join(directory, 'packages'), 'dir'); + writeFileSync( + join(directory, '.craft.yml'), + `minVersion: ${WORKSPACES_MIN_VERSION}\nworkspaces:\n packages/cli: {}\n`, + ); + process.chdir(directory); + + setActiveWorkspace('packages/cli'); + expect(() => getChangelogConfig()).toThrow( + 'Workspace directory escapes the repository', + ); + }); + test('a different workspace resolves independently', () => { setActiveWorkspace('mcp'); const config = loadConfigurationFromString(WS_CONFIG); diff --git a/src/__tests__/prepare-dry-run.e2e.test.ts b/src/__tests__/prepare-dry-run.e2e.test.ts index ba4972e9..49375940 100644 --- a/src/__tests__/prepare-dry-run.e2e.test.ts +++ b/src/__tests__/prepare-dry-run.e2e.test.ts @@ -672,6 +672,48 @@ targets: [] expect(combinedOutput).toContain('release/1.1.0'); }, 60000); + test('prepares a workspace changelog without changing the root changelog', async () => { + tempDir = await createTestRepo(); + // eslint-disable-next-line no-restricted-syntax -- Test setup needs direct git access + const git = simpleGit(tempDir); + await mkdir(join(tempDir, 'packages', 'cli'), { recursive: true }); + await writeFile( + join(tempDir, '.craft.yml'), + `minVersion: "2.29.0" +github: + owner: test-owner + repo: test-repo +preReleaseCommand: "" +workspaces: + packages/cli: + changelog: + policy: auto + versioning: + policy: auto + targets: [] +`, + ); + await writeFile( + join(tempDir, 'packages', 'cli', 'CHANGELOG.md'), + '# CLI changelog\n\n## 1.0.0\n\n- Initial release\n', + ); + await git.add('.'); + await git.commit('chore: Configure CLI workspace'); + await git.push('origin', (await git.status()).current!); + + const { stdout, stderr } = await execFileAsync( + CLI_BIN, + ['prepare', '--workspace=packages/cli', '--dry-run', '--no-input'], + { cwd: tempDir, env: { ...CLI_ENV, GITHUB_TOKEN: '' } }, + ); + + expect(stdout + stderr).toContain('packages/cli/CHANGELOG.md'); + expect(stdout + stderr).not.toContain('diff --git a/CHANGELOG.md'); + expect(await readFile(join(tempDir, 'CHANGELOG.md'), 'utf8')).toContain( + '## 1.0.0', + ); + }, 60000); + test('auto changelog policy creates CHANGELOG.md if it does not exist', async () => { tempDir = await mkdtemp(join(tmpdir(), 'craft-e2e-')); // eslint-disable-next-line no-restricted-syntax -- Test setup needs direct git access diff --git a/src/commands/prepare.ts b/src/commands/prepare.ts index 88e8c6cc..89a64671 100644 --- a/src/commands/prepare.ts +++ b/src/commands/prepare.ts @@ -9,6 +9,7 @@ import { createDryRunIsolation, safeFs } from '../utils/dryRun'; import { CONFIG_FILE_NAME, DEFAULT_RELEASE_BRANCH_NAME, + getChangelogConfig, getConfigFileDir, getConfiguration, getGitTagPrefix, @@ -909,18 +910,9 @@ export async function prepareMain(argv: PrepareOptions): Promise { // The order matters here, do not move this command above createReleaseBranch! const oldVersion = await getLatestTag(git, getGitTagPrefix()); - // Check & update the changelog - // Extract changelog path from config (can be string or object) - const changelogPath = - typeof config.changelog === 'string' - ? config.changelog - : config.changelog?.filePath; - // Get policy from new format or legacy changelogPolicy - const changelogPolicy = ( - typeof config.changelog === 'object' && config.changelog?.policy - ? config.changelog.policy - : config.changelogPolicy - ) as ChangelogPolicy | undefined; + // Check & update the changelog with the selected workspace's default path. + const { filePath: changelogPath, policy: changelogPolicy } = + getChangelogConfig(); const changelogBody = await prepareChangelog( git, oldVersion, @@ -932,10 +924,7 @@ export async function prepareMain(argv: PrepareOptions): Promise { // Explicitly stage the changelog file so it is committed even when no // pre-release command runs. git-add on an unmodified file is a no-op. if (changelogBody) { - const resolvedChangelogPath = relative( - '', - changelogPath || DEFAULT_CHANGELOG_PATH, - ); + const resolvedChangelogPath = relative('', changelogPath); await git.add(resolvedChangelogPath); } @@ -984,7 +973,7 @@ export async function prepareMain(argv: PrepareOptions): Promise { // Build a GitHub permalink to the changelog entry on the release // branch, including line numbers so GitHub renders it inline when // pasted into an issue body. - const resolvedChangelogPath = changelogPath || DEFAULT_CHANGELOG_PATH; + const resolvedChangelogPath = changelogPath; const lineRange = await getChangelogLineRange( git, resolvedChangelogPath, diff --git a/src/config.ts b/src/config.ts index 65e8b4c0..7a15e663 100644 --- a/src/config.ts +++ b/src/config.ts @@ -906,6 +906,33 @@ export interface NormalizedChangelogConfig { const DEFAULT_CHANGELOG_FILE_PATH = 'CHANGELOG.md'; +function getDefaultChangelogFilePath(): string { + const workspace = getActiveWorkspace(); + if (!workspace) { + return DEFAULT_CHANGELOG_FILE_PATH; + } + + if (!isSafeWorkspacePath(workspace)) { + throw new ConfigurationError(`Invalid workspace path: "${workspace}"`); + } + + const root = getConfigFileDir() || process.cwd(); + const directory = path.resolve(root, workspace); + if (!existsSync(directory) || !lstatSync(directory).isDirectory()) { + return DEFAULT_CHANGELOG_FILE_PATH; + } + + const realRoot = realpathSync(root); + const realDirectory = realpathSync(directory); + if (!realDirectory.startsWith(`${realRoot}${path.sep}`)) { + throw new ConfigurationError( + `Workspace directory escapes the repository: "${workspace}"`, + ); + } + + return path.posix.join(workspace, DEFAULT_CHANGELOG_FILE_PATH); +} + /** * Returns the normalized changelog configuration from .craft.yml * @@ -916,7 +943,7 @@ export function getChangelogConfig(): NormalizedChangelogConfig { const config = getConfiguration(); // Default values - let filePath = DEFAULT_CHANGELOG_FILE_PATH; + let filePath: string | undefined; let policy = ChangelogPolicy.None; let scopeGrouping = true; let includePaths: string[] = []; @@ -952,7 +979,7 @@ export function getChangelogConfig(): NormalizedChangelogConfig { } return { - filePath, + filePath: filePath ?? getDefaultChangelogFilePath(), policy, scopeGrouping, includePaths, diff --git a/src/targets/__tests__/github.test.ts b/src/targets/__tests__/github.test.ts index 19a409fd..f64d39ab 100644 --- a/src/targets/__tests__/github.test.ts +++ b/src/targets/__tests__/github.test.ts @@ -6,6 +6,7 @@ import { } from '../github'; import { NoneArtifactProvider } from '../../artifact_providers/none'; import { setGlobals } from '../../utils/helpers'; +import { loadConfigurationFromString, setActiveWorkspace } from '../../config'; describe('isLatestRelease', () => { it('works with missing latest release', () => { @@ -90,6 +91,7 @@ describe('GitHubTarget', () => { afterEach(() => { process.env = cleanEnv; + setActiveWorkspace(undefined); }); describe('publish', () => { @@ -116,6 +118,17 @@ describe('GitHubTarget', () => { }) as any; }); + it('includes release notes when only a changelog path is configured', async () => { + loadConfigurationFromString('changelog: CHANGELOG.md\n'); + const getChangelog = vi + .spyOn(githubTarget, 'getChangelog') + .mockResolvedValue({ name: '1.0.0', body: 'Release notes' }); + + await githubTarget.publish('1.0.0', 'abc123'); + + expect(getChangelog).toHaveBeenCalledWith('1.0.0'); + }); + it('cleans up draft release when publishRelease fails', async () => { const publishError = new Error('Publish failed'); githubTarget.publishRelease = vi.fn().mockRejectedValue(publishError); diff --git a/src/targets/github.ts b/src/targets/github.ts index 8f6e6c18..28e2c996 100644 --- a/src/targets/github.ts +++ b/src/targets/github.ts @@ -2,18 +2,14 @@ import { Octokit, RestEndpointMethodTypes } from '@octokit/rest'; import { createReadStream, promises, statSync } from 'fs'; import { basename } from 'path'; -import { getConfiguration } from '../config'; +import { getChangelogConfig, getConfiguration } from '../config'; import { ChangelogPolicy, GitHubGlobalConfig, TargetConfig, TypedTargetConfig, } from '../schemas/project_config'; -import { - Changeset, - DEFAULT_CHANGELOG_PATH, - findChangeset, -} from '../utils/changelog'; +import { Changeset, findChangeset } from '../utils/changelog'; import { getGitHubClient } from '../utils/githubApi'; import { isDryRun } from '../utils/helpers'; import { safeExec } from '../utils/dryRun'; @@ -110,11 +106,7 @@ export class GitHubTarget extends BaseTarget { const typedConfig = this.config as TypedTargetConfig; const owner = typedConfig.owner || githubRepo.owner; const repo = typedConfig.repo || githubRepo.repo; - const configChangelog = getConfiguration().changelog; - const changelog = - typeof configChangelog === 'string' - ? configChangelog - : configChangelog?.filePath || DEFAULT_CHANGELOG_PATH; + const changelog = getChangelogConfig().filePath; this.githubConfig = { owner, @@ -719,9 +711,8 @@ export class GitHubTarget extends BaseTarget { return; } - const config = getConfiguration(); let changelog; - if (config.changelogPolicy !== ChangelogPolicy.None) { + if (getConfiguration().changelogPolicy !== ChangelogPolicy.None) { changelog = await this.getChangelog(version); }