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);
+ });
+ });
+});