DashboardcraftSession 02mszzU5eMAe

Session 02mszzU5eMAe

Full ID: 02mszzU5eMAeVyLhX
Messages: 60
Distillations: 3
Time range: 2026-07-16 12:45:42 — 2026-07-16 12:56:19

Conversation (60 messages)

You are an adversarial senior code reviewer for the getsentry/craft repo (a TypeScript release-automation CLI). Critically review a change that adds full monorepo prefixed-tag support so two independently-versioned products (e.g. cli@1.2.3, mcp@2.0.0) can coexist in one repo. Be skeptical — find real bugs, edge cases, regressions, and inconsistencies. Do NOT rubber-stamp. Do NOT modify files.

What changed (review the actual committed-but-uncommitted working-tree diff)

Run cd /home/byk/Code/getsentry/craft && git diff -- ':!.lore.md' to see the full diff. Key files:

  1. src/utils/git.tsgetLatestTag(git, tagPrefix='') now optionally appends --match '<prefix>*' to git describe --tags --abbrev=0. NOTE: it changed from git.raw('describe','--tags','--abbrev=0') (spread string args) to git.raw([...]) (single array arg). Verify simple-git accepts the array form identically (it does, but confirm no behavioral difference and that existing callers/tests are consistent).

    • Also findReleaseBranches was changed: previously it derived a branch's prefix as the substring up to the FIRST slash; now it compares the first N path segments (N = number of segments in the search prefix) so slashed prefixes like release/cli match release/cli/1.2.3. Scrutinize this carefully for regressions against the OLD behavior for single-segment prefixes (e.g. does release still match release/1.2.3? does origin/main still NOT match? does a bare origin/release branch with no version still behave the same? edit-distance fuzzy matching still correct?). Check the branchSegments.length <= prefixSegmentCount guard — could it wrongly exclude anything the old code included, or include anything it excluded?
  2. src/config.tsgetGitTagPrefix() now filters ALL github targets (was: .find first). If multiple github targets have DIFFERING tagPrefix, it logs logger.warn(...) and returns the first. ...

gateway · 2026-07-16 12:45:42

I'll review this change systematically. Let me start by examining the actual diff.

anthropic/claude-opus-4.8 · 2026-07-16 12:45:42 · ~28 tokens
result
diff --git a/docs/src/content/docs/configuration.md b/docs/src/content/docs/configuration.md
index d081699..6258a93 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. ...
gateway · 2026-07-16 12:46:25

Now let me look at the full context of the changed files, especially findReleaseBranches and the surrounding code.

anthropic/claude-opus-4.8 · 2026-07-16 12:46:25 · ~39 tokens
result
<path>/home/byk/Code/getsentry/craft/src/utils/git.ts</path>
<type>file</type>
<content>
220:  * @returns Object with exactMatches and fuzzyMatches arrays
221:  */
222: export async function findReleaseBranches(
223:   git: SimpleGit,
224:   prefix: string,
225:   limit: number = 10,
226: ): Promise<ReleaseBranchSearchResult> {
227:   const MAX_EDIT_DISTANCE = 3;
228: 
229:   try {
230:     await git.fetch();
231:   } catch (_err) {
232:     logger.debug('Failed to fetch from remote, using locally cached refs');
233:   }
234: 
235:   let allBranches: string[];
236:   try {
237:     const output = await git.raw('branch', '-r');
238:     allBranches = parseGitBranchOutput(output);
239:   } catch (_err) {
240:     logger.debug('Failed to list remote branches');
241:     return { exactMatches: [], fuzzyMatches: [] };
242:   }
243: 
244:   const exactMatches: string[] = [];
245:   const fuzzyMatches: string[] = [];
246: 
247:   for (const branch of allBranches) {
248:     // ...
gateway · 2026-07-16 12:46:41

Now let me look at the OLD version of findReleaseBranches to compare carefully, and the existing tests.

anthropic/claude-opus-4.8 · 2026-07-16 12:46:41 · ~35 tokens
result
    const slashIndex = withoutRemote.indexOf('/');
    const branchPrefix =
      slashIndex >= 0 ? withoutRemote.slice(0, slashIndex) : withoutRemote;

    if (!branchPrefix) {
      continue;
    }

    if (branchPrefix === prefix) {
      exactMatches.push(branch);
    } else if (levenshtein(branchPrefix, prefix) <= MAX_EDIT_DISTANCE) {
      fuzzyMatches.push(branch);
    }
  }

  // git branch -r lists alphabetically; for semver branches, taking from the end gives roughly the highest versions
  return {
    exactMatches: exactMatches.slice(-limit).reverse(),
    fuzzyMatches: fuzzyMatches.slice(-limit).reverse(),
  };
}
gateway · 2026-07-16 12:46:45

Now let me read the existing tests for findReleaseBranches to walk through each case.

