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
10 changes: 9 additions & 1 deletion pkg/parser/import_field_extractor.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ type importAccumulator struct {
importPaths []string // Import paths for runtime-import macro generation
stepsBuilder strings.Builder
copilotSetupStepsBuilder strings.Builder // Steps from copilot-setup-steps.yml (inserted at start)
preStepsBuilder strings.Builder
runtimesBuilder strings.Builder
servicesBuilder strings.Builder
networkBuilder strings.Builder
Expand Down Expand Up @@ -72,7 +73,7 @@ func newImportAccumulator() *importAccumulator {
// extractAllImportFields extracts all frontmatter fields from a single imported file
// and accumulates the results. Handles tools, engines, mcp-servers, safe-outputs,
// mcp-scripts, steps, runtimes, services, network, permissions, secret-masking, bots,
// skip-roles, skip-bots, post-steps, labels, cache, and features.
// skip-roles, skip-bots, pre-steps, post-steps, labels, cache, and features.
func (acc *importAccumulator) extractAllImportFields(content []byte, item importQueueItem, visited map[string]bool) error {
log.Printf("Extracting all import fields: path=%s, section=%s, inputs=%d, content_size=%d bytes", item.fullPath, item.sectionName, len(item.inputs), len(content))

Expand Down Expand Up @@ -310,6 +311,12 @@ func (acc *importAccumulator) extractAllImportFields(content []byte, item import
}
}

// Extract pre-steps from imported file (prepend in order)
preStepsContent, err := extractYAMLFieldFromMap(fm, "pre-steps")
if err == nil && preStepsContent != "" {
acc.preStepsBuilder.WriteString(preStepsContent + "\n")
}

// Extract post-steps from imported file (append in order)
postStepsContent, err := extractYAMLFieldFromMap(fm, "post-steps")
if err == nil && postStepsContent != "" {
Expand Down Expand Up @@ -408,6 +415,7 @@ func (acc *importAccumulator) toImportsResult(topologicalOrder []string) *Import
ImportPaths: acc.importPaths,
MergedSteps: acc.stepsBuilder.String(),
CopilotSetupSteps: acc.copilotSetupStepsBuilder.String(),
MergedPreSteps: acc.preStepsBuilder.String(),
MergedRuntimes: acc.runtimesBuilder.String(),
MergedRunInstallScripts: acc.runInstallScripts,
MergedServices: acc.servicesBuilder.String(),
Expand Down
1 change: 1 addition & 0 deletions pkg/parser/import_processor.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ type ImportsResult struct {
ImportPaths []string // List of import file paths for runtime-import macro generation (replaces MergedMarkdown)
MergedSteps string // Merged steps configuration from all imports (excluding copilot-setup-steps)
CopilotSetupSteps string // Steps from copilot-setup-steps.yml (inserted at start)
MergedPreSteps string // Merged pre-steps configuration from all imports (prepended in order)
MergedRuntimes string // Merged runtimes configuration from all imports
MergedRunInstallScripts bool // true if any imported workflow sets run-install-scripts: true (global or node-level)
MergedServices string // Merged services configuration from all imports
Expand Down
35 changes: 35 additions & 0 deletions pkg/parser/schemas/main_workflow_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -3317,6 +3317,41 @@
}
]
},
"pre-steps": {
"description": "Custom workflow steps to run at the very beginning of the agent job, before checkout and any other built-in steps. Use pre-steps to mint short-lived tokens or perform any setup that must happen before the repository is checked out. Step outputs are available via ${{ steps.<id>.outputs.<name> }} and can be referenced in checkout.token to avoid masked-value cross-job-boundary issues.",
"oneOf": [
{
"type": "object",
"additionalProperties": true
},
{
"type": "array",
"items": {
"oneOf": [
{
"type": "string"
},
{
"type": "object",
"additionalProperties": true
}
]
},
"examples": [
[
{
"name": "Mint short-lived token",
"id": "mint",
"uses": "some-org/token-minting-action@v1",
"with": {
"scope": "target-org/target-repo"
}
}
]
]
}
]
},
"post-steps": {
"description": "Custom workflow steps to run after AI execution",
"oneOf": [
Expand Down
134 changes: 109 additions & 25 deletions pkg/workflow/compiler_orchestrator_workflow.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,8 +147,11 @@ func (c *Compiler) ParseWorkflowFile(markdownPath string) (*WorkflowData, error)
// 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)
c.processAndMergePostSteps(result.Frontmatter, workflowData, engineSetup.importsResult)

// Process and merge services
c.processAndMergeServices(result.Frontmatter, workflowData, engineSetup.importsResult)
Expand Down Expand Up @@ -493,40 +496,121 @@ func (c *Compiler) processAndMergeSteps(frontmatter map[string]any, workflowData
}
}

// processAndMergePostSteps handles the processing of post-steps with action pinning
func (c *Compiler) processAndMergePostSteps(frontmatter map[string]any, workflowData *WorkflowData) {
orchestratorWorkflowLog.Print("Processing post-steps")

workflowData.PostSteps = c.extractTopLevelYAMLSection(frontmatter, "post-steps")
// 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)
}
}
}

