From c6400ba8720008d284804e74f94d7c51f3041bce Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:55:01 +0000 Subject: [PATCH] feat(config): Expand workspace placeholders in release workspace settings Fixes #937 Co-Authored-By: Burak Yigit Kaya <126780+BYK@users.noreply.github.com> --- docs/src/content/docs/workspaces.md | 48 ++++++++++++- src/__tests__/config.test.ts | 91 +++++++++++++++++++++++++ src/config.ts | 102 +++++++++++++++++++++++++++- 3 files changed, 238 insertions(+), 3 deletions(-) diff --git a/docs/src/content/docs/workspaces.md b/docs/src/content/docs/workspaces.md index c5f499331..050896c06 100644 --- a/docs/src/content/docs/workspaces.md +++ b/docs/src/content/docs/workspaces.md @@ -88,8 +88,9 @@ workspaces: :::caution Every directory matched by a pattern gets the **same** settings, including the -same `tagPrefix` and `releaseBranchPrefix`. Give each product a literal key -when it needs its own tags and release branches. +same `tagPrefix` and `releaseBranchPrefix`. Use +[placeholders](#placeholders) to give each match its own tags and release +branches, or give it a literal key. ::: Workspace paths use nonempty ASCII path segments containing letters, digits, @@ -99,6 +100,49 @@ path belongs to exactly one key. A configuration with `workspaces` cannot use `github.projectPath`. +### Placeholders + +String settings can use placeholders that expand for the selected workspace. +They work in workspace settings and in the top-level settings a workspace +inherits: + +| Placeholder | Value for `static/packages/scraps` | +| ----------------------- | ---------------------------------- | +| `{{workspace}}` | `static/packages/scraps` | +| `{{workspaceName}}` | `scraps` | +| `{{packageName}}` | `@sentry/scraps` | +| `{{packageSimpleName}}` | `sentry-scraps` | + +`{{packageName}}` and `{{packageSimpleName}}` read `name` from the workspace's +`package.json`; Craft fails if the file or its `name` is missing. Other +`{{...}}` markers, such as the npm target's `{{name}}`, `{{simpleName}}`, and +`{{version}}` [artifact templates](../targets/npm/#artifact-naming), are left alone. + +One pattern can then cover independently released packages: + +```yaml +workspaces: + static/packages/*: + releaseBranchPrefix: '{{workspaceName}}/release' + statusProvider: + name: github + config: + contexts: ['{{workspaceName}}-package-pack'] + artifactProvider: + name: github + config: + artifacts: + Package: ['{{workspaceName}}-tarball'] + targets: + - name: github + tagPrefix: '{{workspaceName}}@' + - name: npm + access: public +``` + +Releasing `static/packages/scraps` at `1.2.3` uses the branch +`scraps/release/1.2.3` and the tag `scraps@1.2.3`. + ## Select a workspace Once `workspaces` is defined, every release command needs to know which diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index 8da41407a..2bb9f0a04 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -767,6 +767,97 @@ describe('workspaces', () => { expect(getConfiguration(true).releaseBranchPrefix).toBe('release/package'); }); + test('expands workspace placeholders for each glob match', () => { + const directory = mkdtempSync(join(tmpdir(), 'craft-workspaces-')); + temporaryDirectories.push(directory); + for (const name of ['scraps', 'icons']) { + mkdirSync(join(directory, 'packages', name), { recursive: true }); + writeFileSync( + join(directory, 'packages', name, 'package.json'), + JSON.stringify({ name: `@sentry/${name}` }), + ); + } + writeFileSync( + join(directory, '.craft.yml'), + [ + `minVersion: ${WORKSPACES_MIN_VERSION}`, + 'github:', + ' owner: getsentry', + ' repo: sentry', + // Inherited top-level settings expand too. + "releaseBranchPrefix: '{{workspaceName}}/release'", + 'workspaces:', + ' packages/*:', + ' statusProvider:', + ' name: github', + ' config:', + " contexts: ['{{workspaceName}}-package-pack']", + ' artifactProvider:', + ' name: github', + ' config:', + ' artifacts:', + " Package: ['{{packageSimpleName}}-tarball']", + ' targets:', + ' - name: github', + " tagPrefix: '{{workspaceName}}@'", + ' - name: npm', + " id: '{{packageName}} in {{workspace}}'", + // Target-level templates keep their own markers. + " artifactTemplate: '{{simpleName}}-{{version}}.tgz'", + ].join('\n'), + ); + process.chdir(directory); + + setActiveWorkspace('packages/scraps'); + const scraps = getConfiguration(true); + expect(scraps.releaseBranchPrefix).toBe('scraps/release'); + expect(scraps.statusProvider?.config?.contexts).toEqual([ + 'scraps-package-pack', + ]); + expect(scraps.artifactProvider?.config?.artifacts).toEqual({ + Package: ['sentry-scraps-tarball'], + }); + expect(scraps.targets).toEqual([ + { name: 'github', tagPrefix: 'scraps@' }, + { + name: 'npm', + id: '@sentry/scraps in packages/scraps', + artifactTemplate: '{{simpleName}}-{{version}}.tgz', + }, + ]); + expect(getGitTagPrefix()).toBe('scraps@'); + + setActiveWorkspace('packages/icons'); + expect(getConfiguration(true).releaseBranchPrefix).toBe('icons/release'); + expect(getGitTagPrefix()).toBe('icons@'); + }); + + test('rejects package placeholders without a package name', () => { + const directory = mkdtempSync(join(tmpdir(), 'craft-workspaces-')); + temporaryDirectories.push(directory); + mkdirSync(join(directory, 'packages', 'cli'), { recursive: true }); + writeFileSync( + join(directory, '.craft.yml'), + [ + `minVersion: ${WORKSPACES_MIN_VERSION}`, + 'workspaces:', + ' packages/*:', + " releaseBranchPrefix: '{{packageName}}/release'", + ].join('\n'), + ); + process.chdir(directory); + + setActiveWorkspace('packages/cli'); + expect(() => getConfiguration(true)).toThrow( + 'Workspace "packages/cli" uses {{packageName}}, but packages/cli/package.json could not be read', + ); + + writeFileSync(join(directory, 'packages', 'cli', 'package.json'), '{}'); + expect(() => getConfiguration(true)).toThrow( + 'packages/cli/package.json has no "name"', + ); + }); + test('expands remote configuration globs from the repository root', () => { const directory = mkdtempSync(join(tmpdir(), 'craft-workspaces-')); temporaryDirectories.push(directory); diff --git a/src/config.ts b/src/config.ts index 0b8edace8..50b5832db 100644 --- a/src/config.ts +++ b/src/config.ts @@ -110,6 +110,9 @@ export function getActiveWorkspace(): string | undefined { * - `github` is shallow-merged (owner/repo) so a workspace can override either * value while inheriting the other. * - `minVersion` and `workspaces` themselves are stripped from the result. + * - Workspace placeholders (see `WORKSPACE_PLACEHOLDERS`) in any string + * setting expand for the selected workspace, so one glob key can give each + * matched directory its own tags, branches, and artifact names. */ function resolveWorkspaceConfig( base: CraftProjectConfig, @@ -164,7 +167,104 @@ function resolveWorkspaceConfig( } } - return resolved; + return expandWorkspacePlaceholders( + resolved, + getWorkspacePlaceholderResolver(workspaceName, workspaceDirectory), + ); +} + +/** + * Placeholders that expand to the selected workspace in its settings. Other + * `{{...}}` markers, such as the npm target's `{{name}}`, `{{simpleName}}`, + * and `{{version}}` artifact template fields, stay untouched. + */ +const WORKSPACE_PLACEHOLDERS = [ + 'workspace', + 'workspaceName', + 'packageName', + 'packageSimpleName', +] as const; + +type WorkspacePlaceholder = (typeof WORKSPACE_PLACEHOLDERS)[number]; + +const WORKSPACE_PLACEHOLDER_REGEX = new RegExp( + `\\{\\{(${WORKSPACE_PLACEHOLDERS.join('|')})\\}\\}`, + 'g', +); + +function getWorkspacePlaceholderResolver( + workspaceName: string, + workspaceDirectory: string, +): (placeholder: WorkspacePlaceholder) => string { + let packageName: string | undefined; + const readPackageName = (placeholder: WorkspacePlaceholder): string => { + if (packageName !== undefined) { + return packageName; + } + const packageJsonPath = path.posix.join(workspaceName, 'package.json'); + let name: unknown; + try { + name = JSON.parse( + readFileSync(path.join(workspaceDirectory, packageJsonPath), 'utf-8'), + )?.name; + } catch (error) { + throw new ConfigurationError( + `Workspace "${workspaceName}" uses {{${placeholder}}}, but ` + + `${packageJsonPath} could not be read: ${(error as Error).message}`, + ); + } + if (typeof name !== 'string' || name === '') { + throw new ConfigurationError( + `Workspace "${workspaceName}" uses {{${placeholder}}}, but ` + + `${packageJsonPath} has no "name".`, + ); + } + packageName = name; + return name; + }; + + return placeholder => { + switch (placeholder) { + case 'workspace': + return workspaceName; + case 'workspaceName': + return path.posix.basename(workspaceName); + case 'packageName': + return readPackageName(placeholder); + case 'packageSimpleName': + return readPackageName(placeholder) + .replace(/^@/, '') + .replace(/\//g, '-'); + } + }; +} + +function expandWorkspacePlaceholders( + value: T, + resolve: (placeholder: WorkspacePlaceholder) => string, +): T { + if (typeof value === 'string') { + return value.replace(WORKSPACE_PLACEHOLDER_REGEX, (_match, placeholder) => + resolve(placeholder as WorkspacePlaceholder), + ) as T; + } + if (Array.isArray(value)) { + return value.map(item => expandWorkspacePlaceholders(item, resolve)) as T; + } + // Only plain objects: YAML may also produce values such as Dates. + if ( + value !== null && + typeof value === 'object' && + Object.getPrototypeOf(value) === Object.prototype + ) { + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [ + key, + expandWorkspacePlaceholders(item, resolve), + ]), + ) as T; + } + return value; } function getWorkspaceConfig(