anthropic/claude-opus-4.8 · 2026-07-16 12:46:45 · ~29 tokens
result
<path>/home/byk/Code/getsentry/craft/src/utils/__tests__/git.test.ts</path>
<type>file</type>
<content>
180:     expect(result.fuzzyMatches).toEqual(['origin/releases/1.0.0']);
181:   });
182: 
183:   it('returns empty results when no branches match', async () => {
184:     const git = createMockGit(
185:       '  origin/main\n  origin/develop\n  origin/feature/foo\n',
186:     );
187: 
188:     const result = await findReleaseBranches(git, 'release');
189: 
190:     expect(result.exactMatches).toEqual([]);
191:     expect(result.fuzzyMatches).toEqual([]);
192:   });
193: 
194:   it('filters out HEAD pointer entries', async () => {
195:     const git = createMockGit(
196:       '  origin/HEAD -> origin/main\n  origin/release/1.0.0\n',
197:     );
198: 
199:     const result = await findReleaseBranches(git, 'release');
200: 
201:     expect(result.exactMatches).toEqual(['origin/release/1.0.0']);
202:     expect(result.fuzzyMatches).toEqual([]);
203:   });
204: 
205:   it('respects the ...
gateway · 2026-07-16 12:46:51
result
<path>/home/byk/Code/getsentry/craft/src/utils/__tests__/git.test.ts</path>
<type>file</type>
<content>
140:     const git = createMockGit(
141:       '  origin/release/1.2.0\n  origin/release/1.2.1\n  origin/release/1.2.2\n',
142:     );
143: 
144:     const result = await findReleaseBranches(git, 'release');
145: 
146:     expect(result.exactMatches).toEqual([
147:       'origin/release/1.2.2',
148:       'origin/release/1.2.1',
149:       'origin/release/1.2.0',
150:     ]);
151:     expect(result.fuzzyMatches).toEqual([]);
152:   });
153: 
154:   it('returns fuzzy matches for branches with similar prefix (edit distance <= 3)', async () => {
155:     const git = createMockGit(
156:       '  origin/releases/1.0.0\n  origin/relaese/2.0.0\n',
157:     );
158: 
159:     const result = await findReleaseBranches(git, 'release');
160: 
161:     expect(result.exactMatches).toEqual([]);
162:     // "releases" has distance 1, "relaese" has distance 2
163:     ...
gateway · 2026-07-16 12:47:12

I notice a critical detail: the test at line 226 asserts git.raw was called with 'branch', '-r' (spread args), but the diff changed getLatestTag to use array form. Let me check whether git.raw('branch', '-r') (line 237 in source) is still spread form. It is — only getLatestTag changed. Good.

Now let me walk through the OLD vs NEW findReleaseBranches behavior carefully. The key question is single-segment prefixes. Let me trace critical cases:

OLD code for single-segment prefix release:

  • Branch origin/release/1.2.3withoutRemote = release/1.2.3slashIndex >= 0branchPrefix = "release". Matches.
  • Branch origin/mainwithoutRemote = main → no slash → branchPrefix = "main". Then levenshtein("main","release") > 3 → no match.
  • Branch origin/release (bare, no version) → withoutRemote = release → no slash → branchPrefix = "release"exact match in OLD code!

NEW code for prefix release (prefixSegmentCount=1):

  • Branch origin/releasebranchSegments = ["release"], length 1 ≤ 1 → skipped (continue).

This is a behavioral difference. Let me verify against the specific test case "handles branches without a slash after prefix" — that test only has origin/main and origin/release/1.0.0, so it doesn't cover a bare origin/release. Let me check the "excludes branches with edit distance > 3" test: origin/rel/1.0.0 and origin/r/1.0.0.

anthropic/claude-opus-4.8 · 2026-07-16 12:47:12 · ~474 tokens
result
rel vs release: 4
r vs release: 6
release/mcp vs release/cli: 3
gateway · 2026-07-16 12:47:25

Now let me verify the semver regex on prefixed tags empirically, and check getVersion/parseVersion.

anthropic/claude-opus-4.8 · 2026-07-16 12:47:25 · ~35 tokens
result
10:const semverRegex = () =>
23:export function getVersion(text: string): string | null {
24:  const matches = semverRegex().exec(text);
38:  return !!text && text === getVersion(text);
63:export function parseVersion(text: string): SemVer | null {
64:  const matches = semverRegex().exec(text);
gateway · 2026-07-16 12:47:52
result
<path>/home/byk/Code/getsentry/craft/src/utils/version.ts</path>
<type>file</type>
<content>
1: import { getGitTagPrefix } from '../config';
2: 
3: /**
4:  * Regular expression for matching semver versions.
5:  *
6:  * Modified to match version components
7:  * Copyright (c) Sindre Sorhus <sindresorhus@gmail.com> (sindresorhus.com)
8:  * @see https://github.com/sindresorhus/semver-regex
9:  */
10: const semverRegex = () =>
11:   /\bv?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-?([\da-z-]+(?:\.[\da-z-]+)*))?(?:\+([\da-z-]+(?:\.[\da-z-]+)*))?\b/gi;
12: 
13: /**
14:  * Extracts a version number from the given text.
15:  *
16:  * In case the version contains a leading "v", it is stripped from the result.
17:  * All semantic versions are supported. See {@link http://semver.org/} for
18:  * more information. ...
gateway · 2026-07-16 12:48:01

Interesting — version.ts:1 imports getGitTagPrefix from config. Let me check where it's used (that's a potential existing coupling). Now let me empirically verify the regex on the claimed prefixed tags, especially the tricky edge cases.

anthropic/claude-opus-4.8 · 2026-07-16 12:48:01 · ~80 tokens
result
"cli@1.2.3" => "1.2.3"
"mcp@2.0.0-dev.1" => "2.0.0-dev.1"
"sentry-cli@10.20.30" => "10.20.30"
"cli@v1.2.3" => "1.2.3"
"foo2@1.2.3" => "1.2.3"
"@spotlightjs/spotlight@4.10.0" => "4.10.0"
"climate@1.2.3" => "1.2.3"
"v2@1.2.3" => "1.2.3"
gateway · 2026-07-16 12:48:23

Interesting edge case: foo2@1.2.31.2.3 works because \b word boundary between 2 and @ (non-word). But wait — foo2@1.2.3: the regex has \bv?(0|[1-9]...). Let me check more carefully whether a prefix ending in a digit could be mis-parsed. foo2@1.2.3 gave 1.2.3 correctly. But what about a prefix like v2@1.2.3? It gave 1.2.3 — correct too (the \b allows matching starting at 1). Good, no over-capture.

Now the important concern: does --match 'cli@*' correctly scope. @ is literal in git glob. Let me verify git describe --match returns the full tag name including prefix. Let me confirm callers of getLatestTag that were NOT updated.

anthropic/claude-opus-4.8 · 2026-07-16 12:48:23 · ~223 tokens
result
src/commands/changelog.ts:63:    since = await getLatestTag(git, tagPrefix);
src/commands/prepare.ts:762:    const latestTag = await getLatestTag(git, getGitTagPrefix());
src/commands/prepare.ts:870:    const oldVersion = await getLatestTag(git, getGitTagPrefix());
src/utils/git.ts:45:export async function getLatestTag(
gateway · 2026-07-16 12:48:35

All callers updated. Now let me check other read paths that might need prefix scoping — autoVersion.ts, versionBump.ts, publish.ts, calver.ts.

anthropic/claude-opus-4.8 · 2026-07-16 12:48:35 · ~50 tokens
result
src/commands/changelog.ts:4:import { findConfigFile, getGitTagPrefix, getVersioningPolicy } from '../config';
src/commands/changelog.ts:5:import { getGitClient, getLatestTag } from '../utils/git';
src/commands/changelog.ts:62:    const tagPrefix = findConfigFile() ? getGitTagPrefix() : '';
src/commands/changelog.ts:63:    since = await getLatestTag(git, tagPrefix);
src/commands/prepare.ts:14:  getGitTagPrefix,
src/commands/prepare.ts:51:  getLatestTag,
src/commands/prepare.ts:762:    const latestTag = await getLatestTag(git, getGitTagPrefix());
src/commands/prepare.ts:870:    const oldVersion = await getLatestTag(git, getGitTagPrefix());
src/status_providers/github.ts:152:   * @param context String that describes a commit check (e.g. a CI run)
src/targets/maven.ts:458: * @param files an object containing the input files, as described above
src/targets/pypi.ts:53:   * 3. [tool.setuptools_scm] in pyproject.toml → no-op (version from git tags)
src/targets/pypi.ts:81:      // ...
gateway · 2026-07-16 12:48:40

Let me check calver.ts and how it filters tags, plus the versionToTag in version.ts around line 136.

anthropic/claude-opus-4.8 · 2026-07-16 12:48:40 · ~34 tokens
result
<path>/home/byk/Code/getsentry/craft/src/utils/calver.ts</path>
<type>file</type>
<content>
75:     `CalVer: using date ${date.toISOString()}, date part: ${datePart}`,
76:   );
77: 
78:   // Find existing tags and determine next patch version
79:   // Account for git tag prefix (e.g., 'v') when searching
80:   const gitTagPrefix = getGitTagPrefix();
81:   const searchPrefix = `${gitTagPrefix}${datePart}.`;
82: 
83:   logger.debug(`CalVer: searching for tags with prefix: ${searchPrefix}`);
84: 
85:   const tags = await git.tags();
86:   let patch = 0;
87: 
88:   // Find the highest patch version for this date part
89:   for (const tag of tags.all) {
90:     if (tag.startsWith(searchPrefix)) {
91:       const patchStr = tag.slice(searchPrefix.length);
92:       const patchNum = parseInt(patchStr, 10);
93:       if (!isNaN(patchNum) && patchNum >= patch) {
94:         patch = patchNum + 1;
95:       }
96:     }
97:   }
98: 
99:   const version = `${datePart}.${patch}`;
100:   ...
gateway · 2026-07-16 12:49:07

CalVer is genuinely prefix-correct: it uses getGitTagPrefix() + startsWith(${prefix}${datePart}.), so cli@250716. won't match mcp@250716.. Confirmed no change needed.

Now let me check version.ts line 136 context (versionToTag) and the changelog.ts around line 58 (the doc referenced line 58 for changelog but the diff shows line 63).

anthropic/claude-opus-4.8 · 2026-07-16 12:49:07 · ~117 tokens
result
<path>/home/byk/Code/getsentry/craft/src/utils/version.ts</path>
<type>file</type>
<content>
125: 
126: /**
127:  * Returns the Git version based on the provided version.
128:  *
129:  * If no tag prefix is provided, it is taken from the configuration.
130:  *
131:  * @param version Version we're releasing
132:  * @param tagPrefix Git tag prefix
133:  * @returns Git tag
134:  */
135: export function versionToTag(version: string, tagPrefix?: string): string {
136:   const prefix = tagPrefix === undefined ? getGitTagPrefix() : tagPrefix;
137:   return `${prefix}${version}`;
138: }
139: 
140: /**
141:  * Reads "package.json" from project root and returns its contents.
142:  */
143: export function getPackage(): any {
144:   const pkg = require('../../package.json') || {};
145:   // Sanity check
146:   if (Object.keys(pkg).length === 0) {
147:     throw new Error('Invalid package.json: the file is empty!');
148:   }
149:   return pkg;
150: }
151: 
152: /**
153:  * Reads the package's ...
gateway · 2026-07-16 12:49:17

Write path (versionToTag) is unchanged and prefix-aware. Now let me look at the prepare.ts context around lines 759-762 and 866-870 to check if config is guaranteed loaded, and check getConfiguration() behavior when not loaded.

anthropic/claude-opus-4.8 · 2026-07-16 12:49:17 · ~77 tokens
result
<path>/home/byk/Code/getsentry/craft/src/commands/prepare.ts</path>
<type>file</type>
<content>
730:     }
731: 
732:     // Build CalVer config with overrides
733:     const calverOffset =
734:       options.calverOffset ??
735:       (process.env.CRAFT_CALVER_OFFSET
736:         ? Number.parseInt(process.env.CRAFT_CALVER_OFFSET, 10)
737:         : undefined) ??
738:       config.versioning?.calver?.offset ??
739:       DEFAULT_CALVER_CONFIG.offset;
740: 
741:     const calverFormat =
742:       config.versioning?.calver?.format ?? DEFAULT_CALVER_CONFIG.format;
743: 
744:     return calculateCalVer(git, {
745:       offset: calverOffset,
746:       format: calverFormat,
747:     });
748:   }
749: 
750:   // Handle automatic version detection or version bump types
751:   if (version === 'auto' || isBumpType(version)) {
752:     if (!requiresMinVersion(AUTO_VERSION_MIN_VERSION)) {
753:       const featureName = isBumpType(version)
754:         ? ...
gateway · 2026-07-16 12:49:30

prepareMain is invoked with config already loaded (the requiresMinVersion/CalVer paths all use getConfiguration()). Let me confirm getConfiguration() throws if not loaded, and that prepare loads config early.

anthropic/claude-opus-4.8 · 2026-07-16 12:49:30 · ~72 tokens
result
export function getConfiguration(clearCache = false): CraftProjectConfig {
  if (!clearCache && _configCache) {
    return _configCache;
  }

  const configPath = getConfigFilePath();
  logger.debug('Configuration file found: ', configPath);
  const rawConfig = load(readFileSync(configPath, 'utf-8')) as Record<
    string,
    any
  >;
  _configCache = validateConfiguration(rawConfig);
  checkMinimalConfigVersion(_configCache);
  return _configCache;
}
gateway · 2026-07-16 12:49:33
result
export function getConfigFilePath(): string {
  const configFilePath = findConfigFile();
  if (!configFilePath) {
    throw new ConfigurationError(
      `Cannot find Craft configuration file. Have you added "${CONFIG_FILE_NAME}" to your project?`,
    );
  }
  return configFilePath;
}
gateway · 2026-07-16 12:50:02

So getGitTagPrefix()getConfiguration() → throws ConfigurationError if no config file. In prepare.ts, this is fine — prepare always requires a config. But note: the changelog guard uses findConfigFile() ? getGitTagPrefix() : '' which is correct.

Now, one concern: in prepare.ts:762, is config guaranteed loaded at that point? It reads config just before (config.versioning). Let me verify config variable in scope comes from getConfiguration(). Also check the oldVersion path at line ~866.

anthropic/claude-opus-4.8 · 2026-07-16 12:50:02 · ~171 tokens
result
<path>/home/byk/Code/getsentry/craft/src/commands/prepare.ts</path>
<type>file</type>
<content>
830:     // Check that we're in an acceptable state for the release
831:     checkGitStatus(repoStatus, rev);
832:   }
833: 
834:   // Resolve version from input, policy, or automatic detection
835:   const newVersion = await resolveVersion(git, {
836:     versionArg: argv.newVersion,
837:     calverOffset: argv.calverOffset,
838:   });
839: 
840:   // Emit resolved version for GitHub Actions
841:   setGitHubActionsOutput('version', newVersion);
842: 
843:   logger.info(`Releasing version ${newVersion} from ${rev}`);
844:   if (!argv.rev && rev !== defaultBranch) {
845:     logger.warn("You're not on your default branch, so I have to ask...");
846:     await promptConfirmation();
847:   }
848: 
849:   logger.info(`Preparing to release the version: ${newVersion}`);
850: 
851:   // Create isolation context (worktree in dry-run mode, passthrough otherwise)
852:   const isolation = await ...
gateway · 2026-07-16 12:50:10

Config is loaded well before both call sites. Both are safe. Now there's a critical concern at line 870: getLatestTag(git, getGitTagPrefix()) runs AFTER createReleaseBranch created the branch ${prefix}/${newVersion} and checked it out. But wait — it creates a branch, not a tag. git describe --tags looks at tags, not branches, so the new release branch doesn't affect it. However, --match with --abbrev=0 describes from the current HEAD. Since we just branched from rev, HEAD is on the release branch pointing at rev. git describe --tags --abbrev=0 --match 'cli@*' will find the most recent cli@* tag reachable from HEAD. That's correct — the old version. Good.

Now let me run the verification suite.

anthropic/claude-opus-4.8 · 2026-07-16 12:50:10 · ~244 tokens
result
EXIT: 0
gateway · 2026-07-16 12:50:32
result
> @sentry/craft@2.27.0-dev.0 test /home/byk/Code/getsentry/craft
> vitest run src/utils/__tests__/git.test.ts src/utils/__tests__/version.test.ts src/__tests__/config.test.ts src/commands/__tests__/changelog-versioning-policy.test.ts


 RUN  v4.1.8 /home/byk/Code/getsentry/craft

[debug] Failed to fetch from remote, using locally cached refs
[debug] Failed to list remote branches

 Test Files  4 passed (4)
      Tests  95 passed (95)
   Start at  12:50:33
   Duration  2.16s (transform 819ms, setup 0ms, import 3.84s, tests 779ms, environment 1ms)

gateway · 2026-07-16 12:50:39
result
[warn] [[target/github]] Refusing to delete release "v1.0.0" because it is not a draft
[info] [dry-run] Would execute: github.repos.deleteRelease(...)
12:50:53 PM [vite] (ssr) warning: This assignment will throw because "checkExecutableIsPresent" is an import
56 |  
57 |    test('config missing', () => {
58 |      (checkExecutableIsPresent as MockedFunction<
   |       ^
59 |        typeof checkExecutableIsPresent
60 |      >) = vi.fn();

  Plugin: vite:esbuild
  File: /home/byk/Code/getsentry/craft/src/targets/__tests__/symbolCollector.test.ts
12:50:53 PM [vite] (ssr) warning: This assignment will throw because "checkExecutableIsPresent" is an import
66 |  
67 |    test('symbol collector present and config ok', () => {
68 |      (checkExecutableIsPresent as MockedFunction<
   |       ^
69 |        typeof checkExecutableIsPresent
70 |      >) = vi.fn();

  Plugin: vite:esbuild
  File: /home/byk/Code/getsentry/craft/src/targets/__tests__/symbolCollector.test.ts
[debug] Failed to fetch ...
gateway · 2026-07-16 12:51:19

Full suite passes (1054 passed, 1 pre-existing skip). Now let me verify the config.test.ts loadConfigurationFromString and check the conflict-detection logic edge case with id. Let me also verify the docs anchor link is valid.

anthropic/claude-opus-4.8 · 2026-07-16 12:51:19 · ~77 tokens
result
56:let _configCache: CraftProjectConfig;
148:  if (!clearCache && _configCache) {
149:    return _configCache;
158:  _configCache = validateConfiguration(rawConfig);
159:  checkMinimalConfigVersion(_configCache);
160:  return _configCache;
170:export function loadConfigurationFromString(
175:  _configCache = validateConfiguration(rawConfig);
176:  checkMinimalConfigVersion(_configCache);
177:  return _configCache;
gateway · 2026-07-16 12:51:48

Now let me examine the getGitTagPrefix conflict logic edge cases. The concern is empty targets array and firstPrefix when githubTargets[0] is undefined.

Trace: empty github targets → githubTargets[0] is undefinedfirstPrefix = undefined || '' = ''. .some on empty array → false. Returns ''. Safe.

Multiple targets, one with tagPrefix: 'cli@' and one with no tagPrefix (undefined): firstPrefix = 'cli@'. .some: second target (undefined || '') !== 'cli@''' !== 'cli@'truewarns. This is arguably correct (mixed defined/undefined is genuinely ambiguous), but worth noting. If the FIRST has no prefix and second has cli@: firstPrefix = '', second 'cli@' !== '' → warns, returns ''. Reasonable.

Now let me double-check the docs anchor. The link is ./targets/github/#monorepo-independently-versioned-products and heading is ## Monorepo: independently-versioned products.

anthropic/claude-opus-4.8 · 2026-07-16 12:51:48 · ~309 tokens
result
docs/astro.config.mjs:27:            { label: 'Introduction', slug: '' },
docs/astro.config.mjs:28:            { label: 'Installation', slug: 'getting-started' },
docs/astro.config.mjs:29:            { label: 'Configuration', slug: 'configuration' },
docs/astro.config.mjs:30:            { label: 'GitHub Actions', slug: 'github-actions' },
docs/astro.config.mjs:39:          items: [{ label: 'Contributing', slug: 'contributing' }],
docs/pnpm-lock.yaml:1110:  github-slugger@2.0.0:
docs/pnpm-lock.yaml:1966:      github-slugger: 2.0.0
docs/pnpm-lock.yaml:1992:      github-slugger: 2.0.0
docs/pnpm-lock.yaml:2018:      github-slugger: 2.0.0
docs/pnpm-lock.yaml:2737:      github-slugger: 2.0.0
---heading---
8:## Configuration
17:## Environment Variables
23:## Example
32:## Floating Tags
46:## Monorepo: independently-versioned products
80:## Preview Releases
gateway · 2026-07-16 12:52:11

github-slugger converts ## Monorepo: independently-versioned productsmonorepo-independently-versioned-products (drops the colon, lowercases, hyphenates). The anchor #monorepo-independently-versioned-products is correct.

Now let me check the .craft.yml example in docs — the github.md example uses top-level github: block AND a targets: - name: github with tagPrefix. Let me verify that's a valid config structure (top-level github for owner/repo vs target). Let me check the schema.

anthropic/claude-opus-4.8 · 2026-07-16 12:52:11 · ~168 tokens
result
44:  owner: z.string(),
45:  repo: z.string(),
175:  releaseBranchPrefix: z.string().optional(),
gateway · 2026-07-16 12:52:17
result
}

/**
 * Global (non-target!) GitHub configuration for the project
 */
export const GitHubGlobalConfigSchema = z.object({
  owner: z.string(),
  repo: z.string(),
  projectPath: z.string().optional(),
});

export type GitHubGlobalConfig = z.infer<typeof GitHubGlobalConfigSchema>;

/**
 * Generic target configuration
 */
export const TargetConfigSchema = z
  .object({
    name: z.string(),
    id: z.string().optional(),
    includeNames: z.string().optional(),
    excludeNames: z.string().optional(),
  })
gateway · 2026-07-16 12:52:32

The TargetConfigSchema uses a base object but targets can have arbitrary passthrough fields (that's how tagPrefix works on the github target). The docs example with top-level github: (global owner/repo) + targets: - name: github, tagPrefix: is structurally valid.

Now let me verify one subtle regression concern in findReleaseBranches: the OLD code, for a single-segment prefix like release, matched a bare origin/release branch (no version) as an exact match. Let me confirm this was actually reachable/intended and whether it's a real regression. Let me look at how findReleaseBranches is used.

anthropic/claude-opus-4.8 · 2026-07-16 12:52:32 · ~206 tokens
result
src/commands/publish.ts:46:  findReleaseBranches,
src/commands/publish.ts:608:      const { exactMatches, fuzzyMatches } = await findReleaseBranches(
src/utils/git.ts:222:export async function findReleaseBranches(
gateway · 2026-07-16 12:52:52
result
<path>/home/byk/Code/getsentry/craft/src/commands/publish.ts</path>
<type>file</type>
<content>
585:   const branchPrefix =
586:     config.releaseBranchPrefix || DEFAULT_RELEASE_BRANCH_NAME;
587: 
588:   const rev = argv.rev;
589:   let checkoutTarget;
590:   let branchName;
591:   if (rev) {
592:     logger.debug(`Trying to get branch name for provided revision: "${rev}"`);
593:     branchName = (
594:       await git.raw('name-rev', '--name-only', '--no-undefined', rev)
595:     ).trim();
596:     checkoutTarget = branchName || rev;
597:     logger.debug('Checking out revision', checkoutTarget);
598:     await git.checkout(checkoutTarget);
599:   } else {
600:     // Find the remote branch
601:     branchName = `${branchPrefix}/${newVersion}`;
602:     checkoutTarget = branchName;
603: 
604:     try {
605:       logger.debug('Checking out release branch', branchName);
606:       await git.checkout(checkoutTarget);
607:     } catch (err) {
608:       const { exactMatches, ...
gateway · 2026-07-16 12:53:13

This is purely an error-message helper — it lists candidate release branches when a checkout fails. A bare origin/release branch (no version) is not a real release branch (which is always ${prefix}/${version}), so excluding it from the suggestion list is actually a correct improvement, not a harmful regression. The behavioral difference exists but only affects a hint message, and in a benign direction.

Now let me carefully walk through ALL existing single-segment test cases with the NEW code to confirm identical results. Let me verify with a script.

anthropic/claude-opus-4.8 · 2026-07-16 12:53:13 · ~189 tokens
result
SAME "release" ["origin/release/1.2.0","origin/release/1.2.1"] OLD {"ex":["origin/release/1.2.0","origin/release/1.2.1"],"fz":[]} NEW {"ex":["origin/release/1.2.0","origin/release/1.2.1"],"fz":[]}
SAME "release" ["origin/releases/1.0.0","origin/relaese/2.0.0"] OLD {"ex":[],"fz":["origin/releases/1.0.0","origin/relaese/2.0.0"]} NEW {"ex":[],"fz":["origin/releases/1.0.0","origin/relaese/2.0.0"]}
SAME "release" ["origin/release/1.0.0","origin/releases/1.0.0","origin/release/2.0.0"] OLD {"ex":["origin/release/1.0.0","origin/release/2.0.0"],"fz":["origin/releases/1.0.0"]} NEW {"ex":["origin/release/1.0.0","origin/release/2.0.0"],"fz":["origin/releases/1.0.0"]}
SAME "release" ["origin/main","origin/develop","origin/feature/foo"] OLD {"ex":[],"fz":[]} NEW {"ex":[],"fz":[]}
SAME "release" ["origin/release/1.0.0"] OLD {"ex":["origin/release/1.0.0"],"fz":[]} NEW {"ex":["origin/release/1.0.0"],"fz":[]}
SAME "release" ["origin/rel/1.0.0","origin/r/1.0.0"] OLD {"ex":[],"fz":[]} NEW ...
gateway · 2026-07-16 12:53:38

This confirms my analysis empirically. Every existing test case produces identical results. The only behavioral difference is the bare origin/release (no version segment) case, which:

  1. Is not covered by any existing test (no regression in test terms).
  2. Is a benign, arguably-correct change (bare release is not a real release branch).

One more edge case to check: what if withoutRemote has FEWER segments than the prefix (e.g., prefix release/cli but branch is origin/foo)? branchSegments = ["foo"], length 1 ≤ 2 → skipped. And slice(0,2) of ["foo"] = ["foo"]branchPrefix = "foo". Guard catches it via length <= prefixSegmentCount. Also, a branch origin/other/thing with prefix release/cli: branchSegments=["other","thing"], length 2 ≤ 2 → skipped even though it has 2 segments. That means a slashed prefix requires branch to have MORE than prefixSegmentCount segments (i.e., an actual version part). Correct.

But there's a subtle case: what about a fuzzy match for slashed prefix where the branch has exactly the same number of segments as prefix + version? Covered by the test (release/mcp/2.0.0 fuzzy matches release/cli). Good.

Now let me check the --match glob concern with an edge: for prefix v, --match 'v*' — this matches v1.2.3 but ALSO any tag starting with v (e.g. verbose-tag). But git describe --abbrev=0 returns the nearest, and non-version tags would then be passed to getVersion which extracts semver. This is the same behavior as before for the common v case (previously no --match at all, so it'd pick ANY nearest tag). So v* is actually more restrictive than before. No regression.

Let me verify the changelog-versioning-policy test mock is complete and check if changelog.ts uses anything else from config.

anthropic/claude-opus-4.8 · 2026-07-16 12:53:38 · ~599 tokens
result
4:import { findConfigFile, getGitTagPrefix, getVersioningPolicy } from '../config';
---
gateway · 2026-07-16 12:53:46

changelog.ts imports exactly findConfigFile, getGitTagPrefix, getVersioningPolicy — all three are mocked in the test. Good.

Now one final scrutiny — the getGitTagPrefix conflict check reads getConfiguration() which may emit the warning EVERY time it's called. In prepare.ts it's called twice (line 762 and 870). In a conflicting-config scenario, the warning would fire twice. Minor, but worth noting. Let me also verify the lint passes.

anthropic/claude-opus-4.8 · 2026-07-16 12:53:46 · ~149 tokens
result

/home/byk/Code/getsentry/craft/src/commands/publish.ts
  441:14  warning  '_abortError' is defined but never used   @typescript-eslint/no-unused-vars
  458:14  warning  '_abortError' is defined but never used   @typescript-eslint/no-unused-vars
  474:16  warning  '_statusError' is defined but never used  @typescript-eslint/no-unused-vars
  480:18  warning  '_diffError' is defined but never used    @typescript-eslint/no-unused-vars
  486:16  warning  '_abortError' is defined but never used   @typescript-eslint/no-unused-vars

/home/byk/Code/getsentry/craft/src/utils/git.ts
  231:12  warning  '_err' is defined but never used  @typescript-eslint/no-unused-vars
  239:12  warning  '_err' is defined but never used  @typescript-eslint/no-unused-vars

✖ 7 problems (0 errors, 7 warnings)

EXIT: 0
gateway · 2026-07-16 12:54:01

Only pre-existing warnings (unrelated to this change — _err, _abortError, etc. are intentional underscore-prefixed). Lint passes with 0 errors.

Let me do a final check on the isLatestRelease / GitHub "latest" cross-contamination concern the prompt raised.

anthropic/claude-opus-4.8 · 2026-07-16 12:54:01 · ~88 tokens
result
594:    options: { makeLatest: boolean } = { makeLatest: true },
600:      make_latest: options.makeLatest ? 'true' : 'false',
762:    const makeLatest = isPreview
764:      : isLatestRelease(latestRelease, version);
801:      await this.publishRelease(draftRelease, { makeLatest });
855:export function isLatestRelease(
gateway · 2026-07-16 12:54:19
result
<path>/home/byk/Code/getsentry/craft/src/targets/github.ts</path>
<type>file</type>
<content>
855: export function isLatestRelease(
856:   githubRelease: { tag_name: string } | undefined,
857:   version: string,
858: ) {
859:   const latestVersion = githubRelease && parseVersion(githubRelease.tag_name);
860:   const versionToPublish = parseVersion(version);
861:   return latestVersion && versionToPublish
862:     ? versionGreaterOrEqualThan(versionToPublish, latestVersion)
863:     : true; // By default, we tag as latest
864: }

(End of file - total 864 lines)
</content>
gateway · 2026-07-16 12:54:28
result
<path>/home/byk/Code/getsentry/craft/src/targets/github.ts</path>
<type>file</type>
<content>
755:         ? `Previous release: ${latestReleaseTag}`
756:         : 'No previous release found',
757:     );
758: 
759:     // Preview versions should never be marked as latest
760:     const isPreview =
761:       this.githubConfig.previewReleases && isPreviewRelease(version);
762:     const makeLatest = isPreview
763:       ? false
764:       : isLatestRelease(latestRelease, version);
765: 
766:     // Check if a published release for this tag already exists. This
767:     // handles the case where a previous publish run succeeded but crashed
768:     // before persisting state (e.g., floating tag failure or process crash).
769:     // Note: getReleaseByTag only returns published releases — draft releases
770:     // return 404. Draft cleanup is handled separately below via 422 recovery.
771:     const tag = versionToTag(version, this.githubConfig.tagPrefix);
772:     const ...
gateway · 2026-07-16 12:55:28

Let me check where latestRelease comes from — this is the potential cross-contamination point for GitHub's make_latest flag.

anthropic/claude-opus-4.8 · 2026-07-16 12:55:28 · ~43 tokens
result
<path>/home/byk/Code/getsentry/craft/src/targets/github.ts</path>
<type>file</type>
<content>
740:           owner: this.githubConfig.owner,
741:           repo: this.githubConfig.repo,
742:         })
743:       ).data;
744:     } catch (error) {
745:       // if the error is a 404 error, it means that no release exists yet
746:       // all other errors should be rethrown
747:       if (error.status !== 404) {
748:         throw error;
749:       }
750:     }
751: 
752:     const latestReleaseTag = latestRelease?.tag_name;
753:     this.logger.info(
754:       latestReleaseTag
755:         ? `Previous release: ${latestReleaseTag}`
756:         : 'No previous release found',
757:     );
758: 
759:     // Preview versions should never be marked as latest
760:     const isPreview =
761:       this.githubConfig.previewReleases && isPreviewRelease(version);
762:     const makeLatest = isPreview
763:       ? ...
gateway · 2026-07-16 12:55:32
result
<path>/home/byk/Code/getsentry/craft/src/targets/github.ts</path>
<type>file</type>
<content>
732:         path: await this.artifactProvider.downloadArtifact(artifact),
733:       })),
734:     );
735: 
736:     let latestRelease: { tag_name: string } | undefined = undefined;
737:     try {
738:       latestRelease = (
739:         await this.github.repos.getLatestRelease({
740:           owner: this.githubConfig.owner,
741:           repo: this.githubConfig.repo,
742:         })
743:       ).data;
744:     } catch (error) {
745:       // if the error is a 404 error, it means that no release exists yet
746:       // all other errors should be rethrown

(Showing lines 732-746 of 864. Use offset=747 to continue.)
</content>
gateway · 2026-07-16 12:56:19

This confirms a pre-existing limitation that becomes newly relevant with monorepo support: github.repos.getLatestRelease() returns the repo-wide latest release (whatever GitHub considers "latest" across the whole repo), NOT scoped to tagPrefix. So isLatestRelease compares against the wrong product's version, and make_latest is set based on cross-product comparison.

Concretely: if mcp@3.0.0 is the repo's GitHub "latest", then releasing cli@1.2.3 will compute isLatestRelease(mcp@3.0.0, cli@1.2.3)versionGreaterOrEqualThan(1.2.3, 3.0.0)falsecli@1.2.3 won't be marked latest (even though it IS the latest CLI). Conversely cli@9.0.0 would steal "latest" from mcp. This is NOT touched by this PR and is only partially mitigated by the "one .craft.yml per product" model (both configs still point at the same repo, so getLatestRelease is still repo-wide). This is a genuine gap worth flagging as a limitation.

I have enough to write up the review. Let me compile the findings.


Adversarial Review: Monorepo Prefixed-Tag Support

Verification status: tsc --noEmit -p tsconfig.build.json clean · targeted tests 95/95 pass · full suite 1054 passed / 1 pre-existing skip · pnpm lint 0 errors (7 pre-existing underscore-var warnings). Regex behavior empirically confirmed. All findings below verified against source, not just tests.

Verdict on the riskiest change: findReleaseBranches is regression-free ✅

I re-implemented both OLD and NEW logic and ran every existing test scenario plus adversarial extras. All existing cases produce byte-identical output. Walk-through:

| Case (prefix release) | OLD | NEW | Same? | |---|---|---|---| | release/1.2.0, release/1.2.1 | exact both | exact both | ✅ | | releases/…, relaese/… | fuzzy (dist 1,2) | fuzzy | ✅ | | exact + fuzzy mix | ✅ | ✅ | ✅ | | main,develop,feature/foo | none | none | ✅ | | rel/…(dist 4), r/…(dist 6) | excluded | excluded | ✅ | | main+release/1.0.0 ...

anthropic/claude-opus-4.8 · 2026-07-16 12:56:19 · ~2.6K tokens

Distillations (3)

Gen 0 2026-07-16 12:57:09 · 716 tokens

Date: July 16, 2026  * 🔴 (12:49) User provided the source of `getConfiguration` in `src/config.ts` showing config caching logic and `validateConfiguration` call. * 🔴 (12:50) User provided the source of `getConfigFilePath` in `src/config.ts` showing `findConfigFile` usage and `ConfigurationError` throwing. * 🔴 (12:50) User provided the source of `prepare.ts` (lines 830-874) showing `getLatestTa…

Gen 0 2026-07-16 12:50:54 · 954 tokens

Date: July 16, 2026  * 🔴 (12:46) User stated that in a monorepo, products should never cross-contaminate each other's version history. * 🟡 (12:46) User provided a diff showing changes to `findReleaseBranches` in `src/utils/git.ts` that handles slashed prefixes (e.g. `release/cli`) by comparing leading segments rather than just the first path segment. * 🟡 (12:46) User provided a diff showing ch…

Gen 0 2026-07-16 12:49:58 · 455 tokens

Date: July 16, 2026 * 🟡 (12:45) User asked for an adversarial senior code review of a monorepo prefixed-tag support change in the getsentry/craft TypeScript repository, with specific instructions to find bugs, edge cases, regressions, and inconsistencies without modifying files. * 🟡 (12:45) User provided detailed context about the change, including key files modified (src/utils/git.ts, src/conf…