Skip to content
Merged
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
3 changes: 3 additions & 0 deletions pkg/workflow/compiler.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,9 @@ func (c *Compiler) CompileWorkflowData(workflowData *WorkflowData, markdownPath
// Reset the step order tracker for this compilation
c.stepOrderTracker = NewStepOrderTracker()

// Reset schedule friendly formats for this compilation
c.scheduleFriendlyFormats = nil

// Reset the artifact manager for this compilation
if c.artifactManager == nil {
c.artifactManager = NewArtifactManager()
Expand Down
57 changes: 29 additions & 28 deletions pkg/workflow/compiler_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,34 +16,35 @@ type FileTracker interface {

// Compiler handles converting markdown workflows to GitHub Actions YAML
type Compiler struct {
verbose bool
engineOverride string
customOutput string // If set, output will be written to this path instead of default location
version string // Version of the extension
skipValidation bool // If true, skip schema validation
noEmit bool // If true, validate without generating lock files
strictMode bool // If true, enforce strict validation requirements
trialMode bool // If true, suppress safe outputs for trial mode execution
trialLogicalRepoSlug string // If set in trial mode, the logical repository to checkout
refreshStopTime bool // If true, regenerate stop-after times instead of preserving existing ones
forceRefreshActionPins bool // If true, clear action cache and resolve all actions from GitHub API
actionCacheCleared bool // Tracks if action cache has already been cleared (for forceRefreshActionPins)
markdownPath string // Path to the markdown file being compiled (for context in dynamic tool generation)
actionMode ActionMode // Mode for generating JavaScript steps (inline vs custom actions)
actionTag string // Override action SHA or tag for actions/setup (when set, overrides actionMode to release)
jobManager *JobManager // Manages jobs and dependencies
engineRegistry *EngineRegistry // Registry of available agentic engines
fileTracker FileTracker // Optional file tracker for tracking created files
warningCount int // Number of warnings encountered during compilation
stepOrderTracker *StepOrderTracker // Tracks step ordering for validation
actionCache *ActionCache // Shared cache for action pin resolutions across all workflows
actionResolver *ActionResolver // Shared resolver for action pins across all workflows
actionPinWarnings map[string]bool // Shared cache of already-warned action pin failures (key: "repo@version")
importCache *parser.ImportCache // Shared cache for imported workflow files
workflowIdentifier string // Identifier for the current workflow being compiled (for schedule scattering)
scheduleWarnings []string // Accumulated schedule warnings for this compiler instance
repositorySlug string // Repository slug (owner/repo) used as seed for scattering
artifactManager *ArtifactManager // Tracks artifact uploads/downloads for validation
verbose bool
engineOverride string
customOutput string // If set, output will be written to this path instead of default location
version string // Version of the extension
skipValidation bool // If true, skip schema validation
noEmit bool // If true, validate without generating lock files
strictMode bool // If true, enforce strict validation requirements
trialMode bool // If true, suppress safe outputs for trial mode execution
trialLogicalRepoSlug string // If set in trial mode, the logical repository to checkout
refreshStopTime bool // If true, regenerate stop-after times instead of preserving existing ones
forceRefreshActionPins bool // If true, clear action cache and resolve all actions from GitHub API
actionCacheCleared bool // Tracks if action cache has already been cleared (for forceRefreshActionPins)
markdownPath string // Path to the markdown file being compiled (for context in dynamic tool generation)
actionMode ActionMode // Mode for generating JavaScript steps (inline vs custom actions)
actionTag string // Override action SHA or tag for actions/setup (when set, overrides actionMode to release)
jobManager *JobManager // Manages jobs and dependencies
engineRegistry *EngineRegistry // Registry of available agentic engines
fileTracker FileTracker // Optional file tracker for tracking created files
warningCount int // Number of warnings encountered during compilation
stepOrderTracker *StepOrderTracker // Tracks step ordering for validation
actionCache *ActionCache // Shared cache for action pin resolutions across all workflows
actionResolver *ActionResolver // Shared resolver for action pins across all workflows
actionPinWarnings map[string]bool // Shared cache of already-warned action pin failures (key: "repo@version")
importCache *parser.ImportCache // Shared cache for imported workflow files
workflowIdentifier string // Identifier for the current workflow being compiled (for schedule scattering)
scheduleWarnings []string // Accumulated schedule warnings for this compiler instance
repositorySlug string // Repository slug (owner/repo) used as seed for scattering
artifactManager *ArtifactManager // Tracks artifact uploads/downloads for validation
scheduleFriendlyFormats map[int]string // Maps schedule item index to friendly format string for current workflow
}

// NewCompiler creates a new workflow compiler with optional configuration
Expand Down
43 changes: 16 additions & 27 deletions pkg/workflow/schedule_preprocessing.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,6 @@ import (

var schedulePreprocessingLog = logger.New("workflow:schedule_preprocessing")

// scheduleFriendlyFormats stores the friendly formats for schedule cron expressions
// Key is: "on.schedule[index]"
var scheduleFriendlyFormats = make(map[string]map[int]string)

// normalizeScheduleString handles the common schedule string parsing, warning emission,
// fuzzy scattering, and validation logic. It returns the normalized cron expression
// and the original friendly format, or an error if validation fails.
Expand Down Expand Up @@ -187,10 +183,10 @@ func (c *Compiler) preprocessScheduleFields(frontmatter map[string]any, markdown

// Store friendly format if it was converted
if original != "" {
friendlyFormatsKey := fmt.Sprintf("%p", frontmatter)
friendlyFormats := make(map[int]string)
friendlyFormats[0] = original
scheduleFriendlyFormats[friendlyFormatsKey] = friendlyFormats
if c.scheduleFriendlyFormats == nil {
c.scheduleFriendlyFormats = make(map[int]string)
}
c.scheduleFriendlyFormats[0] = original
}

return nil
Expand Down Expand Up @@ -228,10 +224,10 @@ func (c *Compiler) preprocessScheduleFields(frontmatter map[string]any, markdown

// Store friendly format if it was converted
if original != "" {
friendlyFormatsKey := fmt.Sprintf("%p", frontmatter)
friendlyFormats := make(map[int]string)
friendlyFormats[0] = original
scheduleFriendlyFormats[friendlyFormatsKey] = friendlyFormats
if c.scheduleFriendlyFormats == nil {
c.scheduleFriendlyFormats = make(map[int]string)
}
c.scheduleFriendlyFormats[0] = original
}

// Add workflow_dispatch if not already present
Expand All @@ -249,10 +245,10 @@ func (c *Compiler) preprocessScheduleFields(frontmatter map[string]any, markdown
return fmt.Errorf("schedule field must be a string or an array")
}

// Store friendly formats in a compiler-specific map
// Use the frontmatter map's pointer as a unique key
friendlyFormatsKey := fmt.Sprintf("%p", frontmatter)
friendlyFormats := make(map[int]string)
// Initialize friendly formats map for this compilation
if c.scheduleFriendlyFormats == nil {
c.scheduleFriendlyFormats = make(map[int]string)
}

// Process each schedule item
schedulePreprocessingLog.Printf("Processing %d schedule items", len(scheduleArray))
Expand Down Expand Up @@ -284,15 +280,10 @@ func (c *Compiler) preprocessScheduleFields(frontmatter map[string]any, markdown

// If there was an original friendly format, store it for later use
if original != "" {
friendlyFormats[i] = original
c.scheduleFriendlyFormats[i] = original
}
}

// Store the friendly formats if any were found
if len(friendlyFormats) > 0 {
scheduleFriendlyFormats[friendlyFormatsKey] = friendlyFormats
}

// Add workflow_dispatch if not already present
if _, hasWorkflowDispatch := onMap["workflow_dispatch"]; !hasWorkflowDispatch {
schedulePreprocessingLog.Printf("Adding workflow_dispatch to scheduled workflow")
Expand Down Expand Up @@ -376,10 +367,8 @@ func (c *Compiler) createTriggerParseError(filePath, content, triggerStr string,
// addFriendlyScheduleComments adds comments showing the original friendly format for schedule cron expressions
// This function is called after the YAML has been generated from the frontmatter
func (c *Compiler) addFriendlyScheduleComments(yamlStr string, frontmatter map[string]any) string {
// Retrieve the friendly formats for this frontmatter
friendlyFormatsKey := fmt.Sprintf("%p", frontmatter)
friendlyFormats, exists := scheduleFriendlyFormats[friendlyFormatsKey]
if !exists || len(friendlyFormats) == 0 {
// Retrieve the friendly formats for this compilation
if len(c.scheduleFriendlyFormats) == 0 {
return yamlStr
}

Expand Down Expand Up @@ -411,7 +400,7 @@ func (c *Compiler) addFriendlyScheduleComments(yamlStr string, frontmatter map[s
result = append(result, line)

// Add friendly format comment if available
if friendly, exists := friendlyFormats[scheduleItemIndex]; exists {
if friendly, exists := c.scheduleFriendlyFormats[scheduleItemIndex]; exists {
// Get the indentation of the cron line
indentation := ""
if len(line) > len(trimmedLine) {
Expand Down
107 changes: 107 additions & 0 deletions pkg/workflow/schedule_preprocessing_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1234,3 +1234,110 @@ func TestFuzzyScheduleScatteringWarningWithoutRepoSlug(t *testing.T) {
t.Errorf("expected warning about missing repository context, got warnings: %v", warnings)
}
}

// TestFriendlyFormatDeterminism verifies that friendly format comments are generated
// deterministically and don't carry over between compilations
func TestFriendlyFormatDeterminism(t *testing.T) {
// Create a compiler instance
compiler := NewCompiler(false, "", "test")
compiler.SetWorkflowIdentifier("test-workflow.md")

// Frontmatter with daily schedule
frontmatter1 := map[string]any{
"on": map[string]any{
"schedule": "daily",
},
}

// First compilation - preprocess schedule
err := compiler.preprocessScheduleFields(frontmatter1, "test-workflow-1.md", "")
if err != nil {
t.Fatalf("failed to preprocess schedule: %v", err)
}

// Verify friendly format was stored
if len(compiler.scheduleFriendlyFormats) == 0 {
t.Fatalf("expected friendly formats to be stored after preprocessing")
}

firstFormat := compiler.scheduleFriendlyFormats[0]
if !strings.Contains(firstFormat, "daily") {
t.Errorf("expected friendly format to contain 'daily', got: %s", firstFormat)
}

// Generate YAML for first compilation
yamlStr1 := `"on":
schedule:
- cron: "30 13 * * *"
workflow_dispatch:`

result1 := compiler.addFriendlyScheduleComments(yamlStr1, frontmatter1)
if !strings.Contains(result1, "# Friendly format: daily (scattered)") {
t.Errorf("expected friendly format comment in first compilation, got:\n%s", result1)
}

// Second compilation with different frontmatter (weekly schedule)
// Reset compiler state as would happen in a new compilation
compiler.scheduleFriendlyFormats = nil

frontmatter2 := map[string]any{
"on": map[string]any{
"schedule": "weekly",
},
}

err = compiler.preprocessScheduleFields(frontmatter2, "test-workflow-2.md", "")
if err != nil {
t.Fatalf("failed to preprocess schedule: %v", err)
}

// Verify friendly format was replaced, not appended
if len(compiler.scheduleFriendlyFormats) == 0 {
t.Fatalf("expected friendly formats to be stored after second preprocessing")
}

secondFormat := compiler.scheduleFriendlyFormats[0]
if !strings.Contains(secondFormat, "weekly") {
t.Errorf("expected friendly format to contain 'weekly', got: %s", secondFormat)
}

// Verify the old format doesn't leak into the second compilation
yamlStr2 := `"on":
schedule:
- cron: "15 9 * * 1"
workflow_dispatch:`

result2 := compiler.addFriendlyScheduleComments(yamlStr2, frontmatter2)
if !strings.Contains(result2, "# Friendly format: weekly (scattered)") {
t.Errorf("expected 'weekly' friendly format comment in second compilation, got:\n%s", result2)
}
if strings.Contains(result2, "# Friendly format: daily") {
t.Errorf("unexpected 'daily' format leaking into second compilation, got:\n%s", result2)
}

// Third compilation - compile the first workflow again
// This ensures the format is deterministic across repeated compilations
compiler.scheduleFriendlyFormats = nil

// Create a fresh frontmatter map (important: don't reuse the modified one)
frontmatter3 := map[string]any{
"on": map[string]any{
"schedule": "daily",
},
}

err = compiler.preprocessScheduleFields(frontmatter3, "test-workflow-1.md", "")
if err != nil {
t.Fatalf("failed to preprocess schedule in third compilation: %v", err)
}

result3 := compiler.addFriendlyScheduleComments(yamlStr1, frontmatter3)
if !strings.Contains(result3, "# Friendly format: daily (scattered)") {
t.Errorf("expected 'daily' friendly format comment in third compilation, got:\n%s", result3)
}

// Verify the results are identical for the same workflow
if result1 != result3 {
t.Errorf("expected identical results for same workflow, got:\n===First===\n%s\n===Third===\n%s", result1, result3)
}
}
Loading