Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pkg/cli/add_interactive_orchestrator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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, "")
Expand Down
4 changes: 2 additions & 2 deletions pkg/cli/add_interactive_workflow.go
Original file line number Diff line number Diff line change
Expand Up @@ -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!"))
Expand Down Expand Up @@ -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)
}
Expand Down
4 changes: 2 additions & 2 deletions pkg/cli/compile_validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion pkg/cli/dependency_graph.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion pkg/cli/enable.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
4 changes: 2 additions & 2 deletions pkg/cli/run_workflow_execution.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down
14 changes: 4 additions & 10 deletions pkg/cli/run_workflow_validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,24 +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"
func getLockFilePath(markdownPath string) string {
// Handle regular workflow files
return strings.TrimSuffix(markdownPath, ".md") + ".lock.yml"
}

// 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)
Expand Down Expand Up @@ -85,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)
Expand Down Expand Up @@ -294,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)))
Expand Down
2 changes: 1 addition & 1 deletion pkg/cli/trial_command.go
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
3 changes: 2 additions & 1 deletion pkg/cli/update_workflows.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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
}

Expand Down
9 changes: 9 additions & 0 deletions pkg/constants/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
3 changes: 2 additions & 1 deletion pkg/parser/import_bfs.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"path"
"strings"

"github.com/github/gh-aw/pkg/constants"
"github.com/goccy/go-yaml"
)

Expand Down Expand Up @@ -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{
Expand Down
3 changes: 2 additions & 1 deletion pkg/parser/yaml_import.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -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
}
Expand Down
97 changes: 82 additions & 15 deletions pkg/stringutil/identifiers.go
Original file line number Diff line number Diff line change
@@ -1,34 +1,43 @@
package stringutil

import (
"os"
"path/filepath"
"strings"

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

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)
if before, ok := strings.CutSuffix(name, ".lock.yml"); ok {
// Remove .lock.yaml extension first (longer extension, .yaml variant)
if before, ok := strings.CutSuffix(name, constants.LockExtensionYAML); ok {
return before
}

// Remove .lock.yml extension (longer extension)
if before, ok := strings.CutSuffix(name, constants.LockExtensionYML); ok {
return before
}

Expand Down Expand Up @@ -64,35 +73,72 @@ 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, 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
}

// 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, constants.LockExtensionYML) || strings.HasSuffix(mdPath, constants.LockExtensionYAML) {
return mdPath
}

cleaned := filepath.Clean(mdPath)
base := strings.TrimSuffix(cleaned, ".md")

// Prefer .lock.yaml if it already exists on disk
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 + constants.LockExtensionYML
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"
Expand All @@ -103,7 +149,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, constants.LockExtensionYAML); ok {
mdPath = before + ".md"
} else {
mdPath = strings.TrimSuffix(cleaned, constants.LockExtensionYML) + ".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, constants.LockExtensionYAML); ok {
return before
}
return strings.TrimSuffix(lockPath, constants.LockExtensionYML)
}
Loading