From 33467a6f5b7165c648665b9e0147ecf14922a7bd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Mar 2026 20:52:54 +0000 Subject: [PATCH 1/4] Initial plan From 18f372b1d071a0ba91f39222a4702e1080e8d72f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Mar 2026 21:15:01 +0000 Subject: [PATCH 2/4] feat: support .lock.yaml files in addition to .lock.yml When a workflow.lock.yaml file already exists on disk, gh aw compile will update that file instead of creating a new workflow.lock.yml. Changes: - Add MarkdownToLockFileOnDisk() - prefers existing .lock.yaml over .lock.yml - Add StripLockExtension() - strips .lock.yml or .lock.yaml - Update NormalizeWorkflowName/LockFileToMarkdown to handle .lock.yaml - Update compiler, compile_validation, run_workflow_validation to use MarkdownToLockFileOnDisk - Update findWorkflowFile to check for .lock.yaml (with lockExtension field) - Update safe_outputs_*.go to use actual lock extension from fileResult - Update lock_validation.go, resolve.go to handle .lock.yaml - Add tests for new functionality Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> Agent-Logs-Url: https://github.com/github/gh-aw/sessions/e2670aee-1a3e-43f3-91cf-66897c9db4d7 --- pkg/cli/compile_validation.go | 4 +- pkg/cli/run_workflow_validation.go | 9 +- pkg/stringutil/identifiers.go | 92 +++++++++-- pkg/stringutil/identifiers_test.go | 161 ++++++++++++++++++- pkg/workflow/call_workflow_validation.go | 4 +- pkg/workflow/compiler.go | 10 +- pkg/workflow/compiler_jobs.go | 2 +- pkg/workflow/compiler_string_api.go | 2 +- pkg/workflow/dispatch_workflow_validation.go | 43 +++-- pkg/workflow/lock_validation.go | 7 +- pkg/workflow/resolve.go | 7 +- pkg/workflow/safe_outputs_call_workflow.go | 6 +- pkg/workflow/safe_outputs_dispatch.go | 6 +- pkg/workflow/safe_outputs_tools_filtering.go | 20 +-- pkg/workflow/stop_after.go | 2 +- 15 files changed, 308 insertions(+), 67 deletions(-) diff --git a/pkg/cli/compile_validation.go b/pkg/cli/compile_validation.go index 517776bb8b1..f4f8b46c6a7 100644 --- a/pkg/cli/compile_validation.go +++ b/pkg/cli/compile_validation.go @@ -65,7 +65,7 @@ func CompileWorkflowWithValidation(compiler *workflow.Compiler, filePath string, } // Always validate that the generated lock file is valid YAML (CLI requirement) - lockFile := stringutil.MarkdownToLockFile(filePath) + lockFile := stringutil.MarkdownToLockFileOnDisk(filePath) if _, err := os.Stat(lockFile); err != nil { compileValidationLog.Print("Lock file not found, skipping validation (likely no-emit mode)") // Lock file doesn't exist (likely due to no-emit), skip YAML validation @@ -133,7 +133,7 @@ func CompileWorkflowDataWithValidation(compiler *workflow.Compiler, workflowData } // Always validate that the generated lock file is valid YAML (CLI requirement) - lockFile := stringutil.MarkdownToLockFile(filePath) + lockFile := stringutil.MarkdownToLockFileOnDisk(filePath) if _, err := os.Stat(lockFile); err != nil { compileValidationLog.Print("Lock file not found, skipping validation (likely no-emit mode)") // Lock file doesn't exist (likely due to no-emit), skip YAML validation diff --git a/pkg/cli/run_workflow_validation.go b/pkg/cli/run_workflow_validation.go index b768065d8b9..eed777e8be9 100644 --- a/pkg/cli/run_workflow_validation.go +++ b/pkg/cli/run_workflow_validation.go @@ -15,17 +15,18 @@ import ( "github.com/github/gh-aw/pkg/constants" "github.com/github/gh-aw/pkg/logger" "github.com/github/gh-aw/pkg/parser" + "github.com/github/gh-aw/pkg/stringutil" "github.com/github/gh-aw/pkg/workflow" "github.com/goccy/go-yaml" ) var validationLog = logger.New("cli:run_workflow_validation") -// getLockFilePath converts a markdown workflow path to its compiled lock file path -// Example: "/path/to/workflow.md" -> "/path/to/workflow.lock.yml" +// getLockFilePath converts a markdown workflow path to its compiled lock file path, +// preferring an existing .lock.yaml file over the default .lock.yml. +// Example: "/path/to/workflow.md" -> "/path/to/workflow.lock.yml" (or .lock.yaml if it exists) func getLockFilePath(markdownPath string) string { - // Handle regular workflow files - return strings.TrimSuffix(markdownPath, ".md") + ".lock.yml" + return stringutil.MarkdownToLockFileOnDisk(markdownPath) } // IsRunnable checks if a workflow can be run (has schedule or workflow_dispatch trigger) diff --git a/pkg/stringutil/identifiers.go b/pkg/stringutil/identifiers.go index f1ed2f24ceb..0f3eb63b347 100644 --- a/pkg/stringutil/identifiers.go +++ b/pkg/stringutil/identifiers.go @@ -1,6 +1,7 @@ package stringutil import ( + "os" "path/filepath" "strings" @@ -9,25 +10,32 @@ import ( var identifiersLog = logger.New("stringutil:identifiers") -// NormalizeWorkflowName removes .md and .lock.yml extensions from workflow names. +// NormalizeWorkflowName removes .md, .lock.yml, and .lock.yaml extensions from workflow names. // This is used to standardize workflow identifiers regardless of the file format. // // The function checks for extensions in order of specificity: -// 1. Removes .lock.yml extension (the compiled workflow format) -// 2. Removes .md extension (the markdown source format) -// 3. Returns the name unchanged if no recognized extension is found +// 1. Removes .lock.yaml extension (the compiled workflow format, .yaml variant) +// 2. Removes .lock.yml extension (the compiled workflow format) +// 3. Removes .md extension (the markdown source format) +// 4. Returns the name unchanged if no recognized extension is found // // This function performs normalization only - it assumes the input is already // a valid identifier and does NOT perform character validation or sanitization. // // Examples: // -// NormalizeWorkflowName("weekly-research") // returns "weekly-research" -// NormalizeWorkflowName("weekly-research.md") // returns "weekly-research" -// NormalizeWorkflowName("weekly-research.lock.yml") // returns "weekly-research" -// NormalizeWorkflowName("my.workflow.md") // returns "my.workflow" +// NormalizeWorkflowName("weekly-research") // returns "weekly-research" +// NormalizeWorkflowName("weekly-research.md") // returns "weekly-research" +// NormalizeWorkflowName("weekly-research.lock.yml") // returns "weekly-research" +// NormalizeWorkflowName("weekly-research.lock.yaml") // returns "weekly-research" +// NormalizeWorkflowName("my.workflow.md") // returns "my.workflow" func NormalizeWorkflowName(name string) string { - // Remove .lock.yml extension first (longer extension) + // Remove .lock.yaml extension first (longer extension, .yaml variant) + if before, ok := strings.CutSuffix(name, ".lock.yaml"); ok { + return before + } + + // Remove .lock.yml extension (longer extension) if before, ok := strings.CutSuffix(name, ".lock.yml"); ok { return before } @@ -64,17 +72,18 @@ func NormalizeSafeOutputIdentifier(identifier string) string { // This is the standard transformation for agentic workflow files. // // The function removes the .md extension and adds .lock.yml extension. -// If the input already has a .lock.yml extension, it returns the path unchanged. +// If the input already has a .lock.yml or .lock.yaml extension, it returns the path unchanged. // // Examples: // // MarkdownToLockFile("weekly-research.md") // returns "weekly-research.lock.yml" // MarkdownToLockFile(".github/workflows/test.md") // returns ".github/workflows/test.lock.yml" // MarkdownToLockFile("workflow.lock.yml") // returns "workflow.lock.yml" (unchanged) +// MarkdownToLockFile("workflow.lock.yaml") // returns "workflow.lock.yaml" (unchanged) // MarkdownToLockFile("my.workflow.md") // returns "my.workflow.lock.yml" func MarkdownToLockFile(mdPath string) string { // If already a lock file, return unchanged - if strings.HasSuffix(mdPath, ".lock.yml") { + if strings.HasSuffix(mdPath, ".lock.yml") || strings.HasSuffix(mdPath, ".lock.yaml") { return mdPath } @@ -84,15 +93,51 @@ func MarkdownToLockFile(mdPath string) string { return lockPath } +// MarkdownToLockFileOnDisk converts a workflow markdown file path to its compiled lock file path, +// preferring an existing .lock.yaml file over the default .lock.yml. +// +// If a .lock.yaml file already exists on disk, that path is returned so that recompilation +// updates the existing file rather than creating a new .lock.yml alongside it. +// Otherwise behaves identically to MarkdownToLockFile. +// +// Examples (assuming workflow.lock.yaml exists on disk): +// +// MarkdownToLockFileOnDisk("workflow.md") // returns "workflow.lock.yaml" +// +// Examples (assuming no lock file exists): +// +// MarkdownToLockFileOnDisk("workflow.md") // returns "workflow.lock.yml" +func MarkdownToLockFileOnDisk(mdPath string) string { + // If already a lock file, return unchanged + if strings.HasSuffix(mdPath, ".lock.yml") || strings.HasSuffix(mdPath, ".lock.yaml") { + return mdPath + } + + cleaned := filepath.Clean(mdPath) + base := strings.TrimSuffix(cleaned, ".md") + + // Prefer .lock.yaml if it already exists on disk + lockYamlPath := base + ".lock.yaml" + if _, err := os.Stat(lockYamlPath); err == nil { + identifiersLog.Printf("MarkdownToLockFileOnDisk: found existing .lock.yaml: %s -> %s", mdPath, lockYamlPath) + return lockYamlPath + } + + lockPath := base + ".lock.yml" + identifiersLog.Printf("MarkdownToLockFileOnDisk: %s -> %s", mdPath, lockPath) + return lockPath +} + // LockFileToMarkdown converts a compiled lock file path back to its markdown source path. // This is used when navigating from compiled workflows back to source files. // -// The function removes the .lock.yml extension and adds .md extension. +// The function removes the .lock.yml or .lock.yaml extension and adds .md extension. // If the input already has a .md extension, it returns the path unchanged. // // Examples: // // LockFileToMarkdown("weekly-research.lock.yml") // returns "weekly-research.md" +// LockFileToMarkdown("weekly-research.lock.yaml") // returns "weekly-research.md" // LockFileToMarkdown(".github/workflows/test.lock.yml") // returns ".github/workflows/test.md" // LockFileToMarkdown("workflow.md") // returns "workflow.md" (unchanged) // LockFileToMarkdown("my.workflow.lock.yml") // returns "my.workflow.md" @@ -103,7 +148,28 @@ func LockFileToMarkdown(lockPath string) string { } cleaned := filepath.Clean(lockPath) - mdPath := strings.TrimSuffix(cleaned, ".lock.yml") + ".md" + var mdPath string + if before, ok := strings.CutSuffix(cleaned, ".lock.yaml"); ok { + mdPath = before + ".md" + } else { + mdPath = strings.TrimSuffix(cleaned, ".lock.yml") + ".md" + } identifiersLog.Printf("LockFileToMarkdown: %s -> %s", lockPath, mdPath) return mdPath } + +// StripLockExtension removes the .lock.yml or .lock.yaml extension from a lock file path, +// returning just the base path without the lock extension. This is useful when constructing +// related file paths (e.g. ".invalid.yml" files for debugging). +// +// Examples: +// +// StripLockExtension("workflow.lock.yml") // returns "workflow" +// StripLockExtension("workflow.lock.yaml") // returns "workflow" +// StripLockExtension("workflow.md") // returns "workflow.md" (unchanged) +func StripLockExtension(lockPath string) string { + if before, ok := strings.CutSuffix(lockPath, ".lock.yaml"); ok { + return before + } + return strings.TrimSuffix(lockPath, ".lock.yml") +} diff --git a/pkg/stringutil/identifiers_test.go b/pkg/stringutil/identifiers_test.go index 9ed78399864..b07744dd652 100644 --- a/pkg/stringutil/identifiers_test.go +++ b/pkg/stringutil/identifiers_test.go @@ -3,6 +3,7 @@ package stringutil import ( + "os" "testing" ) @@ -27,6 +28,11 @@ func TestNormalizeWorkflowName(t *testing.T) { input: "weekly-research.lock.yml", expected: "weekly-research", }, + { + name: "name with .lock.yaml extension", + input: "weekly-research.lock.yaml", + expected: "weekly-research", + }, { name: "name with dots in filename", input: "my.workflow.md", @@ -37,6 +43,11 @@ func TestNormalizeWorkflowName(t *testing.T) { input: "my.workflow.lock.yml", expected: "my.workflow", }, + { + name: "name with dots and lock.yaml", + input: "my.workflow.lock.yaml", + expected: "my.workflow", + }, { name: "name with other extension", input: "workflow.yaml", @@ -62,6 +73,11 @@ func TestNormalizeWorkflowName(t *testing.T) { input: ".lock.yml", expected: "", }, + { + name: "just .lock.yaml", + input: ".lock.yaml", + expected: "", + }, { name: "multiple extensions priority", input: "workflow.md.lock.yml", @@ -183,10 +199,15 @@ func TestMarkdownToLockFile(t *testing.T) { expected: ".github/workflows/test.lock.yml", }, { - name: "already a lock file", + name: "already a lock file .yml", input: "workflow.lock.yml", expected: "workflow.lock.yml", }, + { + name: "already a lock file .yaml", + input: "workflow.lock.yaml", + expected: "workflow.lock.yaml", + }, { name: "file with dots in name", input: "my.workflow.md", @@ -221,15 +242,25 @@ func TestLockFileToMarkdown(t *testing.T) { expected string }{ { - name: "simple lock file", + name: "simple lock file .yml", input: "weekly-research.lock.yml", expected: "weekly-research.md", }, + { + name: "simple lock file .yaml", + input: "weekly-research.lock.yaml", + expected: "weekly-research.md", + }, { name: "lock file with path", input: ".github/workflows/test.lock.yml", expected: ".github/workflows/test.md", }, + { + name: "lock file .yaml with path", + input: ".github/workflows/test.lock.yaml", + expected: ".github/workflows/test.md", + }, { name: "already a markdown file", input: "workflow.md", @@ -240,6 +271,11 @@ func TestLockFileToMarkdown(t *testing.T) { input: "my.workflow.lock.yml", expected: "my.workflow.md", }, + { + name: "file with dots in name .yaml", + input: "my.workflow.lock.yaml", + expected: "my.workflow.md", + }, { name: "absolute path", input: "/home/user/.github/workflows/daily.lock.yml", @@ -281,4 +317,125 @@ func TestRoundTripConversions(t *testing.T) { t.Errorf("Round trip failed: %q -> %q -> %q", original, mdFile, backToLock) } }) + + t.Run("lock.yaml to markdown and back", func(t *testing.T) { + original := "workflow.lock.yaml" + mdFile := LockFileToMarkdown(original) + if mdFile != "workflow.md" { + t.Errorf("LockFileToMarkdown(%q) = %q, expected %q", original, mdFile, "workflow.md") + } + }) +} + +func TestMarkdownToLockFileOnDisk(t *testing.T) { + t.Run("no existing lock file defaults to .lock.yml", func(t *testing.T) { + tmpDir := t.TempDir() + mdPath := tmpDir + "/workflow.md" + result := MarkdownToLockFileOnDisk(mdPath) + expected := tmpDir + "/workflow.lock.yml" + if result != expected { + t.Errorf("MarkdownToLockFileOnDisk(%q) = %q, expected %q", mdPath, result, expected) + } + }) + + t.Run("existing .lock.yaml is preferred", func(t *testing.T) { + tmpDir := t.TempDir() + mdPath := tmpDir + "/workflow.md" + lockYamlPath := tmpDir + "/workflow.lock.yaml" + // Create the .lock.yaml file + if err := writeFile(lockYamlPath, ""); err != nil { + t.Fatalf("failed to create .lock.yaml: %v", err) + } + result := MarkdownToLockFileOnDisk(mdPath) + if result != lockYamlPath { + t.Errorf("MarkdownToLockFileOnDisk(%q) = %q, expected %q", mdPath, result, lockYamlPath) + } + }) + + t.Run("existing .lock.yml is not overridden by .lock.yaml when only .lock.yml present", func(t *testing.T) { + tmpDir := t.TempDir() + mdPath := tmpDir + "/workflow.md" + lockYmlPath := tmpDir + "/workflow.lock.yml" + // Create the .lock.yml file (but not .lock.yaml) + if err := writeFile(lockYmlPath, ""); err != nil { + t.Fatalf("failed to create .lock.yml: %v", err) + } + result := MarkdownToLockFileOnDisk(mdPath) + if result != lockYmlPath { + t.Errorf("MarkdownToLockFileOnDisk(%q) = %q, expected %q", mdPath, result, lockYmlPath) + } + }) + + t.Run("already a .lock.yml returns unchanged", func(t *testing.T) { + result := MarkdownToLockFileOnDisk("workflow.lock.yml") + if result != "workflow.lock.yml" { + t.Errorf("MarkdownToLockFileOnDisk(%q) = %q, expected %q", "workflow.lock.yml", result, "workflow.lock.yml") + } + }) + + t.Run("already a .lock.yaml returns unchanged", func(t *testing.T) { + result := MarkdownToLockFileOnDisk("workflow.lock.yaml") + if result != "workflow.lock.yaml" { + t.Errorf("MarkdownToLockFileOnDisk(%q) = %q, expected %q", "workflow.lock.yaml", result, "workflow.lock.yaml") + } + }) +} + +func TestStripLockExtension(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: ".lock.yml extension", + input: "workflow.lock.yml", + expected: "workflow", + }, + { + name: ".lock.yaml extension", + input: "workflow.lock.yaml", + expected: "workflow", + }, + { + name: "path with .lock.yml", + input: "/path/to/workflow.lock.yml", + expected: "/path/to/workflow", + }, + { + name: "path with .lock.yaml", + input: "/path/to/workflow.lock.yaml", + expected: "/path/to/workflow", + }, + { + name: "no lock extension unchanged", + input: "workflow.md", + expected: "workflow.md", + }, + { + name: "dots in name .lock.yml", + input: "my.workflow.lock.yml", + expected: "my.workflow", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := StripLockExtension(tt.input) + if result != tt.expected { + t.Errorf("StripLockExtension(%q) = %q, expected %q", tt.input, result, tt.expected) + } + }) + } +} + +// writeFile is a test helper to create a file with given content +func writeFile(path, content string) error { + f, err := os.Create(path) + if err != nil { + return err + } + defer f.Close() + _, err = f.WriteString(content) + return err } diff --git a/pkg/workflow/call_workflow_validation.go b/pkg/workflow/call_workflow_validation.go index fc5b7910825..0fa082d02ba 100644 --- a/pkg/workflow/call_workflow_validation.go +++ b/pkg/workflow/call_workflow_validation.go @@ -74,7 +74,7 @@ func (c *Compiler) validateCallWorkflow(data *WorkflowData, workflowPath string) repoRoot := filepath.Dir(githubDir) workflowsDir := filepath.Join(repoRoot, ".github", "workflows") - notFoundErr := fmt.Errorf("call-workflow: workflow '%s' not found in %s\n\nChecked for: %s.md, %s.lock.yml, %s.yml\n\nTo fix:\n1. Verify the workflow file exists in .github/workflows/\n2. Ensure the filename matches exactly (case-sensitive)\n3. Use the filename without extension in your configuration", workflowName, workflowsDir, workflowName, workflowName, workflowName) + notFoundErr := fmt.Errorf("call-workflow: workflow '%s' not found in %s\n\nChecked for: %s.md, %s.lock.yml, %s.lock.yaml, %s.yml\n\nTo fix:\n1. Verify the workflow file exists in .github/workflows/\n2. Ensure the filename matches exactly (case-sensitive)\n3. Use the filename without extension in your configuration", workflowName, workflowsDir, workflowName, workflowName, workflowName, workflowName) if returnErr := collector.Add(notFoundErr); returnErr != nil { return returnErr } @@ -82,7 +82,7 @@ func (c *Compiler) validateCallWorkflow(data *WorkflowData, workflowPath string) } // Validate that the workflow supports workflow_call. - // Priority: .lock.yml > .yml > .md (same-batch compilation target) + // Priority: .lock.yaml/.lock.yml > .yml > .md (same-batch compilation target) if fileResult.lockExists { workflowContent, readErr := os.ReadFile(fileResult.lockPath) // #nosec G304 -- lockPath is validated via isPathWithinDir() in findWorkflowFile() before being returned if readErr != nil { diff --git a/pkg/workflow/compiler.go b/pkg/workflow/compiler.go index 6bcaec47fe1..e766ec3118b 100644 --- a/pkg/workflow/compiler.go +++ b/pkg/workflow/compiler.go @@ -419,7 +419,7 @@ func (c *Compiler) generateAndValidateYAML(workflowData *WorkflowData, markdownP // Store error first so we can write invalid YAML before returning formattedErr := formatCompilerError(markdownPath, "error", fmt.Sprintf("expression size validation failed: %v", err), err) // Write the invalid YAML to a .invalid.yml file for inspection - invalidFile := strings.TrimSuffix(lockFile, ".lock.yml") + ".invalid.yml" + invalidFile := stringutil.StripLockExtension(lockFile) + ".invalid.yml" if writeErr := os.WriteFile(invalidFile, []byte(yamlContent), 0644); writeErr == nil { fmt.Fprintln(os.Stderr, console.FormatWarningMessage("Invalid workflow YAML written to: "+console.ToRelativePath(invalidFile))) } @@ -432,7 +432,7 @@ func (c *Compiler) generateAndValidateYAML(workflowData *WorkflowData, markdownP // Store error first so we can write invalid YAML before returning formattedErr := formatCompilerError(markdownPath, "error", err.Error(), err) // Write the invalid YAML to a .invalid.yml file for inspection - invalidFile := strings.TrimSuffix(lockFile, ".lock.yml") + ".invalid.yml" + invalidFile := stringutil.StripLockExtension(lockFile) + ".invalid.yml" if writeErr := os.WriteFile(invalidFile, []byte(yamlContent), 0644); writeErr == nil { fmt.Fprintln(os.Stderr, console.FormatWarningMessage("Workflow with template injection risks written to: "+console.ToRelativePath(invalidFile))) } @@ -458,7 +458,7 @@ func (c *Compiler) generateAndValidateYAML(workflowData *WorkflowData, markdownP formattedErr := formatCompilerErrorWithPosition(markdownPath, fieldLine, 1, "error", fmt.Sprintf("invalid workflow: %v", err), err) // Write the invalid YAML to a .invalid.yml file for inspection - invalidFile := strings.TrimSuffix(lockFile, ".lock.yml") + ".invalid.yml" + invalidFile := stringutil.StripLockExtension(lockFile) + ".invalid.yml" if writeErr := os.WriteFile(invalidFile, []byte(yamlContent), 0644); writeErr == nil { fmt.Fprintln(os.Stderr, console.FormatWarningMessage("Invalid workflow YAML written to: "+console.ToRelativePath(invalidFile))) } @@ -591,8 +591,8 @@ func (c *Compiler) CompileWorkflowData(workflowData *WorkflowData, markdownPath c.artifactManager.Reset() } - // Generate lock file name - lockFile := stringutil.MarkdownToLockFile(markdownPath) + // Generate lock file name, preferring .lock.yaml if it already exists on disk + lockFile := stringutil.MarkdownToLockFileOnDisk(markdownPath) // Sanitize the lock file path to prevent path traversal attacks lockFile = filepath.Clean(lockFile) diff --git a/pkg/workflow/compiler_jobs.go b/pkg/workflow/compiler_jobs.go index 160d0285b2f..3663bfaa6ea 100644 --- a/pkg/workflow/compiler_jobs.go +++ b/pkg/workflow/compiler_jobs.go @@ -196,7 +196,7 @@ func (c *Compiler) buildJobs(data *WorkflowData, markdownPath string) error { } // Extract lock filename for timestamp check - lockFilename := filepath.Base(stringutil.MarkdownToLockFile(markdownPath)) + lockFilename := filepath.Base(stringutil.MarkdownToLockFileOnDisk(markdownPath)) // Resolve custom safe-output actions early so that tool schemas (derived from action.yml) // are available when buildMainJobWrapper → generateMCPSetup → generateToolsMetaJSON → diff --git a/pkg/workflow/compiler_string_api.go b/pkg/workflow/compiler_string_api.go index 7edd204a9ab..eb1359e417e 100644 --- a/pkg/workflow/compiler_string_api.go +++ b/pkg/workflow/compiler_string_api.go @@ -36,7 +36,7 @@ func (c *Compiler) CompileToYAML(workflowData *WorkflowData, markdownPath string c.artifactManager.Reset() } - lockFile := stringutil.MarkdownToLockFile(markdownPath) + lockFile := stringutil.MarkdownToLockFileOnDisk(markdownPath) if err := c.validateWorkflowData(workflowData, markdownPath); err != nil { return "", err diff --git a/pkg/workflow/dispatch_workflow_validation.go b/pkg/workflow/dispatch_workflow_validation.go index 7f3b1d4c16d..03703e9120a 100644 --- a/pkg/workflow/dispatch_workflow_validation.go +++ b/pkg/workflow/dispatch_workflow_validation.go @@ -66,7 +66,7 @@ func (c *Compiler) validateDispatchWorkflow(data *WorkflowData, workflowPath str repoRoot := filepath.Dir(githubDir) workflowsDir := filepath.Join(repoRoot, ".github", "workflows") - notFoundErr := fmt.Errorf("dispatch-workflow: workflow '%s' not found in %s\n\nChecked for: %s.md, %s.lock.yml, %s.yml\n\nTo fix:\n1. Verify the workflow file exists in .github/workflows/\n2. Ensure the filename matches exactly (case-sensitive)\n3. Use the filename without extension in your configuration", workflowName, workflowsDir, workflowName, workflowName, workflowName) + notFoundErr := fmt.Errorf("dispatch-workflow: workflow '%s' not found in %s\n\nChecked for: %s.md, %s.lock.yml, %s.lock.yaml, %s.yml\n\nTo fix:\n1. Verify the workflow file exists in .github/workflows/\n2. Ensure the filename matches exactly (case-sensitive)\n3. Use the filename without extension in your configuration", workflowName, workflowsDir, workflowName, workflowName, workflowName, workflowName) if returnErr := collector.Add(notFoundErr); returnErr != nil { return returnErr // Fail-fast mode } @@ -74,7 +74,7 @@ func (c *Compiler) validateDispatchWorkflow(data *WorkflowData, workflowPath str } // Validate that the workflow supports workflow_dispatch - // Priority: .lock.yml (compiled agentic workflow) > .yml (standard GitHub Actions) > .md (needs compilation) + // Priority: .lock.yaml/.lock.yml (compiled agentic workflow) > .yml (standard GitHub Actions) > .md (needs compilation) var workflowContent []byte // #nosec G304 -- All file paths are validated via isPathWithinDir() before use var workflowFile string var readErr error @@ -216,8 +216,9 @@ func extractWorkflowDispatchInputs(workflowPath string) (map[string]any, error) // getCurrentWorkflowName extracts the workflow name from the file path func getCurrentWorkflowName(workflowPath string) string { filename := filepath.Base(workflowPath) - // Remove .md or .lock.yml extension + // Remove .md, .lock.yml, or .lock.yaml extension filename = strings.TrimSuffix(filename, ".md") + filename = strings.TrimSuffix(filename, ".lock.yaml") filename = strings.TrimSuffix(filename, ".lock.yml") return filename } @@ -247,16 +248,18 @@ func isPathWithinDir(path, dir string) bool { // findWorkflowFileResult holds the result of finding a workflow file type findWorkflowFileResult struct { - mdPath string - lockPath string - ymlPath string - mdExists bool - lockExists bool - ymlExists bool + mdPath string + lockPath string // Path to the lock file (.lock.yaml preferred over .lock.yml if both exist) + lockExtension string // Extension of the lock file: ".lock.yaml" or ".lock.yml" + ymlPath string + mdExists bool + lockExists bool + ymlExists bool } // findWorkflowFile searches for a workflow file in .github/workflows directory only -// Returns paths and existence flags for .md, .lock.yml, and .yml files +// Returns paths and existence flags for .md, .lock.yml/.lock.yaml, and .yml files. +// When both .lock.yaml and .lock.yml exist, .lock.yaml takes priority. func findWorkflowFile(workflowName string, currentWorkflowPath string) (*findWorkflowFileResult, error) { dispatchWorkflowValidationLog.Printf("Finding workflow file: name=%s, current_path=%s", workflowName, currentWorkflowPath) result := &findWorkflowFileResult{} @@ -274,23 +277,33 @@ func findWorkflowFile(workflowName string, currentWorkflowPath string) (*findWor // Build paths for the workflows directory mdPath := filepath.Clean(filepath.Join(searchDir, workflowName+".md")) - lockPath := filepath.Clean(filepath.Join(searchDir, workflowName+".lock.yml")) + lockYamlPath := filepath.Clean(filepath.Join(searchDir, workflowName+".lock.yaml")) + lockYmlPath := filepath.Clean(filepath.Join(searchDir, workflowName+".lock.yml")) ymlPath := filepath.Clean(filepath.Join(searchDir, workflowName+".yml")) // Validate paths are within the search directory (prevent path traversal) - if !isPathWithinDir(mdPath, searchDir) || !isPathWithinDir(lockPath, searchDir) || !isPathWithinDir(ymlPath, searchDir) { + if !isPathWithinDir(mdPath, searchDir) || !isPathWithinDir(lockYmlPath, searchDir) || !isPathWithinDir(ymlPath, searchDir) { return result, fmt.Errorf("invalid workflow name '%s' (path traversal not allowed)", workflowName) } // Check which files exist result.mdPath = mdPath - result.lockPath = lockPath result.ymlPath = ymlPath result.mdExists = fileutil.FileExists(mdPath) - result.lockExists = fileutil.FileExists(lockPath) result.ymlExists = fileutil.FileExists(ymlPath) - dispatchWorkflowValidationLog.Printf("Workflow file search results: md_exists=%v, lock_exists=%v, yml_exists=%v", result.mdExists, result.lockExists, result.ymlExists) + // Prefer .lock.yaml over .lock.yml if both exist + if fileutil.FileExists(lockYamlPath) { + result.lockPath = lockYamlPath + result.lockExtension = ".lock.yaml" + result.lockExists = true + } else { + result.lockPath = lockYmlPath + result.lockExtension = ".lock.yml" + result.lockExists = fileutil.FileExists(lockYmlPath) + } + + dispatchWorkflowValidationLog.Printf("Workflow file search results: md_exists=%v, lock_exists=%v (ext=%s), yml_exists=%v", result.mdExists, result.lockExists, result.lockExtension, result.ymlExists) return result, nil } diff --git a/pkg/workflow/lock_validation.go b/pkg/workflow/lock_validation.go index c518fe33835..9f4e71d2882 100644 --- a/pkg/workflow/lock_validation.go +++ b/pkg/workflow/lock_validation.go @@ -20,7 +20,8 @@ package workflow import ( "fmt" - "strings" + + "github.com/github/gh-aw/pkg/stringutil" ) // ValidateLockSchemaCompatibility validates that a lock file's schema is compatible. @@ -41,7 +42,7 @@ func ValidateLockSchemaCompatibility(content string, lockFilePath string) error if metadata == nil { return fmt.Errorf("lock file %s is missing required metadata. This file may be corrupted or manually edited.\n\nTo fix this, recompile the workflow:\n gh aw compile %s", lockFilePath, - strings.TrimSuffix(lockFilePath, ".lock.yml")+".md") + stringutil.StripLockExtension(lockFilePath)+".md") } // Check schema compatibility @@ -51,7 +52,7 @@ func ValidateLockSchemaCompatibility(content string, lockFilePath string) error lockFilePath, metadata.SchemaVersion, formatSupportedVersions(), - strings.TrimSuffix(lockFilePath, ".lock.yml")+".md") + stringutil.StripLockExtension(lockFilePath)+".md") } lockSchemaLog.Printf("Lock file schema validated: %s (version=%s)", lockFilePath, metadata.SchemaVersion) diff --git a/pkg/workflow/resolve.go b/pkg/workflow/resolve.go index 1199aa3059d..b705f72f781 100644 --- a/pkg/workflow/resolve.go +++ b/pkg/workflow/resolve.go @@ -57,8 +57,11 @@ func ResolveWorkflowName(workflowInput string) (string, error) { } // The corresponding lock file name is what GitHub Actions uses as the workflow name - lockFileName := normalizedName + ".lock.yml" - lockFile := filepath.Join(workflowsDir, lockFileName) + // Check for .lock.yaml first (user preference), then fall back to .lock.yml + lockFile := filepath.Join(workflowsDir, normalizedName+".lock.yaml") + if _, err := os.Stat(lockFile); err != nil { + lockFile = filepath.Join(workflowsDir, normalizedName+".lock.yml") + } // Check if the lock file exists (should be generated by compile) if _, err := os.Stat(lockFile); err != nil { diff --git a/pkg/workflow/safe_outputs_call_workflow.go b/pkg/workflow/safe_outputs_call_workflow.go index 96a0968b565..6cfbd5498de 100644 --- a/pkg/workflow/safe_outputs_call_workflow.go +++ b/pkg/workflow/safe_outputs_call_workflow.go @@ -46,17 +46,17 @@ func populateCallWorkflowFiles(data *WorkflowData, markdownPath string) { continue } - // Determine which file to use - priority: .lock.yml > .yml > .md (batch target) + // Determine which file to use - priority: .lock.yaml/.lock.yml > .yml > .md (batch target) var extension string if fileResult.lockExists { - extension = ".lock.yml" + extension = fileResult.lockExtension } else if fileResult.ymlExists { extension = ".yml" } else if fileResult.mdExists { // .md-only: the workflow is a same-batch compilation target that will produce a .lock.yml extension = ".lock.yml" } else { - callWorkflowLog.Printf("Warning: no workflow file found for %s (checked .lock.yml, .yml, .md)", workflowName) + callWorkflowLog.Printf("Warning: no workflow file found for %s (checked .lock.yaml, .lock.yml, .yml, .md)", workflowName) continue } diff --git a/pkg/workflow/safe_outputs_dispatch.go b/pkg/workflow/safe_outputs_dispatch.go index b04dfd06f1c..e682c67ae72 100644 --- a/pkg/workflow/safe_outputs_dispatch.go +++ b/pkg/workflow/safe_outputs_dispatch.go @@ -47,17 +47,17 @@ func populateDispatchWorkflowFiles(data *WorkflowData, markdownPath string) { continue } - // Determine which file to use - priority: .lock.yml > .yml > .md (batch target) + // Determine which file to use - priority: .lock.yaml/.lock.yml > .yml > .md (batch target) var extension string if fileResult.lockExists { - extension = ".lock.yml" + extension = fileResult.lockExtension } else if fileResult.ymlExists { extension = ".yml" } else if fileResult.mdExists { // .md-only: the workflow is a same-batch compilation target that will produce a .lock.yml extension = ".lock.yml" } else { - safeOutputsConfigLog.Printf("Warning: no workflow file found for %s (checked .lock.yml, .yml, .md)", workflowName) + safeOutputsConfigLog.Printf("Warning: no workflow file found for %s (checked .lock.yaml, .lock.yml, .yml, .md)", workflowName) continue } diff --git a/pkg/workflow/safe_outputs_tools_filtering.go b/pkg/workflow/safe_outputs_tools_filtering.go index b8e9a531559..ec37673b0c4 100644 --- a/pkg/workflow/safe_outputs_tools_filtering.go +++ b/pkg/workflow/safe_outputs_tools_filtering.go @@ -293,13 +293,13 @@ func generateFilteredToolsJSON(data *WorkflowData, markdownPath string) (string, continue } - // Determine which file to use - priority: .lock.yml > .yml > .md (batch target) + // Determine which file to use - priority: .lock.yaml/.lock.yml > .yml > .md (batch target) var workflowPath string var extension string var useMD bool if fileResult.lockExists { workflowPath = fileResult.lockPath - extension = ".lock.yml" + extension = fileResult.lockExtension } else if fileResult.ymlExists { workflowPath = fileResult.ymlPath extension = ".yml" @@ -309,7 +309,7 @@ func generateFilteredToolsJSON(data *WorkflowData, markdownPath string) (string, extension = ".lock.yml" useMD = true } else { - safeOutputsConfigLog.Printf("Warning: no workflow file found for %s (checked .lock.yml, .yml, .md)", workflowName) + safeOutputsConfigLog.Printf("Warning: no workflow file found for %s (checked .lock.yaml, .lock.yml, .yml, .md)", workflowName) // Continue with empty inputs tool := generateDispatchWorkflowTool(workflowName, make(map[string]any)) filteredTools = append(filteredTools, tool) @@ -358,13 +358,13 @@ func generateFilteredToolsJSON(data *WorkflowData, markdownPath string) (string, continue } - // Determine which file to use - priority: .lock.yml > .yml > .md (batch target) + // Determine which file to use - priority: .lock.yaml/.lock.yml > .yml > .md (batch target) var workflowPath string var extension string var useMD bool if fileResult.lockExists { workflowPath = fileResult.lockPath - extension = ".lock.yml" + extension = fileResult.lockExtension } else if fileResult.ymlExists { workflowPath = fileResult.ymlPath extension = ".yml" @@ -373,7 +373,7 @@ func generateFilteredToolsJSON(data *WorkflowData, markdownPath string) (string, extension = ".lock.yml" useMD = true } else { - safeOutputsConfigLog.Printf("Warning: no workflow file found for %s (checked .lock.yml, .yml, .md)", workflowName) + safeOutputsConfigLog.Printf("Warning: no workflow file found for %s (checked .lock.yaml, .lock.yml, .yml, .md)", workflowName) tool := generateCallWorkflowTool(workflowName, make(map[string]any)) filteredTools = append(filteredTools, tool) continue @@ -824,7 +824,7 @@ func generateDynamicTools(data *WorkflowData, markdownPath string) ([]map[string var useMD bool if fileResult.lockExists { workflowPath = fileResult.lockPath - extension = ".lock.yml" + extension = fileResult.lockExtension } else if fileResult.ymlExists { workflowPath = fileResult.ymlPath extension = ".yml" @@ -833,7 +833,7 @@ func generateDynamicTools(data *WorkflowData, markdownPath string) ([]map[string extension = ".lock.yml" useMD = true } else { - safeOutputsConfigLog.Printf("Warning: no workflow file found for %s (checked .lock.yml, .yml, .md)", workflowName) + safeOutputsConfigLog.Printf("Warning: no workflow file found for %s (checked .lock.yaml, .lock.yml, .yml, .md)", workflowName) dynamicTools = append(dynamicTools, generateDispatchWorkflowTool(workflowName, make(map[string]any))) continue } @@ -877,7 +877,7 @@ func generateDynamicTools(data *WorkflowData, markdownPath string) ([]map[string var useMD bool if fileResult.lockExists { workflowPath = fileResult.lockPath - extension = ".lock.yml" + extension = fileResult.lockExtension } else if fileResult.ymlExists { workflowPath = fileResult.ymlPath extension = ".yml" @@ -886,7 +886,7 @@ func generateDynamicTools(data *WorkflowData, markdownPath string) ([]map[string extension = ".lock.yml" useMD = true } else { - safeOutputsConfigLog.Printf("Warning: no workflow file found for %s (checked .lock.yml, .yml, .md)", workflowName) + safeOutputsConfigLog.Printf("Warning: no workflow file found for %s (checked .lock.yaml, .lock.yml, .yml, .md)", workflowName) dynamicTools = append(dynamicTools, generateCallWorkflowTool(workflowName, make(map[string]any))) continue } diff --git a/pkg/workflow/stop_after.go b/pkg/workflow/stop_after.go index 7353297aa7e..809aff9fe98 100644 --- a/pkg/workflow/stop_after.go +++ b/pkg/workflow/stop_after.go @@ -64,7 +64,7 @@ func (c *Compiler) processStopAfterConfiguration(frontmatter map[string]any, wor if workflowData.StopTime != "" { stopAfterLog.Printf("Stop-after value specified: %s", workflowData.StopTime) // Check if there's already a lock file with a stop time (recompilation case) - lockFile := stringutil.MarkdownToLockFile(markdownPath) + lockFile := stringutil.MarkdownToLockFileOnDisk(markdownPath) existingStopTime := ExtractStopTimeFromLockFile(lockFile) // If refresh flag is set, always regenerate the stop time From 5882929f91cd48c35515669d429bf6354f51ec20 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Mar 2026 21:55:47 +0000 Subject: [PATCH 3/4] refactor: inline getLockFilePath stub, call MarkdownToLockFileOnDisk directly Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> Agent-Logs-Url: https://github.com/github/gh-aw/sessions/cafc8ef7-84d0-4dc1-bf08-e8066ca1ee41 --- pkg/cli/run_workflow_validation.go | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/pkg/cli/run_workflow_validation.go b/pkg/cli/run_workflow_validation.go index eed777e8be9..47213a08b95 100644 --- a/pkg/cli/run_workflow_validation.go +++ b/pkg/cli/run_workflow_validation.go @@ -22,18 +22,11 @@ import ( var validationLog = logger.New("cli:run_workflow_validation") -// getLockFilePath converts a markdown workflow path to its compiled lock file path, -// preferring an existing .lock.yaml file over the default .lock.yml. -// Example: "/path/to/workflow.md" -> "/path/to/workflow.lock.yml" (or .lock.yaml if it exists) -func getLockFilePath(markdownPath string) string { - return stringutil.MarkdownToLockFileOnDisk(markdownPath) -} - // IsRunnable checks if a workflow can be run (has schedule or workflow_dispatch trigger) // This function checks the compiled .lock.yml file because that's what GitHub Actions uses. func IsRunnable(markdownPath string) (bool, error) { // Convert markdown path to lock file path - lockPath := getLockFilePath(markdownPath) + lockPath := stringutil.MarkdownToLockFileOnDisk(markdownPath) cleanLockPath := filepath.Clean(lockPath) validationLog.Printf("Checking if workflow is runnable: markdown=%s, lock=%s", markdownPath, lockPath) @@ -86,7 +79,7 @@ func IsRunnable(markdownPath string) (bool, error) { // This function checks the .lock.yml file because that's what GitHub Actions uses. func getWorkflowInputs(markdownPath string) (map[string]*workflow.InputDefinition, error) { // Convert markdown path to lock file path - lockPath := getLockFilePath(markdownPath) + lockPath := stringutil.MarkdownToLockFileOnDisk(markdownPath) cleanLockPath := filepath.Clean(lockPath) validationLog.Printf("Extracting workflow inputs from lock file: %s", lockPath) From 1e316fa1f7ad29bbfa677a08fbf9a75781fe210c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Mar 2026 22:07:27 +0000 Subject: [PATCH 4/4] feat: add LockExtensionYML/YAML constants and use throughout codebase Add constants.LockExtensionYML (".lock.yml") and constants.LockExtensionYAML (".lock.yaml") to pkg/constants/constants.go, then replace all inline string literals in non-test Go source files with those constants. Files updated: - pkg/constants/constants.go: add LockExtensionYML and LockExtensionYAML - pkg/stringutil/identifiers.go: import constants, use LockExtension* - pkg/workflow/dispatch_workflow_validation.go: import constants, use LockExtension* - pkg/workflow/safe_outputs_tools_filtering.go: import constants, use LockExtensionYML - pkg/workflow/safe_outputs_call_workflow.go: import constants, use LockExtensionYML - pkg/workflow/safe_outputs_dispatch.go: import constants, use LockExtensionYML - pkg/workflow/resolve.go: use LockExtension* (constants already imported) - pkg/workflow/call_workflow_validation.go: import constants, use LockExtension* in error message - pkg/workflow/compiler_safe_output_jobs.go: import constants, use LockExtensionYML - pkg/parser/import_bfs.go: import constants, use LockExtensionYML - pkg/parser/yaml_import.go: import constants, use LockExtensionYML - pkg/cli/run_workflow_validation.go: use LockExtensionYML (constants already imported) - pkg/cli/trial_command.go: use LockExtensionYML (constants already imported) - pkg/cli/add_interactive_orchestrator.go: use LockExtensionYML (constants already imported) - pkg/cli/update_workflows.go: import constants, use LockExtensionYML - pkg/cli/dependency_graph.go: import constants, use LockExtensionYML - pkg/cli/add_interactive_workflow.go: use LockExtensionYML (constants already imported) - pkg/cli/enable.go: use LockExtensionYML (constants already imported) - pkg/cli/run_workflow_execution.go: use LockExtensionYML (constants already imported) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cli/add_interactive_orchestrator.go | 2 +- pkg/cli/add_interactive_workflow.go | 4 ++-- pkg/cli/dependency_graph.go | 3 ++- pkg/cli/enable.go | 2 +- pkg/cli/run_workflow_execution.go | 4 ++-- pkg/cli/run_workflow_validation.go | 2 +- pkg/cli/trial_command.go | 2 +- pkg/cli/update_workflows.go | 3 ++- pkg/constants/constants.go | 9 ++++++++ pkg/parser/import_bfs.go | 3 ++- pkg/parser/yaml_import.go | 3 ++- pkg/stringutil/identifiers.go | 23 ++++++++++---------- pkg/workflow/call_workflow_validation.go | 3 ++- pkg/workflow/compiler_safe_output_jobs.go | 3 ++- pkg/workflow/dispatch_workflow_validation.go | 13 ++++++----- pkg/workflow/resolve.go | 14 ++++++------ pkg/workflow/safe_outputs_call_workflow.go | 3 ++- pkg/workflow/safe_outputs_dispatch.go | 3 ++- pkg/workflow/safe_outputs_tools_filtering.go | 9 ++++---- 19 files changed, 64 insertions(+), 44 deletions(-) diff --git a/pkg/cli/add_interactive_orchestrator.go b/pkg/cli/add_interactive_orchestrator.go index 124783ac312..7a7335cc6bf 100644 --- a/pkg/cli/add_interactive_orchestrator.go +++ b/pkg/cli/add_interactive_orchestrator.go @@ -202,7 +202,7 @@ func (c *AddInteractiveConfig) determineFilesToAdd() (workflowFiles []string, in return nil, nil, fmt.Errorf("invalid workflow specification '%s': %w", spec, parseErr) } workflowFiles = append(workflowFiles, parsed.WorkflowName+".md") - workflowFiles = append(workflowFiles, parsed.WorkflowName+".lock.yml") + workflowFiles = append(workflowFiles, parsed.WorkflowName+constants.LockExtensionYML) } fmt.Fprintln(os.Stderr, "") diff --git a/pkg/cli/add_interactive_workflow.go b/pkg/cli/add_interactive_workflow.go index 272dabab501..9700a46de90 100644 --- a/pkg/cli/add_interactive_workflow.go +++ b/pkg/cli/add_interactive_workflow.go @@ -134,7 +134,7 @@ func (c *AddInteractiveConfig) checkStatusAndOfferRun(ctx context.Context) error } // Get the run URL for step 10 - runInfo, err := getLatestWorkflowRunWithRetry(parsed.WorkflowName+".lock.yml", c.RepoOverride, c.Verbose) + runInfo, err := getLatestWorkflowRunWithRetry(parsed.WorkflowName+constants.LockExtensionYML, c.RepoOverride, c.Verbose) if err == nil && runInfo.URL != "" { fmt.Fprintln(os.Stderr, "") fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("Workflow triggered successfully!")) @@ -180,7 +180,7 @@ func findWorkflowsByFilenamePattern(pattern, repoOverride string, verbose bool) // The pattern is the workflow name (e.g., "daily-repo-status") // The path is like ".github/workflows/daily-repo-status.lock.yml" // We check if the path contains the pattern - if strings.Contains(string(output), pattern+".lock.yml") || strings.Contains(string(output), pattern+".md") { + if strings.Contains(string(output), pattern+constants.LockExtensionYML) || strings.Contains(string(output), pattern+".md") { if verbose { fmt.Fprintf(os.Stderr, "Workflow with filename '%s' found in workflow list\n", pattern) } diff --git a/pkg/cli/dependency_graph.go b/pkg/cli/dependency_graph.go index 288428dfcda..16319de8d7f 100644 --- a/pkg/cli/dependency_graph.go +++ b/pkg/cli/dependency_graph.go @@ -6,6 +6,7 @@ import ( "path/filepath" "strings" + "github.com/github/gh-aw/pkg/constants" "github.com/github/gh-aw/pkg/logger" "github.com/github/gh-aw/pkg/parser" "github.com/github/gh-aw/pkg/workflow" @@ -66,7 +67,7 @@ func (g *DependencyGraph) BuildGraph(compiler *workflow.Compiler) error { if err != nil { return err } - if !info.IsDir() && strings.HasSuffix(path, ".md") && !strings.HasSuffix(path, ".lock.yml") { + if !info.IsDir() && strings.HasSuffix(path, ".md") && !strings.HasSuffix(path, constants.LockExtensionYML) { allWorkflows = append(allWorkflows, path) } return nil diff --git a/pkg/cli/enable.go b/pkg/cli/enable.go index aed89e2ef57..b08475fadc2 100644 --- a/pkg/cli/enable.go +++ b/pkg/cli/enable.go @@ -305,7 +305,7 @@ func DisableAllWorkflowsExcept(repoSlug string, exceptWorkflows []string, verbos for _, workflowName := range exceptWorkflows { // Add both .md and .lock.yml variants keepEnabled[workflowName+".md"] = true - keepEnabled[workflowName+".lock.yml"] = true + keepEnabled[workflowName+constants.LockExtensionYML] = true keepEnabled[workflowName] = true // In case the full filename is provided } diff --git a/pkg/cli/run_workflow_execution.go b/pkg/cli/run_workflow_execution.go index 0c2fbc75758..8d865cae016 100644 --- a/pkg/cli/run_workflow_execution.go +++ b/pkg/cli/run_workflow_execution.go @@ -182,7 +182,7 @@ func RunWorkflowOnGitHub(ctx context.Context, workflowIdOrName string, opts RunO normalizedID := normalizeWorkflowID(workflowIdOrName) // Construct lock file name from normalized ID (same for both local and remote) - lockFileName := normalizedID + ".lock.yml" + lockFileName := normalizedID + constants.LockExtensionYML // For local workflows, validate the workflow exists and check for lock file var lockFilePath string @@ -580,7 +580,7 @@ func RunWorkflowsOnGitHub(ctx context.Context, workflowNames []string, opts RunO var results []WorkflowRunResult for _, workflowName := range workflowNames { normalizedID := normalizeWorkflowID(workflowName) - lockFileName := normalizedID + ".lock.yml" + lockFileName := normalizedID + constants.LockExtensionYML status := "triggered" if opts.DryRun { status = "dry_run" diff --git a/pkg/cli/run_workflow_validation.go b/pkg/cli/run_workflow_validation.go index 47213a08b95..a859cc85051 100644 --- a/pkg/cli/run_workflow_validation.go +++ b/pkg/cli/run_workflow_validation.go @@ -288,7 +288,7 @@ func validateRemoteWorkflow(workflowName string, repoOverride string, verbose bo normalizedID := normalizeWorkflowID(workflowName) // Add .lock.yml extension - lockFileName := normalizedID + ".lock.yml" + lockFileName := normalizedID + constants.LockExtensionYML if verbose { fmt.Fprintln(os.Stderr, console.FormatProgressMessage(fmt.Sprintf("Checking if workflow '%s' exists in repository '%s'...", lockFileName, repoOverride))) diff --git a/pkg/cli/trial_command.go b/pkg/cli/trial_command.go index 97141023d7f..846f3f261e9 100644 --- a/pkg/cli/trial_command.go +++ b/pkg/cli/trial_command.go @@ -816,7 +816,7 @@ func triggerWorkflowRun(repoSlug, workflowName string, triggerContext string, ve } // Trigger workflow using gh CLI - lockFileName := workflowName + ".lock.yml" + lockFileName := workflowName + constants.LockExtensionYML // Build the command args args := []string{"workflow", "run", lockFileName, "--repo", repoSlug} diff --git a/pkg/cli/update_workflows.go b/pkg/cli/update_workflows.go index e297df67499..3cfadc59146 100644 --- a/pkg/cli/update_workflows.go +++ b/pkg/cli/update_workflows.go @@ -10,6 +10,7 @@ import ( "strings" "github.com/github/gh-aw/pkg/console" + "github.com/github/gh-aw/pkg/constants" "github.com/github/gh-aw/pkg/parser" "github.com/github/gh-aw/pkg/workflow" ) @@ -88,7 +89,7 @@ func findWorkflowsWithSource(workflowsDir string, filterNames []string, verbose } // Skip .lock.yml files - if strings.HasSuffix(entry.Name(), ".lock.yml") { + if strings.HasSuffix(entry.Name(), constants.LockExtensionYML) { continue } diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go index ff3fd18da9a..9e06550d120 100644 --- a/pkg/constants/constants.go +++ b/pkg/constants/constants.go @@ -1046,6 +1046,15 @@ var SharedWorkflowForbiddenFields = []string{ "tracker-id", // Tracker ID } +// LockExtensionYML is the file extension for compiled workflow lock files. +// It is the standard output extension when compiling agentic workflow markdown files. +const LockExtensionYML = ".lock.yml" + +// LockExtensionYAML is the alternative YAML extension for compiled workflow lock files. +// When a workflow lock file already exists with this extension, it takes priority over +// LockExtensionYML during recompilation to avoid creating duplicate files. +const LockExtensionYAML = ".lock.yaml" + func GetWorkflowDir() string { return filepath.Join(".github", "workflows") } diff --git a/pkg/parser/import_bfs.go b/pkg/parser/import_bfs.go index e9c423db64b..8bdb5f4c06b 100644 --- a/pkg/parser/import_bfs.go +++ b/pkg/parser/import_bfs.go @@ -12,6 +12,7 @@ import ( "path" "strings" + "github.com/github/gh-aw/pkg/constants" "github.com/goccy/go-yaml" ) @@ -121,7 +122,7 @@ func processImportsFromFrontmatterWithManifestAndSource(frontmatter map[string]a } // Validate that .lock.yml files are not imported - if strings.HasSuffix(strings.ToLower(fullPath), ".lock.yml") { + if strings.HasSuffix(strings.ToLower(fullPath), constants.LockExtensionYML) { if workflowFilePath != "" && yamlContent != "" { line, column := findImportItemLocation(yamlContent, importPath) importErr := &ImportError{ diff --git a/pkg/parser/yaml_import.go b/pkg/parser/yaml_import.go index 7f1d7af347b..3295f38aefd 100644 --- a/pkg/parser/yaml_import.go +++ b/pkg/parser/yaml_import.go @@ -7,6 +7,7 @@ import ( "path/filepath" "strings" + "github.com/github/gh-aw/pkg/constants" "github.com/github/gh-aw/pkg/logger" "github.com/goccy/go-yaml" ) @@ -20,7 +21,7 @@ func isYAMLWorkflowFile(filePath string) bool { lower := strings.ToLower(filePath) // Reject .lock.yml files (these are compiled outputs from gh-aw) - if strings.HasSuffix(lower, ".lock.yml") { + if strings.HasSuffix(lower, constants.LockExtensionYML) { yamlImportLog.Printf("Rejecting lock file: %s", filePath) return false } diff --git a/pkg/stringutil/identifiers.go b/pkg/stringutil/identifiers.go index 0f3eb63b347..bc2194c9703 100644 --- a/pkg/stringutil/identifiers.go +++ b/pkg/stringutil/identifiers.go @@ -5,6 +5,7 @@ import ( "path/filepath" "strings" + "github.com/github/gh-aw/pkg/constants" "github.com/github/gh-aw/pkg/logger" ) @@ -31,12 +32,12 @@ var identifiersLog = logger.New("stringutil:identifiers") // NormalizeWorkflowName("my.workflow.md") // returns "my.workflow" func NormalizeWorkflowName(name string) string { // Remove .lock.yaml extension first (longer extension, .yaml variant) - if before, ok := strings.CutSuffix(name, ".lock.yaml"); ok { + if before, ok := strings.CutSuffix(name, constants.LockExtensionYAML); ok { return before } // Remove .lock.yml extension (longer extension) - if before, ok := strings.CutSuffix(name, ".lock.yml"); ok { + if before, ok := strings.CutSuffix(name, constants.LockExtensionYML); ok { return before } @@ -83,12 +84,12 @@ func NormalizeSafeOutputIdentifier(identifier string) string { // MarkdownToLockFile("my.workflow.md") // returns "my.workflow.lock.yml" func MarkdownToLockFile(mdPath string) string { // If already a lock file, return unchanged - if strings.HasSuffix(mdPath, ".lock.yml") || strings.HasSuffix(mdPath, ".lock.yaml") { + if strings.HasSuffix(mdPath, constants.LockExtensionYML) || strings.HasSuffix(mdPath, constants.LockExtensionYAML) { return mdPath } cleaned := filepath.Clean(mdPath) - lockPath := strings.TrimSuffix(cleaned, ".md") + ".lock.yml" + lockPath := strings.TrimSuffix(cleaned, ".md") + constants.LockExtensionYML identifiersLog.Printf("MarkdownToLockFile: %s -> %s", mdPath, lockPath) return lockPath } @@ -109,7 +110,7 @@ func MarkdownToLockFile(mdPath string) string { // MarkdownToLockFileOnDisk("workflow.md") // returns "workflow.lock.yml" func MarkdownToLockFileOnDisk(mdPath string) string { // If already a lock file, return unchanged - if strings.HasSuffix(mdPath, ".lock.yml") || strings.HasSuffix(mdPath, ".lock.yaml") { + if strings.HasSuffix(mdPath, constants.LockExtensionYML) || strings.HasSuffix(mdPath, constants.LockExtensionYAML) { return mdPath } @@ -117,13 +118,13 @@ func MarkdownToLockFileOnDisk(mdPath string) string { base := strings.TrimSuffix(cleaned, ".md") // Prefer .lock.yaml if it already exists on disk - lockYamlPath := base + ".lock.yaml" + lockYamlPath := base + constants.LockExtensionYAML if _, err := os.Stat(lockYamlPath); err == nil { identifiersLog.Printf("MarkdownToLockFileOnDisk: found existing .lock.yaml: %s -> %s", mdPath, lockYamlPath) return lockYamlPath } - lockPath := base + ".lock.yml" + lockPath := base + constants.LockExtensionYML identifiersLog.Printf("MarkdownToLockFileOnDisk: %s -> %s", mdPath, lockPath) return lockPath } @@ -149,10 +150,10 @@ func LockFileToMarkdown(lockPath string) string { cleaned := filepath.Clean(lockPath) var mdPath string - if before, ok := strings.CutSuffix(cleaned, ".lock.yaml"); ok { + if before, ok := strings.CutSuffix(cleaned, constants.LockExtensionYAML); ok { mdPath = before + ".md" } else { - mdPath = strings.TrimSuffix(cleaned, ".lock.yml") + ".md" + mdPath = strings.TrimSuffix(cleaned, constants.LockExtensionYML) + ".md" } identifiersLog.Printf("LockFileToMarkdown: %s -> %s", lockPath, mdPath) return mdPath @@ -168,8 +169,8 @@ func LockFileToMarkdown(lockPath string) string { // StripLockExtension("workflow.lock.yaml") // returns "workflow" // StripLockExtension("workflow.md") // returns "workflow.md" (unchanged) func StripLockExtension(lockPath string) string { - if before, ok := strings.CutSuffix(lockPath, ".lock.yaml"); ok { + if before, ok := strings.CutSuffix(lockPath, constants.LockExtensionYAML); ok { return before } - return strings.TrimSuffix(lockPath, ".lock.yml") + return strings.TrimSuffix(lockPath, constants.LockExtensionYML) } diff --git a/pkg/workflow/call_workflow_validation.go b/pkg/workflow/call_workflow_validation.go index 0fa082d02ba..710f7f2ce39 100644 --- a/pkg/workflow/call_workflow_validation.go +++ b/pkg/workflow/call_workflow_validation.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" + "github.com/github/gh-aw/pkg/constants" "github.com/github/gh-aw/pkg/parser" "github.com/goccy/go-yaml" ) @@ -74,7 +75,7 @@ func (c *Compiler) validateCallWorkflow(data *WorkflowData, workflowPath string) repoRoot := filepath.Dir(githubDir) workflowsDir := filepath.Join(repoRoot, ".github", "workflows") - notFoundErr := fmt.Errorf("call-workflow: workflow '%s' not found in %s\n\nChecked for: %s.md, %s.lock.yml, %s.lock.yaml, %s.yml\n\nTo fix:\n1. Verify the workflow file exists in .github/workflows/\n2. Ensure the filename matches exactly (case-sensitive)\n3. Use the filename without extension in your configuration", workflowName, workflowsDir, workflowName, workflowName, workflowName, workflowName) + notFoundErr := fmt.Errorf("call-workflow: workflow '%s' not found in %s\n\nChecked for: %s.md, %s%s, %s%s, %s.yml\n\nTo fix:\n1. Verify the workflow file exists in .github/workflows/\n2. Ensure the filename matches exactly (case-sensitive)\n3. Use the filename without extension in your configuration", workflowName, workflowsDir, workflowName, workflowName, constants.LockExtensionYML, workflowName, constants.LockExtensionYAML, workflowName) if returnErr := collector.Add(notFoundErr); returnErr != nil { return returnErr } diff --git a/pkg/workflow/compiler_safe_output_jobs.go b/pkg/workflow/compiler_safe_output_jobs.go index 053bb10a534..d6250301365 100644 --- a/pkg/workflow/compiler_safe_output_jobs.go +++ b/pkg/workflow/compiler_safe_output_jobs.go @@ -4,6 +4,7 @@ import ( "fmt" "strings" + "github.com/github/gh-aw/pkg/constants" "github.com/github/gh-aw/pkg/logger" "github.com/github/gh-aw/pkg/stringutil" ) @@ -164,7 +165,7 @@ func (c *Compiler) buildCallWorkflowJobs(data *WorkflowData, markdownPath string workflowPath, ok := config.WorkflowFiles[workflowName] if !ok || workflowPath == "" { // Fallback: construct path from name - workflowPath = fmt.Sprintf("./.github/workflows/%s.lock.yml", workflowName) + workflowPath = fmt.Sprintf("./.github/workflows/%s%s", workflowName, constants.LockExtensionYML) } // Build the with: block. Always include the canonical payload transport, diff --git a/pkg/workflow/dispatch_workflow_validation.go b/pkg/workflow/dispatch_workflow_validation.go index 03703e9120a..08348e22354 100644 --- a/pkg/workflow/dispatch_workflow_validation.go +++ b/pkg/workflow/dispatch_workflow_validation.go @@ -7,6 +7,7 @@ import ( "path/filepath" "strings" + "github.com/github/gh-aw/pkg/constants" "github.com/github/gh-aw/pkg/fileutil" "github.com/github/gh-aw/pkg/parser" "github.com/goccy/go-yaml" @@ -218,8 +219,8 @@ func getCurrentWorkflowName(workflowPath string) string { filename := filepath.Base(workflowPath) // Remove .md, .lock.yml, or .lock.yaml extension filename = strings.TrimSuffix(filename, ".md") - filename = strings.TrimSuffix(filename, ".lock.yaml") - filename = strings.TrimSuffix(filename, ".lock.yml") + filename = strings.TrimSuffix(filename, constants.LockExtensionYAML) + filename = strings.TrimSuffix(filename, constants.LockExtensionYML) return filename } @@ -277,8 +278,8 @@ func findWorkflowFile(workflowName string, currentWorkflowPath string) (*findWor // Build paths for the workflows directory mdPath := filepath.Clean(filepath.Join(searchDir, workflowName+".md")) - lockYamlPath := filepath.Clean(filepath.Join(searchDir, workflowName+".lock.yaml")) - lockYmlPath := filepath.Clean(filepath.Join(searchDir, workflowName+".lock.yml")) + lockYamlPath := filepath.Clean(filepath.Join(searchDir, workflowName+constants.LockExtensionYAML)) + lockYmlPath := filepath.Clean(filepath.Join(searchDir, workflowName+constants.LockExtensionYML)) ymlPath := filepath.Clean(filepath.Join(searchDir, workflowName+".yml")) // Validate paths are within the search directory (prevent path traversal) @@ -295,11 +296,11 @@ func findWorkflowFile(workflowName string, currentWorkflowPath string) (*findWor // Prefer .lock.yaml over .lock.yml if both exist if fileutil.FileExists(lockYamlPath) { result.lockPath = lockYamlPath - result.lockExtension = ".lock.yaml" + result.lockExtension = constants.LockExtensionYAML result.lockExists = true } else { result.lockPath = lockYmlPath - result.lockExtension = ".lock.yml" + result.lockExtension = constants.LockExtensionYML result.lockExists = fileutil.FileExists(lockYmlPath) } diff --git a/pkg/workflow/resolve.go b/pkg/workflow/resolve.go index b705f72f781..d423afde771 100644 --- a/pkg/workflow/resolve.go +++ b/pkg/workflow/resolve.go @@ -58,9 +58,9 @@ func ResolveWorkflowName(workflowInput string) (string, error) { // The corresponding lock file name is what GitHub Actions uses as the workflow name // Check for .lock.yaml first (user preference), then fall back to .lock.yml - lockFile := filepath.Join(workflowsDir, normalizedName+".lock.yaml") + lockFile := filepath.Join(workflowsDir, normalizedName+constants.LockExtensionYAML) if _, err := os.Stat(lockFile); err != nil { - lockFile = filepath.Join(workflowsDir, normalizedName+".lock.yml") + lockFile = filepath.Join(workflowsDir, normalizedName+constants.LockExtensionYML) } // Check if the lock file exists (should be generated by compile) @@ -213,9 +213,9 @@ func GetWorkflowLockFileName(input string) (string, error) { // This handles workflow IDs and filenames with .md or .lock.yml extensions. workflowsDir := constants.GetWorkflowDir() normalizedName := stringutil.NormalizeWorkflowName(input) - lockFile := filepath.Join(workflowsDir, normalizedName+".lock.yml") + lockFile := filepath.Join(workflowsDir, normalizedName+constants.LockExtensionYML) if _, err := os.Stat(lockFile); err == nil { - return normalizedName + ".lock.yml", nil + return normalizedName + constants.LockExtensionYML, nil } // Strategy 2: Match by display name (case-insensitive) via GetAllWorkflows. @@ -228,7 +228,7 @@ func GetWorkflowLockFileName(input string) (string, error) { lowerInput := strings.ToLower(input) for _, wf := range workflows { if strings.ToLower(wf.DisplayName) == lowerInput { - return wf.WorkflowID + ".lock.yml", nil + return wf.WorkflowID + constants.LockExtensionYML, nil } } @@ -240,7 +240,7 @@ func GetAllWorkflows() ([]WorkflowNameMatch, error) { workflowsDir := constants.GetWorkflowDir() // Get all .lock.yml files - lockFiles, err := filepath.Glob(filepath.Join(workflowsDir, "*.lock.yml")) + lockFiles, err := filepath.Glob(filepath.Join(workflowsDir, "*"+constants.LockExtensionYML)) if err != nil { return nil, fmt.Errorf("failed to glob .lock.yml files: %w", err) } @@ -249,7 +249,7 @@ func GetAllWorkflows() ([]WorkflowNameMatch, error) { for _, lockFile := range lockFiles { // Extract workflow ID from filename base := filepath.Base(lockFile) - workflowID := strings.TrimSuffix(base, ".lock.yml") + workflowID := strings.TrimSuffix(base, constants.LockExtensionYML) // Read and parse the lock file to get display name content, err := os.ReadFile(lockFile) diff --git a/pkg/workflow/safe_outputs_call_workflow.go b/pkg/workflow/safe_outputs_call_workflow.go index 6cfbd5498de..0271a6db71d 100644 --- a/pkg/workflow/safe_outputs_call_workflow.go +++ b/pkg/workflow/safe_outputs_call_workflow.go @@ -4,6 +4,7 @@ import ( "fmt" "sort" + "github.com/github/gh-aw/pkg/constants" "github.com/github/gh-aw/pkg/logger" "github.com/github/gh-aw/pkg/stringutil" ) @@ -54,7 +55,7 @@ func populateCallWorkflowFiles(data *WorkflowData, markdownPath string) { extension = ".yml" } else if fileResult.mdExists { // .md-only: the workflow is a same-batch compilation target that will produce a .lock.yml - extension = ".lock.yml" + extension = constants.LockExtensionYML } else { callWorkflowLog.Printf("Warning: no workflow file found for %s (checked .lock.yaml, .lock.yml, .yml, .md)", workflowName) continue diff --git a/pkg/workflow/safe_outputs_dispatch.go b/pkg/workflow/safe_outputs_dispatch.go index e682c67ae72..da2d6f5e414 100644 --- a/pkg/workflow/safe_outputs_dispatch.go +++ b/pkg/workflow/safe_outputs_dispatch.go @@ -4,6 +4,7 @@ import ( "fmt" "sort" + "github.com/github/gh-aw/pkg/constants" "github.com/github/gh-aw/pkg/logger" "github.com/github/gh-aw/pkg/stringutil" ) @@ -55,7 +56,7 @@ func populateDispatchWorkflowFiles(data *WorkflowData, markdownPath string) { extension = ".yml" } else if fileResult.mdExists { // .md-only: the workflow is a same-batch compilation target that will produce a .lock.yml - extension = ".lock.yml" + extension = constants.LockExtensionYML } else { safeOutputsConfigLog.Printf("Warning: no workflow file found for %s (checked .lock.yaml, .lock.yml, .yml, .md)", workflowName) continue diff --git a/pkg/workflow/safe_outputs_tools_filtering.go b/pkg/workflow/safe_outputs_tools_filtering.go index ec37673b0c4..556d42b26f1 100644 --- a/pkg/workflow/safe_outputs_tools_filtering.go +++ b/pkg/workflow/safe_outputs_tools_filtering.go @@ -6,6 +6,7 @@ import ( "maps" "sort" + "github.com/github/gh-aw/pkg/constants" "github.com/github/gh-aw/pkg/stringutil" ) @@ -306,7 +307,7 @@ func generateFilteredToolsJSON(data *WorkflowData, markdownPath string) (string, } else if fileResult.mdExists { // .md-only: the workflow is a same-batch compilation target that will produce a .lock.yml workflowPath = fileResult.mdPath - extension = ".lock.yml" + extension = constants.LockExtensionYML useMD = true } else { safeOutputsConfigLog.Printf("Warning: no workflow file found for %s (checked .lock.yaml, .lock.yml, .yml, .md)", workflowName) @@ -370,7 +371,7 @@ func generateFilteredToolsJSON(data *WorkflowData, markdownPath string) (string, extension = ".yml" } else if fileResult.mdExists { workflowPath = fileResult.mdPath - extension = ".lock.yml" + extension = constants.LockExtensionYML useMD = true } else { safeOutputsConfigLog.Printf("Warning: no workflow file found for %s (checked .lock.yaml, .lock.yml, .yml, .md)", workflowName) @@ -830,7 +831,7 @@ func generateDynamicTools(data *WorkflowData, markdownPath string) ([]map[string extension = ".yml" } else if fileResult.mdExists { workflowPath = fileResult.mdPath - extension = ".lock.yml" + extension = constants.LockExtensionYML useMD = true } else { safeOutputsConfigLog.Printf("Warning: no workflow file found for %s (checked .lock.yaml, .lock.yml, .yml, .md)", workflowName) @@ -883,7 +884,7 @@ func generateDynamicTools(data *WorkflowData, markdownPath string) ([]map[string extension = ".yml" } else if fileResult.mdExists { workflowPath = fileResult.mdPath - extension = ".lock.yml" + extension = constants.LockExtensionYML useMD = true } else { safeOutputsConfigLog.Printf("Warning: no workflow file found for %s (checked .lock.yaml, .lock.yml, .yml, .md)", workflowName)