Skip to content

Commit f0759ec

Browse files
Copilotpelikhan
andcommitted
Implement semver-compatible action pin resolution
- Add extractMajorVersion() and isSemverCompatible() helper functions - Update GetActionPinWithData() to prefer semver-compatible versions - When requesting v5, now resolves to v5.0.0 (not v6.0.0) - When requesting v4, now resolves to v4.6.2 (not v6.0.0) - Update tests to reflect new semver-compatible behavior Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
1 parent c6b8e8a commit f0759ec

3 files changed

Lines changed: 73 additions & 21 deletions

File tree

pkg/workflow/action_pins.go

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -232,23 +232,38 @@ func GetActionPinWithData(actionRepo, version string, data *WorkflowData) (strin
232232
}
233233

234234
// No exact match found
235-
// In non-strict mode, use the first pin we found (which is the highest due to sorting above)
236-
// This matches the behavior of GetActionPin and GetActionPinByRepo, which return
237-
// "the latest version by semver" without regard to major version boundaries.
238-
// This ensures consistency across all action pin lookup functions and satisfies
239-
// the requirement to "always pick the highest release according to semver".
235+
// In non-strict mode, find the highest semver-compatible version
236+
// Semver compatibility means respecting major version boundaries
237+
// (e.g., v5 -> highest v5.x.x, not v6.x.x)
240238
if !data.StrictMode && len(matchingPins) > 0 {
241-
highestPin := matchingPins[0]
242-
actionPinsLog.Printf("No exact match for version %s, using highest available: %s", version, highestPin.Version)
239+
// Filter for semver-compatible pins (matching major version)
240+
var compatiblePins []ActionPin
241+
for _, pin := range matchingPins {
242+
if isSemverCompatible(pin.Version, version) {
243+
compatiblePins = append(compatiblePins, pin)
244+
}
245+
}
246+
247+
// If we found compatible pins, use the highest one (first after sorting)
248+
// Otherwise fall back to the highest overall pin
249+
var selectedPin ActionPin
250+
if len(compatiblePins) > 0 {
251+
selectedPin = compatiblePins[0]
252+
actionPinsLog.Printf("No exact match for version %s, using highest semver-compatible version: %s", version, selectedPin.Version)
253+
} else {
254+
selectedPin = matchingPins[0]
255+
actionPinsLog.Printf("No exact match for version %s, no semver-compatible versions found, using highest available: %s", version, selectedPin.Version)
256+
}
257+
243258
// Only emit warning if the version is not a SHA (SHAs shouldn't generate warnings)
244259
if !isAlreadySHA {
245260
warningMsg := fmt.Sprintf("Unable to resolve %s@%s dynamically, using hardcoded pin for %s@%s",
246-
actionRepo, version, actionRepo, highestPin.Version)
261+
actionRepo, version, actionRepo, selectedPin.Version)
247262
fmt.Fprintln(os.Stderr, console.FormatWarningMessage(warningMsg))
248263
}
249-
actionPinsLog.Printf("Using highest version in non-strict mode: %s@%s (requested) → %s@%s (used)",
250-
actionRepo, version, actionRepo, highestPin.Version)
251-
return actionRepo + "@" + highestPin.SHA + " # " + highestPin.Version, nil
264+
actionPinsLog.Printf("Using version in non-strict mode: %s@%s (requested) → %s@%s (used)",
265+
actionRepo, version, actionRepo, selectedPin.Version)
266+
return actionRepo + "@" + selectedPin.SHA + " # " + selectedPin.Version, nil
252267
}
253268
}
254269

pkg/workflow/action_pins_test.go

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -666,20 +666,23 @@ func TestGetActionPinWithData_SemverPreference(t *testing.T) {
666666
shouldFallback: true,
667667
},
668668
{
669-
name: "fallback to highest version for upload-artifact when requesting v4",
669+
name: "fallback to highest semver-compatible version for upload-artifact when requesting v4",
670670
repo: "actions/upload-artifact",
671671
requestedVer: "v4",
672-
expectedVer: "v6.0.0", // Falls back to v6.0.0, crossing major version boundary
672+
expectedVer: "v4.6.2", // Falls back to highest v4.x.x (semver-compatible)
673+
strictMode: false,
674+
shouldFallback: true,
675+
// Note: When requesting v4, the system returns v4.6.2 (the highest v4.x.x version),
676+
// respecting semver compatibility and not crossing major version boundaries.
677+
// This ensures that users get compatible upgrades within the same major version.
678+
},
679+
{
680+
name: "fallback to highest semver-compatible version for upload-artifact when requesting v5",
681+
repo: "actions/upload-artifact",
682+
requestedVer: "v5",
683+
expectedVer: "v5.0.0", // Falls back to highest v5.x.x (semver-compatible), not v6.0.0
673684
strictMode: false,
674685
shouldFallback: true,
675-
// Note: This behavior matches GetActionPin and GetActionPinByRepo which return
676-
// "the latest version by semver" without regard to major version boundaries.
677-
// When requesting v4, the system returns v6.0.0 (the highest available version),
678-
// which crosses a major version boundary. This ensures consistency across all
679-
// action pin lookup functions and matches the requirement to "always pick the
680-
// highest release according to semver". Users expecting semver-compatible
681-
// resolution within the same major version should request specific versions
682-
// (e.g., v4.6.2) instead of generic major versions (v4).
683686
},
684687
{
685688
name: "exact match for upload-artifact v4",

pkg/workflow/semver.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,37 @@ func compareVersions(v1, v2 string) int {
4444
semverLog.Printf("Version comparison result: %s == %s", v1, v2)
4545
return 0
4646
}
47+
48+
// extractMajorVersion extracts the major version number from a version string
49+
// Examples: "v5.0.0" -> 5, "v6" -> 6, "5.1.0" -> 5
50+
func extractMajorVersion(version string) int {
51+
// Strip 'v' prefix if present
52+
v := strings.TrimPrefix(version, "v")
53+
54+
// Split by '.' and get the first part
55+
parts := strings.Split(v, ".")
56+
if len(parts) > 0 {
57+
var major int
58+
fmt.Sscanf(parts[0], "%d", &major)
59+
return major
60+
}
61+
62+
return 0
63+
}
64+
65+
// isSemverCompatible checks if pinVersion is semver-compatible with requestedVersion
66+
// Semver compatibility means the major version must match
67+
// Examples:
68+
// - isSemverCompatible("v5.0.0", "v5") -> true
69+
// - isSemverCompatible("v5.1.0", "v5.0.0") -> true
70+
// - isSemverCompatible("v6.0.0", "v5") -> false
71+
func isSemverCompatible(pinVersion, requestedVersion string) bool {
72+
pinMajor := extractMajorVersion(pinVersion)
73+
requestedMajor := extractMajorVersion(requestedVersion)
74+
75+
compatible := pinMajor == requestedMajor
76+
semverLog.Printf("Checking semver compatibility: pin=%s (major=%d), requested=%s (major=%d) -> %v",
77+
pinVersion, pinMajor, requestedVersion, requestedMajor, compatible)
78+
79+
return compatible
80+
}

0 commit comments

Comments
 (0)