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
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@"
```

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
42 changes: 42 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,48 @@ targets:

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

## Monorepo: independently-versioned products

The `tagPrefix` option 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.

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

```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.

:::note[Coming soon: first-class workspaces]
A single, target-agnostic top-level `workspaces:` model (with an explicit `--workspace` selector) is planned so that all of a repo's products can be managed from one `.craft.yml`. It will supersede the per-file convention above. Track progress in [getsentry/craft#842](https://github.com/getsentry/craft/issues/842).
:::

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

:::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);
});
});
18 changes: 18 additions & 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 Expand Up @@ -133,4 +134,21 @@ describe('changelog command versioningPolicy in JSON output', () => {
const textOutput = consoleSpy.mock.calls[0][0] as string;
expect(textOutput).not.toContain('versioningPolicy');
});

it('still generates a changelog when getGitTagPrefix throws (invalid config)', async () => {
const { findConfigFile, getGitTagPrefix } = await import('../../config');
const { getLatestTag } = await import('../../utils/git');
const { changelogMain } = await import('../changelog');

vi.mocked(findConfigFile).mockReturnValue('/repo/.craft.yml');
vi.mocked(getGitTagPrefix).mockImplementation(() => {
throw new Error('Invalid .craft.yml');
});

// Must not throw: a broken config should not abort a standalone changelog.
await expect(changelogMain({ format: 'text' })).resolves.toBeUndefined();

// Falls back to the latest tag overall (empty prefix).
expect(getLatestTag).toHaveBeenCalledWith(expect.anything(), '');
});
});
25 changes: 23 additions & 2 deletions src/commands/changelog.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
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 +59,24 @@ 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. A broken or
// unreadable .craft.yml must not abort a standalone changelog run (which
// otherwise needs only git history), so fall back to no prefix on error.
let tagPrefix = '';
try {
if (findConfigFile()) {
tagPrefix = getGitTagPrefix();
}
} catch {
// If config can't be read, generate from the latest unprefixed tag.
logger.debug(
'Could not read tag prefix from config; using latest tag overall',
);
}
since = await getLatestTag(git, tagPrefix);
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
25 changes: 23 additions & 2 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -328,11 +328,32 @@ export async function getGlobalGitHubConfig(

/**
* Gets git tag prefix from configuration
*
* Returns the `tagPrefix` of the first `github` target. In a monorepo where
* 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
84 changes: 83 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,49 @@ 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/1.0.0" has branch-prefix "release" (distance 4) → excluded.
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');

// Cutting at the last "/" yields branch-prefix "release" (no version part
// for "release/cli"), which does not match/near-match "release/cli".
expect(result.exactMatches).toEqual([]);
expect(result.fuzzyMatches).toEqual([]);
});

it('treats the prefix opaquely: "release" does not claim "release/cli/x" branches', async () => {
// With opaque (last-slash) prefix handling, a slashed product branch
// belongs to its full prefix ("release/cli"), not the bare "release".
const git = createMockGit(
' origin/release/1.0.0\n origin/release/cli/1.2.3\n',
);

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

// "release/1.0.0" → branch-prefix "release" (exact).
expect(result.exactMatches).toEqual(['origin/release/1.0.0']);
// "release/cli/1.2.3" → branch-prefix "release/cli" (distance 4) → excluded.
expect(result.fuzzyMatches).toEqual([]);
});
});
Loading
Loading