Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(gomod): preserve go/toolchain directives #34779

Open
wants to merge 35 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 28 commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
72f8adf
preserve go mod toolchain directive in any case
trim21 Mar 13, 2025
3629f60
preserve go mod toolchain directive in any case
trim21 Mar 13, 2025
ab9f5b9
add more test
trim21 Mar 13, 2025
be3d457
fix eslint
trim21 Mar 13, 2025
0d94a80
fix regex
trim21 Mar 13, 2025
59a450d
Update lib/modules/manager/gomod/artifacts.ts
trim21 Mar 13, 2025
4b3fd95
Update lib/modules/manager/gomod/artifacts.ts
trim21 Mar 13, 2025
7843e56
preserve -d location
trim21 Mar 13, 2025
3f2750a
better variable name
trim21 Mar 13, 2025
4db59ef
Merge branch 'main' into fix/go-mod-toolchain
trim21 Mar 13, 2025
125e3db
preserve go/toolchain directive
trim21 Mar 13, 2025
de81b17
fix constraints
trim21 Mar 13, 2025
1667c12
fix test
trim21 Mar 13, 2025
ffe38b8
keep old flag order
trim21 Mar 13, 2025
6f2afba
go: control updates of directives when constraint is present
casaqori Apr 2, 2025
9064156
Merge branch 'main' into fix/go-mod-toolchain
trim21 Apr 2, 2025
8f46eb1
add notice when nothing changed
casaqori Apr 2, 2025
f91d392
Merge branch 'main' into fix/go-mod-toolchain
trim21 Apr 3, 2025
2fc88c9
Revert "add notice when nothing changed"
trim21 Apr 5, 2025
875aa50
revert
trim21 Apr 5, 2025
ed66459
fix version
trim21 Apr 5, 2025
a4c4690
fix test
trim21 Apr 5, 2025
e238578
use join instead of string concat
trim21 Apr 5, 2025
0eb2aac
handle major upgrade
trim21 Apr 5, 2025
26c511c
fix lint
trim21 Apr 5, 2025
ffec247
cov
trim21 Apr 5, 2025
5264a45
cov
trim21 Apr 5, 2025
0858735
docs
trim21 Apr 5, 2025
c3bf9f8
fix(gomod): disable toolchain switch
trim21 Apr 7, 2025
dea2c8c
fix(gomod): disable toolchain switch
trim21 Apr 7, 2025
e321b50
pick minial version
trim21 Apr 11, 2025
60bdc84
Merge remote-tracking branch 'origin/fix/go-no-switch-toolchain' into…
trim21 Apr 11, 2025
f72f7ed
fix test
trim21 Apr 11, 2025
6a5b70d
fix test
trim21 Apr 11, 2025
575376f
add comment
trim21 Apr 11, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
171 changes: 164 additions & 7 deletions lib/modules/manager/gomod/artifacts.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2105,7 +2105,7 @@ describe('modules/manager/gomod/artifacts', () => {
' bash -l -c "' +
'install-tool golang 1.23.3' +
' && ' +
'go get -d -t ./...' +
'go get -t ./...' +
' && ' +
'go install github.com/marwan-at-work/mod/cmd/mod@latest' +
' && ' +
Expand Down Expand Up @@ -2134,6 +2134,14 @@ describe('modules/manager/gomod/artifacts', () => {
.mockResolvedValueOnce('New go.sum')
.mockResolvedValueOnce('New go.mod');

datasource.getPkgReleases.mockResolvedValueOnce({
releases: [
{ version: '1.17.0' },
{ version: '1.23.3' },
{ version: '1.23.6' },
],
});

const res = await gomod.updateArtifacts({
packageFileName: 'go.mod',
updatedDeps: [{ depName: 'golang.org/x/crypto', newVersion: '0.35.0' }],
Expand All @@ -2156,8 +2164,6 @@ describe('modules/manager/gomod/artifacts', () => {
cmd: 'go get -d -t ./...',
},
]);

expect(datasource.getPkgReleases).toBeCalledTimes(0);
});

it('go.mod file contains full go version without toolchain', async () => {
Expand All @@ -2174,9 +2180,21 @@ describe('modules/manager/gomod/artifacts', () => {
.mockResolvedValueOnce('New go.sum')
.mockResolvedValueOnce('New go.mod');

datasource.getPkgReleases.mockResolvedValueOnce({
releases: [
{ version: '1.17.0' },
{ version: '1.23.3' },
{ version: '1.24.1' },
],
});

const res = await gomod.updateArtifacts({
packageFileName: 'go.mod',
updatedDeps: [{ depName: 'golang.org/x/crypto', newVersion: '0.35.0' }],
updatedDeps: [
{ depName: 'golang.org/x/crypto', newVersion: 'v0.35.0' },
{ depName: 'go', newVersion: '1.23.5' },
{ newVersion: '1.23.5' },
],
newPackageFileContent: `someText\n\ngo 1.23.5\n\n${gomod1}`,
config: {
updateType: 'minor',
Expand All @@ -2190,14 +2208,153 @@ describe('modules/manager/gomod/artifacts', () => {

expect(execSnapshots).toMatchObject([
{
cmd: 'install-tool golang 1.23.5',
cmd: 'install-tool golang 1.24.1',
},
{
cmd: 'go get -d -t ./...',
cmd: 'go get -t ./... toolchain@none [email protected] golang.org/x/[email protected]',
},
]);
});

expect(datasource.getPkgReleases).toBeCalledTimes(0);
it('preserve go.mod file without toolchain', async () => {
GlobalConfig.set({ ...adminConfig, binarySource: 'install' });
fs.readLocalFile.mockResolvedValueOnce('Current go.sum');
fs.readLocalFile.mockResolvedValueOnce(null); // vendor modules filename
const execSnapshots = mockExecAll();
git.getRepoStatus.mockResolvedValueOnce(
partial<StatusResult>({
modified: ['go.sum'],
}),
);
fs.readLocalFile
.mockResolvedValueOnce('New go.sum')
.mockResolvedValueOnce('New go.mod');

datasource.getPkgReleases.mockResolvedValueOnce({
releases: [
{ version: '1.17.0' },
{ version: '1.23.3' },
{ version: '1.24.1' },
],
});

const res = await gomod.updateArtifacts({
packageFileName: 'go.mod',
updatedDeps: [{ depName: 'golang.org/x/crypto', newVersion: 'v0.35.0' }],
newPackageFileContent: `someText\n\ngo 1.23\n\n${gomod1}`,
config: {
updateType: 'minor',
},
});

expect(res).toEqual([
{ file: { type: 'addition', path: 'go.sum', contents: 'New go.sum' } },
{ file: { type: 'addition', path: 'go.mod', contents: 'New go.mod' } },
]);

expect(execSnapshots).toMatchObject([
{
cmd: 'install-tool golang 1.24.1',
},
{
cmd: 'go get -t ./... toolchain@none [email protected] golang.org/x/[email protected]',
},
]);
});

it('preserve work with major upgrade', async () => {
GlobalConfig.set({ ...adminConfig, binarySource: 'install' });
fs.readLocalFile.mockResolvedValueOnce('Current go.sum');
fs.readLocalFile.mockResolvedValueOnce(null); // vendor modules filename
const execSnapshots = mockExecAll();
git.getRepoStatus.mockResolvedValueOnce(
partial<StatusResult>({
modified: ['go.sum'],
}),
);
fs.readLocalFile
.mockResolvedValueOnce('New go.sum')
.mockResolvedValueOnce('New go.mod');

datasource.getPkgReleases.mockResolvedValueOnce({
releases: [
{ version: '1.17.0' },
{ version: '1.23.3' },
{ version: '1.24.1' },
],
});

const res = await gomod.updateArtifacts({
packageFileName: 'go.mod',
updatedDeps: [
{
datasource: 'go',
depName: 'gopkg.in/yaml.v2',
fixedVersion: 'v2.4.0',
currentVersion: 'v2.4.0',
currentValue: 'v2.4.0',
newValue: 'v3.0.1',
newVersion: 'v3.0.1',
packageFile: 'go.mod',
updateType: 'major',
packageName: 'gopkg.in/yaml.v2',
},
{
depName: 'github.com/google/go-github/v24',
packageName: 'github.com/google/go-github/v24',
newVersion: 'v28.0.0',
datasource: 'go',
fixedVersion: 'v24.0.0',
currentVersion: 'v24.0.0',
currentValue: 'v24.0.0',
newValue: 'v28.0.0',
packageFile: 'go.mod',
updateType: 'major',
},
{
depName: 'github.com/renovatebot/renovate',
packageName: 'github.com/renovatebot/renovate',
newVersion: 'v1.0.0',
datasource: 'go',
fixedVersion: 'v0.0.1',
currentVersion: 'v0.0.1',
currentValue: 'v0.0.1',
newValue: 'v1.0.0',
packageFile: 'go.mod',
updateType: 'major',
},
{
depName: 'github.com/some/lib',
packageName: 'github.com/some/lib',
datasource: 'go',
fixedVersion: 'v0.0.1',
currentVersion: 'v0.0.1',
currentValue: 'v0.0.1',
newVersion: 'v2.0.0',
newValue: 'v2.0.0',
packageFile: 'go.mod',
updateType: 'major',
},
],
newPackageFileContent: `someText\n\ngo 1.23\n\n${gomod1}`,
config: {
updateType: 'minor',
},
});

expect(res).toEqual([
{ file: { type: 'addition', path: 'go.sum', contents: 'New go.sum' } },
{ file: { type: 'addition', path: 'go.mod', contents: 'New go.mod' } },
]);

expect(execSnapshots).toMatchObject([
{
cmd: 'install-tool golang 1.24.1',
},
{
cmd: 'go get -t ./... toolchain@none [email protected] gopkg.in/[email protected] github.com/google/go-github/[email protected] github.com/renovatebot/[email protected] github.com/some/lib/[email protected]',
},
]);
});

it('returns artifact notices', async () => {
Expand Down
112 changes: 85 additions & 27 deletions lib/modules/manager/gomod/artifacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,8 +190,38 @@ export async function updateArtifacts({
);
}
}
const goConstraints =
config.constraints?.go ?? getGoConstraints(newGoModContent);

const goMod = getGoConfig(newGoModContent);
const goConstraints = config.constraints?.go ?? `^${goMod.minimalGoVersion}`;
Copy link

@casaqori casaqori Apr 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This code does enforce the version currently declared by the go directive.
Which in turn will prevent any dependency upgrade to succeed that requires even a higher minor version of go.

go 1.23.0

config.constraints.go 1.23 (or GOTOOLCHAIN="local")
installed toolchain go1.23.1

required by dependency: go1.23.7

enforced by constraint: go1.23.0

Wasn't there agreement that this is undesired?

Copy link
Contributor Author

@trim21 trim21 Apr 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

https://github.com/renovatebot/renovate/pull/34779/files#diff-4c37b8edc62aa49295d264c221717bd0361ccff0a791d4327f4421a0ea23521fR2214

Consider x/crypto would specify go1.23.7 in it's go.mod

[email protected] would not allow that ...
And users can not control that behavior in renovate. They would explicitly need to bump go before that. Why not allow the bump in same PR?

Copy link
Contributor Author

@trim21 trim21 Apr 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO the language version should have more priority than library version. Users can always enable updating go directive, but they can't suppress updating go directive caused by library if they want, therefore is this PR

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The question is what a sane default is:

  1. allow automated go version bumping that is supported by local toolchain when dependency requires that (have PRs that can be merged, toolchain always latest, current state of affairs)
  2. avoid that (have PRs that break), enforce users to maintain their go directive separately
  3. allow users to choose from 1) and 2) - e.g. if config.constraints.go is set, use strategy 1) for allowing updates within the constrained minor only

Who would be able to take such decision @viceice ?

Copy link

@casaqori casaqori Apr 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

..., but they can't suppress updating go directive caused by library if they want

That is only the case for auto-merges of PRs. Otherwise users can transparently review and close unwanted PRs.

Please also consider that the changes currently WILL ALLOW go directive bumping. Here is the observation:

go.mod is at 1.22.0

go get -t ./... toolchain@none [email protected] github.com/someorg/[email protected]
go: github.com/someorg/[email protected] requires go >= 1.22.2; switching to go1.23.8
go: github.com/someorg/[email protected] requires [email protected], not [email protected]
go.mod is at 1.22

go get -t ./... toolchain@none [email protected] github.com/someorg/[email protected]
go: github.com/someorg/[email protected] requires go >= 1.22.2; switching to go1.23.8
go: upgraded go 1.22.0 => 1.22.12

go.mod is now at 1.22.12

And that is if GOTOOLCHAIN is "auto". On "local" with a 1.22.0 toolchain, we get no switching:

go get -t ./... toolchain@none [email protected] github.com/someorg/[email protected]
go: github.com/someorg/[email protected] requires [email protected], not [email protected]

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, I don't know this case.

A workaround would be using go 1.23 instead of go 1.23.0 in go mod, but that requires user to change their code ...

A better solution would be disabling go version switching and pick 1.23.0 when it has go 1.23 directive 😅

Copy link
Contributor Author

@trim21 trim21 Apr 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The question is what a sane default is:

  1. allow automated go version bumping that is supported by local toolchain when dependency requires that (have PRs that can be merged, toolchain always latest, current state of affairs)
  2. avoid that (have PRs that break), enforce users to maintain their go directive separately
  3. allow users to choose from 1) and 2) - e.g. if config.constraints.go is set, use strategy 1) for allowing updates within the constrained minor only

Who would be able to take such decision @viceice ?

IMO, if users want renovate to touch go version, they can manually enable it.

So renovate will update the go directive to expected version based on user config.

If they group go directive with packages, they get one working PR.

And if they have seprated PRs for go direcive and package, they can still get a working PR for package after they merge the PR for go directive.

But I'm not opposed to a new config that let go command updating go directive, it would very easy to add such feature. we just don't push anything into extraGetArguments.

For example

const preserveGoDirective = config.preserveGoDirectives ?? 'enabled';
if (
  preserveGoDirective !== 'enabled' &&
  semver.satisfies(goMod.minimalGoVersion, '>=1.21.0')
) {
  extraGetArguments.push(`toolchain@${goMod.toolchainDirective ?? 'none'}`);
  extraGetArguments.push(`go@${goMod.goDirective}`);

  ...
}

But it should be done in a seprated PR.


const getFlags = ['-t'];
if (!semver.satisfies(goMod.minimalGoVersion, '>=1.17.0')) {
getFlags.unshift('-d');
}

const extraGetArguments: string[] = [];
if (semver.satisfies(goMod.minimalGoVersion, '>=1.21.0')) {
extraGetArguments.push(`toolchain@${goMod.toolchainDirective ?? 'none'}`);
extraGetArguments.push(`go@${goMod.goDirective}`);
// add package@version to go get command to let golang check if it work with current go directive
// if some package requires a too new go version,
// the go get command will fail and user get a "artifact update problem" notice.
for (const pkg of updatedDeps) {
const name = pkg.packageName ?? pkg.depName ?? pkg.name;
if (name === 'go' || !name) {
continue;
}

if (pkg.updateType === 'major') {
const newMajor = major(pkg.newVersion!);
extraGetArguments.push(
`${upgradePackageMajorVersion(name, newMajor)}@${pkg.newVersion}`,
);
} else {
extraGetArguments.push(`${name}@${pkg.newVersion}`);
}
}
}

try {
await writeLocalFile(goModFileName, massagedGoMod);
Expand Down Expand Up @@ -241,7 +271,13 @@ export async function updateArtifacts({
}
}

let args = `get -d -t ${goGetDirs ?? './...'}`;
let args = [
'get',
...getFlags,
goGetDirs ?? './...',
...extraGetArguments,
].join(' ');

logger.trace({ cmd, args }, 'go get command included');
execCommands.push(`${cmd} ${args}`);

Expand Down Expand Up @@ -442,37 +478,59 @@ export async function updateArtifacts({
}
}

function getGoConstraints(content: string): string | undefined {
// prefer toolchain directive when go.mod has one
function getGoConfig(content: string): {
toolchainDirective?: string;
minimalGoVersion: string;
goDirective: string;
} {
const toolchainMatch = regEx(/^toolchain\s*go(?<gover>\d+\.\d+\.\d+)$/m).exec(
content,
);
const toolchainVer = toolchainMatch?.groups?.gover;
if (toolchainVer) {
logger.debug(
`Using go version ${toolchainVer} found in toolchain directive`,
);
return toolchainVer;

const goMatch = regEx(/^go\s*(?<gover>\d+(\.\d+)+)$/m).exec(content);

// go mod spec says if go directive is missing it's 1.16
const goDirective = goMatch?.groups?.gover ?? '1.16';
let goVersion = goDirective;

// partial semver version without patch
if (/^\d+\.\d+$/.test(goVersion)) {
goVersion = `${goVersion}.0`;
}

return {
toolchainDirective: toolchainVer,
minimalGoVersion: goVersion,
goDirective,
};
}

function upgradePackageMajorVersion(name: string, newMajor: number): string {
if (name.startsWith('gopkg.in/')) {
const s = name.split('.');
return `${s.slice(0, -1).join('.')}.v${newMajor}`;
}

// v0 => v1, no need to handle it
if (newMajor === 1) {
return name;
}

// If go.mod doesn't have toolchain directive and has a full go version spec,
// for example `go 1.23.6`, pick this version, this doesn't match major.minor version spec.
//
// This is because when go.mod have same version defined in go directive and toolchain directive,
// go will remove toolchain directive from go.mod.
//
// For example, go will rewrite `go 1.23.5\ntoolchain go1.23.5` to `go 1.23.5` by default,
// in this case, the go directive is the toolchain directive.
const goFullVersion = regEx(/^go\s*(?<gover>\d+\.\d+\.\d+)$/m).exec(content)
?.groups?.gover;
if (goFullVersion) {
return goFullVersion;
const s = name.split('/');
const last = s.at(-1);

// there is no valid case that a go pakcage name doesn't contain slash.
/* v8 ignore next 5 -- typescript strict null check */
if (!last) {
throw new Error(
`unreadable: go package name ${name} doesn't contain any slash`,
);
}

const re = regEx(/^go\s*(?<gover>\d+\.\d+)$/m);
const match = re.exec(content);
if (!match?.groups?.gover) {
return undefined;
if (/^v\d+$/.test(last)) {
return `${s.slice(0, -1).join('/')}/v${newMajor}`;
}
return `^${match.groups.gover}`;

return `${name}/v${newMajor}`;
}