// 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 {
// Convert to typed steps for action pinning
typedPostSteps, err := SliceToSteps(postSteps)
// 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 post-steps to typed steps: %v", err)
orchestratorWorkflowLog.Printf("Failed to convert main pre-steps to typed steps: %v", err)
} else {
// Apply action pinning to post steps using type-safe version
typedPostSteps = ApplyActionPinsToTypedSteps(typedPostSteps, workflowData)
// Convert back to []any for YAML marshaling
postSteps = StepsToSlice(typedPostSteps)
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")

// 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))
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
Expand Down
96 changes: 94 additions & 2 deletions pkg/workflow/compiler_orchestrator_workflow_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -401,8 +401,9 @@ func TestProcessAndMergePostSteps_NoPostSteps(t *testing.T) {
compiler := NewCompiler()
workflowData := &WorkflowData{}
frontmatter := map[string]any{}
importsResult := &parser.ImportsResult{}

compiler.processAndMergePostSteps(frontmatter, workflowData)
compiler.processAndMergePostSteps(frontmatter, workflowData, importsResult)

assert.Empty(t, workflowData.PostSteps)
}
Expand Down Expand Up @@ -430,14 +431,105 @@ func TestProcessAndMergePostSteps_WithPostSteps(t *testing.T) {
},
},
}
importsResult := &parser.ImportsResult{}

compiler.processAndMergePostSteps(frontmatter, workflowData)
compiler.processAndMergePostSteps(frontmatter, workflowData, importsResult)

assert.NotEmpty(t, workflowData.PostSteps)
assert.Contains(t, workflowData.PostSteps, "Cleanup")
assert.Contains(t, workflowData.PostSteps, "Upload logs")
}

// TestProcessAndMergePostSteps_WithImportedPostSteps tests that imported post-steps are appended
func TestProcessAndMergePostSteps_WithImportedPostSteps(t *testing.T) {
compiler := NewCompiler()
workflowData := &WorkflowData{}

frontmatter := map[string]any{
"post-steps": []any{
map[string]any{"name": "Main post step", "run": "echo 'main'"},
},
}

importedPostStepsYAML, err := yaml.Marshal([]any{
map[string]any{"name": "Imported post step", "run": "echo 'imported'"},
})
require.NoError(t, err, "yaml.Marshal should not fail for well-formed post-steps")
importsResult := &parser.ImportsResult{
MergedPostSteps: string(importedPostStepsYAML),
}

compiler.processAndMergePostSteps(frontmatter, workflowData, importsResult)

assert.Contains(t, workflowData.PostSteps, "Main post step")
assert.Contains(t, workflowData.PostSteps, "Imported post step")

// Main workflow's post-steps should come before imported ones
mainIdx := strings.Index(workflowData.PostSteps, "Main post step")
importedIdx := strings.Index(workflowData.PostSteps, "Imported post step")
assert.Less(t, mainIdx, importedIdx, "Main post-steps should come before imported ones")
}

// TestProcessAndMergePreSteps_NoPreSteps tests processAndMergePreSteps with no pre-steps
func TestProcessAndMergePreSteps_NoPreSteps(t *testing.T) {
compiler := NewCompiler()
workflowData := &WorkflowData{}
frontmatter := map[string]any{}
importsResult := &parser.ImportsResult{}

compiler.processAndMergePreSteps(frontmatter, workflowData, importsResult)

assert.Empty(t, workflowData.PreSteps)
}

// TestProcessAndMergePreSteps_WithPreSteps tests processAndMergePreSteps with pre-steps defined
func TestProcessAndMergePreSteps_WithPreSteps(t *testing.T) {
compiler := NewCompiler()
workflowData := &WorkflowData{}

frontmatter := map[string]any{
"pre-steps": []any{
map[string]any{"name": "Mint token", "run": "echo 'minting'"},
},
}
importsResult := &parser.ImportsResult{}

compiler.processAndMergePreSteps(frontmatter, workflowData, importsResult)

assert.NotEmpty(t, workflowData.PreSteps)
assert.Contains(t, workflowData.PreSteps, "Mint token")
}

// TestProcessAndMergePreSteps_WithImportedPreSteps tests that imported pre-steps are prepended
func TestProcessAndMergePreSteps_WithImportedPreSteps(t *testing.T) {
compiler := NewCompiler()
workflowData := &WorkflowData{}

frontmatter := map[string]any{
"pre-steps": []any{
map[string]any{"name": "Main pre step", "run": "echo 'main'"},
},
}

importedPreStepsYAML, err := yaml.Marshal([]any{
map[string]any{"name": "Imported pre step", "run": "echo 'imported'"},
})
require.NoError(t, err, "yaml.Marshal should not fail for well-formed pre-steps")
importsResult := &parser.ImportsResult{
MergedPreSteps: string(importedPreStepsYAML),
}

compiler.processAndMergePreSteps(frontmatter, workflowData, importsResult)

assert.Contains(t, workflowData.PreSteps, "Main pre step")
assert.Contains(t, workflowData.PreSteps, "Imported pre step")

// Imported pre-steps should come before the main workflow's pre-steps
importedIdx := strings.Index(workflowData.PreSteps, "Imported pre step")
mainIdx := strings.Index(workflowData.PreSteps, "Main pre step")
assert.Less(t, importedIdx, mainIdx, "Imported pre-steps should come before main pre-steps")
}

// TestProcessAndMergeServices_NoServices tests processAndMergeServices with no services
func TestProcessAndMergeServices_NoServices(t *testing.T) {
compiler := NewCompiler()
Expand Down
Loading