diff --git a/.github/aw/actions-lock.json b/.github/aw/actions-lock.json new file mode 100644 index 00000000000..e8b24478db8 --- /dev/null +++ b/.github/aw/actions-lock.json @@ -0,0 +1,34 @@ +{ + "entries": { + "actions/ai-inference@v1": { + "repo": "actions/ai-inference", + "version": "v1", + "sha": "b81b2afb8390ee6839b494a404766bef6493c7d9" + }, + "actions/checkout@v5": { + "repo": "actions/checkout", + "version": "v5", + "sha": "08c6903cd8c0fde910a37f88322edcfb5dd907a8" + }, + "actions/setup-go@v5": { + "repo": "actions/setup-go", + "version": "v5", + "sha": "d35c59abb061a4a6fb18e82ac0862c26744d6ab5" + }, + "actions/setup-node@v4": { + "repo": "actions/setup-node", + "version": "v4", + "sha": "49933ea5288caeca8642d1e84afbd3f7d6820020" + }, + "actions/setup-node@v5": { + "repo": "actions/setup-node", + "version": "v5", + "sha": "a0853c24544627f65ddf259abe73b1d18a591444" + }, + "actions/upload-artifact@v4": { + "repo": "actions/upload-artifact", + "version": "v4", + "sha": "ea165f8d65b6e75b540449e92b4886f43607fa02" + } + } +} \ No newline at end of file diff --git a/.github/workflows/go-logger.lock.yml b/.github/workflows/go-logger.lock.yml index 5c886327198..d10b7c33386 100644 --- a/.github/workflows/go-logger.lock.yml +++ b/.github/workflows/go-logger.lock.yml @@ -88,7 +88,7 @@ jobs: with: persist-credentials: false - name: Set up Node.js - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 with: cache: npm cache-dependency-path: pkg/workflow/js/package-lock.json diff --git a/.github/workflows/issue-classifier.lock.yml b/.github/workflows/issue-classifier.lock.yml index 9576235df19..8f11f101be6 100644 --- a/.github/workflows/issue-classifier.lock.yml +++ b/.github/workflows/issue-classifier.lock.yml @@ -1960,7 +1960,7 @@ jobs: path: /tmp/gh-aw/aw_info.json if-no-files-found: warn - name: Run AI Inference - uses: actions/ai-inference@v1 + uses: actions/ai-inference@b81b2afb8390ee6839b494a404766bef6493c7d9 env: GH_AW_MCP_CONFIG: /tmp/gh-aw/mcp-config/mcp-servers.json GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt diff --git a/.github/workflows/technical-doc-writer.lock.yml b/.github/workflows/technical-doc-writer.lock.yml index 6523a65fc16..209febfd396 100644 --- a/.github/workflows/technical-doc-writer.lock.yml +++ b/.github/workflows/technical-doc-writer.lock.yml @@ -493,7 +493,7 @@ jobs: with: persist-credentials: false - name: Setup Node.js - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 with: cache: npm cache-dependency-path: docs/package-lock.json diff --git a/.github/workflows/tidy.lock.yml b/.github/workflows/tidy.lock.yml index c6cde9702b9..85443cc2737 100644 --- a/.github/workflows/tidy.lock.yml +++ b/.github/workflows/tidy.lock.yml @@ -452,7 +452,7 @@ jobs: with: persist-credentials: false - name: Set up Node.js - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 with: cache: npm cache-dependency-path: pkg/workflow/js/package-lock.json diff --git a/.github/workflows/unbloat-docs.lock.yml b/.github/workflows/unbloat-docs.lock.yml index 9d9ed8abe86..29a0534be3e 100644 --- a/.github/workflows/unbloat-docs.lock.yml +++ b/.github/workflows/unbloat-docs.lock.yml @@ -846,7 +846,7 @@ jobs: - name: Checkout repository uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 - name: Setup Node.js - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 with: cache: npm cache-dependency-path: docs/package-lock.json diff --git a/.github/workflows/unbloat-docs.md b/.github/workflows/unbloat-docs.md index 4943bde50e5..4a62ae70992 100644 --- a/.github/workflows/unbloat-docs.md +++ b/.github/workflows/unbloat-docs.md @@ -79,7 +79,7 @@ steps: uses: actions/checkout@v5 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v5 with: node-version: '24' cache: 'npm' diff --git a/pkg/cli/workflows/test-claude-playwright-screenshots.md b/pkg/cli/workflows/test-claude-playwright-screenshots.md index 6ddc4ad1a95..04c1f8e2bb9 100644 --- a/pkg/cli/workflows/test-claude-playwright-screenshots.md +++ b/pkg/cli/workflows/test-claude-playwright-screenshots.md @@ -33,7 +33,7 @@ steps: uses: actions/checkout@v5 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v5 with: node-version: '24' cache: 'npm' diff --git a/pkg/cli/workflows/test-copilot-playwright-screenshots.md b/pkg/cli/workflows/test-copilot-playwright-screenshots.md index 94abe41a81b..fdb3d0ff59a 100644 --- a/pkg/cli/workflows/test-copilot-playwright-screenshots.md +++ b/pkg/cli/workflows/test-copilot-playwright-screenshots.md @@ -32,7 +32,7 @@ steps: uses: actions/checkout@v5 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v5 with: node-version: '24' cache: 'npm' diff --git a/pkg/workflow/action_cache.go b/pkg/workflow/action_cache.go new file mode 100644 index 00000000000..25099ab803a --- /dev/null +++ b/pkg/workflow/action_cache.go @@ -0,0 +1,90 @@ +package workflow + +import ( + "encoding/json" + "os" + "path/filepath" +) + +const ( + // CacheFileName is the name of the cache file in .github/aw/ + CacheFileName = "actions-lock.json" +) + +// ActionCacheEntry represents a cached action pin resolution +type ActionCacheEntry struct { + Repo string `json:"repo"` + Version string `json:"version"` + SHA string `json:"sha"` +} + +// ActionCache manages cached action pin resolutions +type ActionCache struct { + Entries map[string]ActionCacheEntry `json:"entries"` // key: "repo@version" + path string +} + +// NewActionCache creates a new action cache instance +func NewActionCache(repoRoot string) *ActionCache { + cachePath := filepath.Join(repoRoot, ".github", "aw", CacheFileName) + return &ActionCache{ + Entries: make(map[string]ActionCacheEntry), + path: cachePath, + } +} + +// Load loads the cache from disk +func (c *ActionCache) Load() error { + data, err := os.ReadFile(c.path) + if err != nil { + if os.IsNotExist(err) { + // Cache file doesn't exist yet, that's OK + return nil + } + return err + } + + return json.Unmarshal(data, c) +} + +// Save saves the cache to disk +func (c *ActionCache) Save() error { + // Ensure directory exists + dir := filepath.Dir(c.path) + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + + data, err := json.MarshalIndent(c, "", " ") + if err != nil { + return err + } + + return os.WriteFile(c.path, data, 0644) +} + +// Get retrieves a cached entry if it exists +func (c *ActionCache) Get(repo, version string) (string, bool) { + key := repo + "@" + version + entry, exists := c.Entries[key] + if !exists { + return "", false + } + + return entry.SHA, true +} + +// Set stores a new cache entry +func (c *ActionCache) Set(repo, version, sha string) { + key := repo + "@" + version + c.Entries[key] = ActionCacheEntry{ + Repo: repo, + Version: version, + SHA: sha, + } +} + +// GetCachePath returns the path to the cache file +func (c *ActionCache) GetCachePath() string { + return c.path +} diff --git a/pkg/workflow/action_cache_test.go b/pkg/workflow/action_cache_test.go new file mode 100644 index 00000000000..b7a6fea6858 --- /dev/null +++ b/pkg/workflow/action_cache_test.go @@ -0,0 +1,99 @@ +package workflow + +import ( + "os" + "path/filepath" + "testing" +) + +func TestActionCache(t *testing.T) { + // Create temporary directory for testing + tmpDir := t.TempDir() + + cache := NewActionCache(tmpDir) + + // Test setting and getting + cache.Set("actions/checkout", "v5", "abc123") + + sha, found := cache.Get("actions/checkout", "v5") + if !found { + t.Error("Expected to find cached entry") + } + if sha != "abc123" { + t.Errorf("Expected SHA 'abc123', got '%s'", sha) + } + + // Test cache miss + _, found = cache.Get("actions/unknown", "v1") + if found { + t.Error("Expected cache miss for unknown action") + } +} + +func TestActionCacheSaveLoad(t *testing.T) { + // Create temporary directory for testing + tmpDir := t.TempDir() + + // Create and populate cache + cache1 := NewActionCache(tmpDir) + cache1.Set("actions/checkout", "v5", "abc123") + cache1.Set("actions/setup-node", "v4", "def456") + + // Save to disk + err := cache1.Save() + if err != nil { + t.Fatalf("Failed to save cache: %v", err) + } + + // Verify file exists + cachePath := filepath.Join(tmpDir, ".github", "aw", CacheFileName) + if _, err := os.Stat(cachePath); os.IsNotExist(err) { + t.Fatalf("Cache file was not created at %s", cachePath) + } + + // Load into new cache instance + cache2 := NewActionCache(tmpDir) + err = cache2.Load() + if err != nil { + t.Fatalf("Failed to load cache: %v", err) + } + + // Verify entries were loaded + sha, found := cache2.Get("actions/checkout", "v5") + if !found || sha != "abc123" { + t.Errorf("Expected to find actions/checkout@v5 with SHA 'abc123', got '%s' (found=%v)", sha, found) + } + + sha, found = cache2.Get("actions/setup-node", "v4") + if !found || sha != "def456" { + t.Errorf("Expected to find actions/setup-node@v4 with SHA 'def456', got '%s' (found=%v)", sha, found) + } +} + +func TestActionCacheLoadNonExistent(t *testing.T) { + // Create temporary directory for testing + tmpDir := t.TempDir() + + cache := NewActionCache(tmpDir) + + // Try to load non-existent cache - should not error + err := cache.Load() + if err != nil { + t.Errorf("Loading non-existent cache should not error, got: %v", err) + } + + // Cache should be empty + if len(cache.Entries) != 0 { + t.Errorf("Expected empty cache, got %d entries", len(cache.Entries)) + } +} + +func TestActionCacheGetCachePath(t *testing.T) { + tmpDir := t.TempDir() + cache := NewActionCache(tmpDir) + + expectedPath := filepath.Join(tmpDir, ".github", "aw", CacheFileName) + if cache.GetCachePath() != expectedPath { + t.Errorf("Expected cache path '%s', got '%s'", expectedPath, cache.GetCachePath()) + } +} diff --git a/pkg/workflow/action_pins.go b/pkg/workflow/action_pins.go index f4d9f9a141f..a4f2551264d 100644 --- a/pkg/workflow/action_pins.go +++ b/pkg/workflow/action_pins.go @@ -1,6 +1,12 @@ package workflow -import "strings" +import ( + "fmt" + "os" + "strings" + + "github.com/githubnext/gh-aw/pkg/console" +) // ActionPin represents a pinned GitHub Action with its commit SHA type ActionPin struct { @@ -108,10 +114,60 @@ func GetActionPin(actionRepo string) string { return "" } +// GetActionPinWithData returns the pinned action reference for a given action@version +// It tries dynamic resolution first, then falls back to hardcoded pins +// If strictMode is true and resolution fails, it returns an error +func GetActionPinWithData(actionRepo, version string, data *WorkflowData) (string, error) { + // First try dynamic resolution if resolver is available + if data.ActionResolver != nil { + sha, err := data.ActionResolver.ResolveSHA(actionRepo, version) + if err == nil && sha != "" { + // Successfully resolved, save cache + if data.ActionCache != nil { + _ = data.ActionCache.Save() + } + return actionRepo + "@" + sha, nil + } + } + + // Dynamic resolution failed, try hardcoded pins + if pin, exists := actionPins[actionRepo]; exists { + // Check if the version matches the hardcoded version + if pin.Version == version { + return actionRepo + "@" + pin.SHA, nil + } + // Version mismatch, but we can still use the hardcoded SHA if we're not in strict mode + if !data.StrictMode { + warningMsg := fmt.Sprintf("Unable to resolve %s@%s dynamically, using hardcoded pin for %s@%s", + actionRepo, version, actionRepo, pin.Version) + fmt.Fprint(os.Stderr, console.FormatWarningMessage(warningMsg)) + return actionRepo + "@" + pin.SHA, nil + } + } + + // No pin available + if data.StrictMode { + errMsg := fmt.Sprintf("Unable to pin action %s@%s", actionRepo, version) + if data.ActionResolver != nil { + errMsg = fmt.Sprintf("Unable to pin action %s@%s: resolution failed", actionRepo, version) + } + fmt.Fprint(os.Stderr, console.FormatErrorMessage(errMsg)) + return "", fmt.Errorf("%s", errMsg) + } + + // In non-strict mode, emit warning and return empty string + warningMsg := fmt.Sprintf("Unable to pin action %s@%s", actionRepo, version) + if data.ActionResolver != nil { + warningMsg = fmt.Sprintf("Unable to pin action %s@%s: resolution failed", actionRepo, version) + } + fmt.Fprint(os.Stderr, console.FormatWarningMessage(warningMsg)) + return "", nil +} + // ApplyActionPinToStep applies SHA pinning to a step map if it contains a "uses" field // with a pinned action. Returns a modified copy of the step map with pinned references. // If the step doesn't use an action or the action is not pinned, returns the original map. -func ApplyActionPinToStep(stepMap map[string]any) map[string]any { +func ApplyActionPinToStep(stepMap map[string]any, data *WorkflowData) map[string]any { // Check if step has a "uses" field uses, hasUses := stepMap["uses"] if !hasUses { @@ -124,14 +180,26 @@ func ApplyActionPinToStep(stepMap map[string]any) map[string]any { return stepMap } - // Extract action repo from uses field (remove @version or @ref) + // Extract action repo and version from uses field actionRepo := extractActionRepo(usesStr) if actionRepo == "" { return stepMap } - // Check if this action has a pin - pinnedRef := GetActionPin(actionRepo) + version := extractActionVersion(usesStr) + if version == "" { + // No version specified, can't pin + return stepMap + } + + // Try to get pinned SHA + pinnedRef, err := GetActionPinWithData(actionRepo, version, data) + if err != nil { + // In strict mode, this would have already been handled by GetActionPinWithData + // In normal mode, we just return the original step + return stepMap + } + if pinnedRef == "" { // No pin available for this action, return original step return stepMap @@ -167,13 +235,29 @@ func extractActionRepo(uses string) string { return uses[:idx] } +// extractActionVersion extracts the version from a uses string +// For example: +// - "actions/checkout@v4" -> "v4" +// - "actions/setup-node@v5" -> "v5" +// - "actions/checkout" -> "" +func extractActionVersion(uses string) string { + // Split on @ to separate repo from version/ref + idx := strings.Index(uses, "@") + if idx == -1 { + // No @ found, no version + return "" + } + // Return everything after the @ + return uses[idx+1:] +} + // ApplyActionPinsToSteps applies SHA pinning to a slice of step maps // Returns a new slice with pinned references -func ApplyActionPinsToSteps(steps []any) []any { +func ApplyActionPinsToSteps(steps []any, data *WorkflowData) []any { result := make([]any, len(steps)) for i, step := range steps { if stepMap, ok := step.(map[string]any); ok { - result[i] = ApplyActionPinToStep(stepMap) + result[i] = ApplyActionPinToStep(stepMap, data) } else { result[i] = step } diff --git a/pkg/workflow/action_pins_test.go b/pkg/workflow/action_pins_test.go index cd57c8aaa7f..ed35fdef0e6 100644 --- a/pkg/workflow/action_pins_test.go +++ b/pkg/workflow/action_pins_test.go @@ -212,6 +212,50 @@ func TestExtractActionRepo(t *testing.T) { } } +// TestExtractActionVersion tests the extractActionVersion function +func TestExtractActionVersion(t *testing.T) { + tests := []struct { + name string + uses string + expected string + }{ + { + name: "action with version tag", + uses: "actions/checkout@v4", + expected: "v4", + }, + { + name: "action with SHA", + uses: "actions/setup-node@08c6903cd8c0fde910a37f88322edcfb5dd907a8", + expected: "08c6903cd8c0fde910a37f88322edcfb5dd907a8", + }, + { + name: "action with subpath and version", + uses: "github/codeql-action/upload-sarif@v3", + expected: "v3", + }, + { + name: "action without version", + uses: "actions/checkout", + expected: "", + }, + { + name: "action with branch ref", + uses: "actions/setup-python@main", + expected: "main", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := extractActionVersion(tt.uses) + if result != tt.expected { + t.Errorf("extractActionVersion(%q) = %q, want %q", tt.uses, result, tt.expected) + } + }) + } +} + // TestApplyActionPinToStep tests the ApplyActionPinToStep function func TestApplyActionPinToStep(t *testing.T) { tests := []struct { @@ -272,7 +316,9 @@ func TestApplyActionPinToStep(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := ApplyActionPinToStep(tt.stepMap) + // Create a minimal WorkflowData for testing + data := &WorkflowData{} + result := ApplyActionPinToStep(tt.stepMap, data) // Check if uses field exists in result if uses, hasUses := result["uses"]; hasUses { diff --git a/pkg/workflow/action_resolver.go b/pkg/workflow/action_resolver.go new file mode 100644 index 00000000000..e7cb5f6467d --- /dev/null +++ b/pkg/workflow/action_resolver.go @@ -0,0 +1,80 @@ +package workflow + +import ( + "fmt" + "os/exec" + "strings" +) + +// ActionResolver handles resolving action SHAs using GitHub CLI +type ActionResolver struct { + cache *ActionCache +} + +// NewActionResolver creates a new action resolver +func NewActionResolver(cache *ActionCache) *ActionResolver { + return &ActionResolver{ + cache: cache, + } +} + +// ResolveSHA resolves the SHA for a given action@version using GitHub CLI +// Returns the SHA and an error if resolution fails +func (r *ActionResolver) ResolveSHA(repo, version string) (string, error) { + // Check cache first + if sha, found := r.cache.Get(repo, version); found { + return sha, nil + } + + // Resolve using GitHub CLI + sha, err := r.resolveFromGitHub(repo, version) + if err != nil { + return "", err + } + + // Cache the result + r.cache.Set(repo, version, sha) + + return sha, nil +} + +// resolveFromGitHub uses gh CLI to resolve the SHA for an action@version +func (r *ActionResolver) resolveFromGitHub(repo, version string) (string, error) { + // Extract base repository (for actions like "github/codeql-action/upload-sarif") + baseRepo := extractBaseRepo(repo) + + // Use gh api to get the git ref for the tag + // API endpoint: GET /repos/{owner}/{repo}/git/ref/tags/{tag} + apiPath := fmt.Sprintf("/repos/%s/git/ref/tags/%s", baseRepo, version) + + cmd := exec.Command("gh", "api", apiPath, "--jq", ".object.sha") + output, err := cmd.Output() + if err != nil { + // Try without "refs/tags/" prefix in case version is already a ref + return "", fmt.Errorf("failed to resolve %s@%s: %w", repo, version, err) + } + + sha := strings.TrimSpace(string(output)) + if sha == "" { + return "", fmt.Errorf("empty SHA returned for %s@%s", repo, version) + } + + // Validate SHA format (should be 40 hex characters) + if len(sha) != 40 { + return "", fmt.Errorf("invalid SHA format for %s@%s: %s", repo, version, sha) + } + + return sha, nil +} + +// extractBaseRepo extracts the base repository from a repo path +// For "actions/checkout" -> "actions/checkout" +// For "github/codeql-action/upload-sarif" -> "github/codeql-action" +func extractBaseRepo(repo string) string { + parts := strings.Split(repo, "/") + if len(parts) >= 2 { + // Take first two parts (owner/repo) + return parts[0] + "/" + parts[1] + } + return repo +} diff --git a/pkg/workflow/action_resolver_test.go b/pkg/workflow/action_resolver_test.go new file mode 100644 index 00000000000..3341cc9a1a3 --- /dev/null +++ b/pkg/workflow/action_resolver_test.go @@ -0,0 +1,65 @@ +package workflow + +import ( + "testing" +) + +func TestExtractBaseRepo(t *testing.T) { + tests := []struct { + name string + repo string + expected string + }{ + { + name: "simple repo", + repo: "actions/checkout", + expected: "actions/checkout", + }, + { + name: "repo with subpath", + repo: "github/codeql-action/upload-sarif", + expected: "github/codeql-action", + }, + { + name: "repo with multiple subpaths", + repo: "owner/repo/sub/path", + expected: "owner/repo", + }, + { + name: "single part repo", + repo: "myrepo", + expected: "myrepo", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := extractBaseRepo(tt.repo) + if result != tt.expected { + t.Errorf("extractBaseRepo(%q) = %q, want %q", tt.repo, result, tt.expected) + } + }) + } +} + +func TestActionResolverCache(t *testing.T) { + // Create a cache and resolver + tmpDir := t.TempDir() + cache := NewActionCache(tmpDir) + resolver := NewActionResolver(cache) + + // Manually add an entry to the cache + cache.Set("actions/checkout", "v5", "test-sha-123") + + // Resolve should return cached value without making API call + sha, err := resolver.ResolveSHA("actions/checkout", "v5") + if err != nil { + t.Errorf("Expected no error for cached entry, got: %v", err) + } + if sha != "test-sha-123" { + t.Errorf("Expected SHA 'test-sha-123', got '%s'", sha) + } +} + +// Note: Testing the actual GitHub API resolution requires network access +// and is tested in integration tests or with network-dependent test tags diff --git a/pkg/workflow/compiler.go b/pkg/workflow/compiler.go index 9cdfcc918a9..be2f70cfc29 100644 --- a/pkg/workflow/compiler.go +++ b/pkg/workflow/compiler.go @@ -186,6 +186,9 @@ type WorkflowData struct { GitHubToken string // top-level github-token expression from frontmatter ToolsStartupTimeout int // timeout in seconds for MCP server startup (0 = use engine default) Features map[string]bool // feature flags from frontmatter + ActionCache *ActionCache // cache for action pin resolutions + ActionResolver *ActionResolver // resolver for action pins + StrictMode bool // strict mode for action pinning } // BaseSafeOutputConfig holds common configuration fields for all safe output types @@ -810,8 +813,18 @@ func (c *Compiler) ParseWorkflowFile(markdownPath string) (*WorkflowData, error) TrialMode: c.trialMode, TrialLogicalRepo: c.trialLogicalRepoSlug, GitHubToken: extractStringValue(result.Frontmatter, "github-token"), + StrictMode: c.strictMode, } + // Initialize action cache and resolver + cwd, err := os.Getwd() + if err != nil { + cwd = "." + } + workflowData.ActionCache = NewActionCache(cwd) + _ = workflowData.ActionCache.Load() // Ignore errors if cache doesn't exist + workflowData.ActionResolver = NewActionResolver(workflowData.ActionCache) + // Extract YAML sections from frontmatter - use direct frontmatter map extraction // to avoid issues with nested keys (e.g., tools.mcps.*.env being confused with top-level env) workflowData.On = c.extractTopLevelYAMLSection(result.Frontmatter, "on") @@ -831,7 +844,7 @@ func (c *Compiler) ParseWorkflowFile(markdownPath string) (*WorkflowData, error) var importedSteps []any if err := yaml.Unmarshal([]byte(importsResult.MergedSteps), &importedSteps); err == nil { // Apply action pinning to imported steps - importedSteps = ApplyActionPinsToSteps(importedSteps) + importedSteps = ApplyActionPinsToSteps(importedSteps, workflowData) // If there are main workflow steps, parse and merge them if workflowData.CustomSteps != "" { @@ -841,7 +854,7 @@ func (c *Compiler) ParseWorkflowFile(markdownPath string) (*WorkflowData, error) if mainStepsVal, hasSteps := mainStepsWrapper["steps"]; hasSteps { if mainSteps, ok := mainStepsVal.([]any); ok { // Apply action pinning to main steps - mainSteps = ApplyActionPinsToSteps(mainSteps) + mainSteps = ApplyActionPinsToSteps(mainSteps, workflowData) // Prepend imported steps to main steps allSteps := append(importedSteps, mainSteps...) @@ -870,7 +883,7 @@ func (c *Compiler) ParseWorkflowFile(markdownPath string) (*WorkflowData, error) if mainStepsVal, hasSteps := mainStepsWrapper["steps"]; hasSteps { if mainSteps, ok := mainStepsVal.([]any); ok { // Apply action pinning to main steps - mainSteps = ApplyActionPinsToSteps(mainSteps) + mainSteps = ApplyActionPinsToSteps(mainSteps, workflowData) // Convert back to YAML with "steps:" wrapper stepsWrapper := map[string]any{"steps": mainSteps} @@ -892,7 +905,7 @@ func (c *Compiler) ParseWorkflowFile(markdownPath string) (*WorkflowData, error) if postStepsVal, hasPostSteps := postStepsWrapper["post-steps"]; hasPostSteps { if postSteps, ok := postStepsVal.([]any); ok { // Apply action pinning to post steps - postSteps = ApplyActionPinsToSteps(postSteps) + postSteps = ApplyActionPinsToSteps(postSteps, workflowData) // Convert back to YAML with "post-steps:" wrapper stepsWrapper := map[string]any{"post-steps": postSteps} diff --git a/pkg/workflow/compiler_jobs.go b/pkg/workflow/compiler_jobs.go index 3cce66efafe..d349670f2d6 100644 --- a/pkg/workflow/compiler_jobs.go +++ b/pkg/workflow/compiler_jobs.go @@ -718,7 +718,7 @@ func (c *Compiler) buildCustomJobs(data *WorkflowData) error { for _, step := range stepsList { if stepMap, ok := step.(map[string]any); ok { // Apply action pinning before converting to YAML - stepMap = ApplyActionPinToStep(stepMap) + stepMap = ApplyActionPinToStep(stepMap, data) stepYAML, err := c.convertStepToYAML(stepMap) if err != nil { diff --git a/pkg/workflow/custom_engine.go b/pkg/workflow/custom_engine.go index 4cf5fff4900..51502c03102 100644 --- a/pkg/workflow/custom_engine.go +++ b/pkg/workflow/custom_engine.go @@ -46,7 +46,7 @@ func (e *CustomEngine) GetExecutionSteps(workflowData *WorkflowData, logFile str } // Apply action pinning if the step uses an action - stepCopy = ApplyActionPinToStep(stepCopy) + stepCopy = ApplyActionPinToStep(stepCopy, workflowData) // Prepare environment variables to merge envVars := make(map[string]string) diff --git a/pkg/workflow/engine_includes_test.go b/pkg/workflow/engine_includes_test.go index 628ec4218a9..f972ac0f373 100644 --- a/pkg/workflow/engine_includes_test.go +++ b/pkg/workflow/engine_includes_test.go @@ -445,8 +445,10 @@ This workflow imports a custom engine with steps. if !strings.Contains(lockStr, "name: Run AI Inference") { t.Error("Expected lock file to contain 'name: Run AI Inference' step") } - if !strings.Contains(lockStr, "uses: actions/ai-inference@v1") { - t.Error("Expected lock file to contain 'uses: actions/ai-inference@v1'") + // Since ai-inference is not in hardcoded pins, it will either be dynamically resolved + // (if gh CLI is available) or remain as @v1 (if resolution fails) + if !strings.Contains(lockStr, "uses: actions/ai-inference@") { + t.Error("Expected lock file to contain 'uses: actions/ai-inference@' (either @v1 or @)") } if !strings.Contains(lockStr, "prompt-file:") { t.Error("Expected lock file to contain 'prompt-file:' parameter")