diff --git a/docs/manifest-releaser.md b/docs/manifest-releaser.md index 630395848..065a63359 100644 --- a/docs/manifest-releaser.md +++ b/docs/manifest-releaser.md @@ -567,6 +567,20 @@ does _not_ update the dependencies, and the `cargo-workspace` plug-in must be used to update dependencies and bump all dependents — this is the recommended way of managing a Rust monorepo with release-please. +If your Cargo workspace `Cargo.toml` is not at the repository root (e.g. it is +in a `crates/` subdirectory), you can specify the `cargoWorkspacePath` option: + +```json +{ + "plugins": [ + { + "type": "cargo-workspace", + "cargoWorkspacePath": "crates" + } + ] +} +``` + ### maven-workspace The `maven-workspace` plugin operates similarly to the `node-workspace` plugin, diff --git a/schemas/config.json b/schemas/config.json index 2a751312b..274bcba60 100644 --- a/schemas/config.json +++ b/schemas/config.json @@ -335,13 +335,36 @@ "required": ["type", "groupName", "components"] }, { - "description": "Configuration for various `workspace` plugins.", + "description": "Configuration for the `cargo-workspace` plugin.", + "type": "object", + "properties": { + "type": { + "description": "The name of the plugin.", + "type": "string", + "enum": ["cargo-workspace"] + }, + "updateAllPackages": { + "description": "Whether to force updating all packages regardless of the dependency tree. Defaults to `false`.", + "type": "boolean" + }, + "merge": { + "description": "Whether to merge in-scope pull requests into a combined release pull request. Defaults to `true`.", + "type": "boolean" + }, + "cargoWorkspacePath": { + "description": "Path to the directory containing the workspace Cargo.toml. Defaults to the repository root.", + "type": "string" + } + } + }, + { + "description": "Configuration for the `maven-workspace` plugin.", "type": "object", "properties": { "type": { "description": "The name of the plugin.", "type": "string", - "enum": ["cargo-workspace", "maven-workspace"] + "enum": ["maven-workspace"] }, "updateAllPackages": { "description": "Whether to force updating all packages regardless of the dependency tree. Defaults to `false`.", diff --git a/src/factories/plugin-factory.ts b/src/factories/plugin-factory.ts index 5f2756e51..58f69ddaf 100644 --- a/src/factories/plugin-factory.ts +++ b/src/factories/plugin-factory.ts @@ -13,6 +13,7 @@ // limitations under the License. import { + CargoWorkspacePluginConfig, LinkedVersionPluginConfig, PluginType, RepositoryConfig, @@ -75,9 +76,9 @@ const pluginFactories: Record = { options.repositoryConfig, { ...options, - ...(options.type as WorkspacePluginOptions), + ...(options.type as CargoWorkspacePluginConfig), merge: - (options.type as WorkspacePluginOptions).merge ?? + (options.type as CargoWorkspacePluginConfig).merge ?? !options.separatePullRequests, } ), diff --git a/src/manifest.ts b/src/manifest.ts index f0b46a05b..cdd1fbd18 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -246,6 +246,9 @@ export interface WorkspacePluginConfig extends ConfigurablePluginType { export interface NodeWorkspacePluginConfig extends WorkspacePluginConfig { updatePeerDependencies?: boolean; } +export interface CargoWorkspacePluginConfig extends WorkspacePluginConfig { + cargoWorkspacePath?: string; +} export interface GroupPriorityPluginConfig extends ConfigurablePluginType { groups: string[]; } @@ -256,7 +259,8 @@ export type PluginType = | LinkedVersionPluginConfig | SentenceCasePluginConfig | WorkspacePluginConfig - | NodeWorkspacePluginConfig; + | NodeWorkspacePluginConfig + | CargoWorkspacePluginConfig; /** * This is the schema of the manifest config json diff --git a/src/plugins/cargo-workspace.ts b/src/plugins/cargo-workspace.ts index 0a6efe6f2..1fb74809c 100644 --- a/src/plugins/cargo-workspace.ts +++ b/src/plugins/cargo-workspace.ts @@ -12,11 +12,16 @@ // See the License for the specific language governing permissions and // limitations under the License. -import {CandidateReleasePullRequest, ROOT_PROJECT_PATH} from '../manifest'; +import { + CandidateReleasePullRequest, + RepositoryConfig, + ROOT_PROJECT_PATH, +} from '../manifest'; import { WorkspacePlugin, DependencyGraph, DependencyNode, + WorkspacePluginOptions, addPath, appendDependenciesSectionToChangelog, } from './workspace'; @@ -38,6 +43,7 @@ import {BranchName} from '../util/branch-name'; import {PatchVersionUpdate} from '../versioning-strategy'; import {CargoLock} from '../updaters/rust/cargo-lock'; import {ConfigurationError} from '../errors'; +import {GitHub} from '../github'; import {Strategy} from '../strategy'; import {Commit} from '../commit'; import {Release} from '../release'; @@ -74,6 +80,10 @@ interface CrateInfo { manifest: CargoManifest; } +interface CargoWorkspaceOptions extends WorkspacePluginOptions { + cargoWorkspacePath?: string; +} + /** * The plugin analyzed a cargo workspace and will bump dependencies * of managed packages if those dependencies are being updated. @@ -84,6 +94,25 @@ interface CrateInfo { export class CargoWorkspace extends WorkspacePlugin { private strategiesByPath: Record = {}; private releasesByPath: Record = {}; + private workspacePath: string; + + constructor( + github: GitHub, + targetBranch: string, + repositoryConfig: RepositoryConfig, + options: CargoWorkspaceOptions = {} + ) { + super(github, targetBranch, repositoryConfig, options); + // Normalize: strip leading "./" and trailing slashes + // so that "./crates/" becomes "crates" for consistent path joining + this.workspacePath = (options.cargoWorkspacePath ?? '') + .replace(/^\.\//, '') + .replace(/\/+$/, ''); + } + + private resolveWorkspacePath(file: string): string { + return this.workspacePath ? `${this.workspacePath}/${file}` : file; + } protected async buildAllPackages( candidates: CandidateReleasePullRequest[] @@ -92,7 +121,7 @@ export class CargoWorkspace extends WorkspacePlugin { candidatesByPackage: Record; }> { const cargoManifestContent = await this.github.getFileContentsOnBranch( - 'Cargo.toml', + this.resolveWorkspacePath('Cargo.toml'), this.targetBranch ); const cargoManifest = parseCargoManifest( @@ -111,11 +140,14 @@ export class CargoWorkspace extends WorkspacePlugin { const members = ( await Promise.all( cargoManifest.workspace.members.map(member => - this.github.findFilesByGlobAndRef(member, this.targetBranch) + this.github.findFilesByGlobAndRef( + this.resolveWorkspacePath(member), + this.targetBranch + ) ) ) ).flat(); - members.push(ROOT_PROJECT_PATH); + members.push(this.workspacePath || ROOT_PROJECT_PATH); for (const path of members) { const manifestPath = addPath(path, 'Cargo.toml'); @@ -332,7 +364,8 @@ export class CargoWorkspace extends WorkspacePlugin { candidates: CandidateReleasePullRequest[], updatedVersions: VersionsMap ): CandidateReleasePullRequest[] { - let rootCandidate = candidates.find(c => c.path === ROOT_PROJECT_PATH); + const rootPath = this.workspacePath || ROOT_PROJECT_PATH; + let rootCandidate = candidates.find(c => c.path === rootPath); if (!rootCandidate) { this.logger.warn('Unable to find root candidate pull request'); rootCandidate = candidates.find(c => c.config.releaseType === 'rust'); @@ -344,7 +377,7 @@ export class CargoWorkspace extends WorkspacePlugin { // Update the root Cargo.lock if it exists rootCandidate.pullRequest.updates.push({ - path: 'Cargo.lock', + path: this.resolveWorkspacePath('Cargo.lock'), createIfMissing: false, updater: new CargoLock(updatedVersions), }); diff --git a/test/plugins/cargo-workspace.ts b/test/plugins/cargo-workspace.ts index 65f46ad22..04528162f 100644 --- a/test/plugins/cargo-workspace.ts +++ b/test/plugins/cargo-workspace.ts @@ -553,6 +553,148 @@ describe('CargoWorkspace plugin', () => { } ); }); + it('handles non-root workspace path via cargo-workspace-path', async () => { + const candidates: CandidateReleasePullRequest[] = [ + buildMockCandidatePullRequest( + 'crates/packages/rustA', + 'rust', + '1.1.2', + { + component: 'pkgA', + updates: [ + buildMockPackageUpdate( + 'crates/packages/rustA/Cargo.toml', + 'packages/rustA/Cargo.toml' + ), + ], + } + ), + ]; + stubFilesFromFixtures({ + sandbox, + github, + fixturePath: fixturesPath, + files: [], + flatten: false, + targetBranch: 'main', + inlineFiles: [ + ['crates/Cargo.toml', '[workspace]\nmembers = ["packages/rustA"]'], + [ + 'crates/packages/rustA/Cargo.toml', + '[package]\nname = "pkgA"\nversion = "1.1.1"\n\n[dependencies]\ntracing = "1.0.0"', + ], + ], + }); + sandbox + .stub(github, 'findFilesByGlobAndRef') + .withArgs('crates/packages/rustA', 'main') + .resolves(['crates/packages/rustA']); + plugin = new CargoWorkspace( + github, + 'main', + { + 'crates/packages/rustA': { + releaseType: 'rust', + }, + }, + { + cargoWorkspacePath: 'crates', + } + ); + const newCandidates = await plugin.run(candidates); + expect(newCandidates).lengthOf(1); + const rustCandidate = newCandidates.find( + candidate => candidate.config.releaseType === 'rust' + ); + expect(rustCandidate).to.not.be.undefined; + const updates = rustCandidate!.pullRequest.updates; + assertHasUpdate(updates, 'crates/packages/rustA/Cargo.toml'); + assertHasUpdate(updates, 'crates/Cargo.lock'); + }); + it('walks dependency tree with non-root workspace path', async () => { + const candidates: CandidateReleasePullRequest[] = [ + buildMockCandidatePullRequest( + 'crates/packages/rustA', + 'rust', + '1.1.2', + { + component: 'pkgA', + updates: [ + buildMockPackageUpdate( + 'crates/packages/rustA/Cargo.toml', + 'packages/rustA/Cargo.toml' + ), + ], + } + ), + ]; + stubFilesFromFixtures({ + sandbox, + github, + fixturePath: fixturesPath, + files: [], + flatten: false, + targetBranch: 'main', + inlineFiles: [ + [ + 'crates/Cargo.toml', + '[workspace]\nmembers = ["packages/rustA", "packages/rustB", "packages/rustC"]', + ], + [ + 'crates/packages/rustA/Cargo.toml', + '[package]\nname = "pkgA"\nversion = "1.1.1"\n\n[dependencies]\ntracing = "1.0.0"', + ], + [ + 'crates/packages/rustB/Cargo.toml', + '[package]\nname = "pkgB"\nversion = "2.2.2"\n\n[dependencies]\npkgA = { version = "1.1.1", path = "../pkgA" }', + ], + [ + 'crates/packages/rustC/Cargo.toml', + '[package]\nname = "pkgC"\nversion = "3.3.3"\n\n[dependencies]\npkgB = { version = "2.2.2", path = "../pkgB" }', + ], + ], + }); + sandbox + .stub(github, 'findFilesByGlobAndRef') + .withArgs('crates/packages/rustA', 'main') + .resolves(['crates/packages/rustA']) + .withArgs('crates/packages/rustB', 'main') + .resolves(['crates/packages/rustB']) + .withArgs('crates/packages/rustC', 'main') + .resolves(['crates/packages/rustC']); + plugin = new CargoWorkspace( + github, + 'main', + { + 'crates/packages/rustA': { + releaseType: 'rust', + }, + 'crates/packages/rustB': { + releaseType: 'rust', + }, + 'crates/packages/rustC': { + releaseType: 'rust', + }, + }, + { + cargoWorkspacePath: 'crates', + } + ); + const newCandidates = await plugin.run(candidates); + expect(newCandidates).lengthOf(1); + const rustCandidate = newCandidates.find( + candidate => candidate.config.releaseType === 'rust' + ); + expect(rustCandidate).to.not.be.undefined; + const updates = rustCandidate!.pullRequest.updates; + // pkgA is directly released + assertHasUpdate(updates, 'crates/packages/rustA/Cargo.toml', RawContent); + // pkgB depends on pkgA, should be bumped + assertHasUpdate(updates, 'crates/packages/rustB/Cargo.toml', RawContent); + // pkgC depends on pkgB, should be transitively bumped + assertHasUpdate(updates, 'crates/packages/rustC/Cargo.toml', RawContent); + assertHasUpdate(updates, 'crates/Cargo.lock'); + }); it('handles packages with invalid version', async () => { const candidates: CandidateReleasePullRequest[] = [ buildMockCandidatePullRequest('packages/rustA', 'rust', '1.1.2', {