-
Notifications
You must be signed in to change notification settings - Fork 435
Expand file tree
/
Copy pathcompiler_parse.go
More file actions
732 lines (622 loc) · 28 KB
/
Copy pathcompiler_parse.go
File metadata and controls
732 lines (622 loc) · 28 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
package workflow
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"github.com/githubnext/gh-aw/pkg/console"
"github.com/githubnext/gh-aw/pkg/logger"
"github.com/githubnext/gh-aw/pkg/parser"
"github.com/goccy/go-yaml"
)
var detectionLog = logger.New("workflow:detection")
func (c *Compiler) ParseWorkflowFile(markdownPath string) (*WorkflowData, error) {
log.Printf("Reading file: %s", markdownPath)
// Read the file
content, err := os.ReadFile(markdownPath)
if err != nil {
return nil, fmt.Errorf("failed to read file: %w", err)
}
log.Printf("File size: %d bytes", len(content))
// Parse frontmatter and markdown
result, err := parser.ExtractFrontmatterFromContent(string(content))
if err != nil {
// Use FrontmatterStart from result if available, otherwise default to line 2 (after opening ---)
frontmatterStart := 2
if result != nil && result.FrontmatterStart > 0 {
frontmatterStart = result.FrontmatterStart
}
return nil, c.createFrontmatterError(markdownPath, string(content), err, frontmatterStart)
}
if len(result.Frontmatter) == 0 {
return nil, fmt.Errorf("no frontmatter found")
}
if result.Markdown == "" {
return nil, fmt.Errorf("no markdown content found")
}
// Preprocess schedule fields to convert human-friendly format to cron expressions
if err := c.preprocessScheduleFields(result.Frontmatter); err != nil {
return nil, err
}
// Validate main workflow frontmatter contains only expected entries
if err := parser.ValidateMainWorkflowFrontmatterWithSchemaAndLocation(result.Frontmatter, markdownPath); err != nil {
return nil, err
}
// Validate event filter mutual exclusivity (branches/branches-ignore, paths/paths-ignore)
if err := ValidateEventFilters(result.Frontmatter); err != nil {
return nil, err
}
log.Printf("Frontmatter: %d chars, Markdown: %d chars", len(result.Frontmatter), len(result.Markdown))
markdownDir := filepath.Dir(markdownPath)
// Extract AI engine setting from frontmatter
engineSetting, engineConfig := c.ExtractEngineConfig(result.Frontmatter)
// Extract network permissions from frontmatter
networkPermissions := c.extractNetworkPermissions(result.Frontmatter)
// Default to 'defaults' network access if no network permissions specified
if networkPermissions == nil {
networkPermissions = &NetworkPermissions{
Mode: "defaults",
}
}
// Extract sandbox configuration from frontmatter
sandboxConfig := c.extractSandboxConfig(result.Frontmatter)
// Save the initial strict mode state to restore it after this workflow is processed
// This ensures that strict mode from one workflow doesn't affect other workflows
initialStrictMode := c.strictMode
// Check strict mode in frontmatter
// Priority: CLI flag > frontmatter > schema default (true)
if !c.strictMode {
// CLI flag not set, check frontmatter
if strictValue, exists := result.Frontmatter["strict"]; exists {
// Frontmatter explicitly sets strict mode
if strictBool, ok := strictValue.(bool); ok {
c.strictMode = strictBool
}
} else {
// Neither CLI nor frontmatter set - use schema default (true)
c.strictMode = true
}
}
// Perform strict mode validations
if err := c.validateStrictMode(result.Frontmatter, networkPermissions); err != nil {
// Restore strict mode before returning error
c.strictMode = initialStrictMode
return nil, err
}
// Restore the initial strict mode state after validation
// This ensures strict mode doesn't leak to other workflows being compiled
c.strictMode = initialStrictMode
// Validate that @include/@import directives are not used inside template regions
if err := validateNoIncludesInTemplateRegions(result.Markdown); err != nil {
return nil, fmt.Errorf("template region validation failed: %w", err)
}
// Override with command line AI engine setting if provided
if c.engineOverride != "" {
originalEngineSetting := engineSetting
if originalEngineSetting != "" && originalEngineSetting != c.engineOverride {
fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Command line --engine %s overrides markdown file engine: %s", c.engineOverride, originalEngineSetting)))
c.IncrementWarningCount()
}
engineSetting = c.engineOverride
}
// Process imports from frontmatter first (before @include directives)
importCache := c.getSharedImportCache()
// Pass the full file content for accurate line/column error reporting
importsResult, err := parser.ProcessImportsFromFrontmatterWithSource(result.Frontmatter, markdownDir, importCache, markdownPath, string(content))
if err != nil {
return nil, err // Error is already formatted with source location
}
// Merge network permissions from imports with top-level network permissions
if importsResult.MergedNetwork != "" {
networkPermissions, err = c.MergeNetworkPermissions(networkPermissions, importsResult.MergedNetwork)
if err != nil {
return nil, fmt.Errorf("failed to merge network permissions: %w", err)
}
}
// Validate permissions from imports against top-level permissions
// Extract top-level permissions first
topLevelPermissions := c.extractPermissions(result.Frontmatter)
if importsResult.MergedPermissions != "" {
if err := c.ValidateIncludedPermissions(topLevelPermissions, importsResult.MergedPermissions); err != nil {
return nil, fmt.Errorf("permission validation failed: %w", err)
}
}
// Process @include directives to extract engine configurations and check for conflicts
includedEngines, err := parser.ExpandIncludesForEngines(result.Markdown, markdownDir)
if err != nil {
return nil, fmt.Errorf("failed to expand includes for engines: %w", err)
}
// Combine imported engines with included engines
allEngines := append(importsResult.MergedEngines, includedEngines...)
// Validate that only one engine field exists across all files
finalEngineSetting, err := c.validateSingleEngineSpecification(engineSetting, allEngines)
if err != nil {
return nil, err
}
if finalEngineSetting != "" {
engineSetting = finalEngineSetting
}
// If engineConfig is nil (engine was in an included file), extract it from the included engine JSON
if engineConfig == nil && len(allEngines) > 0 {
extractedConfig, err := c.extractEngineConfigFromJSON(allEngines[0])
if err != nil {
return nil, fmt.Errorf("failed to extract engine config from included file: %w", err)
}
engineConfig = extractedConfig
}
// Apply the default AI engine setting if not specified
if engineSetting == "" {
defaultEngine := c.engineRegistry.GetDefaultEngine()
engineSetting = defaultEngine.GetID()
log.Printf("No 'engine:' setting found, defaulting to: %s", engineSetting)
// Create a default EngineConfig with the default engine ID if not already set
if engineConfig == nil {
engineConfig = &EngineConfig{ID: engineSetting}
} else if engineConfig.ID == "" {
engineConfig.ID = engineSetting
}
}
// Validate the engine setting
if err := c.validateEngine(engineSetting); err != nil {
return nil, err
}
// Get the agentic engine instance
agenticEngine, err := c.getAgenticEngine(engineSetting)
if err != nil {
return nil, err
}
log.Printf("AI engine: %s (%s)", agenticEngine.GetDisplayName(), engineSetting)
if agenticEngine.IsExperimental() && c.verbose {
fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Using experimental engine: %s", agenticEngine.GetDisplayName())))
c.IncrementWarningCount()
}
// Enable firewall by default for copilot engine when network restrictions are present
// (unless SRT sandbox is configured, since AWF and SRT are mutually exclusive)
enableFirewallByDefaultForCopilot(engineSetting, networkPermissions, sandboxConfig)
// Re-evaluate strict mode for firewall and network validation
// (it was restored after validateStrictMode but we need it again)
initialStrictModeForFirewall := c.strictMode
if !c.strictMode {
// CLI flag not set, check frontmatter
if strictValue, exists := result.Frontmatter["strict"]; exists {
// Frontmatter explicitly sets strict mode
if strictBool, ok := strictValue.(bool); ok {
c.strictMode = strictBool
}
} else {
// Neither CLI nor frontmatter set - use schema default (true)
c.strictMode = true
}
}
// Validate firewall is enabled in strict mode for copilot with network restrictions
if err := c.validateStrictFirewall(engineSetting, networkPermissions, sandboxConfig); err != nil {
c.strictMode = initialStrictModeForFirewall
return nil, err
}
// Check if the engine supports network restrictions when they are defined
if err := c.checkNetworkSupport(agenticEngine, networkPermissions); err != nil {
// Restore strict mode before returning error
c.strictMode = initialStrictModeForFirewall
return nil, err
}
// Restore the strict mode state after network check
c.strictMode = initialStrictModeForFirewall
log.Print("Processing tools and includes...")
// Extract SafeOutputs configuration early so we can use it when applying default tools
safeOutputs := c.extractSafeOutputsConfig(result.Frontmatter)
// Extract SecretMasking configuration
secretMasking := c.extractSecretMaskingConfig(result.Frontmatter)
// Merge secret-masking from imports with top-level secret-masking
if importsResult.MergedSecretMasking != "" {
secretMasking, err = c.MergeSecretMasking(secretMasking, importsResult.MergedSecretMasking)
if err != nil {
return nil, fmt.Errorf("failed to merge secret-masking: %w", err)
}
}
var tools map[string]any
// Extract tools from the main file
topTools := extractToolsFromFrontmatter(result.Frontmatter)
// Extract mcp-servers from the main file and merge them into tools
mcpServers := extractMCPServersFromFrontmatter(result.Frontmatter)
// Process @include directives to extract additional tools
includedTools, includedToolFiles, err := parser.ExpandIncludesWithManifest(result.Markdown, markdownDir, true)
if err != nil {
return nil, fmt.Errorf("failed to expand includes for tools: %w", err)
}
// Combine imported tools with included tools
var allIncludedTools string
if importsResult.MergedTools != "" && includedTools != "" {
allIncludedTools = importsResult.MergedTools + "\n" + includedTools
} else if importsResult.MergedTools != "" {
allIncludedTools = importsResult.MergedTools
} else {
allIncludedTools = includedTools
}
// Combine imported mcp-servers with top-level mcp-servers
// Imported mcp-servers are in JSON format (newline-separated), need to merge them
allMCPServers := mcpServers
if importsResult.MergedMCPServers != "" {
// Parse and merge imported MCP servers
mergedMCPServers, err := c.MergeMCPServers(mcpServers, importsResult.MergedMCPServers)
if err != nil {
return nil, fmt.Errorf("failed to merge imported mcp-servers: %w", err)
}
allMCPServers = mergedMCPServers
}
// Merge tools including mcp-servers
tools, err = c.mergeToolsAndMCPServers(topTools, allMCPServers, allIncludedTools)
if err != nil {
return nil, fmt.Errorf("failed to merge tools: %w", err)
}
// Extract safety-prompt setting from merged tools (defaults to true)
safetyPrompt := c.extractSafetyPromptSetting(tools)
// Extract timeout setting from merged tools (defaults to 0 which means use engine defaults)
toolsTimeout := c.extractToolsTimeout(tools)
// Extract startup-timeout setting from merged tools (defaults to 0 which means use engine defaults)
toolsStartupTimeout := c.extractToolsStartupTimeout(tools)
// Remove meta fields (safety-prompt, timeout, startup-timeout) from merged tools map
// These are configuration fields, not actual tools
delete(tools, "safety-prompt")
delete(tools, "timeout")
delete(tools, "startup-timeout")
// Extract and merge runtimes from frontmatter and imports
topRuntimes := extractRuntimesFromFrontmatter(result.Frontmatter)
runtimes, err := mergeRuntimes(topRuntimes, importsResult.MergedRuntimes)
if err != nil {
return nil, fmt.Errorf("failed to merge runtimes: %w", err)
}
// Add MCP fetch server if needed (when web-fetch is requested but engine doesn't support it)
tools, _ = AddMCPFetchServerIfNeeded(tools, agenticEngine)
// Validate MCP configurations
if err := ValidateMCPConfigs(tools); err != nil {
return nil, err
}
// Validate HTTP transport support for the current engine
if err := c.validateHTTPTransportSupport(tools, agenticEngine); err != nil {
return nil, err
}
if !agenticEngine.SupportsToolsAllowlist() {
// For engines that don't support tool allowlists (like codex), ignore tools section and provide warnings
fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Using experimental %s support (engine: %s)", agenticEngine.GetDisplayName(), engineSetting)))
c.IncrementWarningCount()
if _, hasTools := result.Frontmatter["tools"]; hasTools {
fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("'tools' section ignored when using engine: %s (%s doesn't support MCP tool allow-listing)", engineSetting, agenticEngine.GetDisplayName())))
c.IncrementWarningCount()
}
tools = map[string]any{}
// For now, we'll add a basic github tool (always uses docker MCP)
githubConfig := map[string]any{}
tools["github"] = githubConfig
}
// Validate max-turns support for the current engine
if err := c.validateMaxTurnsSupport(result.Frontmatter, agenticEngine); err != nil {
return nil, err
}
// Validate web-search support for the current engine (warning only)
c.validateWebSearchSupport(tools, agenticEngine)
// Process @include directives in markdown content
markdownContent, includedMarkdownFiles, err := parser.ExpandIncludesWithManifest(result.Markdown, markdownDir, false)
if err != nil {
return nil, fmt.Errorf("failed to expand includes in markdown: %w", err)
}
// Prepend imported markdown from frontmatter imports field
if importsResult.MergedMarkdown != "" {
markdownContent = importsResult.MergedMarkdown + markdownContent
}
log.Print("Expanded includes in markdown content")
// Combine all included files (from tools and markdown)
// Use a map to deduplicate files
allIncludedFilesMap := make(map[string]bool)
for _, file := range includedToolFiles {
allIncludedFilesMap[file] = true
}
for _, file := range includedMarkdownFiles {
allIncludedFilesMap[file] = true
}
var allIncludedFiles []string
for file := range allIncludedFilesMap {
allIncludedFiles = append(allIncludedFiles, file)
}
// Sort files alphabetically to ensure consistent ordering in lock files
sort.Strings(allIncludedFiles)
// Extract workflow name
workflowName, err := parser.ExtractWorkflowNameFromMarkdown(markdownPath)
if err != nil {
return nil, fmt.Errorf("failed to extract workflow name: %w", err)
}
// Check if frontmatter specifies a custom name and use it instead
frontmatterName := extractStringFromMap(result.Frontmatter, "name", nil)
if frontmatterName != "" {
workflowName = frontmatterName
}
log.Printf("Extracted workflow name: '%s'", workflowName)
// Check if the markdown content uses the text output
needsTextOutput := c.detectTextOutputUsage(markdownContent)
// Extract and validate tracker-id
trackerID, err := c.extractTrackerID(result.Frontmatter)
if err != nil {
return nil, err
}
// Build workflow data
workflowData := &WorkflowData{
Name: workflowName,
FrontmatterName: frontmatterName,
FrontmatterYAML: strings.Join(result.FrontmatterLines, "\n"),
Description: c.extractDescription(result.Frontmatter),
Source: c.extractSource(result.Frontmatter),
TrackerID: trackerID,
ImportedFiles: importsResult.ImportedFiles,
IncludedFiles: allIncludedFiles,
ImportInputs: importsResult.ImportInputs,
Tools: tools,
ParsedTools: NewTools(tools),
Runtimes: runtimes,
MarkdownContent: markdownContent,
AI: engineSetting,
EngineConfig: engineConfig,
AgentFile: importsResult.AgentFile,
NetworkPermissions: networkPermissions,
SandboxConfig: sandboxConfig,
NeedsTextOutput: needsTextOutput,
SafetyPrompt: safetyPrompt,
ToolsTimeout: toolsTimeout,
ToolsStartupTimeout: toolsStartupTimeout,
TrialMode: c.trialMode,
TrialLogicalRepo: c.trialLogicalRepoSlug,
GitHubToken: extractStringFromMap(result.Frontmatter, "github-token", nil),
StrictMode: c.strictMode,
SecretMasking: secretMasking,
}
// Use shared action cache and resolver from the compiler
// This ensures cache is shared across all workflows during compilation
actionCache, actionResolver := c.getSharedActionResolver()
workflowData.ActionCache = actionCache
workflowData.ActionResolver = actionResolver
// Extract YAML sections from frontmatter - use direct frontmatter map extraction
// to avoid issues with nested keys (e.g., tools.mcps.*.env being confused with top-level env)
workflowData.On = c.extractTopLevelYAMLSection(result.Frontmatter, "on")
workflowData.Permissions = c.extractPermissions(result.Frontmatter)
workflowData.Network = c.extractTopLevelYAMLSection(result.Frontmatter, "network")
workflowData.Concurrency = c.extractTopLevelYAMLSection(result.Frontmatter, "concurrency")
workflowData.RunName = c.extractTopLevelYAMLSection(result.Frontmatter, "run-name")
workflowData.Env = c.extractTopLevelYAMLSection(result.Frontmatter, "env")
workflowData.Features = c.extractFeatures(result.Frontmatter)
workflowData.If = c.extractIfCondition(result.Frontmatter)
// Prefer timeout-minutes (new) over timeout_minutes (deprecated)
workflowData.TimeoutMinutes = c.extractTopLevelYAMLSection(result.Frontmatter, "timeout-minutes")
if workflowData.TimeoutMinutes == "" {
workflowData.TimeoutMinutes = c.extractTopLevelYAMLSection(result.Frontmatter, "timeout_minutes")
if workflowData.TimeoutMinutes != "" {
// Emit deprecation warning
fmt.Fprintln(os.Stderr, console.FormatWarningMessage("Field 'timeout_minutes' is deprecated. Please use 'timeout-minutes' instead to follow GitHub Actions naming convention."))
}
}
workflowData.CustomSteps = c.extractTopLevelYAMLSection(result.Frontmatter, "steps")
// Merge imported steps if any
if importsResult.MergedSteps != "" {
// Parse imported steps from YAML array
var importedSteps []any
if err := yaml.Unmarshal([]byte(importsResult.MergedSteps), &importedSteps); err == nil {
// Apply action pinning to imported steps
importedSteps = ApplyActionPinsToSteps(importedSteps, workflowData)
// If there are main workflow steps, parse and merge them
if workflowData.CustomSteps != "" {
// Parse main workflow steps (format: "steps:\n - ...")
var mainStepsWrapper map[string]any
if err := yaml.Unmarshal([]byte(workflowData.CustomSteps), &mainStepsWrapper); err == nil {
if mainStepsVal, hasSteps := mainStepsWrapper["steps"]; hasSteps {
if mainSteps, ok := mainStepsVal.([]any); ok {
// Apply action pinning to main steps
mainSteps = ApplyActionPinsToSteps(mainSteps, workflowData)
// Prepend imported steps to main steps
allSteps := append(importedSteps, mainSteps...)
// Convert back to YAML with "steps:" wrapper
stepsWrapper := map[string]any{"steps": allSteps}
stepsYAML, err := yaml.Marshal(stepsWrapper)
if err == nil {
// Remove quotes from uses values with version comments
workflowData.CustomSteps = unquoteUsesWithComments(string(stepsYAML))
}
}
}
}
} else {
// Only imported steps exist, wrap in "steps:" format
stepsWrapper := map[string]any{"steps": importedSteps}
stepsYAML, err := yaml.Marshal(stepsWrapper)
if err == nil {
// Remove quotes from uses values with version comments
workflowData.CustomSteps = unquoteUsesWithComments(string(stepsYAML))
}
}
}
} else if workflowData.CustomSteps != "" {
// No imported steps, but there are main steps - still apply pinning
var mainStepsWrapper map[string]any
if err := yaml.Unmarshal([]byte(workflowData.CustomSteps), &mainStepsWrapper); err == nil {
if mainStepsVal, hasSteps := mainStepsWrapper["steps"]; hasSteps {
if mainSteps, ok := mainStepsVal.([]any); ok {
// Apply action pinning to main steps
mainSteps = ApplyActionPinsToSteps(mainSteps, workflowData)
// Convert back to YAML with "steps:" wrapper
stepsWrapper := map[string]any{"steps": mainSteps}
stepsYAML, err := yaml.Marshal(stepsWrapper)
if err == nil {
// Remove quotes from uses values with version comments
workflowData.CustomSteps = unquoteUsesWithComments(string(stepsYAML))
}
}
}
}
}
workflowData.PostSteps = c.extractTopLevelYAMLSection(result.Frontmatter, "post-steps")
// Apply action pinning to post-steps if any
if workflowData.PostSteps != "" {
var postStepsWrapper map[string]any
if err := yaml.Unmarshal([]byte(workflowData.PostSteps), &postStepsWrapper); err == nil {
if postStepsVal, hasPostSteps := postStepsWrapper["post-steps"]; hasPostSteps {
if postSteps, ok := postStepsVal.([]any); ok {
// Apply action pinning to post steps
postSteps = ApplyActionPinsToSteps(postSteps, workflowData)
// Convert back to YAML with "post-steps:" wrapper
stepsWrapper := map[string]any{"post-steps": postSteps}
stepsYAML, err := yaml.Marshal(stepsWrapper)
if err == nil {
// Remove quotes from uses values with version comments
workflowData.PostSteps = unquoteUsesWithComments(string(stepsYAML))
}
}
}
}
}
workflowData.RunsOn = c.extractTopLevelYAMLSection(result.Frontmatter, "runs-on")
workflowData.Environment = c.extractTopLevelYAMLSection(result.Frontmatter, "environment")
workflowData.Container = c.extractTopLevelYAMLSection(result.Frontmatter, "container")
workflowData.Services = c.extractTopLevelYAMLSection(result.Frontmatter, "services")
// Merge imported services if any
if importsResult.MergedServices != "" {
// Parse imported services from YAML
var importedServices map[string]any
if err := yaml.Unmarshal([]byte(importsResult.MergedServices), &importedServices); err == nil {
// If there are main workflow services, parse and merge them
if workflowData.Services != "" {
// Parse main workflow services
var mainServicesWrapper map[string]any
if err := yaml.Unmarshal([]byte(workflowData.Services), &mainServicesWrapper); err == nil {
if mainServices, ok := mainServicesWrapper["services"].(map[string]any); ok {
// Merge: main workflow services take precedence over imported
for key, value := range importedServices {
if _, exists := mainServices[key]; !exists {
mainServices[key] = value
}
}
// Convert back to YAML with "services:" wrapper
servicesWrapper := map[string]any{"services": mainServices}
servicesYAML, err := yaml.Marshal(servicesWrapper)
if err == nil {
workflowData.Services = string(servicesYAML)
}
}
}
} else {
// Only imported services exist, wrap in "services:" format
servicesWrapper := map[string]any{"services": importedServices}
servicesYAML, err := yaml.Marshal(servicesWrapper)
if err == nil {
workflowData.Services = string(servicesYAML)
}
}
}
}
workflowData.Cache = c.extractTopLevelYAMLSection(result.Frontmatter, "cache")
// Extract cache-memory config and check for errors
// Use the backward compatibility wrapper to avoid changing all call sites at once
cacheMemoryConfig, err := c.extractCacheMemoryConfigFromMap(tools) // Use merged tools to support imports
if err != nil {
return nil, err
}
workflowData.CacheMemoryConfig = cacheMemoryConfig
// Extract repo-memory config and check for errors
toolsConfig, err := ParseToolsConfig(tools)
if err != nil {
return nil, err
}
repoMemoryConfig, err := c.extractRepoMemoryConfig(toolsConfig)
if err != nil {
return nil, err
}
workflowData.RepoMemoryConfig = repoMemoryConfig
// Process stop-after configuration from the on: section
err = c.processStopAfterConfiguration(result.Frontmatter, workflowData, markdownPath)
if err != nil {
return nil, err
}
// Process skip-if-match configuration from the on: section
err = c.processSkipIfMatchConfiguration(result.Frontmatter, workflowData)
if err != nil {
return nil, err
}
// Process manual-approval configuration from the on: section
err = c.processManualApprovalConfiguration(result.Frontmatter, workflowData)
if err != nil {
return nil, err
}
workflowData.Command, workflowData.CommandEvents = c.extractCommandConfig(result.Frontmatter)
workflowData.Jobs = c.extractJobsFromFrontmatter(result.Frontmatter)
workflowData.Roles = c.extractRoles(result.Frontmatter)
workflowData.Bots = c.extractBots(result.Frontmatter)
// Use the already extracted output configuration
workflowData.SafeOutputs = safeOutputs
// Extract safe-inputs configuration
workflowData.SafeInputs = c.extractSafeInputsConfig(result.Frontmatter)
// Merge safe-inputs from imports
if len(importsResult.MergedSafeInputs) > 0 {
workflowData.SafeInputs = c.mergeSafeInputs(workflowData.SafeInputs, importsResult.MergedSafeInputs)
}
// Extract safe-jobs from safe-outputs.jobs location
topSafeJobs := extractSafeJobsFromFrontmatter(result.Frontmatter)
// Process @include directives to extract additional safe-outputs configurations
includedSafeOutputsConfigs, err := parser.ExpandIncludesForSafeOutputs(result.Markdown, markdownDir)
if err != nil {
return nil, fmt.Errorf("failed to expand includes for safe-outputs: %w", err)
}
// Combine imported safe-outputs with included safe-outputs
var allSafeOutputsConfigs []string
if len(importsResult.MergedSafeOutputs) > 0 {
allSafeOutputsConfigs = append(allSafeOutputsConfigs, importsResult.MergedSafeOutputs...)
}
if len(includedSafeOutputsConfigs) > 0 {
allSafeOutputsConfigs = append(allSafeOutputsConfigs, includedSafeOutputsConfigs...)
}
// Merge safe-jobs from all safe-outputs configurations (imported and included)
includedSafeJobs, err := c.mergeSafeJobsFromIncludedConfigs(topSafeJobs, allSafeOutputsConfigs)
if err != nil {
return nil, fmt.Errorf("failed to merge safe-jobs from includes: %w", err)
}
// Merge app configuration from included safe-outputs configurations
includedApp, err := c.mergeAppFromIncludedConfigs(workflowData.SafeOutputs, allSafeOutputsConfigs)
if err != nil {
return nil, fmt.Errorf("failed to merge app from includes: %w", err)
}
// Ensure SafeOutputs exists and populate the Jobs field with merged jobs
if workflowData.SafeOutputs == nil && len(includedSafeJobs) > 0 {
workflowData.SafeOutputs = &SafeOutputsConfig{}
}
// Always use the merged includedSafeJobs as it contains both main and imported jobs
// The mergeSafeJobsFromIncludedConfigs function already handles conflict detection
if workflowData.SafeOutputs != nil && len(includedSafeJobs) > 0 {
workflowData.SafeOutputs.Jobs = includedSafeJobs
}
// Populate the App field if it's not set in the top-level workflow but is in an included config
if workflowData.SafeOutputs != nil && workflowData.SafeOutputs.App == nil && includedApp != nil {
workflowData.SafeOutputs.App = includedApp
}
// Merge safe-outputs types from imports (create-issue, add-comment, etc.)
mergedSafeOutputs, err := c.MergeSafeOutputs(workflowData.SafeOutputs, allSafeOutputsConfigs)
if err != nil {
return nil, fmt.Errorf("failed to merge safe-outputs from imports: %w", err)
}
workflowData.SafeOutputs = mergedSafeOutputs
// Parse the "on" section for command triggers, reactions, and other events
err = c.parseOnSection(result.Frontmatter, workflowData, markdownPath)
if err != nil {
return nil, err
}
// Apply defaults
c.applyDefaults(workflowData, markdownPath)
// Apply pull request draft filter if specified
c.applyPullRequestDraftFilter(workflowData, result.Frontmatter)
// Apply pull request fork filter if specified
c.applyPullRequestForkFilter(workflowData, result.Frontmatter)
// Apply label filter if specified
c.applyLabelFilter(workflowData, result.Frontmatter)
return workflowData, nil
}
// detectTextOutputUsage checks if the markdown content uses ${{ needs.activation.outputs.text }}
func (c *Compiler) detectTextOutputUsage(markdownContent string) bool {
// Check for the specific GitHub Actions expression
hasUsage := strings.Contains(markdownContent, "${{ needs.activation.outputs.text }}")
detectionLog.Printf("Detected usage of activation.outputs.text: %v", hasUsage)
return hasUsage
}