Skip to content

Commit 7de5d27

Browse files
fix: Rework git tag semver resolution (argoproj#20083) (argoproj#20096)
* Write initial tests Signed-off-by: Paul Larsen <[email protected]> * Improve git tag semver resolution Signed-off-by: Paul Larsen <[email protected]> * Add company to list of users Signed-off-by: Paul Larsen <[email protected]> * Fix broken error string check Signed-off-by: Paul Larsen <[email protected]> * Fix incorrect semver test assumption Signed-off-by: Paul Larsen <[email protected]> * switch to debug statement Signed-off-by: Paul Larsen <[email protected]> * Add more testcases for review Signed-off-by: Paul Larsen <[email protected]> * review comments Signed-off-by: Paul Larsen <[email protected]> --------- Signed-off-by: Paul Larsen <[email protected]>
1 parent 99560fb commit 7de5d27

File tree

4 files changed

+171
-24
lines changed

4 files changed

+171
-24
lines changed

USERS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ Currently, the following organizations are **officially** using Argo CD:
4141
1. [Beez Innovation Labs](https://www.beezlabs.com/)
4242
1. [Bedag Informatik AG](https://www.bedag.ch/)
4343
1. [Beleza Na Web](https://www.belezanaweb.com.br/)
44+
1. [Believable Bots](https://believablebots.io)
4445
1. [BigPanda](https://bigpanda.io)
4546
1. [BioBox Analytics](https://biobox.io)
4647
1. [BMW Group](https://www.bmwgroup.com/)

util/git/client.go

Lines changed: 26 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -631,14 +631,9 @@ func (m *nativeGitClient) lsRemote(revision string) (string, error) {
631631
revision = "HEAD"
632632
}
633633

634-
// Check if the revision is a valid semver constraint before attempting to resolve it
635-
if constraint, err := semver.NewConstraint(revision); err == nil {
636-
semverSha := m.resolveSemverRevision(constraint, refs)
637-
if semverSha != "" {
638-
return semverSha, nil
639-
}
640-
} else {
641-
log.Debugf("Revision '%s' is not a valid semver constraint, skipping semver resolution.", revision)
634+
semverSha := m.resolveSemverRevision(revision, refs)
635+
if semverSha != "" {
636+
return semverSha, nil
642637
}
643638

644639
// refToHash keeps a maps of remote refs to their hash
@@ -684,18 +679,31 @@ func (m *nativeGitClient) lsRemote(revision string) (string, error) {
684679

685680
// If we get here, revision string had non hexadecimal characters (indicating its a branch, tag,
686681
// or symbolic ref) and we were unable to resolve it to a commit SHA.
687-
return "", fmt.Errorf("Unable to resolve '%s' to a commit SHA", revision)
682+
return "", fmt.Errorf("unable to resolve '%s' to a commit SHA", revision)
688683
}
689684

690685
// resolveSemverRevision is a part of the lsRemote method workflow.
691-
// When the user configure correctly the Git repository revision and the revision is a valid semver constraint
692-
// only the for loop in this function will run, otherwise the lsRemote loop will try to resolve the revision.
693-
// Some examples to illustrate the actual behavior, if:
694-
// * The revision is "v0.1.*"/"0.1.*" or "v0.1.2"/"0.1.2" and there's a tag matching that constraint only this function loop will run;
695-
// * The revision is "v0.1.*"/"0.1.*" or "0.1.2"/"0.1.2" and there is no tag matching that constraint this function loop and lsRemote loop will run for backward compatibility;
696-
// * The revision is "custom-tag" only the lsRemote loop will run because that revision is an invalid semver;
697-
// * The revision is "master-branch" only the lsRemote loop will run because that revision is an invalid semver;
698-
func (m *nativeGitClient) resolveSemverRevision(constraint *semver.Constraints, refs []*plumbing.Reference) string {
686+
// When the user correctly configures the Git repository revision, and that revision is a valid semver constraint, we
687+
// use this logic path rather than the standard lsRemote revision resolution loop.
688+
// Some examples to illustrate the actual behavior - if the revision is:
689+
// * "v0.1.2"/"0.1.2" or "v0.1"/"0.1", then this is not a constraint, it's a pinned version - so we fall back to the standard tag matching in the lsRemote loop.
690+
// * "v0.1.*"/"0.1.*", and there's a tag matching that constraint, then we find the latest matching version and return its commit hash.
691+
// * "v0.1.*"/"0.1.*", and there is *no* tag matching that constraint, then we fall back to the standard tag matching in the lsRemote loop.
692+
// * "custom-tag", only the lsRemote loop will run - because that revision is an invalid semver;
693+
// * "master-branch", only the lsRemote loop will run because that revision is an invalid semver;
694+
func (m *nativeGitClient) resolveSemverRevision(revision string, refs []*plumbing.Reference) string {
695+
if _, err := semver.NewVersion(revision); err == nil {
696+
// If the revision is a valid version, then we know it isn't a constraint; it's just a pin.
697+
// In which case, we should use standard tag resolution mechanisms.
698+
return ""
699+
}
700+
701+
constraint, err := semver.NewConstraint(revision)
702+
if err != nil {
703+
log.Debugf("Revision '%s' is not a valid semver constraint, skipping semver resolution.", revision)
704+
return ""
705+
}
706+
699707
maxVersion := semver.New(0, 0, 0, "", "")
700708
maxVersionHash := plumbing.ZeroHash
701709
for _, ref := range refs {
@@ -723,6 +731,7 @@ func (m *nativeGitClient) resolveSemverRevision(constraint *semver.Constraints,
723731
return ""
724732
}
725733

734+
log.Debugf("Semver constraint '%s' resolved to tag '%s', at reference '%s'", revision, maxVersion.Original(), maxVersionHash.String())
726735
return maxVersionHash.String()
727736
}
728737

util/git/client_test.go

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,148 @@ func Test_ChangedFiles(t *testing.T) {
173173
assert.ElementsMatch(t, []string{"README"}, changedFiles)
174174
}
175175

176+
func Test_SemverTags(t *testing.T) {
177+
tempDir := t.TempDir()
178+
179+
client, err := NewClientExt(fmt.Sprintf("file://%s", tempDir), tempDir, NopCreds{}, true, false, "", "")
180+
require.NoError(t, err)
181+
182+
err = client.Init()
183+
require.NoError(t, err)
184+
185+
mapTagRefs := map[string]string{}
186+
for _, tag := range []string{
187+
"v1.0.0-rc1",
188+
"v1.0.0-rc2",
189+
"v1.0.0",
190+
"v1.0",
191+
"v1.0.1",
192+
"v1.1.0",
193+
"2024-apple",
194+
"2024-banana",
195+
} {
196+
err = runCmd(client.Root(), "git", "commit", "-m", tag+" commit", "--allow-empty")
197+
require.NoError(t, err)
198+
199+
// Create an rc semver tag
200+
err = runCmd(client.Root(), "git", "tag", tag)
201+
require.NoError(t, err)
202+
203+
sha, err := client.LsRemote("HEAD")
204+
require.NoError(t, err)
205+
206+
mapTagRefs[tag] = sha
207+
}
208+
209+
for _, tc := range []struct {
210+
name string
211+
ref string
212+
expected string
213+
error bool
214+
}{{
215+
name: "pinned rc version",
216+
ref: "v1.0.0-rc1",
217+
expected: mapTagRefs["v1.0.0-rc1"],
218+
}, {
219+
name: "lt rc constraint",
220+
ref: "< v1.0.0-rc3",
221+
expected: mapTagRefs["v1.0.0-rc2"],
222+
}, {
223+
name: "pinned major version",
224+
ref: "v1.0.0",
225+
expected: mapTagRefs["v1.0.0"],
226+
}, {
227+
name: "pinned patch version",
228+
ref: "v1.0.1",
229+
expected: mapTagRefs["v1.0.1"],
230+
}, {
231+
name: "pinned minor version",
232+
ref: "v1.1.0",
233+
expected: mapTagRefs["v1.1.0"],
234+
}, {
235+
name: "patch wildcard constraint",
236+
ref: "v1.0.*",
237+
expected: mapTagRefs["v1.0.1"],
238+
}, {
239+
name: "patch tilde constraint",
240+
ref: "~v1.0.0",
241+
expected: mapTagRefs["v1.0.1"],
242+
}, {
243+
name: "minor wildcard constraint",
244+
ref: "v1.*",
245+
expected: mapTagRefs["v1.1.0"],
246+
}, {
247+
// The semver library allows for using both * and x as the wildcard modifier.
248+
name: "alternative minor wildcard constraint",
249+
ref: "v1.x",
250+
expected: mapTagRefs["v1.1.0"],
251+
}, {
252+
name: "minor gte constraint",
253+
ref: ">= v1.0.0",
254+
expected: mapTagRefs["v1.1.0"],
255+
}, {
256+
name: "multiple constraints",
257+
ref: "> v1.0.0 < v1.1.0",
258+
expected: mapTagRefs["v1.0.1"],
259+
}, {
260+
// We treat non-specific semver versions as regular tags, rather than constraints.
261+
name: "non-specific version",
262+
ref: "v1.0",
263+
expected: mapTagRefs["v1.0"],
264+
}, {
265+
// Which means a missing tag will raise an error.
266+
name: "missing non-specific version",
267+
ref: "v1.1",
268+
error: true,
269+
}, {
270+
// This is NOT a semver constraint, so it should always resolve to itself - because specifying a tag should
271+
// return the commit for that tag.
272+
// semver/v3 has the unfortunate semver-ish behaviour where any tag starting with a number is considered to be
273+
// "semver-ish", where that number is the semver major version, and the rest then gets coerced into a beta
274+
// version string. This can cause unexpected behaviour with constraints logic.
275+
// In this case, if the tag is being incorrectly coerced into semver (for being semver-ish), it will incorrectly
276+
// return the commit for the 2024-banana tag; which we want to avoid.
277+
name: "apple non-semver tag",
278+
ref: "2024-apple",
279+
expected: mapTagRefs["2024-apple"],
280+
}, {
281+
name: "banana non-semver tag",
282+
ref: "2024-banana",
283+
expected: mapTagRefs["2024-banana"],
284+
}, {
285+
// A semver version (without constraints) should ONLY match itself.
286+
// We do not want "2024-apple" to get "semver-ish'ed" into matching "2024.0.0-apple"; they're different tags.
287+
name: "no semver tag coercion",
288+
ref: "2024.0.0-apple",
289+
error: true,
290+
}, {
291+
// No minor versions are specified, so we would expect a major version of 2025 or more.
292+
// This is because if we specify > 11 in semver, we would not expect 11.1.0 to pass; it should be 12.0.0 or more.
293+
// Similarly, if we were to specify > 11.0, we would expect 11.1.0 or more.
294+
name: "semver constraints on non-semver tags",
295+
ref: "> 2024-apple",
296+
error: true,
297+
}, {
298+
// However, if one specifies the minor/patch versions, semver constraints can be used to match non-semver tags.
299+
// 2024-banana is considered as "2024.0.0-banana" in semver-ish, and banana > apple, so it's a match.
300+
// Note: this is more for documentation and future reference than real testing, as it seems like quite odd behaviour.
301+
name: "semver constraints on non-semver tags",
302+
ref: "> 2024.0.0-apple",
303+
expected: mapTagRefs["2024-banana"],
304+
}} {
305+
t.Run(tc.name, func(t *testing.T) {
306+
commitSHA, err := client.LsRemote(tc.ref)
307+
if tc.error {
308+
require.Error(t, err)
309+
return
310+
}
311+
require.NoError(t, err)
312+
assert.True(t, IsCommitSHA(commitSHA))
313+
assert.Equal(t, tc.expected, commitSHA)
314+
})
315+
}
316+
}
317+
176318
func Test_nativeGitClient_Submodule(t *testing.T) {
177319
tempDir, err := os.MkdirTemp("", "")
178320
require.NoError(t, err)

util/git/git_test.go

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -233,15 +233,10 @@ func TestLsRemote(t *testing.T) {
233233
expectedCommit: "ff87d8cb9e669d3738434733ecba3c6dd2c64d70",
234234
},
235235
{
236-
name: "should resolve a pined tag with semantic versioning",
236+
name: "should resolve a pinned tag with semantic versioning",
237237
revision: "v0.8.0",
238238
expectedCommit: "d7c04ae24c16f8ec611b0331596fbc595537abe9",
239239
},
240-
{
241-
name: "should resolve a pined tag with semantic versioning without the 'v' prefix",
242-
revision: "0.8.0",
243-
expectedCommit: "d7c04ae24c16f8ec611b0331596fbc595537abe9",
244-
},
245240
{
246241
name: "should resolve a range tag with semantic versioning",
247242
revision: "v0.8.*", // it should resolve to v0.8.2
@@ -299,7 +294,7 @@ func TestLsRemote(t *testing.T) {
299294

300295
for _, revision := range xfail {
301296
_, err := clnt.LsRemote(revision)
302-
assert.ErrorContains(t, err, "Unable to resolve")
297+
assert.ErrorContains(t, err, "unable to resolve")
303298
}
304299
})
305300
}

0 commit comments

Comments
 (0)