Skip to content

Commit cc3cd64

Browse files
committed
feat: add cargoWorkspacePath option to cargo-workspace plugin
Support configuring a non-root workspace path so that the plugin can find the workspace Cargo.toml in a subdirectory (e.g. "crates/"). Closes #2589
1 parent 891bcf6 commit cc3cd64

6 files changed

Lines changed: 228 additions & 11 deletions

File tree

docs/manifest-releaser.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -567,6 +567,20 @@ does _not_ update the dependencies, and the `cargo-workspace` plug-in must be
567567
used to update dependencies and bump all dependents — this is the recommended
568568
way of managing a Rust monorepo with release-please.
569569

570+
If your Cargo workspace `Cargo.toml` is not at the repository root (e.g. it is
571+
in a `crates/` subdirectory), you can specify the `cargoWorkspacePath` option:
572+
573+
```json
574+
{
575+
"plugins": [
576+
{
577+
"type": "cargo-workspace",
578+
"cargoWorkspacePath": "crates"
579+
}
580+
]
581+
}
582+
```
583+
570584
### maven-workspace
571585

572586
The `maven-workspace` plugin operates similarly to the `node-workspace` plugin,

schemas/config.json

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -335,13 +335,36 @@
335335
"required": ["type", "groupName", "components"]
336336
},
337337
{
338-
"description": "Configuration for various `workspace` plugins.",
338+
"description": "Configuration for the `cargo-workspace` plugin.",
339+
"type": "object",
340+
"properties": {
341+
"type": {
342+
"description": "The name of the plugin.",
343+
"type": "string",
344+
"enum": ["cargo-workspace"]
345+
},
346+
"updateAllPackages": {
347+
"description": "Whether to force updating all packages regardless of the dependency tree. Defaults to `false`.",
348+
"type": "boolean"
349+
},
350+
"merge": {
351+
"description": "Whether to merge in-scope pull requests into a combined release pull request. Defaults to `true`.",
352+
"type": "boolean"
353+
},
354+
"cargoWorkspacePath": {
355+
"description": "Path to the directory containing the workspace Cargo.toml. Defaults to the repository root.",
356+
"type": "string"
357+
}
358+
}
359+
},
360+
{
361+
"description": "Configuration for the `maven-workspace` plugin.",
339362
"type": "object",
340363
"properties": {
341364
"type": {
342365
"description": "The name of the plugin.",
343366
"type": "string",
344-
"enum": ["cargo-workspace", "maven-workspace"]
367+
"enum": ["maven-workspace"]
345368
},
346369
"updateAllPackages": {
347370
"description": "Whether to force updating all packages regardless of the dependency tree. Defaults to `false`.",

src/factories/plugin-factory.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
// limitations under the License.
1414

1515
import {
16+
CargoWorkspacePluginConfig,
1617
LinkedVersionPluginConfig,
1718
PluginType,
1819
RepositoryConfig,
@@ -75,9 +76,9 @@ const pluginFactories: Record<string, PluginBuilder> = {
7576
options.repositoryConfig,
7677
{
7778
...options,
78-
...(options.type as WorkspacePluginOptions),
79+
...(options.type as CargoWorkspacePluginConfig),
7980
merge:
80-
(options.type as WorkspacePluginOptions).merge ??
81+
(options.type as CargoWorkspacePluginConfig).merge ??
8182
!options.separatePullRequests,
8283
}
8384
),

src/manifest.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,9 @@ export interface WorkspacePluginConfig extends ConfigurablePluginType {
246246
export interface NodeWorkspacePluginConfig extends WorkspacePluginConfig {
247247
updatePeerDependencies?: boolean;
248248
}
249+
export interface CargoWorkspacePluginConfig extends WorkspacePluginConfig {
250+
cargoWorkspacePath?: string;
251+
}
249252
export interface GroupPriorityPluginConfig extends ConfigurablePluginType {
250253
groups: string[];
251254
}
@@ -256,7 +259,8 @@ export type PluginType =
256259
| LinkedVersionPluginConfig
257260
| SentenceCasePluginConfig
258261
| WorkspacePluginConfig
259-
| NodeWorkspacePluginConfig;
262+
| NodeWorkspacePluginConfig
263+
| CargoWorkspacePluginConfig;
260264

261265
/**
262266
* This is the schema of the manifest config json

src/plugins/cargo-workspace.ts

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,16 @@
1212
// See the License for the specific language governing permissions and
1313
// limitations under the License.
1414

15-
import {CandidateReleasePullRequest, ROOT_PROJECT_PATH} from '../manifest';
15+
import {
16+
CandidateReleasePullRequest,
17+
RepositoryConfig,
18+
ROOT_PROJECT_PATH,
19+
} from '../manifest';
1620
import {
1721
WorkspacePlugin,
1822
DependencyGraph,
1923
DependencyNode,
24+
WorkspacePluginOptions,
2025
addPath,
2126
appendDependenciesSectionToChangelog,
2227
} from './workspace';
@@ -38,6 +43,7 @@ import {BranchName} from '../util/branch-name';
3843
import {PatchVersionUpdate} from '../versioning-strategy';
3944
import {CargoLock} from '../updaters/rust/cargo-lock';
4045
import {ConfigurationError} from '../errors';
46+
import {GitHub} from '../github';
4147
import {Strategy} from '../strategy';
4248
import {Commit} from '../commit';
4349
import {Release} from '../release';
@@ -74,6 +80,10 @@ interface CrateInfo {
7480
manifest: CargoManifest;
7581
}
7682

83+
interface CargoWorkspaceOptions extends WorkspacePluginOptions {
84+
cargoWorkspacePath?: string;
85+
}
86+
7787
/**
7888
* The plugin analyzed a cargo workspace and will bump dependencies
7989
* of managed packages if those dependencies are being updated.
@@ -84,6 +94,25 @@ interface CrateInfo {
8494
export class CargoWorkspace extends WorkspacePlugin<CrateInfo> {
8595
private strategiesByPath: Record<string, Strategy> = {};
8696
private releasesByPath: Record<string, Release> = {};
97+
private workspacePath: string;
98+
99+
constructor(
100+
github: GitHub,
101+
targetBranch: string,
102+
repositoryConfig: RepositoryConfig,
103+
options: CargoWorkspaceOptions = {}
104+
) {
105+
super(github, targetBranch, repositoryConfig, options);
106+
// Normalize: strip leading "./" and trailing slashes
107+
// so that "./crates/" becomes "crates" for consistent path joining
108+
this.workspacePath = (options.cargoWorkspacePath ?? '')
109+
.replace(/^\.\//, '')
110+
.replace(/\/+$/, '');
111+
}
112+
113+
private resolveWorkspacePath(file: string): string {
114+
return this.workspacePath ? `${this.workspacePath}/${file}` : file;
115+
}
87116

88117
protected async buildAllPackages(
89118
candidates: CandidateReleasePullRequest[]
@@ -92,7 +121,7 @@ export class CargoWorkspace extends WorkspacePlugin<CrateInfo> {
92121
candidatesByPackage: Record<string, CandidateReleasePullRequest>;
93122
}> {
94123
const cargoManifestContent = await this.github.getFileContentsOnBranch(
95-
'Cargo.toml',
124+
this.resolveWorkspacePath('Cargo.toml'),
96125
this.targetBranch
97126
);
98127
const cargoManifest = parseCargoManifest(
@@ -111,11 +140,14 @@ export class CargoWorkspace extends WorkspacePlugin<CrateInfo> {
111140
const members = (
112141
await Promise.all(
113142
cargoManifest.workspace.members.map(member =>
114-
this.github.findFilesByGlobAndRef(member, this.targetBranch)
143+
this.github.findFilesByGlobAndRef(
144+
this.resolveWorkspacePath(member),
145+
this.targetBranch
146+
)
115147
)
116148
)
117149
).flat();
118-
members.push(ROOT_PROJECT_PATH);
150+
members.push(this.workspacePath || ROOT_PROJECT_PATH);
119151

120152
for (const path of members) {
121153
const manifestPath = addPath(path, 'Cargo.toml');
@@ -332,7 +364,8 @@ export class CargoWorkspace extends WorkspacePlugin<CrateInfo> {
332364
candidates: CandidateReleasePullRequest[],
333365
updatedVersions: VersionsMap
334366
): CandidateReleasePullRequest[] {
335-
let rootCandidate = candidates.find(c => c.path === ROOT_PROJECT_PATH);
367+
const rootPath = this.workspacePath || ROOT_PROJECT_PATH;
368+
let rootCandidate = candidates.find(c => c.path === rootPath);
336369
if (!rootCandidate) {
337370
this.logger.warn('Unable to find root candidate pull request');
338371
rootCandidate = candidates.find(c => c.config.releaseType === 'rust');
@@ -344,7 +377,7 @@ export class CargoWorkspace extends WorkspacePlugin<CrateInfo> {
344377

345378
// Update the root Cargo.lock if it exists
346379
rootCandidate.pullRequest.updates.push({
347-
path: 'Cargo.lock',
380+
path: this.resolveWorkspacePath('Cargo.lock'),
348381
createIfMissing: false,
349382
updater: new CargoLock(updatedVersions),
350383
});

test/plugins/cargo-workspace.ts

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -553,6 +553,148 @@ describe('CargoWorkspace plugin', () => {
553553
}
554554
);
555555
});
556+
it('handles non-root workspace path via cargo-workspace-path', async () => {
557+
const candidates: CandidateReleasePullRequest[] = [
558+
buildMockCandidatePullRequest(
559+
'crates/packages/rustA',
560+
'rust',
561+
'1.1.2',
562+
{
563+
component: 'pkgA',
564+
updates: [
565+
buildMockPackageUpdate(
566+
'crates/packages/rustA/Cargo.toml',
567+
'packages/rustA/Cargo.toml'
568+
),
569+
],
570+
}
571+
),
572+
];
573+
stubFilesFromFixtures({
574+
sandbox,
575+
github,
576+
fixturePath: fixturesPath,
577+
files: [],
578+
flatten: false,
579+
targetBranch: 'main',
580+
inlineFiles: [
581+
['crates/Cargo.toml', '[workspace]\nmembers = ["packages/rustA"]'],
582+
[
583+
'crates/packages/rustA/Cargo.toml',
584+
'[package]\nname = "pkgA"\nversion = "1.1.1"\n\n[dependencies]\ntracing = "1.0.0"',
585+
],
586+
],
587+
});
588+
sandbox
589+
.stub(github, 'findFilesByGlobAndRef')
590+
.withArgs('crates/packages/rustA', 'main')
591+
.resolves(['crates/packages/rustA']);
592+
plugin = new CargoWorkspace(
593+
github,
594+
'main',
595+
{
596+
'crates/packages/rustA': {
597+
releaseType: 'rust',
598+
},
599+
},
600+
{
601+
cargoWorkspacePath: 'crates',
602+
}
603+
);
604+
const newCandidates = await plugin.run(candidates);
605+
expect(newCandidates).lengthOf(1);
606+
const rustCandidate = newCandidates.find(
607+
candidate => candidate.config.releaseType === 'rust'
608+
);
609+
expect(rustCandidate).to.not.be.undefined;
610+
const updates = rustCandidate!.pullRequest.updates;
611+
assertHasUpdate(updates, 'crates/packages/rustA/Cargo.toml');
612+
assertHasUpdate(updates, 'crates/Cargo.lock');
613+
});
614+
it('walks dependency tree with non-root workspace path', async () => {
615+
const candidates: CandidateReleasePullRequest[] = [
616+
buildMockCandidatePullRequest(
617+
'crates/packages/rustA',
618+
'rust',
619+
'1.1.2',
620+
{
621+
component: 'pkgA',
622+
updates: [
623+
buildMockPackageUpdate(
624+
'crates/packages/rustA/Cargo.toml',
625+
'packages/rustA/Cargo.toml'
626+
),
627+
],
628+
}
629+
),
630+
];
631+
stubFilesFromFixtures({
632+
sandbox,
633+
github,
634+
fixturePath: fixturesPath,
635+
files: [],
636+
flatten: false,
637+
targetBranch: 'main',
638+
inlineFiles: [
639+
[
640+
'crates/Cargo.toml',
641+
'[workspace]\nmembers = ["packages/rustA", "packages/rustB", "packages/rustC"]',
642+
],
643+
[
644+
'crates/packages/rustA/Cargo.toml',
645+
'[package]\nname = "pkgA"\nversion = "1.1.1"\n\n[dependencies]\ntracing = "1.0.0"',
646+
],
647+
[
648+
'crates/packages/rustB/Cargo.toml',
649+
'[package]\nname = "pkgB"\nversion = "2.2.2"\n\n[dependencies]\npkgA = { version = "1.1.1", path = "../pkgA" }',
650+
],
651+
[
652+
'crates/packages/rustC/Cargo.toml',
653+
'[package]\nname = "pkgC"\nversion = "3.3.3"\n\n[dependencies]\npkgB = { version = "2.2.2", path = "../pkgB" }',
654+
],
655+
],
656+
});
657+
sandbox
658+
.stub(github, 'findFilesByGlobAndRef')
659+
.withArgs('crates/packages/rustA', 'main')
660+
.resolves(['crates/packages/rustA'])
661+
.withArgs('crates/packages/rustB', 'main')
662+
.resolves(['crates/packages/rustB'])
663+
.withArgs('crates/packages/rustC', 'main')
664+
.resolves(['crates/packages/rustC']);
665+
plugin = new CargoWorkspace(
666+
github,
667+
'main',
668+
{
669+
'crates/packages/rustA': {
670+
releaseType: 'rust',
671+
},
672+
'crates/packages/rustB': {
673+
releaseType: 'rust',
674+
},
675+
'crates/packages/rustC': {
676+
releaseType: 'rust',
677+
},
678+
},
679+
{
680+
cargoWorkspacePath: 'crates',
681+
}
682+
);
683+
const newCandidates = await plugin.run(candidates);
684+
expect(newCandidates).lengthOf(1);
685+
const rustCandidate = newCandidates.find(
686+
candidate => candidate.config.releaseType === 'rust'
687+
);
688+
expect(rustCandidate).to.not.be.undefined;
689+
const updates = rustCandidate!.pullRequest.updates;
690+
// pkgA is directly released
691+
assertHasUpdate(updates, 'crates/packages/rustA/Cargo.toml', RawContent);
692+
// pkgB depends on pkgA, should be bumped
693+
assertHasUpdate(updates, 'crates/packages/rustB/Cargo.toml', RawContent);
694+
// pkgC depends on pkgB, should be transitively bumped
695+
assertHasUpdate(updates, 'crates/packages/rustC/Cargo.toml', RawContent);
696+
assertHasUpdate(updates, 'crates/Cargo.lock');
697+
});
556698
it('handles packages with invalid version', async () => {
557699
const candidates: CandidateReleasePullRequest[] = [
558700
buildMockCandidatePullRequest('packages/rustA', 'rust', '1.1.2', {

0 commit comments

Comments
 (0)