Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
5 changes: 0 additions & 5 deletions .github/aw/actions-lock.json
Original file line number Diff line number Diff line change
Expand Up @@ -148,11 +148,6 @@
"version": "v4.38.0",
"sha": "b96794f015dfd88f77b49b1c93e0fa7110f94c63"
},
"github/gh-aw-actions/setup@v0.89.1": {
"repo": "github/gh-aw-actions/setup",
"version": "v0.89.1",
"sha": "4537e5924c9abb366dcbece06750e498e0218fae"
},
"github/stale-repos@v9.0.17": {
"repo": "github/stale-repos",
"version": "v9.0.17",
Expand Down
168 changes: 116 additions & 52 deletions .github/workflows/release.lock.yml

Large diffs are not rendered by default.

9 changes: 9 additions & 0 deletions pkg/workflow/action_pins.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package workflow

import (
"context"
"fmt"
"os"
"strings"
Expand Down Expand Up @@ -245,6 +246,14 @@ func getActionPinWithData(actionRepo, version string, data *WorkflowData) (strin
return actionpins.ResolveActionPin(actionRepo, version, data.PinContext())
}

func resolveStrictActionPin(ctx context.Context, actionRepo, version string) (string, error) {
return actionpins.ResolveActionPin(actionRepo, version, &actionpins.PinContext{
Ctx: ctx,
StrictMode: true,
EnforcePinned: true,
})
}

// getCachedActionPin returns the pinned action reference for a given repository,
// preferring the dynamic resolver from WorkflowData over the embedded pins.
func getCachedActionPin(repo string, data *WorkflowData) string {
Expand Down
99 changes: 76 additions & 23 deletions pkg/workflow/action_reference.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ package workflow
import (
"context"
"fmt"
"path"
"strings"

"github.com/github/gh-aw/pkg/gitutil"
"github.com/github/gh-aw/pkg/logger"
)

Expand Down Expand Up @@ -32,8 +34,8 @@ const (
// - For dev mode: "./actions/setup" (local path)
// - For release mode with resolver: "github/gh-aw/actions/setup@<sha> # <version>" (SHA-pinned)
// - For release mode without resolver: "github/gh-aw/actions/setup@<version>" (tag-based, SHA resolved later)
// - For action mode with resolver: "github/gh-aw-actions/setup@<sha> # <version>" (SHA-pinned)
// - For action mode without resolver: "github/gh-aw-actions/setup@<version>" (tag-based, SHA resolved later)
// - For action mode with resolver or embedded pin: "github/gh-aw-actions/setup@<sha> # <version>" (SHA-pinned)
// - For action mode without a resolved pin: "" (fail closed rather than emitting a mutable reference)
// - Falls back to local path if version is invalid in release/action mode
func ResolveSetupActionReference(ctx context.Context, actionMode ActionMode, version string, actionTag string, resolver SHAResolver) string {
return resolveSetupActionRef(ctx, actionMode, version, actionTag, resolver, "")
Expand Down Expand Up @@ -68,14 +70,14 @@ func resolveSetupActionModeRef(ctx context.Context, actionTag string, version st
if !ok {
return localPath
}
actionRepo := actionsOrgRepo + "/setup"
actionRepo := path.Join(actionsOrgRepo, "setup")
remoteRef := fmt.Sprintf("%s@%s", actionRepo, tag)
ref := tryResolveSetupSHA(ctx, resolver, actionRepo, tag, remoteRef, "Action mode")
ref := resolveRequiredActionModePin(ctx, resolver, actionRepo, tag, remoteRef)
if ref != "" {
return ref
}
actionRefLog.Printf("Action mode: using tag-based external actions repo reference: %s (SHA will be resolved later)", remoteRef)
return remoteRef
actionRefLog.Printf("Action mode: refusing to emit mutable external actions repo reference: %s", remoteRef)
return ""
Comment on lines +79 to +80
}

func resolveSetupReleaseModeRef(ctx context.Context, actionTag string, version string, resolver SHAResolver, localPath string) string {
Expand All @@ -84,7 +86,7 @@ func resolveSetupReleaseModeRef(ctx context.Context, actionTag string, version s
return localPath
}
actionPath := strings.TrimPrefix(localPath, "./")
actionRepo := fmt.Sprintf("%s/%s", GitHubOrgRepo, actionPath)
actionRepo := path.Join(GitHubOrgRepo, actionPath)
remoteRef := fmt.Sprintf("%s@%s", actionRepo, tag)
ref := tryResolveSetupSHA(ctx, resolver, actionRepo, tag, remoteRef, "Release mode")
if ref != "" {
Expand Down Expand Up @@ -112,6 +114,10 @@ func tryResolveSetupSHA(ctx context.Context, resolver SHAResolver, actionRepo, t
}
sha, err := resolver.ResolveSHA(ctx, actionRepo, tag)
if err == nil && sha != "" {
if !gitutil.IsValidFullSHA(sha) {
actionRefLog.Printf("Failed to resolve full SHA for %s@%s: resolver returned %q", actionRepo, tag, sha)
return ""
}
pinnedRef := formatActionReference(actionRepo, sha, tag)
actionRefLog.Printf("%s: resolved %s to SHA-pinned reference: %s", modeLabel, remoteRef, pinnedRef)
return pinnedRef
Expand All @@ -122,13 +128,48 @@ func tryResolveSetupSHA(ctx context.Context, resolver SHAResolver, actionRepo, t
return ""
}

func resolveRequiredActionModePin(ctx context.Context, resolver SHAResolver, actionRepo, tag, remoteRef string) string {
if ref := tryResolveSetupSHA(ctx, resolver, actionRepo, tag, remoteRef, "Action mode"); ref != "" {
return ref
}
ref, err := resolveStrictActionPin(ctx, actionRepo, tag)
if err != nil {
actionRefLog.Printf("Action mode: failed to pin action %s@%s: %v", actionRepo, tag, err)
return ""
}
if isFullSHAPinnedActionRef(ref) {
actionRefLog.Printf("Action mode: resolved %s to SHA-pinned reference: %s", remoteRef, ref)
return ref
}
if ref != "" {
actionRefLog.Printf("Action mode: refusing non-full-SHA action reference: %s", ref)
}
return ""
}

func isFullSHAPinnedActionRef(ref string) bool {
at := strings.LastIndex(ref, "@")
if at < 0 {
return false
}
actionRef := strings.TrimSpace(ref[at+1:])
cut := len(actionRef)
for _, sep := range []string{" ", "\t", "#"} {
if idx := strings.Index(actionRef, sep); idx >= 0 && idx < cut {
cut = idx
}
}
actionRef = strings.TrimSpace(actionRef[:cut])
return gitutil.IsValidFullSHA(actionRef)
}

// resolveActionReference converts a local action path to the appropriate reference
// based on the current action mode (dev vs release vs action).
// If action-tag is specified in features, it overrides the mode check and enables action mode behavior
// (using the github/gh-aw-actions external repository).
// For dev mode: returns the local path as-is (e.g., "./actions/create-issue")
// For release mode: converts to SHA-pinned remote reference (e.g., "github/gh-aw/actions/create-issue@SHA # tag")
// For action mode: converts to SHA-pinned reference in external repo if possible (e.g., "github/gh-aw-actions/create-issue@SHA # version")
// For action mode: converts to SHA-pinned reference in external repo, or "" when no full SHA is available
func (c *Compiler) resolveActionReference(localActionPath string, data *WorkflowData) string {
hasActionTag, frontmatterActionTag := getFrontmatterActionTag(data)

Expand Down Expand Up @@ -260,8 +301,7 @@ func (c *Compiler) convertToRemoteActionRef(localPath string, data *WorkflowData
// in the external github/gh-aw-actions repository.
// Example: "./actions/create-issue" -> "github/gh-aw-actions/create-issue@<sha> # v1.0.0"
//
// If SHA resolution fails (no resolver or pin not available), falls back to version-tagged reference:
// Example: "./actions/create-issue" -> "github/gh-aw-actions/create-issue@v1.0.0"
// If SHA resolution fails (no resolver or pin not available), returns "" rather than a mutable tag reference.
func (c *Compiler) convertToExternalActionsRef(localPath string, data *WorkflowData) string {
// Strip the leading "./" prefix
actionPath := strings.TrimPrefix(localPath, "./")
Expand Down Expand Up @@ -290,22 +330,35 @@ func (c *Compiler) convertToExternalActionsRef(localPath string, data *WorkflowD
}

// Construct the external actions reference: <actionsRepo>/action-name@tag
actionRepo := fmt.Sprintf("%s/%s", c.effectiveActionsRepo(), actionName)
actionRepo := path.Join(c.effectiveActionsRepo(), actionName)
remoteRef := fmt.Sprintf("%s@%s", actionRepo, tag)

// Try to resolve the SHA using action pins
if data != nil {
pinnedRef, err := getActionPinWithData(actionRepo, tag, data)
if err != nil {
// Log and fall through to tag-based reference (action mode is not strict)
actionRefLog.Printf("Failed to pin action %s@%s: %v, falling back to tag-based reference", actionRepo, tag, err)
} else if pinnedRef != "" {
actionRefLog.Printf("Action mode: resolved %s to SHA-pinned reference: %s", remoteRef, pinnedRef)
return pinnedRef
}
pinData := actionModePinData(data)
if pinData.Ctx == nil {
pinData.Ctx = c.ctx
}
pinnedRef, err := getActionPinWithData(actionRepo, tag, pinData)
if err != nil {
actionRefLog.Printf("Action mode: failed to pin action %s@%s: %v", actionRepo, tag, err)
return ""
}
if isFullSHAPinnedActionRef(pinnedRef) {
actionRefLog.Printf("Action mode: resolved %s to SHA-pinned reference: %s", remoteRef, pinnedRef)
return pinnedRef
}
if pinnedRef != "" {
actionRefLog.Printf("Action mode: refusing non-full-SHA action reference: %s", pinnedRef)
}
actionRefLog.Printf("Action mode: refusing to emit mutable external actions repo reference: %s", remoteRef)
return ""
}

// If SHA resolution unavailable or pin not found, return tag-based reference
actionRefLog.Printf("Action mode: using tag-based external actions repo reference: %s (SHA will be resolved later)", remoteRef)
return remoteRef
func actionModePinData(data *WorkflowData) *WorkflowData {
if data == nil {
return &WorkflowData{StrictMode: true}
}
pinData := *data
pinData.StrictMode = true
Comment on lines +361 to +362
return &pinData
}
75 changes: 54 additions & 21 deletions pkg/workflow/action_reference_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@ import (
"github.com/stretchr/testify/assert"
)

type staticSHAResolver struct {
sha string
err error
}

func (r staticSHAResolver) ResolveSHA(context.Context, string, string) (string, error) {
return r.sha, r.err
}

func TestConvertToRemoteActionRef(t *testing.T) {
tests := []struct {
name string
Expand Down Expand Up @@ -148,31 +157,31 @@ func TestResolveActionReference(t *testing.T) {
description: "Release mode with 'dev' version should return empty",
},
{
name: "release mode with action-tag overrides version",
actionMode: ActionModeRelease,
localPath: "./actions/setup",
version: "v1.0.0",
actionTag: "latest",
expectedRef: "github/gh-aw-actions/setup@latest",
description: "Frontmatter action-tag should use action mode (gh-aw-actions) regardless of compiler mode",
name: "release mode with unresolved action-tag fails closed",
actionMode: ActionModeRelease,
localPath: "./actions/setup",
version: "v1.0.0",
actionTag: "latest",
shouldBeEmpty: true,
description: "Frontmatter action-tag should use strict action mode and fail closed when unresolved",
},
{
name: "release mode with action-tag using SHA",
actionMode: ActionModeRelease,
localPath: "./actions/setup",
version: "v1.0.0",
actionTag: "abc123def456789",
expectedRef: "github/gh-aw-actions/setup@abc123def456789",
description: "Frontmatter action-tag SHA should use action mode (gh-aw-actions)",
name: "release mode with short action-tag SHA fails closed",
actionMode: ActionModeRelease,
localPath: "./actions/setup",
version: "v1.0.0",
actionTag: "abc123def456789",
shouldBeEmpty: true,
description: "Frontmatter action-tag SHA must be a full immutable commit SHA",
},
{
name: "dev mode with action-tag uses external actions repo",
actionMode: ActionModeDev,
localPath: "./actions/setup",
version: "v1.0.0",
actionTag: "latest",
expectedRef: "github/gh-aw-actions/setup@latest",
description: "Dev mode with frontmatter action-tag should use action mode (gh-aw-actions)",
name: "dev mode with unresolved action-tag fails closed",
actionMode: ActionModeDev,
localPath: "./actions/setup",
version: "v1.0.0",
actionTag: "latest",
shouldBeEmpty: true,
description: "Dev mode with frontmatter action-tag should use strict action mode and fail closed when unresolved",
},
}

Expand Down Expand Up @@ -364,3 +373,27 @@ func TestResolveSetupActionReferenceWithData(t *testing.T) {
assert.Equal(t, "github/gh-aw/actions/setup@v1.0.0", ref, "should return tag-based reference when no resolver provided")
})
}

func TestResolveSetupActionReferenceActionModeRequiresFullSHA(t *testing.T) {
const sha = "0123456789abcdef0123456789abcdef01234567"

t.Run("resolver full SHA is accepted", func(t *testing.T) {
ref := ResolveSetupActionReference(context.Background(), ActionModeAction, "v1.0.0", "", staticSHAResolver{sha: sha})
assert.Equal(t, "github/gh-aw-actions/setup@"+sha+" # v1.0.0", ref)
})

t.Run("resolver short SHA is rejected", func(t *testing.T) {
ref := ResolveSetupActionReference(context.Background(), ActionModeAction, "v1.0.0", "", staticSHAResolver{sha: "abc123"})
assert.Empty(t, ref)
})

t.Run("nil resolver unresolved version is rejected", func(t *testing.T) {
ref := ResolveSetupActionReference(context.Background(), ActionModeAction, "v1.0.0", "", nil)
assert.Empty(t, ref)
})

t.Run("full SHA tag is accepted without resolver", func(t *testing.T) {
ref := ResolveSetupActionReference(context.Background(), ActionModeAction, sha, "", nil)
assert.Equal(t, "github/gh-aw-actions/setup@"+sha+" # "+sha, ref)
})
}
43 changes: 27 additions & 16 deletions pkg/workflow/compiler_custom_actions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -398,17 +398,21 @@ func TestCheckoutActionsFolderDevModeAlwaysEmitsCheckout(t *testing.T) {

// TestResolveSetupActionReferenceActionMode tests that action mode resolves to the external gh-aw-actions repo
func TestResolveSetupActionReferenceActionMode(t *testing.T) {
ref := ResolveSetupActionReference(context.Background(), ActionModeAction, "v1.2.3", "", nil)
if ref != "github/gh-aw-actions/setup@v1.2.3" {
t.Errorf("Action mode should resolve to 'github/gh-aw-actions/setup@v1.2.3', got %q", ref)
const sha = "0123456789abcdef0123456789abcdef01234567"
ref := ResolveSetupActionReference(context.Background(), ActionModeAction, sha, "", nil)
expected := "github/gh-aw-actions/setup@" + sha + " # " + sha
if ref != expected {
t.Errorf("Action mode should resolve to %q, got %q", expected, ref)
}
}

// TestResolveSetupActionReferenceActionModeWithTag tests action mode with an explicit action tag
func TestResolveSetupActionReferenceActionModeWithTag(t *testing.T) {
ref := ResolveSetupActionReference(context.Background(), ActionModeAction, "v1.0.0", "v2.0.0", nil)
if ref != "github/gh-aw-actions/setup@v2.0.0" {
t.Errorf("Action mode with tag should resolve to 'github/gh-aw-actions/setup@v2.0.0', got %q", ref)
const sha = "0123456789abcdef0123456789abcdef01234567"
ref := ResolveSetupActionReference(context.Background(), ActionModeAction, "v1.0.0", sha, nil)
expected := "github/gh-aw-actions/setup@" + sha + " # " + sha
if ref != expected {
t.Errorf("Action mode with tag should resolve to %q, got %q", expected, ref)
}
}

Expand Down Expand Up @@ -451,9 +455,12 @@ Test workflow with action mode.
t.Fatalf("Failed to write test workflow: %v", err)
}

const sha = "0123456789abcdef0123456789abcdef01234567"
compiler := NewCompiler(WithVersion("v1.2.3"))
compiler.SetActionMode(ActionModeAction)
compiler.SetNoEmit(false)
cache := compiler.GetSharedActionCache()
cache.Set("github/gh-aw-actions/setup", "v1.2.3", sha)

if err := compiler.CompileWorkflow(workflowPath); err != nil {
t.Fatalf("Compilation failed: %v", err)
Expand All @@ -468,8 +475,12 @@ Test workflow with action mode.
lockStr := string(lockContent)

// Verify it uses the external gh-aw-actions/setup action
if !strings.Contains(lockStr, "github/gh-aw-actions/setup@v1.2.3") {
t.Errorf("Action mode should use 'github/gh-aw-actions/setup@v1.2.3', lock file:\n%s", lockStr)
expected := "github/gh-aw-actions/setup@" + sha + " # v1.2.3"
if !strings.Contains(lockStr, expected) {
t.Errorf("Action mode should use %q, lock file:\n%s", expected, lockStr)
}
if strings.Contains(lockStr, "github/gh-aw-actions/setup@v1.2.3") {
t.Errorf("Action mode should not emit mutable gh-aw-actions setup reference, lock file:\n%s", lockStr)
}

// Verify it does NOT use the internal gh-aw/actions/setup path
Expand All @@ -490,20 +501,20 @@ func TestResolveSetupActionReferenceActionModeWithResolver(t *testing.T) {
cache := NewActionCache("")
resolver := NewActionResolver(cache)

// The resolver will fail to resolve github/gh-aw-actions/setup@v1.0.0
// since it's not a real tag, but it should fall back gracefully to tag-based reference
const sha = "0123456789abcdef0123456789abcdef01234567"
cache.Set("github/gh-aw-actions/setup", "v1.0.0", sha)
ref := ResolveSetupActionReference(context.Background(), ActionModeAction, "v1.0.0", "", resolver)

// Without a valid pin or successful resolution, should return tag-based reference
if ref != "github/gh-aw-actions/setup@v1.0.0" {
t.Errorf("expected 'github/gh-aw-actions/setup@v1.0.0', got %q", ref)
expected := "github/gh-aw-actions/setup@" + sha + " # v1.0.0"
if ref != expected {
t.Errorf("expected %q, got %q", expected, ref)
}
})

t.Run("action mode with nil resolver returns tag-based reference", func(t *testing.T) {
t.Run("action mode with nil resolver fails closed when unresolved", func(t *testing.T) {
ref := ResolveSetupActionReference(context.Background(), ActionModeAction, "v1.0.0", "", nil)
if ref != "github/gh-aw-actions/setup@v1.0.0" {
t.Errorf("expected 'github/gh-aw-actions/setup@v1.0.0', got %q", ref)
if ref != "" {
t.Errorf("expected empty ref, got %q", ref)
}
})
}
2 changes: 1 addition & 1 deletion pkg/workflow/compiler_yaml_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1616,7 +1616,7 @@ func TestLockMetadataVersionInReleaseBuilds(t *testing.T) {
{
name: "release build should include version",
isRelease: true,
version: "v0.1.2",
version: "0123456789abcdef0123456789abcdef01234567",
actionTag: "",
expectVersion: true,
},
Expand Down
Loading