Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
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
20 changes: 18 additions & 2 deletions pkg/actionpins/actionpins_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -320,12 +320,28 @@ func TestFormatPinnedActionWithResolution_ConsistentVersionComment(t *testing.T)
expected: "actions/checkout@abc123 # v4.1.2",
},
{
name: "shows both versions when source differs from resolved",
name: "uses more precise resolved tag",
repo: "actions/checkout",
sha: "abc123",
sourceVersion: "v4",
resolvedVersion: "v4.1.2",
expected: "actions/checkout@abc123 # v4.1.2 (source v4)",
expected: "actions/checkout@abc123 # v4.1.2",
},
{
name: "preserves exact source release tag over resolved major",
repo: "actions/github-script",
sha: "abc123",
sourceVersion: "v9.0.0",
resolvedVersion: "v9",
expected: "actions/github-script@abc123 # v9.0.0",
},
{
name: "uses resolved tag when source refers to another major",
repo: "actions/github-script",
sha: "abc123",
sourceVersion: "v8",
resolvedVersion: "v9",
expected: "actions/github-script@abc123 # v9",
},
{
name: "shows resolved version without source suffix when sourceVersion is empty",
Expand Down
7 changes: 6 additions & 1 deletion pkg/actionpins/references.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package actionpins
import (
"fmt"
"strings"

"github.com/github/gh-aw/pkg/semverutil"
)

// getLatestActionPinReference returns the pinned reference for the latest version of the repo.
Expand Down Expand Up @@ -36,7 +38,10 @@ func formatPinnedActionWithResolution(repo, sha, sourceVersion, resolvedVersion
return FormatPinnedActionReference(repo, sha, resolvedVersion)
}
actionPinsLog.Printf("Version resolved: source=%s resolved=%s for repo=%s", sourceVersion, resolvedVersion, repo)
return FormatPinnedActionReference(repo, sha, resolvedVersion+" (source "+sourceVersion+")")
if semverutil.IsActionVersionTag(sourceVersion) && strings.HasPrefix(sourceVersion, resolvedVersion+".") {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in d46d356: source-version preference is limited to precise vN.N.N release tags; partial minor tags use the resolved version.

return FormatPinnedActionReference(repo, sha, sourceVersion)
}
return FormatPinnedActionReference(repo, sha, resolvedVersion)
}

