From 1afce59296c4868f0e379a90a1fa4168ba8b8017 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Thu, 16 Jul 2026 12:59:38 +0000 Subject: [PATCH] feat: prefix-aware read paths for monorepo multi-product releases Makes craft's version-detection read paths honor a github target's `tagPrefix`, so a single repo can host independently-versioned products (e.g. `cli@1.2.3`, `mcp@2.0.0`) without cross-contaminating each other's latest-tag detection, changelog base, or CalVer scans. Builds on the existing `tagPrefix` write-side support. - `getLatestTag(git, tagPrefix='')` scopes `git describe` via `--match '*'`; threaded through prepare and changelog read paths. - `getGitTagPrefix()` warns when multiple `github` targets declare differing `tagPrefix` values (ambiguous) and returns the first. - `getVersion`/`parseVersion` extract the version from prefixed tags (`cli@1.2.3` -> `1.2.3`); locked with tests. Review fixes folded in: - changelog: wrap the tag-prefix lookup in try/catch (like the versioningPolicy path) so an unreadable/invalid .craft.yml no longer aborts a standalone `craft changelog` run; fall back to the latest tag overall. (Bugbot Medium) - findReleaseBranches: treat the release-branch prefix as an opaque string, cutting at the last "/" (branch = "/") instead of segment arithmetic. A bare `release` run no longer claims another product's `release/cli/x` branches. (review + Bugbot Low) Docs note that a first-class, target-agnostic `workspaces:` model is planned (getsentry/craft#842) and will supersede the per-.craft.yml convention documented here. --- docs/src/content/docs/configuration.md | 16 ++++ docs/src/content/docs/targets/github.md | 42 ++++++++++ src/__tests__/config.test.ts | 76 ++++++++++++++++- .../changelog-versioning-policy.test.ts | 18 ++++ src/commands/changelog.ts | 25 +++++- src/commands/prepare.ts | 5 +- src/config.ts | 25 +++++- src/utils/__tests__/git.test.ts | 84 ++++++++++++++++++- src/utils/__tests__/version.test.ts | 26 ++++++ src/utils/git.ts | 29 +++++-- 10 files changed, 330 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..9f522f2b8 100644 --- a/docs/src/content/docs/targets/github.md +++ b/docs/src/content/docs/targets/github.md @@ -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. 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..c003b9690 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(), })); @@ -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(), ''); + }); }); diff --git a/src/commands/changelog.ts b/src/commands/changelog.ts index 06e5ae096..e026975bd 100644 --- a/src/commands/changelog.ts +++ b/src/commands/changelog.ts @@ -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, @@ -55,7 +59,24 @@ 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. 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 { 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..dcf2dab01 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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; } /** diff --git a/src/utils/__tests__/git.test.ts b/src/utils/__tests__/git.test.ts index 1fe5e8718..dd8800a63 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,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([]); + }); }); 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..1a1e04dd0 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,14 +247,18 @@ 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) { + // A release branch is "/". Treat the prefix as an opaque + // string (slashes carry no special meaning) and recover the branch's own + // prefix by cutting at the LAST "/": everything before it is the prefix, + // everything after is the version. This handles slashed prefixes + // (e.g. "release/cli" → "release/cli/1.2.3") without segment arithmetic. + const lastSlash = withoutRemote.lastIndexOf('/'); + if (lastSlash <= 0) { + // No version part after a prefix (or nothing before the slash): skip. continue; } + const branchPrefix = withoutRemote.slice(0, lastSlash); if (branchPrefix === prefix) { exactMatches.push(branch);