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
48 changes: 46 additions & 2 deletions docs/src/content/docs/workspaces.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down
91 changes: 91 additions & 0 deletions src/__tests__/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
102 changes: 101 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<T>(
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(
Expand Down
Loading