// FormatCacheKey generates a cache key for action resolution.
Expand Down
6 changes: 3 additions & 3 deletions pkg/actionpins/resolve.go
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ func resolveNonStrictHardcodedPin(actionRepo, version string, matchingPins []Act
if foundCompatible {
actionPinsLog.Printf("No exact match for version %s, using highest semver-compatible version: %s", version, selectedPin.Version)
} else {
selectedPin = matchingPins[0]
selectedPin = matchingPins[0] //nolint:uncheckedsliceindex // The caller returns before this function when no pins exist.
actionPinsLog.Printf("No exact match for version %s, no semver-compatible versions found, using highest available: %s", version, selectedPin.Version)
}

Expand All @@ -208,7 +208,7 @@ func resolveNonStrictHardcodedPin(actionRepo, version string, matchingPins []Act

actionPinsLog.Printf("Using version in non-strict mode: %s@%s (requested) → %s@%s (used)",
actionRepo, version, actionRepo, selectedPin.Version)
return formatPinnedActionWithResolution(actionRepo, selectedPin.SHA, version, selectedPin.Version)
return FormatPinnedActionReference(actionRepo, selectedPin.SHA, selectedPin.Version)
}

// ResolveLatestActionPin returns the pinned action reference for a given repository,
Expand All @@ -225,7 +225,7 @@ func ResolveLatestActionPin(repo string, ctx *PinContext) string {
return getLatestActionPinReference(repo)
}

latestVersion := pins[0].Version
latestVersion := pins[0].Version //nolint:uncheckedsliceindex // The empty case returns above.
pinnedRef, err := ResolveActionPin(repo, latestVersion, ctx)
if err != nil || pinnedRef == "" {
actionPinsLog.Printf("Resolution failed for repo=%s latest version=%s, falling back to embedded latest pin", repo, latestVersion)
Expand Down
25 changes: 18 additions & 7 deletions pkg/actionpins/spec_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -601,27 +601,38 @@ func TestSpec_PublicAPI_ResolveActionPin_EmbeddedMatch(t *testing.T) {
}

// TestSpec_DynamicResolution_VersionCommentConsistency validates that when dynamic resolution
// succeeds and the returned SHA matches an embedded pin, the version comment includes both
// the resolved version and the source version — consistent with the embedded-fallback path.
// succeeds and the returned SHA matches an embedded pin, the comment contains
// a single version tag matching the pinned SHA.
func TestSpec_DynamicResolution_VersionCommentConsistency(t *testing.T) {
t.Parallel()
known := "actions/checkout"
latestPin, ok := actionpins.GetLatestActionPinByRepo(known)
require.True(t, ok, "prerequisite: known repo must be in embedded data")

t.Run("shows resolved version and source version when SHA matches embedded pin", func(t *testing.T) {
t.Run("shows resolved version when source tag does not match the resolved pin", func(t *testing.T) {
// Simulate dynamic resolution returning the same SHA as the embedded pin,
// but requested with a shorter version tag (e.g. "v4" instead of "v4.1.2").
// but requested with a different version tag.
sourceVersion := "v4"
ctx := &actionpins.PinContext{
Resolver: &testSHAResolver{sha: latestPin.SHA},
Warnings: make(map[string]bool),
}
result, err := actionpins.ResolveActionPin(known, sourceVersion, ctx)
require.NoError(t, err)
assert.Contains(t, result, latestPin.SHA, "result should contain the resolved SHA")
assert.Contains(t, result, latestPin.Version, "result should contain the resolved version")
assert.Contains(t, result, sourceVersion, "result should contain the source version")
assert.Equal(t, actionpins.FormatPinnedActionReference(known, latestPin.SHA, latestPin.Version), result)
})

t.Run("preserves exact source release tag when resolved pin uses major tag", func(t *testing.T) {
const repo = "actions/github-script"
pin, ok := actionpins.GetLatestActionPinByRepo(repo)
require.True(t, ok)
require.Equal(t, "v9", pin.Version)

result, err := actionpins.ResolveActionPin(repo, "v9.0.0", &actionpins.PinContext{
Resolver: &testSHAResolver{sha: pin.SHA},
})
require.NoError(t, err)
assert.Equal(t, actionpins.FormatPinnedActionReference(repo, pin.SHA, "v9.0.0"), result)
})

t.Run("shows only source version when SHA is not in embedded pins", func(t *testing.T) {
Expand Down
21 changes: 19 additions & 2 deletions pkg/workflow/action_pins.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ import (
var actionPinsLog = logger.New("workflow:action_pins")
var pinnedUsesLine = regexp.MustCompile(`(?m)^(?: {8}uses: | {6}- uses: )[A-Za-z0-9._-]+/[A-Za-z0-9._/-]+@[a-fA-F0-9]{40}(?:[ \t]*#[ \t]*[^\r\n]*)?$`)

// Keep exact mappings distinct from ordinary uses of the same target until prefix rewriting finishes.
const exactPinMappingMarker = " [gh-aw-exact-pin]"

// Type aliases — callers within pkg/workflow use these names directly.

// ActionYAMLInput is defined in pkg/actionpins; aliased here so all files in
Expand Down Expand Up @@ -69,7 +72,8 @@ func getActionPin(repo string) string {
actionPinsLog.Printf("No embedded pins found for repo: %s", repo)
return ""
}
return actionpins.FormatPinnedActionReference(repo, pins[0].SHA, pins[0].Version)
pin := pins[0] //nolint:uncheckedsliceindex // The empty case returns above.
return actionpins.FormatPinnedActionReference(repo, pin.SHA, pin.Version)
}

func getActionPinForData(repo string, data *WorkflowData) string {
Expand Down Expand Up @@ -154,6 +158,9 @@ func mapPinnedUsesInYAML(content string, mappings, prefixes map[string]string, w
}
before, reference, _ := strings.Cut(line, "uses: ")
indentation := before + "uses: "
if originalRef, marked := strings.CutSuffix(reference, exactPinMappingMarker); marked {
return indentation + originalRef
}
repo, suffix, _ := strings.Cut(reference, "@")
_, version, _ := strings.Cut(suffix, " # ")
version = strings.TrimSpace(version)
Expand Down Expand Up @@ -300,12 +307,22 @@ func applyContainerPinMappingFromData(image string, data *WorkflowData) string {
// delegating to pkg/actionpins with a PinContext built from WorkflowData.
func getActionPinWithData(actionRepo, version string, data *WorkflowData) (string, error) {
ctx := pinContextWithoutActionMappings(data)
exactMapped := false
if ctx != nil {
if _, mapped := data.ActionPinMappings[actionpins.FormatCacheKey(actionRepo, version)]; mapped {
ctx.GHES = false
ctx.Mappings = data.ActionPinMappings

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in d46d356: action metadata lookup and caching now use the mapped target repository, subdirectory, and ref.

exactMapped = true
}
}
ref, err := actionpins.ResolveActionPin(actionRepo, version, ctx)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in d46d356: invalid or unresolved exact mappings now return errors, and safe-output, typed-step, and generated-action callers no longer silently retain the source action.

if exactMapped && err == nil && ref != "" {
if !strings.Contains(ref, " # ") {
ref += " #"
}
ref += exactPinMappingMarker

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in d46d356: mapping provenance is now carried on each step’s name through YAML transformations, rather than relying on shared comment metadata. Added a regression for sanitization and identical mapped/ordinary refs.

}
return actionpins.ResolveActionPin(actionRepo, version, ctx)
return ref, err
}

func pinContextWithoutActionMappings(data *WorkflowData) *actionpins.PinContext {
Expand Down
34 changes: 13 additions & 21 deletions pkg/workflow/action_pins_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -640,9 +640,6 @@ func TestGetActionPinWithData_SemverPreference(t *testing.T) {
expectedVer: "v7.0.1",
strictMode: false,
shouldFallback: true,
// Note: When requesting v4 without dynamic resolution, the system uses v4.6.2's SHA
// (the highest v4.x.x version from hardcoded pins), but shows v4 in the comment
// to preserve the user's intent.
},
{
name: "fallback to highest semver-compatible version for upload-artifact when requesting v5",
Expand Down Expand Up @@ -702,8 +699,8 @@ func TestGetActionPinWithData_SemverPreference(t *testing.T) {
tt.repo, tt.requestedVer, result)
}

if tt.shouldFallback && !strings.Contains(result, "(source ") {
t.Errorf("getActionPinWithData(%s, %s) = %s, expected fallback to include resolved-version metadata",
if tt.shouldFallback && !strings.HasSuffix(result, "# "+expectedVersion) {
t.Errorf("getActionPinWithData(%s, %s) = %s, expected a single fallback version comment",
tt.repo, tt.requestedVer, result)
}
})
Expand Down Expand Up @@ -952,7 +949,7 @@ func TestApplyActionPinsToTypedSteps(t *testing.T) {
}
}

// TestGetActionPinWithData_V7Fallback verifies v7 fallback preserves source annotation.
// TestGetActionPinWithData_V7Fallback verifies fallback uses the resolved version.
func TestGetActionPinWithData_V7Fallback(t *testing.T) {
data := &WorkflowData{
StrictMode: false,
Expand All @@ -970,9 +967,8 @@ func TestGetActionPinWithData_V7Fallback(t *testing.T) {

t.Logf("Result: %s", result)

// Should include resolved + source format for fallback.
if !strings.Contains(result, "# v7.0.1 (source v7)") {
t.Errorf("Expected resolved/source comment format in result, got: %s", result)
if !strings.HasSuffix(result, "# v7.0.1") {
t.Errorf("Expected single resolved version comment in result, got: %s", result)
}

// Check the SHA matches v7 (resolves to v7.0.1 pin)
Expand Down Expand Up @@ -1061,9 +1057,9 @@ func TestGetActionPinWithData_ExactVersionResolution(t *testing.T) {
}
}

// TestFallbackVersionUsesRequestedVersionInComment tests that fallback comments
// now record both resolved and source versions.
func TestFallbackVersionUsesRequestedVersionInComment(t *testing.T) {
// TestFallbackVersionUsesResolvedVersionInComment tests that fallback comments
// use the version of the pinned SHA rather than a mismatched requested version.
func TestFallbackVersionUsesResolvedVersionInComment(t *testing.T) {
tests := []struct {
name string
repo string
Expand All @@ -1072,17 +1068,17 @@ func TestFallbackVersionUsesRequestedVersionInComment(t *testing.T) {
expectedSHA string
}{
{
name: "v8 falls back to v9 and comment records source v8",
name: "v8 falls back to v9 and comment records v9",
repo: "actions/github-script",
requestedVer: "v8",
expectedComment: "# v9 (source v8)",
expectedComment: "# v9",
expectedSHA: "3a2844b7e9c422d3c10d287c895573f7108da1b3",
},
{
name: "v7 falls back to v9 and comment records source v7",
name: "v7 falls back to v9 and comment records v9",
repo: "actions/github-script",
requestedVer: "v7",
expectedComment: "# v9 (source v7)",
expectedComment: "# v9",
expectedSHA: "3a2844b7e9c422d3c10d287c895573f7108da1b3",
},
}
Expand All @@ -1098,7 +1094,7 @@ func TestFallbackVersionUsesRequestedVersionInComment(t *testing.T) {
t.Fatalf("getActionPinWithData(%s, %s) returned error: %v", tt.repo, tt.requestedVer, err)
}

if !strings.Contains(result, tt.expectedComment) {
if !strings.HasSuffix(result, tt.expectedComment) {
t.Errorf("getActionPinWithData(%s, %s) = %s, expected comment to contain %s",
tt.repo, tt.requestedVer, result, tt.expectedComment)
}
Expand All @@ -1107,10 +1103,6 @@ func TestFallbackVersionUsesRequestedVersionInComment(t *testing.T) {
t.Errorf("getActionPinWithData(%s, %s) = %s, expected SHA %s",
tt.repo, tt.requestedVer, result, tt.expectedSHA)
}

if tt.requestedVer == "v8" && !strings.Contains(result, "# v9 (source v8)") {
t.Errorf("Expected v8 fallback comment to record resolved version v9, got: %s", result)
}
})
}
}
Expand Down
Loading