Skip to content
Draft
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
16 changes: 16 additions & 0 deletions docs/src/content/docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,22 @@ releaseBranchPrefix: publish

Full branch name: `{releaseBranchPrefix}/{version}`

The prefix may contain slashes, which is useful for monorepos that release
several independently-versioned products from one repository. Pairing a slashed
`releaseBranchPrefix` with a per-product `github.tagPrefix` keeps each product's
release branches and tags separate:

```yaml
releaseBranchPrefix: release/cli
targets:
- name: github
tagPrefix: "cli@"
Comment on lines +185 to +188

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if we we can find a way to unify these. I'd even go as far as saying we should support "workspaces" and associated patterns. Sounds like a natural next step of this evolution.

```

This produces branches like `release/cli/1.2.3` and tags like `cli@1.2.3`. See
the [GitHub target docs](./targets/github/#monorepo-independently-versioned-products)
for the full monorepo pattern.

## Changelog Policies

Craft supports `simple` and `auto` changelog management modes.
Expand Down
38 changes: 38 additions & 0 deletions docs/src/content/docs/targets/github.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,44 @@ targets:

This is useful for users who want to pin to a major version while automatically receiving updates.

## Monorepo: independently-versioned products

`tagPrefix` lets a single repository host several independently-versioned products by namespacing their git tags — for example `cli@1.2.3` and `mcp@2.0.0`. Craft honors the prefix on both the **write** side (the tag it creates) and the **read** side (latest-tag detection, changelog base, and CalVer scans are all scoped to the prefix), so the products don't cross-contaminate each other's version history.

The intended layout is **one `.craft.yml` per product**, each with a single `github` target declaring its own `tagPrefix` and a matching `releaseBranchPrefix` (so release branches don't collide):

```yaml
# .craft.yml for the CLI product
github:
owner: getsentry
repo: toolkit
releaseBranchPrefix: release/cli
targets:
- name: github
tagPrefix: "cli@"
```

```yaml
# .craft.yml for the MCP product
github:
owner: getsentry
repo: toolkit
releaseBranchPrefix: release/mcp
targets:
- name: github
tagPrefix: "mcp@"
```

Releasing `1.2.3` for each product then produces the tags `cli@1.2.3` / `mcp@1.2.3` on release branches `release/cli/1.2.3` / `release/mcp/1.2.3` — no collisions.

:::caution
Declaring **multiple** `github` targets with **different** `tagPrefix` values in a *single* config is ambiguous: Craft uses the first prefix for read-path operations and logs a warning. Use a separate `.craft.yml` per product instead.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't it better if we support this use case explicitly to manage everything from a single top-level file when it makes sense? Otherwise I fear we may have issues with the publish repo?

:::

:::note[Known limitation: the GitHub "Latest" badge is repo-wide]
GitHub tracks a single "Latest" release **per repository**, not per tag prefix. Craft decides whether to mark a release as latest by comparing its version against the repository's current latest release ([`isLatestRelease`](https://github.com/getsentry/craft/blob/master/src/targets/github.ts)), which is not prefix-scoped. In a monorepo this means the "Latest" badge can move between products (e.g. publishing `cli@9.0.0` may take the badge from `mcp@3.0.0`, and publishing `cli@1.2.3` while `mcp@3.0.0` is latest won't earn the badge). Tags, changelogs, release branches, and version detection remain correctly per-product — only GitHub's single "Latest" pointer is shared.
:::

## Preview Releases

If `previewReleases` is `true` (default), releases containing pre-release identifiers like `alpha`, `beta`, `rc`, etc. are marked as pre-releases on GitHub.
76 changes: 74 additions & 2 deletions src/__tests__/config.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import { describe, test, expect } from 'vitest';
import { describe, test, expect, vi, afterEach } from 'vitest';
/**
* Tests of our ability to read craft config files. (This is NOT general test
* configuration).
*/

import { validateConfiguration } from '../config';
import {
getGitTagPrefix,
loadConfigurationFromString,
validateConfiguration,
} from '../config';
import { CraftProjectConfigSchema } from '../schemas/project_config';
import { logger } from '../logger';

describe('validateConfiguration', () => {
test('parses minimal configuration', () => {
Expand Down Expand Up @@ -118,3 +123,70 @@ describe('noMerge config', () => {
expect(() => validateConfiguration({ noMerge: 'yes' })).toThrow(/noMerge/);
});
});

describe('getGitTagPrefix', () => {
afterEach(() => {
vi.restoreAllMocks();
});

function loadWithTargets(targets: unknown[]): void {
loadConfigurationFromString(
[
'github:',
' owner: getsentry',
' repo: craft',
'targets:',
...targets.map(t => ` - ${JSON.stringify(t)}`),
].join('\n'),
);
}

test('returns empty string when no github target has a tagPrefix', () => {
loadWithTargets([{ name: 'npm' }, { name: 'github' }]);
expect(getGitTagPrefix()).toBe('');
});

test("returns the github target's tagPrefix", () => {
loadWithTargets([{ name: 'npm' }, { name: 'github', tagPrefix: 'cli@' }]);
expect(getGitTagPrefix()).toBe('cli@');
});

test('does not warn for a single github target', () => {
const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => {});
loadWithTargets([{ name: 'github', tagPrefix: 'cli@' }]);
expect(getGitTagPrefix()).toBe('cli@');
expect(warnSpy).not.toHaveBeenCalled();
});

test('does not warn when multiple github targets share the same prefix', () => {
const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => {});
loadWithTargets([
{ name: 'github', tagPrefix: 'cli@' },
{ name: 'github', tagPrefix: 'cli@', id: 'second' },
]);
expect(getGitTagPrefix()).toBe('cli@');
expect(warnSpy).not.toHaveBeenCalled();
});

test('warns and returns the first prefix when github targets disagree', () => {
const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => {});
loadWithTargets([
{ name: 'github', tagPrefix: 'cli@' },
{ name: 'github', tagPrefix: 'mcp@', id: 'second' },
]);
expect(getGitTagPrefix()).toBe('cli@');
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(warnSpy.mock.calls[0][0]).toMatch(/different "tagPrefix"/);
});

test('warns when one github target has a prefix and another omits it', () => {
const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => {});
loadWithTargets([
{ name: 'github', tagPrefix: 'cli@' },
{ name: 'github', id: 'second' },
]);
// A mixed defined/undefined prefix is still ambiguous.
expect(getGitTagPrefix()).toBe('cli@');
expect(warnSpy).toHaveBeenCalledTimes(1);
});
});
1 change: 1 addition & 0 deletions src/commands/__tests__/changelog-versioning-policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ vi.mock('../../logger');

