diff --git a/README.md b/README.md index cc33eb608..6fe60833a 100644 --- a/README.md +++ b/README.md @@ -185,7 +185,7 @@ Release Please automates releases for the following flavors of repositories: | `bazel` | [A Bazel module, with a MODULE.bazel and a CHANGELOG.md](https://bazel.build/external/module) | | `dart` | A repository with a pubspec.yaml and a CHANGELOG.md | | `elixir` | A repository with a mix.exs and a CHANGELOG.md | -| `go` | A repository with a CHANGELOG.md | +| `go` | A repository with a CHANGELOG.md or a go.work (note that workspaces require a [manifest driven release](https://github.com/googleapis/release-please/blob/main/docs/manifest-releaser.md) and the "go-workspace" plugin) | | `helm` | A repository with a Chart.yaml and a CHANGELOG.md | | `java` | [A strategy that generates SNAPSHOT version after each release](docs/java.md) | | `krm-blueprint` | [A kpt package, with 1 or more KRM files and a CHANGELOG.md](https://github.com/GoogleCloudPlatform/blueprints/tree/main/catalog/project) | diff --git a/__snapshots__/go-mod.js b/__snapshots__/go-mod.js new file mode 100644 index 000000000..58f19f87e --- /dev/null +++ b/__snapshots__/go-mod.js @@ -0,0 +1,13 @@ +exports['go.mod updateContent updates dependencies 1'] = ` +module example.com/hello/world + +go 1.23.0 + +replace example.com/foo/bar/v2 => ../../foo/bar + +require ( +\texample.com/foo/bar/v2 v2.1.3 +\texample.com/foo/baz v1.2.3 +) + +` diff --git a/__snapshots__/go-workspace.js b/__snapshots__/go-workspace.js new file mode 100644 index 000000000..1759b6a61 --- /dev/null +++ b/__snapshots__/go-workspace.js @@ -0,0 +1,160 @@ +exports['GoWorkspace plugin run appends dependency notes to an updated module 1'] = ` +:robot: I have created a release *beep* *boop* +--- + + +
example.com/packages/goA: 1.1.2 + +Release notes for path: packages/goA, releaseType: go +
+ +
example.com/packages/goB: 2.2.3 + +### Dependencies + +* update dependency foo/bar to 1.2.3 +* The following workspace dependencies were updated + * example.com/packages/goA bumped from 1.1.1 to 1.1.2 +
+ +
example.com/packages/goC: 3.3.4 + +### Dependencies + +* The following workspace dependencies were updated + * example.com/packages/goB/v2 bumped from 2.2.2 to 2.2.3 +
+ +
example.com/packages/goE: 3.3.4 + +### Dependencies + +* The following workspace dependencies were updated + * example.com/packages/goA bumped from 1.1.1 to 1.1.2 +
+ +--- +This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). +` + +exports['GoWorkspace plugin run handles a single go package and normalizes path 1'] = ` +:robot: I have created a release *beep* *boop* +--- + + +
example.com/packages/goA: 1.1.2 + +Release notes for path: packages/goA, releaseType: go +
+ +--- +This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). +` + +exports['GoWorkspace plugin run skips component if not touched 1'] = ` +:robot: I have created a release *beep* *boop* +--- + + +
example.com/packages/goB: 2.3.0 + +Release notes for path: packages/goB, releaseType: go +
+ +
example.com/packages/goC: 3.3.4 + +### Dependencies + +* The following workspace dependencies were updated + * example.com/packages/goB/v2 bumped from 2.2.2 to 2.3.0 +
+ +--- +This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). +` + +exports['GoWorkspace plugin run uses go-work-file config value 1'] = ` +:robot: I have created a release *beep* *boop* +--- + + +
example.com/packages/goA: 1.1.2 + +Release notes for path: packages/goA, releaseType: go +
+ +
example.com/packages/goB/v2: 2.2.3 + +### Dependencies + +* The following workspace dependencies were updated + * example.com/packages/goA bumped from 1.1.1 to 1.1.2 +
+ +
example.com/packages/goC: 3.3.4 + +### Dependencies + +* The following workspace dependencies were updated + * example.com/packages/goB/v2 bumped from 2.2.2 to 2.2.3 +
+ +
example.com/packages/goD: 4.4.5 + +Release notes for path: packages/goD, releaseType: go +
+ +
example.com/packages/goE: 3.3.4 + +### Dependencies + +* The following workspace dependencies were updated + * example.com/packages/goA bumped from 1.1.1 to 1.1.2 +
+ +--- +This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). +` + +exports['GoWorkspace plugin run walks dependency tree and updates previously untouched packages 1'] = ` +:robot: I have created a release *beep* *boop* +--- + + +
example.com/packages/goA: 1.1.2 + +Release notes for path: packages/goA, releaseType: go +
+ +
example.com/packages/goB/v2: 2.2.3 + +### Dependencies + +* The following workspace dependencies were updated + * example.com/packages/goA bumped from 1.1.1 to 1.1.2 +
+ +
example.com/packages/goC: 3.3.4 + +### Dependencies + +* The following workspace dependencies were updated + * example.com/packages/goB/v2 bumped from 2.2.2 to 2.2.3 +
+ +
example.com/packages/goD: 4.4.5 + +Release notes for path: packages/goD, releaseType: go +
+ +
example.com/packages/goE: 3.3.4 + +### Dependencies + +* The following workspace dependencies were updated + * example.com/packages/goA bumped from 1.1.1 to 1.1.2 +
+ +--- +This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). +` diff --git a/docs/manifest-releaser.md b/docs/manifest-releaser.md index 0bf9c0880..0f7c30207 100644 --- a/docs/manifest-releaser.md +++ b/docs/manifest-releaser.md @@ -154,7 +154,7 @@ defaults (those are documented in comments) // see Plugins section below // absence defaults to [] (i.e. no plugins) - "plugins": ["node-workspace", "cargo-workspace"], + "plugins": ["node-workspace", "cargo-workspace", "go-workspace"], // optional top-level defaults that can be overridden per package: @@ -541,6 +541,30 @@ 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. +### go-workspace + +The `go-workspace` plugin operates similarly to the `node-workspace` and +`cargo-workspace` plugins, but on a Go workspace. It builds a dependency graph of +all modules in a workspace and updates any modules that depends +(directly or transitively) on the changed module. The workspace dependencies in +`go.mod` files are updated accordingly. + +#### go.work in a non-default location + +By default, the `go.work` file is expected to be in the root. Set `"goWorkFile"` +to a custom path to use a file in a different location. + +``` +{ + "plugins": [ + { + "type": "go-workspace", + "goWorkFile": "/path/to/filename" + } + ] +} +``` + ### maven-workspace The `maven-workspace` plugin operates similarly to the `node-workspace` plugin, diff --git a/schemas/config.json b/schemas/config.json index 9d883effa..334323f25 100644 --- a/schemas/config.json +++ b/schemas/config.json @@ -327,7 +327,8 @@ "type": "string", "enum": [ "cargo-workspace", - "maven-workspace" + "maven-workspace", + "go-workspace" ] }, "updateAllPackages": { @@ -370,6 +371,10 @@ "updatePeerDependencies": { "description": "Also bump peer dependency versions if they are modified. Defaults to `false`.", "type": "boolean" + }, + "goWorkFile": { + "description": "Path to the go.work file. Defaults to `go.work`.", + "type": "string" } } }, diff --git a/src/factories/plugin-factory.ts b/src/factories/plugin-factory.ts index 5f2756e51..ccfc5cc7f 100644 --- a/src/factories/plugin-factory.ts +++ b/src/factories/plugin-factory.ts @@ -23,6 +23,7 @@ import {GitHub} from '../github'; import {ManifestPlugin} from '../plugin'; import {LinkedVersions} from '../plugins/linked-versions'; import {CargoWorkspace} from '../plugins/cargo-workspace'; +import {GoWorkspace} from '../plugins/go-workspace'; import {NodeWorkspace} from '../plugins/node-workspace'; import {VersioningStrategyType} from './versioning-strategy-factory'; import {MavenWorkspace} from '../plugins/maven-workspace'; @@ -48,6 +49,9 @@ export interface PluginFactoryOptions { updateAllPackages?: boolean; considerAllArtifacts?: boolean; + // go options + goWorkFile?: string; + logger?: Logger; } @@ -81,6 +85,19 @@ const pluginFactories: Record = { !options.separatePullRequests, } ), + 'go-workspace': options => + new GoWorkspace( + options.github, + options.targetBranch, + options.repositoryConfig, + { + ...options, + ...(options.type as WorkspacePluginOptions), + merge: + (options.type as WorkspacePluginOptions).merge ?? + !options.separatePullRequests, + } + ), 'node-workspace': options => new NodeWorkspace( options.github, diff --git a/src/manifest.ts b/src/manifest.ts index 390154573..71744339c 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -235,6 +235,9 @@ export interface WorkspacePluginConfig extends ConfigurablePluginType { export interface NodeWorkspacePluginConfig extends WorkspacePluginConfig { updatePeerDependencies?: boolean; } +export interface GoWorkspacePluginConfig extends WorkspacePluginConfig { + goWorkFile?: string; +} export interface GroupPriorityPluginConfig extends ConfigurablePluginType { groups: string[]; } @@ -245,7 +248,8 @@ export type PluginType = | LinkedVersionPluginConfig | SentenceCasePluginConfig | WorkspacePluginConfig - | NodeWorkspacePluginConfig; + | NodeWorkspacePluginConfig + | GoWorkspacePluginConfig; /** * This is the schema of the manifest config json @@ -335,6 +339,7 @@ export class Manifest { * plugin * @param {boolean} manifestOptions.updatePeerDependencies Option for the node-workspace * plugin + * @param {string} manifestOptions.goWorkFile Option for the go-workspace plugin * @param {boolean} manifestOptions.separatePullRequests If true, create separate pull * requests instead of a single manifest release pull request * @param {boolean} manifestOptions.alwaysUpdate If true, always updates pull requests instead of @@ -456,6 +461,7 @@ export class Manifest { * plugin * @param {boolean} manifestOptions.updatePeerDependencies Option for the node-workspace * plugin + * @param {string} manifestOptions.goWorkFile Option for the go-workspace plugin * @param {boolean} manifestOptions.separatePullRequests If true, create separate pull * requests instead of a single manifest release pull request * @param {PluginType[]} manifestOptions.plugins Any plugins to use for this repository diff --git a/src/plugins/go-workspace.ts b/src/plugins/go-workspace.ts new file mode 100644 index 000000000..ba315523e --- /dev/null +++ b/src/plugins/go-workspace.ts @@ -0,0 +1,484 @@ +// Copyright 2024 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { + CandidateReleasePullRequest, + RepositoryConfig, + DEFAULT_RELEASE_PLEASE_MANIFEST, +} from '../manifest'; +import { + WorkspacePlugin, + WorkspacePluginOptions, + DependencyGraph, + DependencyNode, + addPath, + appendDependenciesSectionToChangelog, +} from './workspace'; +import {parseGoWorkspace} from '../updaters/go/common'; +import {VersionsMap, Version} from '../version'; +import {GoMod} from '../updaters/go/go-mod'; +import {RawContent} from '../updaters/raw-content'; +import {Changelog} from '../updaters/changelog'; +import {ReleasePullRequest} from '../release-pull-request'; +import {PullRequestTitle} from '../util/pull-request-title'; +import {PullRequestBody} from '../util/pull-request-body'; +import {BranchName} from '../util/branch-name'; +import {PatchVersionUpdate} from '../versioning-strategy'; +import {Strategy} from '../strategy'; +import {Release} from '../release'; +import {GitHub} from '../github'; +import {Commit} from '../commit'; + +interface GoModInfo { + /** + * e.g. `packages/goA` + */ + path: string; + + /** + * e.g. `example.com/packages/goA` + */ + name: string; + + /** + * e.g. `1.0.0` + */ + version: string; + + /** + * e.g. `packages/goA/go.mod` + */ + modPath: string; + + /** + * text content of the go.mod, used for updates + */ + modContent: string; +} + +interface GoWorkspaceOptions extends WorkspacePluginOptions { + goWorkFile?: string; +} + +/** + * The plugin analyzes a go workspace and will bump dependencies + * of managed packages if those dependencies are being updated. + * + * The plugin will also update the go.mod files with the new + * dependencies. + */ +export class GoWorkspace extends WorkspacePlugin { + private strategiesByPath: Record = {}; + private releasesByPath: Record = {}; + private readonly releaseManifestPath: string; + private readonly goWorkPath: string; + + constructor( + github: GitHub, + targetBranch: string, + repositoryConfig: RepositoryConfig, + options: GoWorkspaceOptions = {} + ) { + super(github, targetBranch, repositoryConfig, options); + this.releaseManifestPath = + options.manifestPath ?? DEFAULT_RELEASE_PLEASE_MANIFEST; + this.goWorkPath = options.goWorkFile || 'go.work'; + } + + protected bumpVersion(pkg: GoModInfo): Version { + const version = Version.parse(pkg.version); + const strategy = this.strategiesByPath[pkg.path]; + + if (strategy) return strategy.versioningStrategy.bump(version, []); + return new PatchVersionUpdate().bump(version); + } + + protected updateCandidate( + existingCandidate: CandidateReleasePullRequest, + pkg: GoModInfo, + updatedVersions: VersionsMap + ): CandidateReleasePullRequest { + const version = updatedVersions.get(pkg.name); + if (!version) { + throw new Error("Didn't find updated version for ${pkg.name}"); + } + const updater = new GoMod({ + version, + versionsMap: updatedVersions, + }); + const updatedContent = updater.updateContent(pkg.modContent); + const dependencyNotes = getChangelogDepsNotes( + pkg.modContent, + updatedContent + ); + + existingCandidate.pullRequest.updates = + existingCandidate.pullRequest.updates.map(update => { + if (update.path === addPath(existingCandidate.path, 'go.mod')) { + update.updater = new RawContent(updatedContent); + } else if (update.updater instanceof Changelog && dependencyNotes) { + update.updater.changelogEntry = appendDependenciesSectionToChangelog( + update.updater.changelogEntry, + dependencyNotes, + this.logger + ); + } + return update; + }); + + // append dependency notes + if (dependencyNotes) { + if (existingCandidate.pullRequest.body.releaseData.length > 0) { + existingCandidate.pullRequest.body.releaseData[0].notes = + appendDependenciesSectionToChangelog( + existingCandidate.pullRequest.body.releaseData[0].notes, + dependencyNotes, + this.logger + ); + } else { + existingCandidate.pullRequest.body.releaseData.push({ + component: pkg.name, + version: existingCandidate.pullRequest.version, + notes: appendDependenciesSectionToChangelog( + '', + dependencyNotes, + this.logger + ), + }); + } + } + return existingCandidate; + } + + protected async newCandidate( + pkg: GoModInfo, + updatedVersions: VersionsMap + ): Promise { + const newVersion = updatedVersions.get(pkg.name); + if (!newVersion) { + throw new Error(`Didn't find updated version for ${pkg.name}`); + } + const goModUpdater = new GoMod({ + version: newVersion, + versionsMap: updatedVersions, + }); + const updatedGoModContent = goModUpdater.updateContent(pkg.modContent); + const dependencyNotes = getChangelogDepsNotes( + pkg.modContent, + updatedGoModContent + ); + + const updatedPackage = { + ...pkg, + version: newVersion.toString(), + }; + + const strategy = this.strategiesByPath[updatedPackage.path]; + const latestRelease = this.releasesByPath[updatedPackage.path]; + + const basePullRequest = strategy + ? await strategy.buildReleasePullRequest([], latestRelease, false, [], { + newVersion: newVersion, + }) + : undefined; + + if (basePullRequest) { + return this.updateCandidate( + { + path: pkg.path, + pullRequest: basePullRequest, + config: { + releaseType: 'go', + }, + }, + pkg, + updatedVersions + ); + } + + const pullRequest: ReleasePullRequest = { + title: PullRequestTitle.ofTargetBranch(this.targetBranch), + body: new PullRequestBody([ + { + component: updatedPackage.name, + version: newVersion, + notes: appendDependenciesSectionToChangelog( + '', + dependencyNotes, + this.logger + ), + }, + ]), + updates: [ + { + path: addPath(updatedPackage.path, 'go.mod'), + createIfMissing: false, + updater: new RawContent(updatedGoModContent), + }, + { + path: addPath(updatedPackage.path, 'CHANGELOG.md'), + createIfMissing: false, + updater: new Changelog({ + version: newVersion, + changelogEntry: dependencyNotes, + }), + }, + ], + labels: [], + headRefName: BranchName.ofTargetBranch(this.targetBranch).toString(), + version: newVersion, + draft: false, + }; + return { + path: updatedPackage.path, + pullRequest, + config: { + releaseType: 'go', + }, + }; + } + + /** + * Collect all packages being managed in this workspace. + * @param {CanididateReleasePullRequest[]} candidates Existing candidate pull + * requests + * @returns {AllPackages} The list of packages and candidates grouped by package name + */ + protected async buildAllPackages( + candidates: CandidateReleasePullRequest[] + ): Promise<{ + allPackages: GoModInfo[]; + candidatesByPackage: Record; + }> { + const goWorkspaceContent = await this.github.getFileContentsOnBranch( + this.goWorkPath, + this.targetBranch + ); + const goWorkspace = parseGoWorkspace(goWorkspaceContent.parsedContent); + if (!goWorkspace?.members) { + this.logger.warn('go-workspace plugin used, but found no use directives'); + return {allPackages: [], candidatesByPackage: {}}; + } + + const allPackages: GoModInfo[] = []; + const candidatesByPackage: Record = {}; + + const members = ( + await Promise.all( + goWorkspace.members.map(member => + this.github.findFilesByGlobAndRef(member, this.targetBranch) + ) + ) + ).flat(); + + // Read the json file at releaseManifestPath + const manifestContent = await this.github.getFileContentsOnBranch( + this.releaseManifestPath, + this.targetBranch + ); + const manifest = JSON.parse(manifestContent.parsedContent); + + for (const path of members) { + const goModPath = addPath(path, 'go.mod'); + this.logger.info(`looking for candidate with path: ${path}`); + const candidate = candidates.find(c => c.path === path); + // get original content of the module + const moduleContent = + candidate?.pullRequest.updates.find(update => update.path === goModPath) + ?.cachedFileContents || + (await this.github.getFileContentsOnBranch( + goModPath, + this.targetBranch + )); + + // Get path from the manifest + const version = manifest[path]; + if (!version) { + this.logger.warn( + `package at ${path} not found in manifest at ${this.releaseManifestPath}` + ); + continue; + } + + // Package name is defined by module in moduleContent + // e.g. module example.com/application/appname + const modulePattern = /module (.+)/; + const moduleMatch = modulePattern.exec(moduleContent.parsedContent); + if (!moduleMatch) { + this.logger.warn(`package at ${path} is missing a module declaration`); + continue; + } + const packageName = moduleMatch[1]; + + if (candidate) { + candidatesByPackage[packageName] = candidate; + } + + allPackages.push({ + path, + name: packageName, + version, + modPath: goModPath, + modContent: moduleContent.parsedContent, + }); + } + + return { + allPackages, + candidatesByPackage, + }; + } + + /** + * Builds a graph of dependencies that have been touched + * @param {T[]} allPackages All the packages in the workspace + * @returns {DependencyGraph} A map of package name to other workspace packages + * it depends on. + */ + protected async buildGraph( + allPackages: GoModInfo[] + ): Promise> { + const workspacePackageNames = new Set(allPackages.map(pkg => pkg.name)); + const graph = new Map>(); + + // Parses a go.mod file and returns a list of dependencies + const parseDependencies = (content: string): string[] => { + const depRegex = /(\S+)\s+v(\d+\.\d+\.\d+)/gm; + const deps: string[] = []; + let match; + while ((match = depRegex.exec(content)) !== null) { + const [_, name] = match; + deps.push(name); + } + return deps; + }; + + const addDependencies = (pkgName: string, modInfo: GoModInfo) => { + const deps = parseDependencies(modInfo.modContent); + // Direct dependencies that are also in the workspace + const workspaceDeps = deps.filter(dep => workspacePackageNames.has(dep)); + graph.set(pkgName, { + deps: workspaceDeps, + value: modInfo, + }); + + for (const dep of workspaceDeps) { + // If the dependency is not already in the graph, add it and its dependencies + if (!graph.has(dep)) { + const depInfo = allPackages.find(pkg => pkg.name === dep); + if (depInfo) { + addDependencies(dep, depInfo); + } + } + } + }; + + for (const modInfo of allPackages) { + if (!graph.has(modInfo.name)) { + addDependencies(modInfo.name, modInfo); + } + } + + return graph; + } + + protected inScope(candidate: CandidateReleasePullRequest): boolean { + return candidate.config.releaseType === 'go'; + } + + protected packageNameFromPackage(pkg: GoModInfo): string { + return pkg.name; + } + + protected pathFromPackage(pkg: GoModInfo): string { + return pkg.path; + } + + protected postProcessCandidates( + candidates: CandidateReleasePullRequest[], + _: VersionsMap + ): CandidateReleasePullRequest[] { + // Nothing to do at this time + return candidates; + } + + async preconfigure( + strategiesByPath: Record, + _commitsByPath: Record, + _releasesByPath: Record + ): Promise> { + // Using preconfigure to siphon releases and strategies. + this.strategiesByPath = strategiesByPath; + this.releasesByPath = _releasesByPath; + + return strategiesByPath; + } +} + +function getChangelogDepsNotes( + originalContent: string, + updatedContent: string +): string { + // Find dependency lines in the files + // They contain like example.com/foo/bar v1.0.0 + // Iterate over the lines and build a list of dependencies + // Do the same for the updated content + const depRegex = /(\S+)\s+v?(\d+\.\d+\.\d+)/gm; + + const parseDependencies = (content: string): Map => { + const deps = new Map(); + let match; + while ((match = depRegex.exec(content)) !== null) { + const [_, name, version] = match; + deps.set(name, version); + } + return deps; + }; + + const originalDeps = parseDependencies(originalContent); + const updatedDeps = parseDependencies(updatedContent); + + const notes: string[] = []; + + // Find changed and new dependencies + for (const [name, updatedVersion] of updatedDeps.entries()) { + const originalVersion = originalDeps.get(name); + if (!originalVersion) { + notes.push(`* ${name} added ${updatedVersion}`); + } else if (originalVersion !== updatedVersion) { + notes.push( + `* ${name} bumped from ${originalVersion} to ${updatedVersion}` + ); + } + } + + // Find removed dependencies + for (const name of originalDeps.keys()) { + if (!updatedDeps.has(name)) { + notes.push(`* ${name} removed`); + } + } + + let depUpdateNotes = ''; + + if (notes.length > 0) { + for (const note of notes) { + depUpdateNotes += `\n ${note}`; + } + + return `* The following workspace dependencies were updated${depUpdateNotes}`; + } + + return ''; +} diff --git a/src/updaters/go/common.ts b/src/updaters/go/common.ts new file mode 100644 index 000000000..4f3471dc7 --- /dev/null +++ b/src/updaters/go/common.ts @@ -0,0 +1,74 @@ +// Copyright 2024 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import * as path from 'path'; + +export interface GoWorkspace { + members?: string[]; +} + +// Example go.work file +/* +go 1.23.1 + +// Ignore comments + +use ( + ./packages/goA + ./packages/goB + ./packages/goC + ./packages/goD +) + +use ./packages/goE + +*/ +export function parseGoWorkspace(content: string): GoWorkspace { + const lines = content + .split('\n') + .filter(line => line !== '') + .filter(line => !line.startsWith('//')); + + const members: string[] = []; + + let inCommentBlock = false; + let inUseBlock = false; + for (const line of lines) { + if (line.startsWith('/*')) { + inCommentBlock = true; + } + if (line.endsWith('*/')) { + inCommentBlock = false; + continue; + } + if (inCommentBlock) { + continue; + } + if (line.startsWith('use (')) { + inUseBlock = true; + continue; + } + if (inUseBlock && line === ')') { + inUseBlock = false; + continue; + } + if (inUseBlock || line.startsWith('use ')) { + const rawPath = line.replace('use ', '').trim(); + const normalizedPath = path.normalize(rawPath); + members.push(normalizedPath); + } + } + + return {members}; +} diff --git a/src/updaters/go/go-mod.ts b/src/updaters/go/go-mod.ts new file mode 100644 index 000000000..29174cabe --- /dev/null +++ b/src/updaters/go/go-mod.ts @@ -0,0 +1,54 @@ +// Copyright 2024 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import {logger as defaultLogger, Logger} from '../../util/logger'; +import {DefaultUpdater} from '../default'; + +/** + * Updates `go.mod` files, preserving formatting and comments. + */ +export class GoMod extends DefaultUpdater { + /** + * Given initial file contents, return updated contents. + * @param {string} content The initial content + * @returns {string} The updated content + */ + updateContent(content: string, logger: Logger = defaultLogger): string { + let payload = content; + + if (!this.versionsMap) { + throw new Error('updateContent called with no versions'); + } + + for (const [pkgName, pkgVersion] of this.versionsMap) { + const regex = new RegExp(`${pkgName} v\\d+\\.\\d+\\.\\d+`, 'g'); + // Is the dep in the go.mod file? + const deps = regex.exec(payload); + + if (!deps) { + logger.info(`skipping ${pkgName} (not found in go.mod)`); + continue; + } + + for (const dep of deps) { + const oldVersion = dep.split(' ')[1]; + logger.info(`updating ${pkgName} from ${oldVersion} to ${pkgVersion}`); + + payload = payload.replace(dep, `${pkgName} v${pkgVersion}`); + } + } + + return payload; + } +} diff --git a/src/updaters/go/version-go.ts b/src/updaters/go/version-go.ts index f854231bd..0f3f9d0ee 100644 --- a/src/updaters/go/version-go.ts +++ b/src/updaters/go/version-go.ts @@ -1,4 +1,4 @@ -// Copyright 2021 Google LLC +// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/test/factories/plugin-factory.ts b/test/factories/plugin-factory.ts index 2b03db4dd..df9894b91 100644 --- a/test/factories/plugin-factory.ts +++ b/test/factories/plugin-factory.ts @@ -40,6 +40,7 @@ describe('PluginFactory', () => { describe('buildPlugin', () => { const simplePluginTypes: PluginType[] = [ 'cargo-workspace', + 'go-workspace', 'maven-workspace', 'node-workspace', ]; @@ -122,6 +123,7 @@ describe('PluginFactory', () => { it('should return default types', () => { const defaultTypes: PluginType[] = [ 'cargo-workspace', + 'go-workspace', 'node-workspace', 'linked-versions', ]; diff --git a/test/fixtures/manifest/config/plugins.json b/test/fixtures/manifest/config/plugins.json index f1ce3d92d..c60d2df8a 100644 --- a/test/fixtures/manifest/config/plugins.json +++ b/test/fixtures/manifest/config/plugins.json @@ -2,7 +2,8 @@ "release-type": "simple", "plugins": [ "node-workspace", - "cargo-workspace" + "cargo-workspace", + "go-workspace" ], "packages": { ".": { diff --git a/test/fixtures/plugins/go-workspace/.release-please-manifest.json b/test/fixtures/plugins/go-workspace/.release-please-manifest.json new file mode 100644 index 000000000..b02ed9203 --- /dev/null +++ b/test/fixtures/plugins/go-workspace/.release-please-manifest.json @@ -0,0 +1,7 @@ +{ + "packages/goA": "1.1.1", + "packages/goB": "2.2.2", + "packages/goC": "3.3.3", + "packages/goD": "1.2.3", + "packages/goE": "3.3.3" +} \ No newline at end of file diff --git a/test/fixtures/plugins/go-workspace/go.work b/test/fixtures/plugins/go-workspace/go.work new file mode 100644 index 000000000..c4a64d3df --- /dev/null +++ b/test/fixtures/plugins/go-workspace/go.work @@ -0,0 +1,9 @@ +go 1.23.1 + +use ( + ./packages/goA + ./packages/goB + ./packages/goC + ./packages/goD + ./packages/goE +) diff --git a/test/fixtures/plugins/go-workspace/packages/goA/CHANGELOG.md b/test/fixtures/plugins/go-workspace/packages/goA/CHANGELOG.md new file mode 100644 index 000000000..e99171503 --- /dev/null +++ b/test/fixtures/plugins/go-workspace/packages/goA/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## [1.1.1](foo) + +Foo bar diff --git a/test/fixtures/plugins/go-workspace/packages/goA/go.mod b/test/fixtures/plugins/go-workspace/packages/goA/go.mod new file mode 100644 index 000000000..8663f4c04 --- /dev/null +++ b/test/fixtures/plugins/go-workspace/packages/goA/go.mod @@ -0,0 +1,3 @@ +module example.com/packages/goA + +go 1.23.0 diff --git a/test/fixtures/plugins/go-workspace/packages/goA/lib/lib.go b/test/fixtures/plugins/go-workspace/packages/goA/lib/lib.go new file mode 100644 index 000000000..36e9928e0 --- /dev/null +++ b/test/fixtures/plugins/go-workspace/packages/goA/lib/lib.go @@ -0,0 +1,9 @@ +package lib + +func FuncA() string { + return "lib.FuncA" +} + +type StructA struct { + FieldA string +} diff --git a/test/fixtures/plugins/go-workspace/packages/goB/CHANGELOG.md b/test/fixtures/plugins/go-workspace/packages/goB/CHANGELOG.md new file mode 100644 index 000000000..d8e31b9e0 --- /dev/null +++ b/test/fixtures/plugins/go-workspace/packages/goB/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## [2.2.2](foo) + +Foo bar diff --git a/test/fixtures/plugins/go-workspace/packages/goB/go.mod b/test/fixtures/plugins/go-workspace/packages/goB/go.mod new file mode 100644 index 000000000..706b7fd5e --- /dev/null +++ b/test/fixtures/plugins/go-workspace/packages/goB/go.mod @@ -0,0 +1,7 @@ +module example.com/packages/goB/v2 + +go 1.23.0 + +require ( + example.com/packages/goA v1.1.1 +) diff --git a/test/fixtures/plugins/go-workspace/packages/goB/lib/code.go b/test/fixtures/plugins/go-workspace/packages/goB/lib/code.go new file mode 100644 index 000000000..2cfcb8b28 --- /dev/null +++ b/test/fixtures/plugins/go-workspace/packages/goB/lib/code.go @@ -0,0 +1,11 @@ +package lib + +import libA "example.com/packages/goA/lib" + +func FuncB() string { + return "v2.FuncB" +} + +type StructB struct { + FieldB libA.StructA +} diff --git a/test/fixtures/plugins/go-workspace/packages/goC/CHANGELOG.md b/test/fixtures/plugins/go-workspace/packages/goC/CHANGELOG.md new file mode 100644 index 000000000..96c692b83 --- /dev/null +++ b/test/fixtures/plugins/go-workspace/packages/goC/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## [3.3.3](foo) + +Foo bar diff --git a/test/fixtures/plugins/go-workspace/packages/goC/go.mod b/test/fixtures/plugins/go-workspace/packages/goC/go.mod new file mode 100644 index 000000000..933ebbaf0 --- /dev/null +++ b/test/fixtures/plugins/go-workspace/packages/goC/go.mod @@ -0,0 +1,7 @@ +module example.com/packages/goC + +go 1.23.0 + +require ( + example.com/packages/goB/v2 v2.2.2 +) diff --git a/test/fixtures/plugins/go-workspace/packages/goC/lib/lib.go b/test/fixtures/plugins/go-workspace/packages/goC/lib/lib.go new file mode 100644 index 000000000..1df53f280 --- /dev/null +++ b/test/fixtures/plugins/go-workspace/packages/goC/lib/lib.go @@ -0,0 +1,11 @@ +package lib + +import libB "example.com/packages/goB/v2/lib" + +func FuncC() string { + return "FuncC" +} + +type StructC struct { + FieldC libB.StructB +} diff --git a/test/fixtures/plugins/go-workspace/packages/goD/CHANGELOG.md b/test/fixtures/plugins/go-workspace/packages/goD/CHANGELOG.md new file mode 100644 index 000000000..fbe848e4e --- /dev/null +++ b/test/fixtures/plugins/go-workspace/packages/goD/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## [1.2.3](foo) + +Foo bar diff --git a/test/fixtures/plugins/go-workspace/packages/goD/go.mod b/test/fixtures/plugins/go-workspace/packages/goD/go.mod new file mode 100644 index 000000000..2b4bbf000 --- /dev/null +++ b/test/fixtures/plugins/go-workspace/packages/goD/go.mod @@ -0,0 +1,3 @@ +module example.com/packages/goD + +go 1.23.0 diff --git a/test/fixtures/plugins/go-workspace/packages/goD/lib/code.go b/test/fixtures/plugins/go-workspace/packages/goD/lib/code.go new file mode 100644 index 000000000..ba5d2657b --- /dev/null +++ b/test/fixtures/plugins/go-workspace/packages/goD/lib/code.go @@ -0,0 +1,9 @@ +package lib + +func FuncD() string { + return "FuncD" +} + +type StructD struct { + FieldD string +} diff --git a/test/fixtures/plugins/go-workspace/packages/goE/CHANGELOG.md b/test/fixtures/plugins/go-workspace/packages/goE/CHANGELOG.md new file mode 100644 index 000000000..96c692b83 --- /dev/null +++ b/test/fixtures/plugins/go-workspace/packages/goE/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## [3.3.3](foo) + +Foo bar diff --git a/test/fixtures/plugins/go-workspace/packages/goE/go.mod b/test/fixtures/plugins/go-workspace/packages/goE/go.mod new file mode 100644 index 000000000..73dc2f53c --- /dev/null +++ b/test/fixtures/plugins/go-workspace/packages/goE/go.mod @@ -0,0 +1,7 @@ +module example.com/packages/goE + +go 1.23.0 + +require ( + example.com/packages/goA v1.1.1 +) diff --git a/test/fixtures/plugins/go-workspace/packages/goE/lib/code.go b/test/fixtures/plugins/go-workspace/packages/goE/lib/code.go new file mode 100644 index 000000000..92e87accd --- /dev/null +++ b/test/fixtures/plugins/go-workspace/packages/goE/lib/code.go @@ -0,0 +1,11 @@ +package lib + +import libA "example.com/packages/goA/lib" + +func FuncE() string { + return "FuncE" +} + +type StructE struct { + FieldE libA.StructA +} diff --git a/test/manifest.ts b/test/manifest.ts index 9c0d44caf..a22e674c8 100644 --- a/test/manifest.ts +++ b/test/manifest.ts @@ -55,6 +55,7 @@ import {RequestError} from '@octokit/request-error'; import * as nock from 'nock'; import {LinkedVersions} from '../src/plugins/linked-versions'; import {MavenWorkspace} from '../src/plugins/maven-workspace'; +import {GoWorkspace} from '../src/plugins/go-workspace'; nock.disableNetConnect(); @@ -556,9 +557,10 @@ describe('Manifest', () => { github, github.repository.defaultBranch ); - expect(manifest.plugins).lengthOf(2); + expect(manifest.plugins).lengthOf(3); expect(manifest.plugins[0]).instanceOf(NodeWorkspace); expect(manifest.plugins[1]).instanceOf(CargoWorkspace); + expect(manifest.plugins[2]).instanceOf(GoWorkspace); }); it('should build complex plugins from manifest', async () => { const getFileContentsStub = sandbox.stub( diff --git a/test/plugins/go-workspace.ts b/test/plugins/go-workspace.ts new file mode 100644 index 000000000..27db2c168 --- /dev/null +++ b/test/plugins/go-workspace.ts @@ -0,0 +1,444 @@ +// Copyright 2024 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import {describe, it, afterEach, beforeEach} from 'mocha'; +import * as sinon from 'sinon'; +import {GitHub} from '../../src/github'; +import {CandidateReleasePullRequest} from '../../src/manifest'; +import {Update} from '../../src/update'; +import { + buildGitHubFileContent, + buildMockCandidatePullRequest, + assertHasUpdate, + dateSafe, + stubFilesFromFixtures, + assertNoHasUpdate, +} from '../helpers'; +import {Version} from '../../src/version'; +import {ManifestPlugin} from '../../src/plugin'; +import {GoWorkspace} from '../../src/plugins/go-workspace'; +import {expect} from 'chai'; +import snapshot = require('snap-shot-it'); +import {RawContent} from '../../src/updaters/raw-content'; +import {GoMod} from '../../src/updaters/go/go-mod'; + +const sandbox = sinon.createSandbox(); +const fixturesPath = './test/fixtures/plugins/go-workspace'; + +export function buildMockPackageUpdate( + path: string, + fixtureName: string, + version: string +): Update { + const cachedFileContents = buildGitHubFileContent(fixturesPath, fixtureName); + return { + path, + createIfMissing: false, + cachedFileContents, + updater: new GoMod({ + version: Version.parse(version), + }), + }; +} + +describe('GoWorkspace plugin', () => { + let github: GitHub; + let plugin: ManifestPlugin; + beforeEach(async () => { + github = await GitHub.create({ + owner: 'googleapis', + repo: 'go-test-repo', + defaultBranch: 'main', + }); + plugin = new GoWorkspace(github, 'main', { + 'packages/goA': { + releaseType: 'go', + }, + 'packages/goB': { + releaseType: 'go', + }, + 'packages/goC': { + releaseType: 'go', + }, + 'packages/goD': { + releaseType: 'go', + }, + }); + }); + afterEach(() => { + sandbox.restore(); + }); + describe('run', () => { + it('does nothing for non-go strategies', async () => { + const candidates: CandidateReleasePullRequest[] = [ + buildMockCandidatePullRequest('python', 'python', '1.0.0'), + ]; + const newCandidates = await plugin.run(candidates); + expect(newCandidates).to.eql(candidates); + }); + it('handles a single go package and normalizes path', async () => { + const candidates: CandidateReleasePullRequest[] = [ + buildMockCandidatePullRequest('python', 'python', '1.0.0'), + buildMockCandidatePullRequest('packages/goA', 'go', '1.1.2', { + component: 'example.com/packages/goA', + updates: [ + buildMockPackageUpdate( + 'packages/goA/go.mod', + 'packages/goA/go.mod', + '1.1.1' + ), + ], + }), + ]; + + stubFilesFromFixtures({ + sandbox, + github, + fixturePath: fixturesPath, + files: ['packages/goA/go.mod', 'packages/goA/CHANGELOG.md'], + flatten: false, + targetBranch: 'main', + inlineFiles: [ + ['go.work', 'go 1.23.1\nuse ./packages/..//packages/goA\n'], + ['.release-please-manifest.json', '{"packages/goA": "1.1.1"}'], + ], + }); + plugin = new GoWorkspace(github, 'main', { + python: { + releaseType: 'python', + }, + 'packages/goA': { + releaseType: 'go', + }, + }); + sandbox + .stub(github, 'findFilesByGlobAndRef') + .withArgs('packages/goA', 'main') + .resolves(['packages/goA']); + const newCandidates = await plugin.run(candidates); + expect(newCandidates).lengthOf(2); + const goCandidate = newCandidates.find( + candidate => candidate.config.releaseType === 'go' + ); + expect(goCandidate).to.not.be.undefined; + const updates = goCandidate!.pullRequest.updates; + assertHasUpdate(updates, 'packages/goA/go.mod'); + snapshot(dateSafe(goCandidate!.pullRequest.body.toString())); + }); + it('walks dependency tree and updates previously untouched packages', async () => { + const candidates: CandidateReleasePullRequest[] = [ + buildMockCandidatePullRequest('packages/goA', 'go', '1.1.2', { + component: 'example.com/packages/goA', + updates: [ + buildMockPackageUpdate( + 'packages/goA/go.mod', + 'packages/goA/go.mod', + '1.1.1' + ), + ], + }), + buildMockCandidatePullRequest('packages/goD', 'go', '4.4.5', { + component: 'example.com/packages/goD', + updates: [ + buildMockPackageUpdate( + 'packages/goD/go.mod', + 'packages/goD/go.mod', + '4.4.4' + ), + ], + }), + ]; + + stubFilesFromFixtures({ + sandbox, + github, + fixturePath: fixturesPath, + files: [ + '.release-please-manifest.json', + 'go.work', + 'packages/goA/go.mod', + 'packages/goA/CHANGELOG.md', + 'packages/goB/go.mod', + 'packages/goB/CHANGELOG.md', + 'packages/goC/go.mod', + 'packages/goC/CHANGELOG.md', + 'packages/goD/go.mod', + 'packages/goD/CHANGELOG.md', + 'packages/goE/go.mod', + 'packages/goE/CHANGELOG.md', + ], + flatten: false, + targetBranch: 'main', + }); + sandbox + .stub(github, 'findFilesByGlobAndRef') + .withArgs('packages/goA', 'main') + .resolves(['packages/goA']) + .withArgs('packages/goB', 'main') + .resolves(['packages/goB']) + .withArgs('packages/goC', 'main') + .resolves(['packages/goC']) + .withArgs('packages/goD', 'main') + .resolves(['packages/goD']) + .withArgs('packages/goE', 'main') + .resolves(['packages/goE']); + const newCandidates = await plugin.run(candidates); + expect(newCandidates).lengthOf(1); + const goCandidate = newCandidates.find( + candidate => candidate.config.releaseType === 'go' + ); + expect(goCandidate).to.not.be.undefined; + const updates = goCandidate!.pullRequest.updates; + // Check that transitive dependencies are updated + assertHasUpdate(updates, 'packages/goA/go.mod', RawContent); + assertHasUpdate(updates, 'packages/goB/go.mod', RawContent); + assertHasUpdate(updates, 'packages/goC/go.mod', RawContent); + assertHasUpdate(updates, 'packages/goD/go.mod', RawContent); + assertHasUpdate(updates, 'packages/goE/go.mod', RawContent); + snapshot(dateSafe(goCandidate!.pullRequest.body.toString())); + }); + it('appends dependency notes to an updated module', async () => { + const existingNotes = + '### Dependencies\n\n* update dependency foo/bar to 1.2.3'; + const candidates: CandidateReleasePullRequest[] = [ + buildMockCandidatePullRequest('packages/goA', 'go', '1.1.2', { + component: 'example.com/packages/goA', + updates: [ + buildMockPackageUpdate( + 'packages/goA/go.mod', + 'packages/goA/go.mod', + '1.1.1' + ), + ], + }), + buildMockCandidatePullRequest('packages/goB', 'go', '2.2.3', { + component: 'example.com/packages/goB', + updates: [ + buildMockPackageUpdate( + 'packages/goB/go.mod', + 'packages/goB/go.mod', + '2.2.2' + ), + ], + notes: existingNotes, + }), + ]; + + stubFilesFromFixtures({ + sandbox, + github, + fixturePath: fixturesPath, + files: [ + '.release-please-manifest.json', + 'go.work', + 'packages/goA/go.mod', + 'packages/goA/CHANGELOG.md', + 'packages/goB/go.mod', + 'packages/goB/CHANGELOG.md', + 'packages/goC/go.mod', + 'packages/goC/CHANGELOG.md', + 'packages/goD/go.mod', + 'packages/goD/CHANGELOG.md', + 'packages/goE/go.mod', + 'packages/goE/CHANGELOG.md', + ], + flatten: false, + targetBranch: 'main', + }); + sandbox + .stub(github, 'findFilesByGlobAndRef') + .withArgs('packages/goA', 'main') + .resolves(['packages/goA']) + .withArgs('packages/goB', 'main') + .resolves(['packages/goB']) + .withArgs('packages/goC', 'main') + .resolves(['packages/goC']) + .withArgs('packages/goD', 'main') + .resolves(['packages/goD']) + .withArgs('packages/goE', 'main') + .resolves(['packages/goE']); + const newCandidates = await plugin.run(candidates); + expect(newCandidates).lengthOf(1); + const goCandidate = newCandidates.find( + candidate => candidate.config.releaseType === 'go' + ); + expect(goCandidate).to.not.be.undefined; + const updates = goCandidate!.pullRequest.updates; + // Also checks transitive dependencies are updated + assertHasUpdate(updates, 'packages/goA/go.mod', RawContent); + assertHasUpdate(updates, 'packages/goB/go.mod', RawContent); + assertHasUpdate(updates, 'packages/goC/go.mod', RawContent); + assertHasUpdate(updates, 'packages/goE/go.mod', RawContent); + snapshot(dateSafe(goCandidate!.pullRequest.body.toString())); + }); + it('skips component if not touched', async () => { + const candidates: CandidateReleasePullRequest[] = [ + buildMockCandidatePullRequest('packages/goB', 'go', '2.3.0', { + component: 'example.com/packages/goB', + updates: [ + buildMockPackageUpdate( + 'packages/goB/go.mod', + 'packages/goB/go.mod', + '2.2.2' + ), + ], + }), + ]; + + stubFilesFromFixtures({ + sandbox, + github, + fixturePath: fixturesPath, + files: [ + '.release-please-manifest.json', + 'go.work', + 'packages/goA/go.mod', + 'packages/goA/CHANGELOG.md', + 'packages/goB/go.mod', + 'packages/goB/CHANGELOG.md', + 'packages/goC/go.mod', + 'packages/goC/CHANGELOG.md', + 'packages/goD/go.mod', + 'packages/goD/CHANGELOG.md', + 'packages/goE/go.mod', + 'packages/goE/CHANGELOG.md', + ], + flatten: false, + targetBranch: 'main', + }); + sandbox + .stub(github, 'findFilesByGlobAndRef') + .withArgs('packages/goA', 'main') + .resolves(['packages/goA']) + .withArgs('packages/goB', 'main') + .resolves(['packages/goB']) + .withArgs('packages/goC', 'main') + .resolves(['packages/goC']) + .withArgs('packages/goD', 'main') + .resolves(['packages/goD']) + .withArgs('packages/goE', 'main') + .resolves(['packages/goE']); + const newCandidates = await plugin.run(candidates); + expect(newCandidates).lengthOf(1); + const goCandidate = newCandidates.find( + candidate => candidate.config.releaseType === 'go' + ); + expect(goCandidate).to.not.be.undefined; + const updates = goCandidate!.pullRequest.updates; + // goA is not touched and does not have a dependency on goD + assertNoHasUpdate(updates, 'packages/goA/go.mod'); + assertNoHasUpdate(updates, 'packages/goE/go.mod'); + assertHasUpdate(updates, 'packages/goB/go.mod', RawContent); + snapshot(dateSafe(goCandidate!.pullRequest.body.toString())); + }); + it('uses go-work-file config value', async () => { + const options = {goWorkFile: 'some/go.work.file'}; + plugin = new GoWorkspace( + github, + 'main', + { + 'packages/goA': { + releaseType: 'go', + }, + 'packages/goB': { + releaseType: 'go', + }, + 'packages/goC': { + releaseType: 'go', + }, + 'packages/goD': { + releaseType: 'go', + }, + }, + options + ); + + const candidates: CandidateReleasePullRequest[] = [ + buildMockCandidatePullRequest('packages/goA', 'go', '1.1.2', { + component: 'example.com/packages/goA', + updates: [ + buildMockPackageUpdate( + 'packages/goA/go.mod', + 'packages/goA/go.mod', + '1.1.1' + ), + ], + }), + buildMockCandidatePullRequest('packages/goD', 'go', '4.4.5', { + component: 'example.com/packages/goD', + updates: [ + buildMockPackageUpdate( + 'packages/goD/go.mod', + 'packages/goD/go.mod', + '4.4.4' + ), + ], + }), + ]; + + stubFilesFromFixtures({ + sandbox, + github, + fixturePath: fixturesPath, + files: [ + '.release-please-manifest.json', + 'packages/goA/go.mod', + 'packages/goA/CHANGELOG.md', + 'packages/goB/go.mod', + 'packages/goB/CHANGELOG.md', + 'packages/goC/go.mod', + 'packages/goC/CHANGELOG.md', + 'packages/goD/go.mod', + 'packages/goD/CHANGELOG.md', + 'packages/goE/go.mod', + 'packages/goE/CHANGELOG.md', + ], + inlineFiles: [ + [ + 'some/go.work.file', + 'go 1.23.1\nuse ./packages/goA\n\nuse ./packages/goB\nuse ./packages/goC\nuse ./packages/goD\nuse ./packages/goE\n', + ], + ], + flatten: false, + targetBranch: 'main', + }); + sandbox + .stub(github, 'findFilesByGlobAndRef') + .withArgs('packages/goA', 'main') + .resolves(['packages/goA']) + .withArgs('packages/goB', 'main') + .resolves(['packages/goB']) + .withArgs('packages/goC', 'main') + .resolves(['packages/goC']) + .withArgs('packages/goD', 'main') + .resolves(['packages/goD']) + .withArgs('packages/goE', 'main') + .resolves(['packages/goE']); + const newCandidates = await plugin.run(candidates); + expect(newCandidates).lengthOf(1); + const goCandidate = newCandidates.find( + candidate => candidate.config.releaseType === 'go' + ); + expect(goCandidate).to.not.be.undefined; + const updates = goCandidate!.pullRequest.updates; + // Check that transitive dependencies are updated + assertHasUpdate(updates, 'packages/goA/go.mod', RawContent); + assertHasUpdate(updates, 'packages/goB/go.mod', RawContent); + assertHasUpdate(updates, 'packages/goC/go.mod', RawContent); + assertHasUpdate(updates, 'packages/goD/go.mod', RawContent); + assertHasUpdate(updates, 'packages/goE/go.mod', RawContent); + snapshot(dateSafe(goCandidate!.pullRequest.body.toString())); + }); + }); +}); diff --git a/test/updaters/fixtures/go/go.mod b/test/updaters/fixtures/go/go.mod new file mode 100644 index 000000000..0108a90fe --- /dev/null +++ b/test/updaters/fixtures/go/go.mod @@ -0,0 +1,10 @@ +module example.com/hello/world + +go 1.23.0 + +replace example.com/foo/bar/v2 => ../../foo/bar + +require ( + example.com/foo/bar/v2 v2.1.0 + example.com/foo/baz v1.2.3 +) diff --git a/test/updaters/go-mod.ts b/test/updaters/go-mod.ts new file mode 100644 index 000000000..d15f6952a --- /dev/null +++ b/test/updaters/go-mod.ts @@ -0,0 +1,59 @@ +// Copyright 2024 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import {readFileSync} from 'fs'; +import {resolve} from 'path'; +import * as snapshot from 'snap-shot-it'; +import {describe, it} from 'mocha'; +import {expect} from 'chai'; +import {Version} from '../../src/version'; +import {GoMod} from '../../src/updaters/go/go-mod'; + +const fixturesPath = './test/updaters/fixtures/go'; + +describe('go.mod', () => { + describe('updateContent', () => { + it('refuses to update without versions', async () => { + const oldContent = readFileSync( + resolve(fixturesPath, './go.mod'), + 'utf8' + ).replace(/\r\n/g, '\n'); + const updater = new GoMod({ + version: Version.parse('v2.3.4'), + }); + expect(() => { + updater.updateContent(oldContent); + }).to.throw(); + }); + it('updates dependencies', async () => { + const oldContent = readFileSync( + resolve(fixturesPath, './go.mod'), + 'utf8' + ).replace(/\r\n/g, '\n'); + const updatedVersions = new Map(); + updatedVersions.set('example.com/foo/bar/v2', Version.parse('v2.1.3')); + updatedVersions.set( + 'github.com/stretchr/testify', + Version.parse('v1.2.4') + ); + + const updater = new GoMod({ + version: Version.parse('v2.3.4'), + versionsMap: updatedVersions, + }); + const newContent = updater.updateContent(oldContent); + snapshot(newContent); + }); + }); +});