-
Notifications
You must be signed in to change notification settings - Fork 354
Expand file tree
/
Copy pathcompiler_orchestrator_workflow.go
More file actions
1073 lines (948 loc) · 43 KB
/
compiler_orchestrator_workflow.go
File metadata and controls
1073 lines (948 loc) · 43 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
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
package workflow
import (
"encoding/json"
"fmt"
"maps"
"os"
"strings"
"github.com/github/gh-aw/pkg/console"
"github.com/github/gh-aw/pkg/logger"
"github.com/github/gh-aw/pkg/parser"
"github.com/goccy/go-yaml"
)
var orchestratorWorkflowLog = logger.New("workflow:compiler_orchestrator_workflow")
// ParseWorkflowFile parses a workflow markdown file and returns a WorkflowData structure.
// This is the main orchestration function that coordinates all compilation phases.
func (c *Compiler) ParseWorkflowFile(markdownPath string) (*WorkflowData, error) {
orchestratorWorkflowLog.Printf("Starting workflow file parsing: %s", markdownPath)
// Parse frontmatter section
parseResult, err := c.parseFrontmatterSection(markdownPath)
if err != nil {
return nil, err
}
// Handle shared workflows
if parseResult.isSharedWorkflow {
return nil, &SharedWorkflowError{Path: parseResult.cleanPath}
}
// Unpack parse result for convenience
cleanPath := parseResult.cleanPath
content := parseResult.content
result := parseResult.frontmatterResult
markdownDir := parseResult.markdownDir
// Setup engine and process imports
engineSetup, err := c.setupEngineAndImports(result, cleanPath, content, markdownDir)
if err != nil {
// Wrap unformatted errors with file location. Errors produced by
// formatCompilerError/formatCompilerErrorWithPosition are already
// console-formatted and must not be double-wrapped.
if isFormattedCompilerError(err) {
return nil, err
}
// Try to point at the exact line of the "engine:" field so the user can
// navigate directly to the problem location.
engineLine := findFrontmatterFieldLine(result.FrontmatterLines, result.FrontmatterStart, "engine")
if engineLine > 0 {
// Read source context lines (±3 lines around the error) for Rust-style rendering
contextLines := readSourceContextLines(content, engineLine)
return nil, formatCompilerErrorWithContext(cleanPath, engineLine, 1, "error", err.Error(), err, contextLines)
}
return nil, formatCompilerError(cleanPath, "error", err.Error(), err)
}
// Process tools and markdown
toolsResult, err := c.processToolsAndMarkdown(result, cleanPath, markdownDir, engineSetup.agenticEngine, engineSetup.engineSetting, engineSetup.importsResult)
if err != nil {
if isFormattedCompilerError(err) {
return nil, err
}
return nil, formatCompilerError(cleanPath, "error", err.Error(), err)
}
// Build initial workflow data structure
workflowData := c.buildInitialWorkflowData(result, toolsResult, engineSetup, engineSetup.importsResult)
// Store a stable workflow identifier derived from the file name.
workflowData.WorkflowID = GetWorkflowIDFromPath(cleanPath)
// Validate run-install-scripts setting (warning in non-strict mode, error in strict mode)
if err := c.validateRunInstallScripts(workflowData); err != nil {
return nil, fmt.Errorf("%s: %w", cleanPath, err)
}
// Validate engine version: warn when engine.version is explicitly set to "latest"
if err := c.validateEngineVersion(workflowData); err != nil {
return nil, fmt.Errorf("%s: %w", cleanPath, err)
}
// Validate that inlined-imports is not used with agent file imports.
// Agent files require runtime access and cannot be resolved without sources.
if workflowData.InlinedImports && engineSetup.importsResult.AgentFile != "" {
return nil, formatCompilerError(cleanPath, "error",
fmt.Sprintf("inlined-imports cannot be used with agent file imports: '%s'. "+
"Agent files require runtime access and will not be resolved without sources. "+
"Remove 'inlined-imports: true' or do not import agent files.",
engineSetup.importsResult.AgentFile), nil)
}
// Validate bash tool configuration BEFORE applying defaults
// This must happen before applyDefaults() which converts nil bash to default commands
if err := validateBashToolConfig(workflowData.ParsedTools, workflowData.Name); err != nil {
return nil, fmt.Errorf("%s: %w", cleanPath, err)
}
// Validate GitHub tool configuration
if err := validateGitHubToolConfig(workflowData.ParsedTools, workflowData.Name); err != nil {
return nil, fmt.Errorf("%s: %w", cleanPath, err)
}
// Validate GitHub tool read-only configuration
if err := validateGitHubReadOnly(workflowData.ParsedTools, workflowData.Name); err != nil {
return nil, fmt.Errorf("%s: %w", cleanPath, err)
}
// Validate GitHub guard policy configuration
if err := validateGitHubGuardPolicy(workflowData.ParsedTools, workflowData.Name); err != nil {
return nil, fmt.Errorf("%s: %w", cleanPath, err)
}
// Validate integrity-reactions feature configuration
var gatewayConfig *MCPGatewayRuntimeConfig
if workflowData.SandboxConfig != nil {
gatewayConfig = workflowData.SandboxConfig.MCP
}
if err := validateIntegrityReactions(workflowData.ParsedTools, workflowData.Name, workflowData, gatewayConfig); err != nil {
return nil, fmt.Errorf("%s: %w", cleanPath, err)
}
// Use shared action cache and resolver from the compiler
actionCache, actionResolver := c.getSharedActionResolver()
workflowData.ActionCache = actionCache
workflowData.ActionResolver = actionResolver
workflowData.ActionPinWarnings = c.actionPinWarnings
// Extract YAML configuration sections from frontmatter
c.extractYAMLSections(result.Frontmatter, workflowData)
// Merge observability config from imports into RawFrontmatter so that injectOTLPConfig
// can see an OTLP endpoint defined in an imported workflow (first-wins from imports).
if obs := engineSetup.importsResult.MergedObservability; obs != "" {
if _, hasObs := workflowData.RawFrontmatter["observability"]; !hasObs {
var obsMap map[string]any
if err := json.Unmarshal([]byte(obs), &obsMap); err == nil {
workflowData.RawFrontmatter["observability"] = obsMap
orchestratorWorkflowLog.Printf("Merged observability config from imports into RawFrontmatter")
}
}
}
// Inject OTLP configuration: add endpoint domain to firewall allowlist and
// set OTEL env vars in the workflow env block (no-op when not configured).
c.injectOTLPConfig(workflowData)
// Merge features from imports
if len(engineSetup.importsResult.MergedFeatures) > 0 {
mergedFeatures, err := c.MergeFeatures(workflowData.Features, engineSetup.importsResult.MergedFeatures)
if err != nil {
return nil, fmt.Errorf("failed to merge features from imports: %w", err)
}
workflowData.Features = mergedFeatures
}
// Process and merge custom steps with imported steps
c.processAndMergeSteps(result.Frontmatter, workflowData, engineSetup.importsResult)
// Process and merge pre-steps
c.processAndMergePreSteps(result.Frontmatter, workflowData, engineSetup.importsResult)
// Process and merge post-steps
c.processAndMergePostSteps(result.Frontmatter, workflowData, engineSetup.importsResult)
// Process and merge services
c.processAndMergeServices(result.Frontmatter, workflowData, engineSetup.importsResult)
// Extract additional configurations (cache, mcp-scripts, safe-outputs, etc.)
if err := c.extractAdditionalConfigurations(
result.Frontmatter,
toolsResult.tools,
markdownDir,
workflowData,
engineSetup.importsResult,
result.Markdown,
toolsResult.safeOutputs,
); err != nil {
return nil, err
}
// Note: Git commands are automatically injected when safe-outputs needs them (see compiler_safe_outputs.go)
// No validation needed here - the compiler handles adding git to bash allowlist
// Process on section configuration and apply filters
if err := c.processOnSectionAndFilters(result.Frontmatter, workflowData, cleanPath); err != nil {
return nil, err
}
orchestratorWorkflowLog.Printf("Workflow file parsing completed successfully: %s", markdownPath)
return workflowData, nil
}
// buildInitialWorkflowData creates the initial WorkflowData struct with basic fields populated
func (c *Compiler) buildInitialWorkflowData(
result *parser.FrontmatterResult,
toolsResult *toolsProcessingResult,
engineSetup *engineSetupResult,
importsResult *parser.ImportsResult,
) *WorkflowData {
orchestratorWorkflowLog.Print("Building initial workflow data")
inlinedImports := resolveInlinedImports(result.Frontmatter)
// When inlined-imports is true, agent file content is already inlined via ImportPaths → step 1b.
// Clear AgentFile/AgentImportSpec so engines don't read it from disk separately at runtime.
agentFile := importsResult.AgentFile
agentImportSpec := importsResult.AgentImportSpec
if inlinedImports {
agentFile = ""
agentImportSpec = ""
}
workflowData := &WorkflowData{
Name: toolsResult.workflowName,
FrontmatterName: toolsResult.frontmatterName,
FrontmatterYAML: strings.Join(result.FrontmatterLines, "\n"),
Description: c.extractDescription(result.Frontmatter),
Source: c.extractSource(result.Frontmatter),
TrackerID: toolsResult.trackerID,
ImportedFiles: importsResult.ImportedFiles,
ImportedMarkdown: toolsResult.importedMarkdown, // Only imports WITH inputs
ImportPaths: toolsResult.importPaths, // Import paths for runtime-import macros (imports without inputs)
MainWorkflowMarkdown: toolsResult.mainWorkflowMarkdown,
IncludedFiles: toolsResult.allIncludedFiles,
ImportInputs: importsResult.ImportInputs,
Tools: toolsResult.tools,
ParsedTools: NewTools(toolsResult.tools),
Runtimes: toolsResult.runtimes,
RunInstallScripts: toolsResult.runInstallScripts,
MarkdownContent: toolsResult.markdownContent,
AI: engineSetup.engineSetting,
EngineConfig: engineSetup.engineConfig,
AgentFile: agentFile,
AgentImportSpec: agentImportSpec,
RepositoryImports: importsResult.RepositoryImports,
NetworkPermissions: engineSetup.networkPermissions,
SandboxConfig: applySandboxDefaults(engineSetup.sandboxConfig, engineSetup.engineConfig),
NeedsTextOutput: toolsResult.needsTextOutput,
ToolsTimeout: toolsResult.toolsTimeout,
ToolsStartupTimeout: toolsResult.toolsStartupTimeout,
TrialMode: c.trialMode,
TrialLogicalRepo: c.trialLogicalRepoSlug,
StrictMode: c.strictMode,
SecretMasking: toolsResult.secretMasking,
ParsedFrontmatter: toolsResult.parsedFrontmatter,
RawFrontmatter: result.Frontmatter,
ResolvedMCPServers: toolsResult.resolvedMCPServers,
HasExplicitGitHubTool: toolsResult.hasExplicitGitHubTool,
ActionMode: c.actionMode,
InlinedImports: inlinedImports,
EngineConfigSteps: engineSetup.configSteps,
}
// Populate checkout configs from parsed frontmatter.
// Fall back to raw frontmatter parsing when full ParseFrontmatterConfig fails
// (e.g. due to unrecognised tool config shapes like bash: ["*"]).
if toolsResult.parsedFrontmatter != nil {
workflowData.CheckoutConfigs = toolsResult.parsedFrontmatter.CheckoutConfigs
workflowData.CheckoutDisabled = toolsResult.parsedFrontmatter.CheckoutDisabled
} else if rawCheckout, ok := result.Frontmatter["checkout"]; ok {
if checkoutValue, ok := rawCheckout.(bool); ok && !checkoutValue {
workflowData.CheckoutDisabled = true
} else if configs, err := ParseCheckoutConfigs(rawCheckout); err == nil {
workflowData.CheckoutConfigs = configs
}
}
// Populate check-for-updates flag: disabled when check-for-updates: false is set in frontmatter.
if toolsResult.parsedFrontmatter != nil && toolsResult.parsedFrontmatter.UpdateCheck != nil {
workflowData.UpdateCheckDisabled = !*toolsResult.parsedFrontmatter.UpdateCheck
} else if rawVal, ok := result.Frontmatter["check-for-updates"]; ok {
if boolVal, ok := rawVal.(bool); ok && !boolVal {
workflowData.UpdateCheckDisabled = true
}
}
// Populate stale-check flag: disabled when on.stale-check: false is set in frontmatter.
if onVal, ok := result.Frontmatter["on"]; ok {
if onMap, ok := onVal.(map[string]any); ok {
if staleCheck, ok := onMap["stale-check"]; ok {
if boolVal, ok := staleCheck.(bool); ok && !boolVal {
workflowData.StaleCheckDisabled = true
}
}
}
}
return workflowData
}
// resolveInlinedImports returns true if inlined-imports is enabled.
// It reads the value directly from the raw (pre-parsed) frontmatter map, which is always
// populated regardless of whether ParseFrontmatterConfig succeeded.
func resolveInlinedImports(rawFrontmatter map[string]any) bool {
return ParseBoolFromConfig(rawFrontmatter, "inlined-imports", nil)
}
// extractYAMLSections extracts YAML configuration sections from frontmatter
func (c *Compiler) extractYAMLSections(frontmatter map[string]any, workflowData *WorkflowData) {
orchestratorWorkflowLog.Print("Extracting YAML sections from frontmatter")
workflowData.On = c.extractTopLevelYAMLSection(frontmatter, "on")
workflowData.HasDispatchItemNumber = extractDispatchItemNumber(frontmatter)
workflowData.Permissions = c.extractPermissions(frontmatter)
workflowData.Network = c.extractTopLevelYAMLSection(frontmatter, "network")
workflowData.ConcurrencyJobDiscriminator = extractConcurrencyJobDiscriminator(frontmatter)
workflowData.Concurrency = c.extractConcurrencySection(frontmatter)
workflowData.RunName = c.extractTopLevelYAMLSection(frontmatter, "run-name")
workflowData.Env = c.extractTopLevelYAMLSection(frontmatter, "env")
workflowData.Features = c.extractFeatures(frontmatter)
workflowData.If = c.extractIfCondition(frontmatter)
// Extract timeout-minutes (canonical form)
workflowData.TimeoutMinutes = c.extractTopLevelYAMLSection(frontmatter, "timeout-minutes")
workflowData.RunsOn = c.extractTopLevelYAMLSection(frontmatter, "runs-on")
// Extract runs-on-slim as a plain string (no YAML formatting needed)
if v, ok := frontmatter["runs-on-slim"]; ok {
if s, ok := v.(string); ok {
workflowData.RunsOnSlim = s
}
}
workflowData.Environment = c.extractTopLevelYAMLSection(frontmatter, "environment")
workflowData.Container = c.extractTopLevelYAMLSection(frontmatter, "container")
workflowData.Cache = c.extractTopLevelYAMLSection(frontmatter, "cache")
}
// extractConcurrencyJobDiscriminator reads the job-discriminator value from the
// frontmatter concurrency block without modifying the original map.
// Returns the discriminator expression string or empty string if not present.
func extractConcurrencyJobDiscriminator(frontmatter map[string]any) string {
concurrencyRaw, ok := frontmatter["concurrency"]
if !ok {
return ""
}
concurrencyMap, ok := concurrencyRaw.(map[string]any)
if !ok {
return ""
}
discriminator, ok := concurrencyMap["job-discriminator"]
if !ok {
return ""
}
discriminatorStr, ok := discriminator.(string)
if !ok {
return ""
}
return discriminatorStr
}
// extractConcurrencySection extracts the workflow-level concurrency YAML section,
// stripping the gh-aw-specific job-discriminator field so it does not appear in
// the compiled lock file (which must be valid GitHub Actions YAML).
func (c *Compiler) extractConcurrencySection(frontmatter map[string]any) string {
concurrencyRaw, ok := frontmatter["concurrency"]
if !ok {
return ""
}
concurrencyMap, ok := concurrencyRaw.(map[string]any)
if !ok || len(concurrencyMap) == 0 {
// String or empty format: serialize as-is (no job-discriminator possible)
return c.extractTopLevelYAMLSection(frontmatter, "concurrency")
}
_, hasDiscriminator := concurrencyMap["job-discriminator"]
if !hasDiscriminator {
return c.extractTopLevelYAMLSection(frontmatter, "concurrency")
}
// Build a copy of the concurrency map without job-discriminator for serialization.
// Use len(concurrencyMap) for capacity: at most one entry (job-discriminator) will be
// omitted, so this is a slight over-allocation that avoids a subtle negative-capacity
// edge case if job-discriminator were the only key.
cleanMap := make(map[string]any, len(concurrencyMap))
for k, v := range concurrencyMap {
if k != "job-discriminator" {
cleanMap[k] = v
}
}
// When job-discriminator is the only field, there is no user-specified workflow-level
// group to emit; return empty so the compiler can generate the default concurrency.
if len(cleanMap) == 0 {
return ""
}
// Use a minimal temporary frontmatter containing only the concurrency key to avoid
// copying the entire (potentially large) frontmatter map.
return c.extractTopLevelYAMLSection(map[string]any{"concurrency": cleanMap}, "concurrency")
}
// extractDispatchItemNumber reports whether the frontmatter's on.workflow_dispatch
// trigger exposes an item_number input. This is the signature produced by the label
// trigger shorthand (e.g. "on: pull_request labeled my-label"). Reading the
// structured map avoids re-parsing the rendered YAML string later.
func extractDispatchItemNumber(frontmatter map[string]any) bool {
onVal, ok := frontmatter["on"]
if !ok {
return false
}
onMap, ok := onVal.(map[string]any)
if !ok {
return false
}
wdVal, ok := onMap["workflow_dispatch"]
if !ok {
return false
}
wdMap, ok := wdVal.(map[string]any)
if !ok {
return false
}
inputsVal, ok := wdMap["inputs"]
if !ok {
return false
}
inputsMap, ok := inputsVal.(map[string]any)
if !ok {
return false
}
_, ok = inputsMap["item_number"]
return ok
}
// processAndMergeSteps handles the merging of imported steps with main workflow steps
func (c *Compiler) processAndMergeSteps(frontmatter map[string]any, workflowData *WorkflowData, importsResult *parser.ImportsResult) {
orchestratorWorkflowLog.Print("Processing and merging custom steps")
workflowData.CustomSteps = c.extractTopLevelYAMLSection(frontmatter, "steps")
// Parse copilot-setup-steps if present (these go at the start)
var copilotSetupSteps []any
if importsResult.CopilotSetupSteps != "" {
if err := yaml.Unmarshal([]byte(importsResult.CopilotSetupSteps), &copilotSetupSteps); err != nil {
orchestratorWorkflowLog.Printf("Failed to unmarshal copilot-setup steps: %v", err)
} else {
// Convert to typed steps for action pinning
typedCopilotSteps, err := SliceToSteps(copilotSetupSteps)
if err != nil {
orchestratorWorkflowLog.Printf("Failed to convert copilot-setup steps to typed steps: %v", err)
} else {
// Apply action pinning to copilot-setup steps
typedCopilotSteps = ApplyActionPinsToTypedSteps(typedCopilotSteps, workflowData)
// Convert back to []any for YAML marshaling
copilotSetupSteps = StepsToSlice(typedCopilotSteps)
}
}
}
// Parse other imported steps if present (these go after copilot-setup but before main steps)
var otherImportedSteps []any
if importsResult.MergedSteps != "" {
if err := yaml.Unmarshal([]byte(importsResult.MergedSteps), &otherImportedSteps); err == nil {
// Convert to typed steps for action pinning
typedOtherSteps, err := SliceToSteps(otherImportedSteps)
if err != nil {
orchestratorWorkflowLog.Printf("Failed to convert other imported steps to typed steps: %v", err)
} else {
// Apply action pinning to other imported steps
typedOtherSteps = ApplyActionPinsToTypedSteps(typedOtherSteps, workflowData)
// Convert back to []any for YAML marshaling
otherImportedSteps = StepsToSlice(typedOtherSteps)
}
}
}
// If there are main workflow steps, parse them
var mainSteps []any
if workflowData.CustomSteps != "" {
var mainStepsWrapper map[string]any
if err := yaml.Unmarshal([]byte(workflowData.CustomSteps), &mainStepsWrapper); err == nil {
if mainStepsVal, hasSteps := mainStepsWrapper["steps"]; hasSteps {
if steps, ok := mainStepsVal.([]any); ok {
mainSteps = steps
// Convert to typed steps for action pinning
typedMainSteps, err := SliceToSteps(mainSteps)
if err != nil {
orchestratorWorkflowLog.Printf("Failed to convert main steps to typed steps: %v", err)
} else {
// Apply action pinning to main steps
typedMainSteps = ApplyActionPinsToTypedSteps(typedMainSteps, workflowData)
// Convert back to []any for YAML marshaling
mainSteps = StepsToSlice(typedMainSteps)
}
}
}
}
}
// Merge steps in the correct order:
// 1. copilot-setup-steps (at start)
// 2. other imported steps (after copilot-setup)
// 3. main frontmatter steps (last)
var allSteps []any
if len(copilotSetupSteps) > 0 || len(mainSteps) > 0 || len(otherImportedSteps) > 0 {
allSteps = append(allSteps, copilotSetupSteps...)
allSteps = append(allSteps, otherImportedSteps...)
allSteps = append(allSteps, 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))
}
}
}
// processAndMergePreSteps handles the processing and merging of pre-steps with action pinning.
// Pre-steps run at the very beginning of the agent job, before checkout and the subsequent
// built-in steps, allowing users to mint tokens or perform other setup that must happen
// before the repository is checked out. Imported pre-steps are merged before the main
// workflow's pre-steps so that the main workflow can override or extend the imports.
func (c *Compiler) processAndMergePreSteps(frontmatter map[string]any, workflowData *WorkflowData, importsResult *parser.ImportsResult) {
orchestratorWorkflowLog.Print("Processing and merging pre-steps")
mainPreStepsYAML := c.extractTopLevelYAMLSection(frontmatter, "pre-steps")
// Parse imported pre-steps if present (these go before the main workflow's pre-steps)
var importedPreSteps []any
if importsResult.MergedPreSteps != "" {
if err := yaml.Unmarshal([]byte(importsResult.MergedPreSteps), &importedPreSteps); err != nil {
orchestratorWorkflowLog.Printf("Failed to unmarshal imported pre-steps: %v", err)
} else {
typedImported, err := SliceToSteps(importedPreSteps)
if err != nil {
orchestratorWorkflowLog.Printf("Failed to convert imported pre-steps to typed steps: %v", err)
} else {
typedImported = ApplyActionPinsToTypedSteps(typedImported, workflowData)
importedPreSteps = StepsToSlice(typedImported)
}
}
}
// Parse main workflow pre-steps if present
var mainPreSteps []any
if mainPreStepsYAML != "" {
var mainWrapper map[string]any
if err := yaml.Unmarshal([]byte(mainPreStepsYAML), &mainWrapper); err == nil {
if mainVal, ok := mainWrapper["pre-steps"]; ok {
if steps, ok := mainVal.([]any); ok {
mainPreSteps = steps
typedMain, err := SliceToSteps(mainPreSteps)
if err != nil {
orchestratorWorkflowLog.Printf("Failed to convert main pre-steps to typed steps: %v", err)
} else {
typedMain = ApplyActionPinsToTypedSteps(typedMain, workflowData)
mainPreSteps = StepsToSlice(typedMain)
}
}
}
}
}
// Merge in order: imported pre-steps first, then main workflow's pre-steps
var allPreSteps []any
if len(importedPreSteps) > 0 || len(mainPreSteps) > 0 {
allPreSteps = append(allPreSteps, importedPreSteps...)
allPreSteps = append(allPreSteps, mainPreSteps...)
stepsWrapper := map[string]any{"pre-steps": allPreSteps}
stepsYAML, err := yaml.Marshal(stepsWrapper)
if err == nil {
workflowData.PreSteps = unquoteUsesWithComments(string(stepsYAML))
}
}
}
// processAndMergePostSteps handles the processing and merging of post-steps with action pinning.
// Imported post-steps are appended after the main workflow's post-steps.
func (c *Compiler) processAndMergePostSteps(frontmatter map[string]any, workflowData *WorkflowData, importsResult *parser.ImportsResult) {
orchestratorWorkflowLog.Print("Processing and merging post-steps")
mainPostStepsYAML := c.extractTopLevelYAMLSection(frontmatter, "post-steps")
// Parse imported post-steps if present (these go after the main workflow's post-steps)
var importedPostSteps []any
if importsResult.MergedPostSteps != "" {
if err := yaml.Unmarshal([]byte(importsResult.MergedPostSteps), &importedPostSteps); err != nil {
orchestratorWorkflowLog.Printf("Failed to unmarshal imported post-steps: %v", err)
} else {
typedImported, err := SliceToSteps(importedPostSteps)
if err != nil {
orchestratorWorkflowLog.Printf("Failed to convert imported post-steps to typed steps: %v", err)
} else {
typedImported = ApplyActionPinsToTypedSteps(typedImported, workflowData)
importedPostSteps = StepsToSlice(typedImported)
}
}
}
// Parse main workflow post-steps if present
var mainPostSteps []any
if mainPostStepsYAML != "" {
var mainWrapper map[string]any
if err := yaml.Unmarshal([]byte(mainPostStepsYAML), &mainWrapper); err == nil {
if mainVal, ok := mainWrapper["post-steps"]; ok {
if steps, ok := mainVal.([]any); ok {
mainPostSteps = steps
typedMain, err := SliceToSteps(mainPostSteps)
if err != nil {
orchestratorWorkflowLog.Printf("Failed to convert main post-steps to typed steps: %v", err)
} else {
typedMain = ApplyActionPinsToTypedSteps(typedMain, workflowData)
mainPostSteps = StepsToSlice(typedMain)
}
}
}
}
}
// Merge in order: main workflow's post-steps first, then imported post-steps
var allPostSteps []any
if len(mainPostSteps) > 0 || len(importedPostSteps) > 0 {
allPostSteps = append(allPostSteps, mainPostSteps...)
allPostSteps = append(allPostSteps, importedPostSteps...)
stepsWrapper := map[string]any{"post-steps": allPostSteps}
stepsYAML, err := yaml.Marshal(stepsWrapper)
if err == nil {
workflowData.PostSteps = unquoteUsesWithComments(string(stepsYAML))
}
}
}
// processAndMergeServices handles the merging of imported services with main workflow services
func (c *Compiler) processAndMergeServices(frontmatter map[string]any, workflowData *WorkflowData, importsResult *parser.ImportsResult) {
orchestratorWorkflowLog.Print("Processing and merging services")
workflowData.Services = c.extractTopLevelYAMLSection(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)
}
}
}
}
// Extract service port expressions for AWF --allow-host-service-ports
if workflowData.Services != "" {
expressions, warnings := ExtractServicePortExpressions(workflowData.Services)
workflowData.ServicePortExpressions = expressions
for _, w := range warnings {
orchestratorWorkflowLog.Printf("Warning: %s", w)
fmt.Fprintln(os.Stderr, console.FormatWarningMessage(w))
c.IncrementWarningCount()
}
if expressions != "" {
orchestratorWorkflowLog.Printf("Extracted service port expressions: %s", expressions)
}
}
}
// mergeJobsFromYAMLImports merges jobs from imported YAML workflows with main workflow jobs
// Main workflow jobs take precedence over imported jobs (override behavior)
func (c *Compiler) mergeJobsFromYAMLImports(mainJobs map[string]any, mergedJobsJSON string) map[string]any {
orchestratorWorkflowLog.Print("Merging jobs from imported YAML workflows")
if mergedJobsJSON == "" || mergedJobsJSON == "{}" {
orchestratorWorkflowLog.Print("No imported jobs to merge")
return mainJobs
}
// Initialize result with main jobs or create empty map
result := make(map[string]any)
maps.Copy(result, mainJobs)
// Split by newlines to handle multiple JSON objects from different imports
lines := strings.Split(mergedJobsJSON, "\n")
orchestratorWorkflowLog.Printf("Processing %d job definition lines", len(lines))
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || line == "{}" {
continue
}
// Parse JSON line to map
var importedJobs map[string]any
if err := json.Unmarshal([]byte(line), &importedJobs); err != nil {
orchestratorWorkflowLog.Printf("Skipping malformed job entry: %v", err)
continue
}
// Merge jobs - main workflow jobs take precedence (don't override)
for jobName, jobConfig := range importedJobs {
if _, exists := result[jobName]; !exists {
orchestratorWorkflowLog.Printf("Adding imported job: %s", jobName)
result[jobName] = jobConfig
} else {
orchestratorWorkflowLog.Printf("Skipping imported job %s (already defined in main workflow)", jobName)
}
}
}
orchestratorWorkflowLog.Printf("Successfully merged jobs: total=%d, imported=%d", len(result), len(result)-len(mainJobs))
return result
}
// extractTopLevelGitHubApp extracts the 'github-app' field from the top-level frontmatter.
// This provides a single GitHub App configuration that serves as a fallback for all nested
// github-app token minting operations (on, safe-outputs, checkout, tools.github, dependencies).
func extractTopLevelGitHubApp(frontmatter map[string]any) *GitHubAppConfig {
appAny, ok := frontmatter["github-app"]
if !ok {
return nil
}
appMap, ok := appAny.(map[string]any)
if !ok {
return nil
}
app := parseAppConfig(appMap)
if app.AppID == "" || app.PrivateKey == "" {
return nil
}
return app
}
// resolveTopLevelGitHubApp resolves the top-level github-app for token minting fallback.
// Precedence:
// 1. Current workflow's top-level github-app (explicit override wins)
// 2. First top-level github-app found across imported shared workflows
// 3. Nil (no fallback configured)
func resolveTopLevelGitHubApp(frontmatter map[string]any, importsResult *parser.ImportsResult) *GitHubAppConfig {
if app := extractTopLevelGitHubApp(frontmatter); app != nil {
return app
}
if importsResult != nil && importsResult.MergedTopLevelGitHubApp != "" {
var appMap map[string]any
if err := json.Unmarshal([]byte(importsResult.MergedTopLevelGitHubApp), &appMap); err == nil {
app := parseAppConfig(appMap)
if app.AppID != "" && app.PrivateKey != "" {
orchestratorWorkflowLog.Print("Using top-level github-app from imported shared workflow")
return app
}
}
}
return nil
}
// topLevelFallbackNeeded reports whether the top-level github-app should be applied as a
// fallback for a given section. It returns true when the section has neither an explicit
// github-app nor an explicit github-token already configured.
//
// Rules (consistent across all sections):
// - If a section-specific github-app is set → keep it, no fallback needed.
// - If a section-specific github-token is set → keep it, no fallback needed (a token
// already provides the auth; injecting a github-app would silently change precedence).
// - Otherwise → apply the top-level fallback.
func topLevelFallbackNeeded(app *GitHubAppConfig, token string) bool {
return app == nil && token == ""
}
// applyTopLevelGitHubAppFallbacks applies the top-level github-app as a fallback for all
// nested github-app token minting operations when no section-specific github-app is configured.
// Precedence: section-specific github-app > section-specific github-token > top-level github-app.
//
// Every section uses topLevelFallbackNeeded to decide whether the fallback is required,
// ensuring consistent behaviour across all token-minting sites.
func applyTopLevelGitHubAppFallbacks(data *WorkflowData) {
fallback := data.TopLevelGitHubApp
if fallback == nil {
return
}
// Fallback for activation (on.github-app / on.github-token)
if topLevelFallbackNeeded(data.ActivationGitHubApp, data.ActivationGitHubToken) {
orchestratorWorkflowLog.Print("Applying top-level github-app fallback for activation")
data.ActivationGitHubApp = fallback
}
// Fallback for safe-outputs (safe-outputs.github-app / safe-outputs.github-token)
if data.SafeOutputs != nil && topLevelFallbackNeeded(data.SafeOutputs.GitHubApp, data.SafeOutputs.GitHubToken) {
orchestratorWorkflowLog.Print("Applying top-level github-app fallback for safe-outputs")
data.SafeOutputs.GitHubApp = fallback
}
// Fallback for checkout configs (checkout.github-app / checkout.github-token per entry)
for _, cfg := range data.CheckoutConfigs {
if topLevelFallbackNeeded(cfg.GitHubApp, cfg.GitHubToken) {
orchestratorWorkflowLog.Print("Applying top-level github-app fallback for checkout")
cfg.GitHubApp = fallback
}
}
// Fallback for tools.github (tools.github.github-app / tools.github.github-token).
// Also skipped when tools.github is explicitly disabled (github: false) — do not re-enable it.
if data.ParsedTools != nil && data.ParsedTools.GitHub != nil &&
topLevelFallbackNeeded(data.ParsedTools.GitHub.GitHubApp, data.ParsedTools.GitHub.GitHubToken) &&
data.Tools["github"] != false {
orchestratorWorkflowLog.Print("Applying top-level github-app fallback for tools.github")
data.ParsedTools.GitHub.GitHubApp = fallback
// Also update the raw tools map so applyDefaultTools (called from applyDefaults in
// processOnSectionAndFilters) does not lose the fallback when it rebuilds ParsedTools
// from the map.
appMap := map[string]any{
"app-id": fallback.AppID,
"private-key": fallback.PrivateKey,
}
if fallback.Owner != "" {
appMap["owner"] = fallback.Owner
}
if len(fallback.Repositories) > 0 {
repos := make([]any, len(fallback.Repositories))
for i, r := range fallback.Repositories {
repos[i] = r
}
appMap["repositories"] = repos
}
// Normalize data.Tools["github"] to a map so the github-app survives re-parsing.
// Configurations like `github: true` are normalized here rather than losing the fallback.
if github, ok := data.Tools["github"].(map[string]any); ok {
// Already a map; inject into existing settings.
github["github-app"] = appMap
} else {
// Non-map value (e.g. true) — create a fresh map.
data.Tools["github"] = map[string]any{"github-app": appMap}
}
}
}
// extractAdditionalConfigurations extracts cache-memory, repo-memory, mcp-scripts, and safe-outputs configurations
func (c *Compiler) extractAdditionalConfigurations(
frontmatter map[string]any,
tools map[string]any,
markdownDir string,
workflowData *WorkflowData,
importsResult *parser.ImportsResult,
markdown string,
safeOutputs *SafeOutputsConfig,
) error {
orchestratorWorkflowLog.Print("Extracting additional configurations")
// Extract cache-memory config and check for errors
cacheMemoryConfig, err := c.extractCacheMemoryConfigFromMap(tools)
if err != nil {
return err
}
workflowData.CacheMemoryConfig = cacheMemoryConfig
// Extract repo-memory config and check for errors
toolsConfig, err := ParseToolsConfig(tools)
if err != nil {
return err
}
repoMemoryConfig, err := c.extractRepoMemoryConfig(toolsConfig, workflowData.WorkflowID)
if err != nil {
return err
}
workflowData.RepoMemoryConfig = repoMemoryConfig
// Extract and process mcp-scripts and safe-outputs
workflowData.Command, workflowData.CommandEvents = c.extractCommandConfig(frontmatter)
workflowData.LabelCommand, workflowData.LabelCommandEvents, workflowData.LabelCommandRemoveLabel = c.extractLabelCommandConfig(frontmatter)
workflowData.Jobs = c.extractJobsFromFrontmatter(frontmatter)
// Merge jobs from imported YAML workflows
if importsResult.MergedJobs != "" && importsResult.MergedJobs != "{}" {
workflowData.Jobs = c.mergeJobsFromYAMLImports(workflowData.Jobs, importsResult.MergedJobs)
}
workflowData.Roles = c.extractRoles(frontmatter)
workflowData.Bots = c.extractBots(frontmatter)
workflowData.RateLimit = c.extractRateLimitConfig(frontmatter)
workflowData.SkipRoles = c.mergeSkipRoles(c.extractSkipRoles(frontmatter), importsResult.MergedSkipRoles)
workflowData.SkipBots = c.mergeSkipBots(c.extractSkipBots(frontmatter), importsResult.MergedSkipBots)
workflowData.ActivationGitHubToken = c.resolveActivationGitHubToken(frontmatter, importsResult)
workflowData.ActivationGitHubApp = c.resolveActivationGitHubApp(frontmatter, importsResult)
workflowData.TopLevelGitHubApp = resolveTopLevelGitHubApp(frontmatter, importsResult)
// Use the already extracted output configuration
workflowData.SafeOutputs = safeOutputs
// Extract mcp-scripts configuration
workflowData.MCPScripts = c.extractMCPScriptsConfig(frontmatter)
// Merge mcp-scripts from imports
if len(importsResult.MergedMCPScripts) > 0 {
workflowData.MCPScripts = c.mergeMCPScripts(workflowData.MCPScripts, importsResult.MergedMCPScripts)
}
// Extract safe-jobs from safe-outputs.jobs location
topSafeJobs := extractSafeJobsFromFrontmatter(frontmatter)
// Process @include directives to extract additional safe-outputs configurations
includedSafeOutputsConfigs, err := parser.ExpandIncludesForSafeOutputs(markdown, markdownDir)
if err != nil {
return 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 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 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
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.GitHubApp == nil && includedApp != nil {
workflowData.SafeOutputs.GitHubApp = includedApp
}
// Merge safe-outputs types from imports.
// Pass the raw safe-outputs map from frontmatter so MergeSafeOutputs can distinguish
// between types the user explicitly configured and types that were auto-defaulted by
// extractSafeOutputsConfig. Without this, auto-defaults (e.g. threat-detection) would
// prevent imported configurations for those types from being merged.
rawSafeOutputsMap, _ := frontmatter["safe-outputs"].(map[string]any)
mergedSafeOutputs, err := c.MergeSafeOutputs(workflowData.SafeOutputs, allSafeOutputsConfigs, rawSafeOutputsMap)
if err != nil {
return fmt.Errorf("failed to merge safe-outputs from imports: %w", err)
}
workflowData.SafeOutputs = mergedSafeOutputs
// Apply default threat detection when safe-outputs came entirely from imports/includes
// (i.e. the main frontmatter has no safe-outputs: section). In this case the merge
// produces a non-nil SafeOutputs but leaves ThreatDetection nil, which would suppress
// the detection gate on the safe_outputs job. Mirroring the behaviour of
// extractSafeOutputsConfig for direct frontmatter declarations, we enable detection by
// default unless any imported config explicitly sets threat-detection: false.
if safeOutputs == nil && workflowData.SafeOutputs != nil && workflowData.SafeOutputs.ThreatDetection == nil {
if !isThreatDetectionExplicitlyDisabledInConfigs(allSafeOutputsConfigs) {
orchestratorWorkflowLog.Print("Applying default threat-detection for safe-outputs assembled from imports/includes")
workflowData.SafeOutputs.ThreatDetection = &ThreatDetectionConfig{}
}
}
// Auto-inject create-issues if safe-outputs is configured but has no non-builtin outputs.
// This ensures every workflow with safe-outputs has at least one meaningful action handler.
applyDefaultCreateIssue(workflowData)
// Apply the top-level github-app as a fallback for all nested github-app token minting operations.
// This runs last so that all section-specific configurations have been resolved first.
applyTopLevelGitHubAppFallbacks(workflowData)
return nil
}
// processOnSectionAndFilters processes the on section configuration and applies various filters
func (c *Compiler) processOnSectionAndFilters(
frontmatter map[string]any,
workflowData *WorkflowData,
cleanPath string,
) error {
orchestratorWorkflowLog.Print("Processing on section and filters")
// Process stop-after configuration from the on: section