vi.mock('../../config', () => ({
findConfigFile: vi.fn(),
getGitTagPrefix: vi.fn(() => ''),
getVersioningPolicy: vi.fn(),
}));

Expand Down
9 changes: 7 additions & 2 deletions src/commands/changelog.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Argv, CommandBuilder } from 'yargs';

import { logger } from '../logger';
import { findConfigFile, getVersioningPolicy } from '../config';
import { findConfigFile, getGitTagPrefix, getVersioningPolicy } from '../config';
import { getGitClient, getLatestTag } from '../utils/git';
import {
generateChangesetFromGit,
Expand Down Expand Up @@ -55,7 +55,12 @@ export async function changelogMain(argv: ChangelogOptions): Promise<void> {
// Determine base revision for changelog generation
let since = argv.since;
if (!since) {
since = await getLatestTag(git);
// Scope the latest-tag lookup to the configured tag prefix (if any) so
// monorepos with interleaved product tags (e.g. `cli@`, `mcp@`) resolve
// the correct base. Only read the prefix when a config file is present;
// the changelog command can run standalone without one.
const tagPrefix = findConfigFile() ? getGitTagPrefix() : '';
since = await getLatestTag(git, tagPrefix);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changelog lacks config error guard

Medium Severity

getGitTagPrefix() runs whenever a config file exists, but unlike the versioningPolicy path below it is not wrapped in try/catch. An unreadable or invalid .craft.yml now aborts craft changelog before generating output, including standalone runs that previously only needed git history.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 8aa8586. Configure here.

if (since) {
logger.debug(`Using latest tag as base revision: ${since}`);
} else {
Expand Down
5 changes: 3 additions & 2 deletions src/commands/prepare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
DEFAULT_RELEASE_BRANCH_NAME,
getConfigFileDir,
getConfiguration,
getGitTagPrefix,
getGlobalGitHubConfig,
getVersioningPolicy,
loadConfigurationFromString,
Expand Down Expand Up @@ -758,7 +759,7 @@ async function resolveVersion(
);
}

const latestTag = await getLatestTag(git);
const latestTag = await getLatestTag(git, getGitTagPrefix());

// Determine bump type - either from arg or from commit analysis
let bumpType: BumpType;
Expand Down Expand Up @@ -866,7 +867,7 @@ export async function prepareMain(argv: PrepareOptions): Promise<any> {
// before a specific revision.
// TL;DR - WARNING:
// The order matters here, do not move this command above createReleaseBranch!
const oldVersion = await getLatestTag(git);
const oldVersion = await getLatestTag(git, getGitTagPrefix());

// Check & update the changelog
// Extract changelog path from config (can be string or object)
Expand Down
26 changes: 24 additions & 2 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -328,11 +328,33 @@ export async function getGlobalGitHubConfig(

/**
* Gets git tag prefix from configuration
*
* Returns the `tagPrefix` of the first `github` target. In a monorepo where

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why limit this to the first target and not allow indexing at all?

* multiple products are released from separate `.craft.yml` files, each config
* has a single `github` target with its own prefix (e.g. `cli@`, `mcp@`), so
* this resolves unambiguously per release run. If a single config declares
* multiple `github` targets with *differing* prefixes, the configuration is
* ambiguous: the first prefix is returned and a warning is emitted.
*/
export function getGitTagPrefix(): string {
const targets = getConfiguration().targets || [];
const githubTarget = targets.find(target => target.name === 'github');
return (githubTarget?.tagPrefix as string | undefined) || '';
const githubTargets = targets.filter(target => target.name === 'github');
const firstPrefix =
(githubTargets[0]?.tagPrefix as string | undefined) || '';

const hasConflictingPrefix = githubTargets.some(
target => ((target.tagPrefix as string | undefined) || '') !== firstPrefix,
);
if (hasConflictingPrefix) {
logger.warn(
'Multiple "github" targets with different "tagPrefix" values found. ' +
`Using "${firstPrefix}". For independently-versioned products in a ` +
'monorepo, use a separate .craft.yml per product, each with a single ' +
'"github" target and its own "tagPrefix".',
);
}

return firstPrefix;
}

/**
Expand Down
68 changes: 67 additions & 1 deletion src/utils/__tests__/git.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,34 @@ describe('getLatestTag', () => {
const latestTag = await getLatestTag(git);
expect(latestTag).toBe('1.0.0');

expect(git.raw).toHaveBeenCalledWith('describe', '--tags', '--abbrev=0');
expect(git.raw).toHaveBeenCalledWith(['describe', '--tags', '--abbrev=0']);
});

it('scopes `git describe` to the tag prefix via --match when provided', async () => {
const git = {
raw: vi.fn().mockResolvedValue('cli@1.2.3'),
} as any;

const latestTag = await getLatestTag(git, 'cli@');
expect(latestTag).toBe('cli@1.2.3');

expect(git.raw).toHaveBeenCalledWith([
'describe',
'--tags',
'--abbrev=0',
'--match',
'cli@*',
]);
});

it('does not add --match for an empty prefix', async () => {
const git = {
raw: vi.fn().mockResolvedValue('1.0.0'),
} as any;

await getLatestTag(git, '');

expect(git.raw).toHaveBeenCalledWith(['describe', '--tags', '--abbrev=0']);
});

it('moves on with empty string when no tags are found', async () => {
Expand All @@ -26,6 +53,16 @@ describe('getLatestTag', () => {
const latestTag = await getLatestTag(git);
expect(latestTag).toBe('');
});

it('returns empty string when prefix matches no tags', async () => {
const error = new Error('fatal: No names found');
const git = {
raw: vi.fn().mockRejectedValue(error),
} as any;

const latestTag = await getLatestTag(git, 'mcp@');
expect(latestTag).toBe('');
});
});

describe('isRepoDirty', () => {
Expand Down Expand Up @@ -231,4 +268,33 @@ describe('findReleaseBranches', () => {
// "main" has distance > 3 from "release", so no fuzzy match
expect(result.fuzzyMatches).toEqual([]);
});

it('matches slashed (monorepo) release-branch prefixes exactly', async () => {
const git = createMockGit(
' origin/release/cli/1.2.0\n' +
' origin/release/cli/1.2.1\n' +
' origin/release/mcp/2.0.0\n' +
' origin/release/1.0.0\n',
);

const result = await findReleaseBranches(git, 'release/cli');

expect(result.exactMatches).toEqual([
'origin/release/cli/1.2.1',
'origin/release/cli/1.2.0',
]);
// "release/mcp" is distance 3 from "release/cli" (c→m, l→c, i→p) → fuzzy;
// "release" alone has too few segments to be considered.
expect(result.fuzzyMatches).toEqual(['origin/release/mcp/2.0.0']);
});

it('does not match a slashed prefix branch that lacks a version segment', async () => {
const git = createMockGit(' origin/release/cli\n');

const result = await findReleaseBranches(git, 'release/cli');

// No version part after the prefix → not a release branch
expect(result.exactMatches).toEqual([]);
expect(result.fuzzyMatches).toEqual([]);
});
});
26 changes: 26 additions & 0 deletions src/utils/__tests__/version.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,15 @@ describe('getVersion', () => {
test('extracts a SemVer version from scoped package tag', () => {
expect(getVersion('@spotlightjs/spotlight@4.10.0')).toBe('4.10.0');
});

test('extracts a SemVer version from a monorepo prefixed tag', () => {
expect(getVersion('cli@1.2.3')).toBe('1.2.3');
expect(getVersion('mcp@2.0.0-dev.1')).toBe('2.0.0-dev.1');
// Prefix ending in a digit still works ("@" provides the boundary)
expect(getVersion('sentry-cli@10.20.30')).toBe('10.20.30');
// Prefixed tag that also carries a leading "v"
expect(getVersion('cli@v1.2.3')).toBe('1.2.3');
});
});

describe('isValidVersion', () => {
Expand Down Expand Up @@ -131,6 +140,23 @@ describe('parseVersion', () => {
expect(parseVersion('v1.2')).toBeNull();
});

test('parses a version out of a monorepo prefixed tag', () => {
expect(parseVersion('cli@1.2.3')).toEqual({
major: 1,
minor: 2,
patch: 3,
build: undefined,
pre: undefined,
});
expect(parseVersion('mcp@2.0.0-dev.1')).toEqual({
major: 2,
minor: 0,
patch: 0,
build: undefined,
pre: 'dev.1',
});
});

test('cannot parse empty value', () => {
expect(parseVersion('')).toBeNull();
});
Expand Down
Loading
Loading