From 8aa85867e1f968b1c6b5397626806cd57b1403b5 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Thu, 16 Jul 2026 12:59:38 +0000 Subject: [PATCH] feat: support prefixed tags for monorepo multi-product releases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enable two (or more) independently-versioned products to live in one repository (e.g. `cli@1.2.3`, `mcp@2.0.0`) without cross-contaminating each other's latest-tag/changelog-base detection or release branches. The write path already honored `github.tagPrefix`; this fills the gaps on the read paths: - `getLatestTag(git, tagPrefix)` now scopes `git describe` with `--match '*'` so the latest tag is resolved per-product. - Thread the configured prefix through the auto-version base and changelog base in `prepare.ts`, and through the `changelog` command (guarded by a config-file check). - `getGitTagPrefix()` now warns when a single config declares multiple `github` targets with differing prefixes (ambiguous), returning the first. The intended layout is one `.craft.yml` per product. - `findReleaseBranches` handles slashed release-branch prefixes (`release/cli` -> `release/cli/1.2.3`) so the error-hint suggestions work for the per-product `releaseBranchPrefix` collision fix. CalVer and `versionToTag` were already prefix-aware; `getVersion`/ `parseVersion` already extract the version out of a prefixed tag — both are now covered by explicit tests. Docs: document the monorepo pattern (per-product `.craft.yml` with its own `tagPrefix` + slashed `releaseBranchPrefix`) and note the known limitation that GitHub's single per-repo "Latest" badge is not prefix-scoped. --- docs/src/content/docs/configuration.md | 16 ++++ docs/src/content/docs/targets/github.md | 38 ++++++++++ src/__tests__/config.test.ts | 76 ++++++++++++++++++- .../changelog-versioning-policy.test.ts | 1 + src/commands/changelog.ts | 9 ++- src/commands/prepare.ts | 5 +- src/config.ts | 26 ++++++- src/utils/__tests__/git.test.ts | 68 ++++++++++++++++- src/utils/__tests__/version.test.ts | 26 +++++++ src/utils/git.ts | 34 +++++++-- 10 files changed, 283 insertions(+), 16 deletions(-) diff --git a/docs/src/content/docs/configuration.md b/docs/src/content/docs/configuration.md index d08169988..6258a9345 100644 --- a/docs/src/content/docs/configuration.md +++ b/docs/src/content/docs/configuration.md @@ -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. diff --git a/docs/src/content/docs/targets/github.md b/docs/src/content/docs/targets/github.md index 6f681e4ec..2ef631ccc 100644 --- a/docs/src/content/docs/targets/github.md +++ b/docs/src/content/docs/targets/github.md @@ -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. +::: + +:::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. diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index fa09584a9..243835d0b 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -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', () => { @@ -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); + }); +}); diff --git a/src/commands/__tests__/changelog-versioning-policy.test.ts b/src/commands/__tests__/changelog-versioning-policy.test.ts index a15d5ad42..b6a377055 100644 --- a/src/commands/__tests__/changelog-versioning-policy.test.ts +++ b/src/commands/__tests__/changelog-versioning-policy.test.ts @@ -7,6 +7,7 @@ vi.mock('../../logger'); vi.mock('../../config', () => ({ findConfigFile: vi.fn(), + getGitTagPrefix: vi.fn(() => ''), getVersioningPolicy: vi.fn(), })); diff --git a/src/commands/changelog.ts b/src/commands/changelog.ts index 06e5ae096..419b41d1e 100644 --- a/src/commands/changelog.ts +++ b/src/commands/changelog.ts @@ -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, @@ -55,7 +55,12 @@ export async function changelogMain(argv: ChangelogOptions): Promise { // 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); if (since) { logger.debug(`Using latest tag as base revision: ${since}`); } else { diff --git a/src/commands/prepare.ts b/src/commands/prepare.ts index afd7f8201..fc97b201b 100644 --- a/src/commands/prepare.ts +++ b/src/commands/prepare.ts @@ -11,6 +11,7 @@ import { DEFAULT_RELEASE_BRANCH_NAME, getConfigFileDir, getConfiguration, + getGitTagPrefix, getGlobalGitHubConfig, getVersioningPolicy, loadConfigurationFromString, @@ -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; @@ -866,7 +867,7 @@ export async function prepareMain(argv: PrepareOptions): Promise { // 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) diff --git a/src/config.ts b/src/config.ts index 723fd05b1..a9bdc1267 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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 + * 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; } /** diff --git a/src/utils/__tests__/git.test.ts b/src/utils/__tests__/git.test.ts index 1fe5e8718..8dd9114a0 100644 --- a/src/utils/__tests__/git.test.ts +++ b/src/utils/__tests__/git.test.ts @@ -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 () => { @@ -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', () => { @@ -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([]); + }); }); diff --git a/src/utils/__tests__/version.test.ts b/src/utils/__tests__/version.test.ts index 486fa9d6d..3d7f1b87d 100644 --- a/src/utils/__tests__/version.test.ts +++ b/src/utils/__tests__/version.test.ts @@ -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', () => { @@ -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(); }); diff --git a/src/utils/git.ts b/src/utils/git.ts index 00d2fa5c5..5b2589bf8 100644 --- a/src/utils/git.ts +++ b/src/utils/git.ts @@ -42,10 +42,21 @@ export async function getDefaultBranch( ); } -export async function getLatestTag(git: SimpleGit): Promise { +export async function getLatestTag( + git: SimpleGit, + tagPrefix = '', +): Promise { try { // This part is courtesy of https://stackoverflow.com/a/7261049/90297 - return (await git.raw('describe', '--tags', '--abbrev=0')).trim(); + const args = ['describe', '--tags', '--abbrev=0']; + if (tagPrefix) { + // In a monorepo, tags for multiple products (e.g. `cli@1.2.3`, + // `mcp@2.0.0`) are interleaved. `--match '*'` scopes + // `git describe` to a single product's tag namespace so the latest tag + // is resolved per-product instead of picking whatever is newest overall. + args.push('--match', `${tagPrefix}*`); + } + return (await git.raw(args)).trim(); } catch (err) { // If there are no tags, return an empty string if ( @@ -236,12 +247,21 @@ export async function findReleaseBranches( for (const branch of allBranches) { // "origin/release/1.2.3" → strip remote → "release/1.2.3" const withoutRemote = branch.replace(/^[^/]+\//, ''); - // "release/1.2.3" → prefix portion = "release" - const slashIndex = withoutRemote.indexOf('/'); - const branchPrefix = - slashIndex >= 0 ? withoutRemote.slice(0, slashIndex) : withoutRemote; - if (!branchPrefix) { + // The prefix may itself contain slashes for monorepo per-product releases + // (e.g. "release/cli" → branches like "release/cli/1.2.3"). Compare the + // branch's leading segments against the prefix rather than only the first + // path segment, so slashed prefixes match exactly and fuzzily. + const prefixSegmentCount = prefix.split('/').length; + const branchSegments = withoutRemote.split('/'); + const branchPrefix = branchSegments + .slice(0, prefixSegmentCount) + .join('/'); + + // Only consider branches that actually have a version part after the + // prefix (i.e. more segments than the prefix), mirroring the original + // single-segment behavior. + if (!branchPrefix || branchSegments.length <= prefixSegmentCount) { continue; }